[MERGE] *: ControlPanel Owl
Refactoring of the control panel with the Owl framework.
The MVC structure of the control panel has been changed to a 'component <-> model'
relationship. As such:
- Removed the old control panel: view/model/controller/renderer
- Created a new Owl Component (ControlPanel)
- Created a new class: the Model (an Owl Store did not fit here) and a newly
designed ControlPanelModel extending it
Refactored all inner subcomponents, including:
- Pager
- ActionMenus (peviously SideBar)
- Filter menu
> Custom filter item
- Group by menu
> Custom group by item
> Removal of groupby_menu_mixin (and refactored graph controller in sibling commit)
- Time range menu
- Favorite menu
> Custom favorite item
> Moved the favorite menu registry to the favorite_menu file
- Search bar
> Removed and splitted auto-completion dropdown logic between the new SearchBar
Component (descriptions) and to the new ControlPanelModel (domains).
The templates (all in web/base.xml) have been adapted to the new standards. Most
of the DOM structure remains unchanged with some occasional improvements (replace
hiding with not rendering, removed pointless wrapper divs, etc.).
Task 2196029
closes odoo/odoo#41268
Related: odoo/enterprise#7108
Signed-off-by: Aaron Bohy (aab) <aab@odoo.com>
Co-authored-by: Mathieu Duckerts-Antoine <dam@odoo.com>
Co-authored-by: Julien Mougenot <jum@odoo.com>
This commit is contained in:
co-authored by
Mathieu Duckerts-Antoine
Julien Mougenot
commit
b7b2475cc2
@@ -20,7 +20,7 @@ odoo.define('account.dashboard.setup.tour', function (require) {
|
||||
}, {
|
||||
trigger: '.o_control_panel button:contains("' + _t('Print') + '")',
|
||||
}, {
|
||||
trigger: '.o_control_panel div.o_dropdown_menu a:contains("' + _t('Invoices without Payment') + '")',
|
||||
trigger: '.o_control_panel .o_dropdown_menu a:contains("' + _t('Invoices without Payment') + '")',
|
||||
}, {
|
||||
trigger: 'iframe .o_report_layout_standard h2',
|
||||
content: 'Primary color is correct',
|
||||
|
||||
@@ -88,60 +88,69 @@ FormController.include({
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
_barcodePagerFirst: function () {
|
||||
var self = this;
|
||||
return this.mutex.exec(function () {}).then(function () {
|
||||
if (!self.pager) {
|
||||
self.do_warn(_t('Error: Pager not available'));
|
||||
return;
|
||||
}
|
||||
self.pager.updateState({
|
||||
current_min: 1,
|
||||
}, {notifyChange: true});
|
||||
});
|
||||
_barcodePagerFirst: async function () {
|
||||
return this._updatePage(() => 1);
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
_barcodePagerLast: function () {
|
||||
var self = this;
|
||||
return this.mutex.exec(function () {}).then(function () {
|
||||
if (!self.pager) {
|
||||
self.do_warn(_t('Error: Pager not available'));
|
||||
return;
|
||||
}
|
||||
var state = self.model.get(self.handle, {raw: true});
|
||||
self.pager.updateState({
|
||||
current_min: state.count,
|
||||
}, {notifyChange: true});
|
||||
});
|
||||
_barcodePagerLast: async function () {
|
||||
return this._updatePage((min, state) => state.count);
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
_barcodePagerNext: function () {
|
||||
var self = this;
|
||||
return this.mutex.exec(function () {}).then(function () {
|
||||
if (!self.pager) {
|
||||
self.do_warn(_t('Error: Pager not available'));
|
||||
return;
|
||||
return this._updatePage((min, state) => {
|
||||
min += 1;
|
||||
if (min > state.count) {
|
||||
min = 1;
|
||||
}
|
||||
self.pager.next();
|
||||
return min;
|
||||
});
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
_barcodePagerPrevious: function () {
|
||||
var self = this;
|
||||
return this.mutex.exec(function () {}).then(function () {
|
||||
if (!self.pager) {
|
||||
self.do_warn(_t('Error: Pager not available'));
|
||||
return;
|
||||
return this._updatePage((min, state) => {
|
||||
min -= 1;
|
||||
if (min < 1) {
|
||||
min = state.count;
|
||||
}
|
||||
self.pager.previous();
|
||||
return min;
|
||||
});
|
||||
},
|
||||
/**
|
||||
* Change the current minimum value of the pager using provided function.
|
||||
* This function will be given the current minimum and state and must return
|
||||
* the updated value.
|
||||
*
|
||||
* @private
|
||||
* @param {Function(currentMin: Number, state: Object)} updater
|
||||
*/
|
||||
_updatePage: async function (updater) {
|
||||
await this.mutex.exec(() => {});
|
||||
const state = this.model.get(this.handle, { raw: true });
|
||||
const pagingInfo = this._getPagingInfo(state);
|
||||
if (!pagingInfo) {
|
||||
return this.do_warn(_t('Error: Pager not available'));
|
||||
}
|
||||
const currentMinimum = updater(pagingInfo.currentMinimum, state);
|
||||
const limit = pagingInfo.limit;
|
||||
const reloadParams = state.groupedBy && state.groupedBy.length ? {
|
||||
groupsLimit: limit,
|
||||
groupsOffset: currentMinimum - 1,
|
||||
} : {
|
||||
limit,
|
||||
offset: currentMinimum - 1,
|
||||
};
|
||||
await this.reload(reloadParams);
|
||||
// reset the scroll position to the top on page changed only
|
||||
if (state.limit === limit) {
|
||||
this.trigger_up('scrollTo', { top: 0 });
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Returns true iff the given barcode matches the given record (candidate).
|
||||
*
|
||||
|
||||
@@ -21,8 +21,8 @@ ActionManager.include({
|
||||
if (action.res_model === 'board.board' && action.view_mode === 'form') {
|
||||
action.target = 'inline';
|
||||
_.extend(action.flags, {
|
||||
hasActionMenus: false,
|
||||
hasSearchView: false,
|
||||
hasSidebar: false,
|
||||
headless: true,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,211 +1,154 @@
|
||||
odoo.define('board.AddToBoardMenu', function (require) {
|
||||
"use strict";
|
||||
"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');
|
||||
const Context = require('web.Context');
|
||||
const Domain = require('web.Domain');
|
||||
const DropdownMenuItem = require('web.DropdownMenuItem');
|
||||
const FavoriteMenu = require('web.FavoriteMenu');
|
||||
const pyUtils = require('web.py_utils');
|
||||
const { sprintf } = require('web.utils');
|
||||
const { useAutofocus } = require('web.custom_hooks');
|
||||
|
||||
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
|
||||
//--------------------------------------------------------------------------
|
||||
const { useState } = owl.hooks;
|
||||
|
||||
/**
|
||||
* Closes the menu and render it.
|
||||
* 'Add to board' menu
|
||||
*
|
||||
* Component consisiting of a toggle button, a text input and an 'Add' button.
|
||||
* The first button is simply used to toggle the component and will determine
|
||||
* whether the other elements should be rendered.
|
||||
* The input will be given the name (or title) of the view that will be added.
|
||||
* Finally, the last button will send the name as well as some of the action
|
||||
* properties to the server to add the current view (and its context) to the
|
||||
* user's dashboard.
|
||||
* This component is only available in actions of type 'ir.actions.act_window'.
|
||||
* @extends DropdownMenuItem
|
||||
*/
|
||||
closeMenu: function () {
|
||||
this.isOpen = false;
|
||||
this._render();
|
||||
},
|
||||
class AddToBoardMenu extends DropdownMenuItem {
|
||||
constructor() {
|
||||
super(...arguments);
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Private
|
||||
//--------------------------------------------------------------------------
|
||||
this.interactive = true;
|
||||
this.state = useState({
|
||||
name: this.env.action.name || "",
|
||||
open: false,
|
||||
});
|
||||
|
||||
/**
|
||||
* 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 {Promise}
|
||||
*/
|
||||
_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: searchQuery.groupBy,
|
||||
orderedBy: searchQuery.orderedBy,
|
||||
});
|
||||
|
||||
this.trigger_up('get_controller_query_params', {
|
||||
callback: function (controllerQueryParams) {
|
||||
var queryContext = controllerQueryParams.context;
|
||||
var allContext = _.extend(
|
||||
_.omit(controllerQueryParams, ['context']),
|
||||
queryContext
|
||||
);
|
||||
context.add(allContext);
|
||||
}
|
||||
});
|
||||
|
||||
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];
|
||||
}
|
||||
useAutofocus();
|
||||
}
|
||||
evalutatedContext.dashboard_merge_domains_contexts = false;
|
||||
|
||||
var name = this.$input.val();
|
||||
//---------------------------------------------------------------------
|
||||
// Private
|
||||
//---------------------------------------------------------------------
|
||||
|
||||
this.closeMenu();
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
async _addToBoard() {
|
||||
const searchQuery = this.env.controlPanelModel.getQuery();
|
||||
const context = new Context(this.env.action.context);
|
||||
context.add(searchQuery.context);
|
||||
context.add({
|
||||
group_by: searchQuery.groupBy,
|
||||
orderedBy: searchQuery.orderedBy,
|
||||
});
|
||||
if (searchQuery.timeRanges && searchQuery.timeRanges.hasOwnProperty('fieldName')) {
|
||||
const { fieldName: field, range, comparisonRange } = searchQuery.timeRanges;
|
||||
context.add({
|
||||
time_ranges : { field, range, comparisonRange },
|
||||
});
|
||||
}
|
||||
let controllerQueryParams;
|
||||
this.env.controlPanelModel.trigger('get-controller-query-params', params => {
|
||||
controllerQueryParams = params || {};
|
||||
});
|
||||
controllerQueryParams.context = controllerQueryParams.context || {};
|
||||
const queryContext = controllerQueryParams.context;
|
||||
delete controllerQueryParams.context;
|
||||
context.add(Object.assign(controllerQueryParams, queryContext));
|
||||
|
||||
return self._rpc({
|
||||
const domainArray = new Domain(this.env.action.domain || []);
|
||||
const domain = Domain.prototype.normalizeArray(domainArray.toArray().concat(searchQuery.domain));
|
||||
|
||||
const evalutatedContext = context.eval();
|
||||
for (const key in evalutatedContext) {
|
||||
if (evalutatedContext.hasOwnProperty(key) && /^search_default_/.test(key)) {
|
||||
delete evalutatedContext[key];
|
||||
}
|
||||
}
|
||||
evalutatedContext.dashboard_merge_domains_contexts = false;
|
||||
|
||||
Object.assign(this.state, {
|
||||
name: this.env.action.name || "",
|
||||
open: false,
|
||||
});
|
||||
|
||||
const result = await this.rpc({
|
||||
route: '/board/add_to_dashboard',
|
||||
params: {
|
||||
action_id: self.action.id || false,
|
||||
action_id: this.env.action.id || false,
|
||||
context_to_save: evalutatedContext,
|
||||
domain: domain,
|
||||
view_mode: controller.viewType,
|
||||
name: name,
|
||||
view_mode: this.env.view.type,
|
||||
name: this.state.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();
|
||||
if (result) {
|
||||
this.env.services.notification.notify({
|
||||
title: sprintf(this.env._t("'%s' added to dashboard"), this.state.name),
|
||||
message: this.env._t("Please refresh your browser for the changes to take effect."),
|
||||
type: 'warning',
|
||||
});
|
||||
} else {
|
||||
this.env.services.notification.notify({
|
||||
message: this.env._t("Could not add filter to dashboard"),
|
||||
type: 'danger',
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Hides and displays the submenu which allows adding custom filters.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_toggleMenu: function () {
|
||||
this.isOpen = !this.isOpen;
|
||||
this._render();
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Handlers
|
||||
//--------------------------------------------------------------------------
|
||||
//---------------------------------------------------------------------
|
||||
// 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 {KeyboardEvent} ev
|
||||
*/
|
||||
_onInputKeydown(ev) {
|
||||
switch (ev.key) {
|
||||
case 'Enter':
|
||||
ev.preventDefault();
|
||||
this._addToBoard();
|
||||
break;
|
||||
case 'Escape':
|
||||
// Gives the focus back to the component.
|
||||
ev.preventDefault();
|
||||
ev.target.blur();
|
||||
break;
|
||||
}
|
||||
}
|
||||
},
|
||||
/**
|
||||
* @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;
|
||||
|
||||
//---------------------------------------------------------------------
|
||||
// Static
|
||||
//---------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @param {Object} env
|
||||
* @returns {boolean}
|
||||
*/
|
||||
static shouldBeDisplayed(env) {
|
||||
return env.action.type === 'ir.actions.act_window';
|
||||
}
|
||||
}
|
||||
|
||||
AddToBoardMenu.props = {};
|
||||
AddToBoardMenu.template = 'AddToBoardMenu';
|
||||
|
||||
FavoriteMenu.registry.add('add-to-board-menu', AddToBoardMenu, 10);
|
||||
|
||||
return AddToBoardMenu;
|
||||
});
|
||||
|
||||
@@ -42,10 +42,7 @@ var BoardController = FormController.extend({
|
||||
* @override
|
||||
*/
|
||||
getTitle: function () {
|
||||
if (this.inDashboard) {
|
||||
return _t("My Dashboard");
|
||||
}
|
||||
return this._super.apply(this, arguments);
|
||||
return _t("My Dashboard");
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
@@ -93,12 +90,6 @@ var BoardController = FormController.extend({
|
||||
});
|
||||
dialog.open();
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
_onEnableDashboard: function () {
|
||||
this.inDashboard = true;
|
||||
},
|
||||
/**
|
||||
* We need to intercept switch_view event coming from sub views, because we
|
||||
* don't actually want to switch view in dashboard, we want to do a
|
||||
@@ -255,6 +246,7 @@ 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];
|
||||
@@ -265,17 +257,32 @@ var BoardRenderer = FormRenderer.extend({
|
||||
.then(function (viewsInfo) {
|
||||
var viewInfo = viewsInfo[viewType];
|
||||
var View = viewRegistry.get(viewType);
|
||||
|
||||
const searchQuery = {
|
||||
context: context,
|
||||
domain: domain,
|
||||
groupBy: typeof context.group_by === 'string' && context.group_by ?
|
||||
[context.group_by] :
|
||||
context.group_by || [],
|
||||
orderedBy: context.orderedBy || [],
|
||||
};
|
||||
|
||||
if (['pivot', 'dashboard', 'graph', 'cohort'].includes(viewType)) {
|
||||
if (context.time_ranges) {
|
||||
const { field: fieldName, range, comparisonRange } = context.time_ranges;
|
||||
searchQuery.timeRanges = { fieldName, range, comparisonRange };
|
||||
} else {
|
||||
searchQuery.timeRanges = {};
|
||||
}
|
||||
}
|
||||
|
||||
var view = new View(viewInfo, {
|
||||
action: action,
|
||||
hasSelectors: false,
|
||||
modelName: action.res_model,
|
||||
searchQuery: {
|
||||
context: context,
|
||||
domain: domain,
|
||||
groupBy: typeof context.group_by === 'string' && context.group_by ? [context.group_by] : context.group_by || [],
|
||||
orderedBy: context.orderedBy || [],
|
||||
},
|
||||
searchQuery,
|
||||
withControlPanel: false,
|
||||
withSearchPanel: false,
|
||||
});
|
||||
return view.getController(self).then(function (controller) {
|
||||
self._boardFormViewIDs[controller.handle] = _.first(
|
||||
@@ -300,7 +307,6 @@ var BoardRenderer = FormRenderer.extend({
|
||||
// this function has a side effect. This is ok because we assume that
|
||||
// once we have a '<board>' tag, we are in a special dashboard mode.
|
||||
this.$el.addClass('o_dashboard');
|
||||
this.trigger_up('enable_dashboard');
|
||||
|
||||
var hasAction = _.detect(node.children, function (column) {
|
||||
return _.detect(column.children,function (element){
|
||||
|
||||
@@ -151,7 +151,8 @@
|
||||
// Favorites menu in control panel
|
||||
.o_add_to_dashboard {
|
||||
display: none; // hidden by default
|
||||
@include o-search-options-dropdown-custom-item;
|
||||
max-width: 250px;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
@include media-breakpoint-down(sm) {
|
||||
|
||||
@@ -80,15 +80,27 @@
|
||||
<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="AddToBoardMenu" owl="1">
|
||||
<li class="o_menu_item o_add_to_board" role="menuitem">
|
||||
<button type="button" class="dropdown-item"
|
||||
t-ref="fallback-focus"
|
||||
t-on-click="state.open = !state.open"
|
||||
>
|
||||
<t>Add to my Dashboard</t>
|
||||
</button>
|
||||
<t t-if="state.open">
|
||||
<div class="dropdown-item-text">
|
||||
<input type="text" class="o_input" autofocus=""
|
||||
t-model.trim="state.name"
|
||||
t-on-keydown="_onInputKeydown"
|
||||
/>
|
||||
</div>
|
||||
<div class="dropdown-item-text">
|
||||
<button type="button" class="btn btn-primary" t-on-click="_addToBoard">Add</button>
|
||||
</div>
|
||||
</t>
|
||||
</li>
|
||||
</t>
|
||||
|
||||
<t t-name="SearchView.addtodashboard">
|
||||
<a href="#" class="dropdown-item o_add_to_dashboard_link o_closed_menu">Add to my Dashboard</a>
|
||||
|
||||
@@ -8,6 +8,7 @@ var testUtils = require('web.test_utils');
|
||||
var ListRenderer = require('web.ListRenderer');
|
||||
var pyUtils = require('web.py_utils');
|
||||
|
||||
const cpHelpers = testUtils.controlPanel;
|
||||
var createActionManager = testUtils.createActionManager;
|
||||
var createView = testUtils.createView;
|
||||
|
||||
@@ -701,7 +702,7 @@ QUnit.test('save actions to dashboard', async function (assert) {
|
||||
var result = this._super.apply(this, arguments);
|
||||
result.context = {
|
||||
'fire': 'on the bayou',
|
||||
}
|
||||
};
|
||||
return result;
|
||||
}
|
||||
});
|
||||
@@ -733,7 +734,7 @@ QUnit.test('save actions to dashboard', async function (assert) {
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
return this._super.apply(this, arguments);
|
||||
},
|
||||
}
|
||||
});
|
||||
|
||||
await actionManager.doAction({
|
||||
@@ -750,15 +751,16 @@ QUnit.test('save actions to dashboard', async function (assert) {
|
||||
await testUtils.dom.click($('.o_column_sortable'));
|
||||
|
||||
// Group It
|
||||
await testUtils.dom.click($('.o_search_options .o_dropdown button:contains(Group By)'));
|
||||
await testUtils.dom.click($('.o_search_options .o_group_by_menu button'));
|
||||
await testUtils.dom.click($('.o_search_options .o_group_by_menu button.o_apply_group'));
|
||||
await cpHelpers.toggleGroupByMenu(actionManager);
|
||||
await cpHelpers.toggleAddCustomGroup(actionManager);
|
||||
await cpHelpers.applyGroup(actionManager);
|
||||
|
||||
// add this action to dashboard
|
||||
await testUtils.dom.click($('.o_search_options .o_dropdown button:contains(Favorites)'));
|
||||
await testUtils.dom.click($('.o_add_to_board.o_menu_header'));
|
||||
testUtils.fields.editInput($('input.o_add_to_board_input'), 'a name');
|
||||
await testUtils.dom.click($('.o_add_to_board_confirm_button'));
|
||||
await cpHelpers.toggleFavoriteMenu(actionManager);
|
||||
|
||||
await testUtils.dom.click($('.o_add_to_board > button'));
|
||||
await testUtils.fields.editInput($('.o_add_to_board input'), 'a name');
|
||||
await testUtils.dom.click($('.o_add_to_board div button'));
|
||||
|
||||
testUtils.unpatch(ListController);
|
||||
|
||||
@@ -802,38 +804,28 @@ QUnit.test('save two searches to dashboard', async function (assert) {
|
||||
|
||||
var filter_count = 0;
|
||||
// Add a first filter
|
||||
await testUtils.dom.click(actionManager.$('.o_filters_menu_button'));
|
||||
await testUtils.dom.click(actionManager.$('.o_add_custom_filter'));
|
||||
actionManager.$('.o_searchview_extended_prop_value .o_input').val('a');
|
||||
await testUtils.dom.click(actionManager.$('.o_apply_filter'));
|
||||
await cpHelpers.toggleFilterMenu(actionManager);
|
||||
await cpHelpers.toggleAddCustomFilter(actionManager);
|
||||
await testUtils.fields.editInput(actionManager.el.querySelector('.o_generator_menu_value .o_input'), 'a');
|
||||
await cpHelpers.applyFilter(actionManager);
|
||||
|
||||
// Add it to dashboard
|
||||
await testUtils.dom.click(actionManager.$('.o_favorites_menu_button'));
|
||||
$('.o_search_options .dropdown-menu.o_favorites_menu').one('click', function (ev) {
|
||||
// This handler is on the webClient
|
||||
// But since the test suite doesn't have one
|
||||
// We manually set it here
|
||||
ev.stopPropagation();
|
||||
});
|
||||
await testUtils.dom.click(actionManager.$('.o_add_to_board'));
|
||||
await testUtils.dom.click(actionManager.$('.o_add_to_board_confirm_button'));
|
||||
await cpHelpers.toggleFavoriteMenu(actionManager);
|
||||
await testUtils.dom.click($('.o_add_to_board > button'));
|
||||
await testUtils.dom.click($('.o_add_to_board div button'));
|
||||
|
||||
// Remove it
|
||||
await testUtils.dom.click(actionManager.$('.o_facet_remove'));
|
||||
await testUtils.dom.click(actionManager.el.querySelector('.o_facet_remove'));
|
||||
|
||||
// Add the second filter
|
||||
await testUtils.dom.click(actionManager.$('.o_filters_menu_button'));
|
||||
await testUtils.dom.click(actionManager.$('.o_add_custom_filter'));
|
||||
actionManager.$('.o_searchview_extended_prop_value .o_input').val('b');
|
||||
await testUtils.dom.click(actionManager.$('.o_apply_filter'));
|
||||
await cpHelpers.toggleFilterMenu(actionManager);
|
||||
await cpHelpers.toggleAddCustomFilter(actionManager);
|
||||
await testUtils.fields.editInput(actionManager.el.querySelector('.o_generator_menu_value .o_input'), "b");
|
||||
await cpHelpers.applyFilter(actionManager);
|
||||
// Add it to dashboard
|
||||
await testUtils.dom.click(actionManager.$('.o_favorites_menu_button'));
|
||||
$('.o_search_options .dropdown-menu.o_favorites_menu').one('click', function (ev) {
|
||||
// This handler is on the webClient
|
||||
// But since the test suite doesn't have one
|
||||
// We manually set it here
|
||||
ev.stopPropagation();
|
||||
});
|
||||
await testUtils.dom.click(actionManager.$('.o_add_to_board'));
|
||||
await testUtils.dom.click(actionManager.$('.o_add_to_board_confirm_button'));
|
||||
await cpHelpers.toggleFavoriteMenu(actionManager);
|
||||
await testUtils.dom.click(actionManager.el.querySelector('.o_add_to_board > button'));
|
||||
await testUtils.dom.click(actionManager.el.querySelector('.o_add_to_board div button'));
|
||||
|
||||
actionManager.destroy();
|
||||
});
|
||||
@@ -846,7 +838,7 @@ QUnit.test('save a action domain to dashboard', async function (assert) {
|
||||
var filter_domain = ["display_name", "ilike", "b"];
|
||||
|
||||
// The filter domain already contains the view domain, but is always added by dashboard..,
|
||||
var expected_domain = ['&', '&', view_domain, view_domain, filter_domain]
|
||||
var expected_domain = ['&', '&', view_domain, view_domain, filter_domain];
|
||||
|
||||
var actionManager = await createActionManager({
|
||||
data: this.data,
|
||||
@@ -873,14 +865,18 @@ QUnit.test('save a action domain to dashboard', async function (assert) {
|
||||
});
|
||||
|
||||
// Add a filter
|
||||
await testUtils.dom.click(actionManager.$('.o_filters_menu_button'));
|
||||
await testUtils.dom.click(actionManager.$('.o_add_custom_filter'));
|
||||
actionManager.$('.o_searchview_extended_prop_value .o_input').val('b');
|
||||
await testUtils.dom.click(actionManager.$('.o_apply_filter'));
|
||||
await cpHelpers.toggleFilterMenu(actionManager);
|
||||
await cpHelpers.toggleAddCustomFilter(actionManager);
|
||||
await testUtils.fields.editInput(
|
||||
actionManager.el.querySelector('.o_generator_menu_value .o_input'),
|
||||
"b"
|
||||
);
|
||||
await cpHelpers.applyFilter(actionManager);
|
||||
// Add it to dashboard
|
||||
await testUtils.dom.click(actionManager.$('.o_favorites_menu_button'));
|
||||
await testUtils.dom.click(actionManager.$('.o_add_to_board'));
|
||||
await testUtils.dom.click(actionManager.$('.o_add_to_board_confirm_button'));
|
||||
await cpHelpers.toggleFavoriteMenu(actionManager);
|
||||
await testUtils.dom.click(actionManager.el.querySelector('.o_add_to_board > button'));
|
||||
// add
|
||||
await testUtils.dom.click(actionManager.el.querySelector('.o_add_to_board div button'));
|
||||
|
||||
actionManager.destroy();
|
||||
});
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
'views/google_drive_templates.xml',
|
||||
'views/res_config_settings_views.xml',
|
||||
],
|
||||
'qweb': ['static/src/xml/*.xml'],
|
||||
'demo': [
|
||||
'data/google_drive_demo.xml'
|
||||
],
|
||||
|
||||
@@ -1,110 +1,90 @@
|
||||
odoo.define('google_drive.sidebar', function (require) {
|
||||
"use strict";
|
||||
odoo.define('google_drive.ActionMenus', function (require) {
|
||||
"use strict";
|
||||
|
||||
/**
|
||||
* The purpose of this file is to include the Sidebar widget to add Google
|
||||
* Drive related items.
|
||||
*/
|
||||
|
||||
var Sidebar = require('web.Sidebar');
|
||||
|
||||
|
||||
Sidebar.include({
|
||||
// TO DO: clean me in master
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
start: function () {
|
||||
var def;
|
||||
if (this.options.viewType === "form") {
|
||||
def = this._addGoogleDocItems(this.env.model, this.env.activeIds[0]);
|
||||
}
|
||||
return Promise.resolve(def).then(this._super.bind(this));
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Private
|
||||
//--------------------------------------------------------------------------
|
||||
const ActionMenus = require('web.ActionMenus');
|
||||
const DropdownMenuItem = require('web.DropdownMenuItem');
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {string} model
|
||||
* @param {integer} resID
|
||||
* @returns {Promise}
|
||||
* Google drive menu
|
||||
*
|
||||
* This component is actually a set of list items used to enrich the ActionMenus's
|
||||
* "Action" dropdown list (@see ActionMenus). It will fetch
|
||||
* the current user's google drive configuration and set the result as its
|
||||
* items if any.
|
||||
* @extends DropdownMenuItem
|
||||
*/
|
||||
_addGoogleDocItems: function (model, resID) {
|
||||
var self = this;
|
||||
if (!resID) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
var gdoc_item = _.indexOf(_.pluck(self.items.other, 'classname'), 'oe_share_gdoc');
|
||||
if (gdoc_item !== -1) {
|
||||
self.items.other.splice(gdoc_item, 1);
|
||||
}
|
||||
return this._rpc({
|
||||
args: [this.env.model, resID],
|
||||
context: this.env.context,
|
||||
method: 'get_google_drive_config',
|
||||
model: 'google.drive.config',
|
||||
}).then(function (r) {
|
||||
if (!_.isEmpty(r)) {
|
||||
_.each(r, function (res) {
|
||||
var already_there = false;
|
||||
for (var i = 0; i < self.items.other.length; i++) {
|
||||
var item = self.items.other[i];
|
||||
if (item.classname === 'oe_share_gdoc' && item.label.indexOf(res.name) > -1) {
|
||||
already_there = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!already_there) {
|
||||
self._addItems('other', [{
|
||||
callback: self._onGoogleDocItemClicked.bind(self, res.id),
|
||||
classname: 'oe_share_gdoc',
|
||||
config_id: res.id,
|
||||
label: res.name,
|
||||
res_id: resID,
|
||||
res_model: model,
|
||||
}]);
|
||||
}
|
||||
});
|
||||
class GoogleDriveMenu extends DropdownMenuItem {
|
||||
|
||||
async willStart() {
|
||||
if (this.env.view.type === "form" && this.props.activeIds[0]) {
|
||||
this.gdriveItems = await this._getGoogleDocItems();
|
||||
} else {
|
||||
this.gdriveItems = [];
|
||||
}
|
||||
});
|
||||
},
|
||||
}
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Handlers
|
||||
//--------------------------------------------------------------------------
|
||||
//---------------------------------------------------------------------
|
||||
// Private
|
||||
//---------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {integer} configID
|
||||
* @param {integer} resID
|
||||
*/
|
||||
_onGoogleDocItemClicked: function (configID) {
|
||||
var self = this;
|
||||
var resID = this.env.activeIds[0];
|
||||
var domain = [['id', '=', configID]];
|
||||
var fields = ['google_drive_resource_id', 'google_drive_client_id'];
|
||||
this._rpc({
|
||||
args: [domain, fields],
|
||||
method: 'search_read',
|
||||
model: 'google.drive.config',
|
||||
}).then(function (configs) {
|
||||
self._rpc({
|
||||
args: [configID, resID, configs[0].google_drive_resource_id],
|
||||
context: self.env.context,
|
||||
/**
|
||||
* @private
|
||||
* @returns {Promise<Object[]>}
|
||||
*/
|
||||
async _getGoogleDocItems() {
|
||||
const items = await this.rpc({
|
||||
args: [this.env.action.res_model, this.props.activeIds[0]],
|
||||
context: this.props.context,
|
||||
method: 'get_google_drive_config',
|
||||
model: 'google.drive.config',
|
||||
});
|
||||
return items;
|
||||
}
|
||||
|
||||
//---------------------------------------------------------------------
|
||||
// Handlers
|
||||
//---------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {number} itemId
|
||||
* @returns {Promise}
|
||||
*/
|
||||
async _onGoogleDocItemClick(itemId) {
|
||||
const resID = this.props.activeIds[0];
|
||||
const domain = [['id', '=', itemId]];
|
||||
const fields = ['google_drive_resource_id', 'google_drive_client_id'];
|
||||
const configs = await this.rpc({
|
||||
args: [domain, fields],
|
||||
method: 'search_read',
|
||||
model: 'google.drive.config',
|
||||
});
|
||||
const url = await this.rpc({
|
||||
args: [itemId, resID, configs[0].google_drive_resource_id],
|
||||
context: this.props.context,
|
||||
method: 'get_google_drive_url',
|
||||
model: 'google.drive.config',
|
||||
}).then(function (url) {
|
||||
if (url){
|
||||
window.open(url, '_blank');
|
||||
}
|
||||
});
|
||||
});
|
||||
},
|
||||
if (url) {
|
||||
window.open(url, '_blank');
|
||||
}
|
||||
}
|
||||
}
|
||||
GoogleDriveMenu.props = {
|
||||
activeIds: Array,
|
||||
context: Object,
|
||||
};
|
||||
GoogleDriveMenu.template = 'GoogleDriveMenu';
|
||||
|
||||
});
|
||||
ActionMenus.registry.add('google-drive-menu', {
|
||||
Component: GoogleDriveMenu,
|
||||
getProps(parentProps) {
|
||||
return {
|
||||
activeIds: parentProps.activeIds,
|
||||
context: parentProps.context,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
return Sidebar;
|
||||
return GoogleDriveMenu;
|
||||
});
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
<templates>
|
||||
<t t-name="GoogleDriveMenu" owl="1">
|
||||
<li>
|
||||
<ul class="o_embed_menu">
|
||||
<li t-foreach="gdriveItems" t-as="gdriveItem" t-key="gdriveItem.id"
|
||||
role="menuitem"
|
||||
class="o_menu_item oe_share_gdoc_item dropdown-item"
|
||||
>
|
||||
<a href="#" t-esc="gdriveItem.name" t-on-click.prevent="_onGoogleDocItemClick(gdriveItem.id)"/>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
</t>
|
||||
</templates>
|
||||
@@ -1,166 +1,129 @@
|
||||
odoo.define('google_drive.gdrive_integration', function (require) {
|
||||
"use strict";
|
||||
//rebuild
|
||||
var FormView = require('web.FormView');
|
||||
var testUtils = require('web.test_utils');
|
||||
var GoogleDriveSideBar = require('google_drive.sidebar');
|
||||
"use strict";
|
||||
|
||||
var createView = testUtils.createView;
|
||||
const FormView = require('web.FormView');
|
||||
const testUtils = require('web.test_utils');
|
||||
|
||||
/*
|
||||
* @override
|
||||
* Avoid breaking other tests because of the new route
|
||||
* that the module introduces
|
||||
*/
|
||||
var _addGoogleDocItemsOriginal = GoogleDriveSideBar.prototype._addGoogleDocItems;
|
||||
const cpHelpers = testUtils.controlPanel;
|
||||
|
||||
var _addGoogleDocItemsMocked = function (model, resID) {
|
||||
return Promise.resolve();
|
||||
};
|
||||
|
||||
GoogleDriveSideBar.prototype._addGoogleDocItems = _addGoogleDocItemsMocked;
|
||||
|
||||
QUnit.module('gdrive_integration', {
|
||||
beforeEach: function () {
|
||||
// For our test to work, the _addGoogleDocItems function needs to be the original
|
||||
GoogleDriveSideBar.prototype._addGoogleDocItems = _addGoogleDocItemsOriginal;
|
||||
|
||||
this.data = {
|
||||
partner: {
|
||||
fields: {
|
||||
display_name: {string: "Displayed name", type: "char", searchable: true},
|
||||
QUnit.module('Google Drive Integration', {
|
||||
beforeEach() {
|
||||
this.data = {
|
||||
partner: {
|
||||
fields: {
|
||||
display_name: { string: "Displayed name", type: "char", searchable: true },
|
||||
},
|
||||
records: [
|
||||
{ id: 1, display_name: "Locomotive Breath" },
|
||||
{ id: 2, display_name: "Hey Macarena" },
|
||||
],
|
||||
},
|
||||
records: [{
|
||||
id: 1,
|
||||
display_name: "Locomotive Breath",
|
||||
}, {
|
||||
id: 2,
|
||||
display_name: "Hey Macarena",
|
||||
}],
|
||||
},
|
||||
'google.drive.config': {
|
||||
fields: {
|
||||
model_id: {string: 'Model', type: 'int'},
|
||||
name: {string: 'Name', type: 'char'},
|
||||
google_drive_resource_id: {string: 'Resource ID', type: 'char'},
|
||||
};
|
||||
},
|
||||
}, function () {
|
||||
|
||||
QUnit.module('Google Drive ActionMenus');
|
||||
|
||||
QUnit.test('rendering of the google drive attachments in action menus', async function (assert) {
|
||||
assert.expect(3);
|
||||
|
||||
const form = await testUtils.createView({
|
||||
actionMenusRegistry: true,
|
||||
arch:
|
||||
`<form string="Partners">
|
||||
<field name="display_name"/>
|
||||
</form>`,
|
||||
data: this.data,
|
||||
async mockRPC(route, args) {
|
||||
switch (route) {
|
||||
case '/web/dataset/call_kw/google.drive.config/get_google_drive_config':
|
||||
assert.deepEqual(args.args, ['partner', 1],
|
||||
'The route to get google drive config should have been called');
|
||||
return [{
|
||||
id: 27,
|
||||
name: 'Cyberdyne Systems',
|
||||
}];
|
||||
case '/web/dataset/call_kw/google.drive.config/search_read':
|
||||
return [{
|
||||
google_drive_resource_id: "T1000",
|
||||
google_drive_client_id: "cyberdyne.org",
|
||||
id: 1,
|
||||
}];
|
||||
case '/web/dataset/call_kw/google.drive.config/get_google_drive_url':
|
||||
assert.deepEqual(args.args, [27, 1, 'T1000'],
|
||||
'The route to get the Google url should have been called');
|
||||
return; // do not return anything or it will open a new tab.
|
||||
}
|
||||
},
|
||||
records: [{
|
||||
id: 27,
|
||||
name: 'Cyberdyne Systems',
|
||||
model_id: 1,
|
||||
google_drive_resource_id: 'T1000',
|
||||
}],
|
||||
},
|
||||
'ir.attachment': {
|
||||
fields: {
|
||||
name: {string: 'Name', type:'char'}
|
||||
model: 'partner',
|
||||
res_id: 1,
|
||||
View: FormView,
|
||||
viewOptions: {
|
||||
hasActionMenus: true,
|
||||
},
|
||||
records: [],
|
||||
}
|
||||
};
|
||||
},
|
||||
});
|
||||
await cpHelpers.toggleActionMenu(form);
|
||||
|
||||
afterEach: function() {
|
||||
GoogleDriveSideBar.prototype._addGoogleDocItems = _addGoogleDocItemsMocked;
|
||||
}
|
||||
assert.containsOnce(form, '.oe_share_gdoc_item',
|
||||
"The button to the google action should be present");
|
||||
|
||||
}, function () {
|
||||
QUnit.module('Google Drive Sidebar');
|
||||
await cpHelpers.toggleMenuItem(form, "Cyberdyne Systems");
|
||||
|
||||
QUnit.test('rendering of the google drive attachments in Sidebar', async function (assert) {
|
||||
assert.expect(3);
|
||||
|
||||
var form = await createView({
|
||||
View: FormView,
|
||||
model: 'partner',
|
||||
data: this.data,
|
||||
arch: '<form string="Partners">' +
|
||||
'<field name="display_name"/>' +
|
||||
'</form>',
|
||||
res_id: 1,
|
||||
viewOptions: {hasSidebar: true},
|
||||
mockRPC: function (route, args) {
|
||||
if (route === '/web/dataset/call_kw/google.drive.config/get_google_drive_config') {
|
||||
assert.deepEqual(args.args, ['partner', 1],
|
||||
'The route to get google drive config should have been called');
|
||||
return Promise.resolve([{id: 27, name: 'Cyberdyne Systems'}]);
|
||||
}
|
||||
if (route === '/web/dataset/call_kw/google.drive.config/search_read'){
|
||||
return Promise.resolve([{google_drive_resource_id: "T1000",
|
||||
google_drive_client_id: "cyberdyne.org",
|
||||
id: 1}]);
|
||||
}
|
||||
if (route === '/web/dataset/call_kw/google.drive.config/get_google_drive_url') {
|
||||
assert.deepEqual(args.args, [27, 1, 'T1000'],
|
||||
'The route to get the Google url should have been called');
|
||||
// We don't return anything useful, otherwise it will open a new tab
|
||||
return Promise.resolve();
|
||||
}
|
||||
return this._super.apply(this, arguments);
|
||||
},
|
||||
form.destroy();
|
||||
});
|
||||
|
||||
var $googleAction = form.sidebar.$('.oe_share_gdoc');
|
||||
QUnit.test('click on the google drive attachments after switching records', async function (assert) {
|
||||
assert.expect(4);
|
||||
|
||||
assert.strictEqual($googleAction.length, 1,
|
||||
'The button to the google action should be present');
|
||||
let currentID;
|
||||
const form = await testUtils.createView({
|
||||
actionMenusRegistry: true,
|
||||
arch:
|
||||
`<form string="Partners">
|
||||
<field name="display_name"/>
|
||||
</form>`,
|
||||
data: this.data,
|
||||
async mockRPC(route, args) {
|
||||
switch (route) {
|
||||
case '/web/dataset/call_kw/google.drive.config/get_google_drive_config':
|
||||
assert.deepEqual(args.args, ['partner', currentID],
|
||||
'The route to get google drive config should have been called');
|
||||
return [{
|
||||
id: 27,
|
||||
name: 'Cyberdyne Systems',
|
||||
}];
|
||||
case '/web/dataset/call_kw/google.drive.config/search_read':
|
||||
return [{
|
||||
google_drive_resource_id: "T1000",
|
||||
google_drive_client_id: "cyberdyne.org",
|
||||
id: 1,
|
||||
}];
|
||||
case '/web/dataset/call_kw/google.drive.config/get_google_drive_url':
|
||||
assert.deepEqual(args.args, [27, currentID, 'T1000'],
|
||||
'The route to get the Google url should have been called');
|
||||
return; // do not return anything or it will open a new tab.
|
||||
}
|
||||
},
|
||||
model: 'partner',
|
||||
res_id: 1,
|
||||
View: FormView,
|
||||
viewOptions: {
|
||||
hasActionMenus: true,
|
||||
ids: [1, 2],
|
||||
index: 0,
|
||||
},
|
||||
});
|
||||
currentID = 1;
|
||||
await cpHelpers.toggleActionMenu(form);
|
||||
await cpHelpers.toggleMenuItem(form, "Cyberdyne Systems");
|
||||
|
||||
// click on gdrive sidebar item
|
||||
await testUtils.dom.click(form.sidebar.$('.o_dropdown_toggler_btn:contains(Action)'));
|
||||
await testUtils.dom.click($googleAction);
|
||||
await cpHelpers.pagerNext(form);
|
||||
|
||||
form.destroy();
|
||||
});
|
||||
currentID = 2;
|
||||
await cpHelpers.toggleActionMenu(form);
|
||||
await cpHelpers.toggleMenuItem(form, "Cyberdyne Systems");
|
||||
|
||||
QUnit.test('click on the google drive attachments after switching records', async function (assert) {
|
||||
assert.expect(3);
|
||||
|
||||
var currentID;
|
||||
var form = await createView({
|
||||
View: FormView,
|
||||
model: 'partner',
|
||||
data: this.data,
|
||||
arch: '<form string="Partners">' +
|
||||
'<field name="display_name"/>' +
|
||||
'</form>',
|
||||
res_id: 1,
|
||||
viewOptions: {
|
||||
hasSidebar: true,
|
||||
ids: [1, 2],
|
||||
index: 0,
|
||||
},
|
||||
mockRPC: function (route, args) {
|
||||
if (route === '/web/dataset/call_kw/google.drive.config/get_google_drive_config') {
|
||||
assert.deepEqual(args.args, ['partner', 1],
|
||||
'The route to get google drive config should have been called');
|
||||
return Promise.resolve([{id: 27, name: 'Cyberdyne Systems'}]);
|
||||
}
|
||||
if (route === '/web/dataset/call_kw/google.drive.config/search_read'){
|
||||
return Promise.resolve([{google_drive_resource_id: "T1000",
|
||||
google_drive_client_id: "cyberdyne.org",
|
||||
id: 1}]);
|
||||
}
|
||||
if (route === '/web/dataset/call_kw/google.drive.config/get_google_drive_url') {
|
||||
assert.deepEqual(args.args, [27, currentID, 'T1000'],
|
||||
'The route to get the Google url should have been called');
|
||||
// We don't return anything useful, otherwise it will open a new tab
|
||||
return Promise.resolve();
|
||||
}
|
||||
return this._super.apply(this, arguments);
|
||||
},
|
||||
form.destroy();
|
||||
});
|
||||
|
||||
currentID = 1;
|
||||
await testUtils.dom.click(form.sidebar.$('.o_dropdown_toggler_btn:contains(Action)'));
|
||||
await testUtils.dom.click(form.sidebar.$('.oe_share_gdoc'));
|
||||
|
||||
await testUtils.dom.click(form.pager.$('.o_pager_next'));
|
||||
currentID = 2;
|
||||
await testUtils.dom.click(form.sidebar.$('.o_dropdown_toggler_btn:contains(Action)'));
|
||||
await testUtils.dom.click(form.sidebar.$('.oe_share_gdoc'));
|
||||
|
||||
form.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
@@ -1,102 +1,63 @@
|
||||
odoo.define('board.AddToGoogleSpreadsheetMenu', function (require) {
|
||||
"use strict";
|
||||
"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
|
||||
//--------------------------------------------------------------------------
|
||||
const Domain = require('web.Domain');
|
||||
const DropdownMenuItem = require('web.DropdownMenuItem');
|
||||
const FavoriteMenu = require('web.FavoriteMenu');
|
||||
|
||||
/**
|
||||
* @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])
|
||||
.then(function (res) {
|
||||
if (res.url){
|
||||
window.open(res.url, '_blank');
|
||||
}
|
||||
});
|
||||
},
|
||||
/**
|
||||
* Renders the `SearchView.addtogooglespreadsheet` template.
|
||||
* 'Add to Google spreadsheet' menu
|
||||
*
|
||||
* @private
|
||||
* Component consisting only of a button calling the server to add the current
|
||||
* view to the user's spreadsheet configuration.
|
||||
* This component is only available in actions of type 'ir.actions.act_window'.
|
||||
* @extends DropdownMenuItem
|
||||
*/
|
||||
_render: function () {
|
||||
var $el = QWeb.render('SearchView.addtogooglespreadsheet', {widget: this});
|
||||
this._replaceElement($el);
|
||||
},
|
||||
class AddToGoogleSpreadsheetMenu extends DropdownMenuItem {
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// 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;
|
||||
//---------------------------------------------------------------------
|
||||
// Handlers
|
||||
//---------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
async _onAddToSpreadsheet() {
|
||||
const searchQuery = this.env.controlPanelModel.getQuery();
|
||||
const listView = this.env.action.views.find(view => view.type === 'list');
|
||||
const modelName = this.env.action.res_model;
|
||||
const domain = Domain.prototype.arrayToString(searchQuery.domain);
|
||||
const groupBys = searchQuery.groupBy.join(" ");
|
||||
const listViewId = listView ? listView.viewID : false;
|
||||
const result = await this.rpc({
|
||||
model: 'google.drive.config',
|
||||
method: 'set_spreadsheet',
|
||||
args: [modelName, domain, groupBys, listViewId],
|
||||
});
|
||||
if (result.url) {
|
||||
// According to MDN doc, one should not use _blank as title.
|
||||
// todo: find a good name for the new window
|
||||
window.open(result.url, '_blank');
|
||||
}
|
||||
}
|
||||
|
||||
//---------------------------------------------------------------------
|
||||
// Static
|
||||
//---------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @param {Object} env
|
||||
* @returns {boolean}
|
||||
*/
|
||||
static shouldBeDisplayed(env) {
|
||||
return env.action.type === 'ir.actions.act_window';
|
||||
}
|
||||
}
|
||||
|
||||
AddToGoogleSpreadsheetMenu.props = {};
|
||||
AddToGoogleSpreadsheetMenu.template = 'AddToGoogleSpreadsheetMenu';
|
||||
|
||||
FavoriteMenu.registry.add('add-to-google-spreadsheet-menu', AddToGoogleSpreadsheetMenu, 20);
|
||||
|
||||
return AddToGoogleSpreadsheetMenu;
|
||||
});
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
<template>
|
||||
<div t-name="SearchView.addtogooglespreadsheet">
|
||||
<a class="add_to_spreadsheet dropdown-item">Add to Google Spreadsheet</a>
|
||||
</div>
|
||||
<t t-name="AddToGoogleSpreadsheetMenu" owl="1">
|
||||
<li class="o_menu_item o_add_to_spreadsheet" role="menuitem">
|
||||
<a class="dropdown-item" href="#" t-on-click.prevent="_onAddToSpreadsheet">Add to Google Spreadsheet</a>
|
||||
</li>
|
||||
</t>
|
||||
</template>
|
||||
|
||||
@@ -24,7 +24,7 @@ mailTestUtils.MockMailService.include({
|
||||
* @param {function} [params.mockSupportRPC]
|
||||
* @param {boolean} [params.enableSupportPoll=false]
|
||||
*/
|
||||
function addMockSupportEnvironment(widget, params) {
|
||||
async function addMockSupportEnvironment(widget, params) {
|
||||
// mock CORS RPCs
|
||||
var originalRPC = supportSession.rpc;
|
||||
var defaultMockSupportRPC = function (route, args) {
|
||||
@@ -58,7 +58,7 @@ function addMockSupportEnvironment(widget, params) {
|
||||
}
|
||||
return result;
|
||||
};
|
||||
testUtils.mock.addMockEnvironment(widget, params);
|
||||
await testUtils.mock.addMockEnvironment(widget, params);
|
||||
var widgetDestroy = widget.destroy;
|
||||
widget.destroy = function () {
|
||||
supportSession.rpc = originalRPC;
|
||||
|
||||
@@ -38,7 +38,7 @@ QUnit.test('messaging menu does not display the Support channel if not available
|
||||
assert.expect(1);
|
||||
|
||||
var messagingMenu = new MessagingMenu();
|
||||
testUtils.mock.addMockEnvironment(messagingMenu, {
|
||||
await testUtils.mock.addMockEnvironment(messagingMenu, {
|
||||
data: this.data,
|
||||
services: this.services,
|
||||
});
|
||||
|
||||
@@ -33,7 +33,7 @@ QUnit.test('messaging menu displays the Support channel', async function (assert
|
||||
assert.expect(1);
|
||||
|
||||
var messagingMenu = new MessagingMenu();
|
||||
addMockSupportEnvironment(messagingMenu, {
|
||||
await addMockSupportEnvironment(messagingMenu, {
|
||||
data: this.data,
|
||||
services: this.services,
|
||||
session: this.supportParams,
|
||||
@@ -51,7 +51,7 @@ QUnit.test('clicking on Support channel: channel not available', async function
|
||||
assert.expect(9);
|
||||
|
||||
var messagingMenu = new MessagingMenu();
|
||||
addMockSupportEnvironment(messagingMenu, {
|
||||
await addMockSupportEnvironment(messagingMenu, {
|
||||
data: this.data,
|
||||
mockRPC: function (route, args) {
|
||||
if (!_.string.endsWith(route, '.png')) { // ignore images
|
||||
@@ -100,7 +100,7 @@ QUnit.test('clicking on Support channel: channel available', async function (ass
|
||||
assert.expect(9);
|
||||
|
||||
var messagingMenu = new MessagingMenu();
|
||||
addMockSupportEnvironment(messagingMenu, {
|
||||
await addMockSupportEnvironment(messagingMenu, {
|
||||
data: this.data,
|
||||
mockRPC: function (route, args) {
|
||||
if (!_.string.endsWith(route, '.png')) { // ignore images
|
||||
@@ -145,7 +145,7 @@ QUnit.test('post messages in Support channel', async function (assert) {
|
||||
assert.expect(8);
|
||||
|
||||
var messagingMenu = new MessagingMenu();
|
||||
addMockSupportEnvironment(messagingMenu, {
|
||||
await addMockSupportEnvironment(messagingMenu, {
|
||||
data: this.data,
|
||||
mockRPC: function (route, args) {
|
||||
if (!_.string.endsWith(route, '.png')) { // ignore images
|
||||
@@ -190,7 +190,7 @@ QUnit.test('fold Support channel', async function (assert) {
|
||||
assert.expect(11);
|
||||
|
||||
var messagingMenu = new MessagingMenu();
|
||||
addMockSupportEnvironment(messagingMenu, {
|
||||
await addMockSupportEnvironment(messagingMenu, {
|
||||
data: this.data,
|
||||
mockRPC: function (route, args) {
|
||||
if (!_.string.endsWith(route, '.png')) { // ignore images
|
||||
@@ -245,7 +245,7 @@ QUnit.test('restore Support channel if necessary', async function (assert) {
|
||||
assert.expect(5);
|
||||
|
||||
var messagingMenu = new MessagingMenu();
|
||||
addMockSupportEnvironment(messagingMenu, {
|
||||
await addMockSupportEnvironment(messagingMenu, {
|
||||
data: this.data,
|
||||
enableSupportPoll: true,
|
||||
mockRPC: function (route, args) {
|
||||
@@ -270,8 +270,8 @@ QUnit.test('restore Support channel if necessary', async function (assert) {
|
||||
"should have open a chat window");
|
||||
|
||||
assert.verifySteps([
|
||||
'cors: /odoo_im_support/get_support_channel',
|
||||
'/mail/init_messaging',
|
||||
'cors: /odoo_im_support/get_support_channel',
|
||||
'cors: /odoo_im_support/fetch_messages',
|
||||
]);
|
||||
|
||||
@@ -284,7 +284,7 @@ QUnit.test('receive messages in the Support channel', async function (assert) {
|
||||
var supportChannelID;
|
||||
|
||||
var messagingMenu = new MessagingMenu();
|
||||
addMockSupportEnvironment(messagingMenu, {
|
||||
await addMockSupportEnvironment(messagingMenu, {
|
||||
data: this.data,
|
||||
enableSupportPoll: true,
|
||||
mockRPC: function (route, args) {
|
||||
@@ -330,8 +330,8 @@ QUnit.test('receive messages in the Support channel', async function (assert) {
|
||||
'A message', "message is correct");
|
||||
|
||||
assert.verifySteps([
|
||||
'cors: /odoo_im_support/get_support_channel',
|
||||
'/mail/init_messaging',
|
||||
'cors: /odoo_im_support/get_support_channel',
|
||||
'cors: /odoo_im_support/fetch_messages',
|
||||
]);
|
||||
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
odoo.define('mail.CustomFilterItem', function (require) {
|
||||
"use strict";
|
||||
|
||||
const CustomFilterItem = require('web.CustomFilterItem');
|
||||
const utils = require('web.utils');
|
||||
|
||||
utils.patch(CustomFilterItem, 'mail.CustomFilterItem', {
|
||||
|
||||
/**
|
||||
* With the `mail` module installed, we want to filter out some of the
|
||||
* available fields in 'Add custom filter' menu (@see CustomFilterItem).
|
||||
* @override
|
||||
*/
|
||||
_validateField(field) {
|
||||
return this._super(...arguments) &&
|
||||
field.relation !== 'mail.message' &&
|
||||
field.name !== 'message_ids';
|
||||
},
|
||||
});
|
||||
|
||||
return CustomFilterItem;
|
||||
});
|
||||
@@ -243,7 +243,6 @@ var Discuss = AbstractAction.extend({
|
||||
custom_events: _.extend({}, AbstractAction.prototype.custom_events, {
|
||||
discard_extended_composer: '_onDiscardExtendedComposer',
|
||||
message_moderation: '_onMessageModeration',
|
||||
search: '_onSearch',
|
||||
update_moderation_buttons: '_onUpdateModerationButtons',
|
||||
}),
|
||||
events: {
|
||||
@@ -292,7 +291,7 @@ var Discuss = AbstractAction.extend({
|
||||
this._throttledUpdateThreads = _.throttle(
|
||||
this._updateThreads.bind(this), 100, { leading: false });
|
||||
|
||||
this.controlPanelParams.modelName = 'mail.message';
|
||||
this.controlPanelModelConfig.modelName = 'mail.message';
|
||||
this.call('mail_service', 'getMailBus').on('messaging_ready', this, this._onMessagingReady);
|
||||
},
|
||||
/**
|
||||
@@ -313,7 +312,7 @@ var Discuss = AbstractAction.extend({
|
||||
*/
|
||||
do_show: function () {
|
||||
this._super.apply(this, arguments);
|
||||
this._updateControlPanel();
|
||||
this.updateControlPanel();
|
||||
this.action_manager.do_push_state({
|
||||
action: this.action.id,
|
||||
active_id: this._thread.getID(),
|
||||
@@ -332,6 +331,7 @@ var Discuss = AbstractAction.extend({
|
||||
* @override
|
||||
*/
|
||||
on_attach_callback: function () {
|
||||
this._super();
|
||||
this.call('mail_service', 'getMailBus').trigger('discuss_open', true);
|
||||
if (this._thread) {
|
||||
this._threadWidget.scrollToPosition(this._threadsScrolltop[this._thread.getID()]);
|
||||
@@ -342,6 +342,7 @@ var Discuss = AbstractAction.extend({
|
||||
* @override
|
||||
*/
|
||||
on_detach_callback: function () {
|
||||
this._super();
|
||||
this.call('mail_service', 'getMailBus').trigger('discuss_open', false);
|
||||
this._threadsScrolltop[this._thread.getID()] = this._threadWidget.getScrolltop();
|
||||
},
|
||||
@@ -937,9 +938,7 @@ var Discuss = AbstractAction.extend({
|
||||
* @param {integer} threadID a thread with such ID
|
||||
* @returns {Promise}
|
||||
*/
|
||||
_setThread: function (threadID) {
|
||||
var self = this;
|
||||
|
||||
_setThread: async function (threadID) {
|
||||
this.$('.o_out_of_office').remove();
|
||||
// Store scroll position and composer state of the previous thread
|
||||
this._storeThreadState();
|
||||
@@ -960,36 +959,39 @@ var Discuss = AbstractAction.extend({
|
||||
this._basicComposer.setThread(this._thread);
|
||||
this._extendedComposer.setThread(this._thread);
|
||||
|
||||
return this._fetchAndRenderThread().then(function () {
|
||||
// Mark thread's messages as read and clear needactions
|
||||
if (self._thread.getType() !== 'mailbox') {
|
||||
self._thread.markAsRead();
|
||||
}
|
||||
// Restore scroll position and composer of the new current thread
|
||||
self._restoreThreadState();
|
||||
await this._fetchAndRenderThread();
|
||||
// Mark thread's messages as read and clear needactions
|
||||
if (this._thread.getType() !== 'mailbox') {
|
||||
this._thread.markAsRead();
|
||||
}
|
||||
// Restore scroll position and composer of the new current thread
|
||||
this._restoreThreadState();
|
||||
|
||||
// Update control panel before focusing the composer, otherwise
|
||||
// focus is on the searchview
|
||||
self._setTitle('#' + self._thread.getName());
|
||||
self._updateControlPanel();
|
||||
self._updateControlPanelButtons(self._thread);
|
||||
// Update control panel before focusing the composer, otherwise
|
||||
// focus is on the searchview
|
||||
await this.updateControlPanel({
|
||||
title: '#' + this._thread.getName(),
|
||||
cp_content: {
|
||||
$buttons: this.$buttons,
|
||||
},
|
||||
});
|
||||
this._updateControlPanelButtons(this._thread);
|
||||
|
||||
// Display and focus the adequate composer, and unselect possibly
|
||||
// selected message to prevent sending messages as reply to that
|
||||
// message
|
||||
self._unselectMessage();
|
||||
// Display and focus the adequate composer, and unselect possibly
|
||||
// selected message to prevent sending messages as reply to that
|
||||
// message
|
||||
this._unselectMessage();
|
||||
|
||||
if (self._thread.hasOutOfOffice()) {
|
||||
const $outOfOffice = $(QWeb.render('mail.thread.OutOfOffice', {
|
||||
thread: self._thread
|
||||
}));
|
||||
$outOfOffice.prependTo(self.$('.o_mail_discuss_content'));
|
||||
}
|
||||
if (this._thread.hasOutOfOffice()) {
|
||||
const $outOfOffice = $(QWeb.render('mail.thread.OutOfOffice', {
|
||||
thread: this._thread
|
||||
}));
|
||||
$outOfOffice.prependTo(this.$('.o_mail_discuss_content'));
|
||||
}
|
||||
|
||||
self.action_manager.do_push_state({
|
||||
action: self.action.id,
|
||||
active_id: self._thread.getID(),
|
||||
});
|
||||
this.action_manager.do_push_state({
|
||||
action: this.action.id,
|
||||
active_id: this._thread.getID(),
|
||||
});
|
||||
},
|
||||
/**
|
||||
@@ -1097,16 +1099,6 @@ var Discuss = AbstractAction.extend({
|
||||
this._updateModerationButtons();
|
||||
}
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
_updateControlPanel: function () {
|
||||
this.updateControlPanel({
|
||||
cp_content: {
|
||||
$buttons: this.$buttons,
|
||||
},
|
||||
});
|
||||
},
|
||||
/**
|
||||
* Updates the control panel buttons visibility based on thread type
|
||||
*
|
||||
@@ -1530,11 +1522,10 @@ var Discuss = AbstractAction.extend({
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @param {OdooEvent} ev
|
||||
* @param {Object} searchQuery
|
||||
*/
|
||||
_onSearch: function (ev) {
|
||||
ev.stopPropagation();
|
||||
this.domain = ev.data.domain;
|
||||
_onSearch: function (searchQuery) {
|
||||
this.domain = searchQuery.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
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
odoo.define('mail.FilterMenu', function (require) {
|
||||
"use strict";
|
||||
|
||||
var FilterMenu = require('web.FilterMenu');
|
||||
|
||||
FilterMenu.include({
|
||||
init: function () {
|
||||
this._super.apply(this, arguments);
|
||||
//remove messages from custom filters
|
||||
this.fields = _.pick(this.fields, function (field, name) {
|
||||
return field.relation !== 'mail.message' && name !== 'message_ids';
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
});
|
||||
@@ -33,13 +33,18 @@ var ActivityController = BasicController.extend({
|
||||
this._super.apply(this, arguments);
|
||||
this.title = params.title;
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Private
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Overridden to remove the pager as it makes no sense in this view.
|
||||
*
|
||||
* @override
|
||||
*/
|
||||
renderPager: function () {
|
||||
return Promise.resolve();
|
||||
_getPagingInfo: function () {
|
||||
return null;
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
@@ -316,7 +316,6 @@ QUnit.test('activity view: activity widget', async function (assert) {
|
||||
},
|
||||
};
|
||||
|
||||
owl.Component.env = testUtils.mock.getMockedOwlEnv(params);
|
||||
var activity = await createView(params);
|
||||
var today = activity.$('table tbody tr:first td:nth-child(2).today');
|
||||
var dropdown = today.find('.dropdown-menu.o_activity');
|
||||
@@ -391,9 +390,9 @@ QUnit.test('activity view: no group_by_menu and no time_range_menu', async funct
|
||||
|
||||
assert.containsN(actionManager, '.o_search_options .o_dropdown button:visible', 2,
|
||||
"only two elements should be available in view search");
|
||||
assert.isVisible(actionManager.$('.o_search_options .o_dropdown button.o_filters_menu_button'),
|
||||
assert.isVisible(actionManager.$('.o_search_options .o_dropdown.o_filter_menu > button'),
|
||||
"filter should be available in view search");
|
||||
assert.isVisible(actionManager.$('.o_search_options .o_dropdown button.o_favorites_menu_button'),
|
||||
assert.isVisible(actionManager.$('.o_search_options .o_dropdown.o_favorite_menu > button'),
|
||||
"favorites should be available in view search");
|
||||
actionManager.destroy();
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@ var mailTestUtils = require('mail.testUtils');
|
||||
var testUtils = require('web.test_utils');
|
||||
|
||||
var createDiscuss = mailTestUtils.createDiscuss;
|
||||
const cpHelpers = testUtils.controlPanel;
|
||||
|
||||
QUnit.module('mail', {}, function () {
|
||||
QUnit.module('Discuss', {
|
||||
@@ -139,6 +140,7 @@ QUnit.test('basic rendering', async function (assert) {
|
||||
var $history = $sidebar.find('.o_mail_discuss_item[data-thread-id=mailbox_history]');
|
||||
assert.strictEqual($history.length, 1,
|
||||
"should have the mailbox item 'mailbox_history' in the sidebar");
|
||||
|
||||
discuss.destroy();
|
||||
});
|
||||
|
||||
@@ -305,43 +307,44 @@ QUnit.test('searchview filter messages', async function (assert) {
|
||||
'</search>',
|
||||
},
|
||||
});
|
||||
assert.containsN(discuss, '.o_thread_message', 2,
|
||||
"there should be two messages in the inbox mailbox");
|
||||
assert.strictEqual($('.o_searchview_input').length, 1,
|
||||
"there should be a searchview on discuss");
|
||||
assert.strictEqual($('.o_searchview_input').val(), '',
|
||||
"the searchview should be empty initially");
|
||||
// needed to handle events triggered by the controlPanelModel
|
||||
discuss.on_attach_callback();
|
||||
await testUtils.nextTick();
|
||||
|
||||
// interact with searchview so that there is only once message
|
||||
$('.o_searchview_input').val("ab").trigger('keyup');
|
||||
await testUtils.nextTick();
|
||||
$('.o_searchview_input').trigger($.Event('keydown', { which: $.ui.keyCode.ENTER }));
|
||||
await testUtils.nextTick();
|
||||
assert.strictEqual($('.o_searchview_facet').length, 1,
|
||||
"the searchview should have a facet");
|
||||
assert.strictEqual($('.o_facet_values').text().trim(), 'ab',
|
||||
"the facet should be a search on 'ab'");
|
||||
assert.containsOnce(discuss, '.o_thread_message',
|
||||
"there should be a single message after filter");
|
||||
assert.containsN(discuss, '.o_thread_message', 2,
|
||||
"there should be two messages in the inbox mailbox");
|
||||
assert.strictEqual($('.o_searchview_input').length, 1,
|
||||
"there should be a searchview on discuss");
|
||||
assert.strictEqual($('.o_searchview_input').val(), '',
|
||||
"the searchview should be empty initially");
|
||||
|
||||
// interact with search view so that there are no matching messages
|
||||
await testUtils.dom.click($('.o_facet_remove'));
|
||||
$('.o_searchview_input').val("abcd").trigger('keyup');
|
||||
await testUtils.nextTick();
|
||||
$('.o_searchview_input').trigger($.Event('keydown', { which: $.ui.keyCode.ENTER }));
|
||||
await testUtils.nextTick();
|
||||
// interact with searchview so that there is only once message
|
||||
await cpHelpers.editSearch(discuss, "ab");
|
||||
await cpHelpers.validateSearch(discuss);
|
||||
assert.strictEqual($('.o_searchview_facet').length, 1,
|
||||
"the searchview should have a facet");
|
||||
assert.strictEqual($('.o_facet_values').text().trim(), 'ab',
|
||||
"the facet should be a search on 'ab'");
|
||||
assert.containsOnce(discuss, '.o_thread_message',
|
||||
"there should be a single message after filter");
|
||||
|
||||
assert.strictEqual($('.o_searchview_facet').length, 1,
|
||||
"the searchview should have a facet");
|
||||
assert.strictEqual($('.o_facet_values').text().trim(), 'abcd',
|
||||
"the facet should be a search on 'abcd'");
|
||||
assert.containsNone(discuss, '.o_thread_message',
|
||||
"there should be no message after 2nd filter");
|
||||
assert.strictEqual(discuss.$('.o_thread_title').text().trim(),
|
||||
"No matches found",
|
||||
"should display that there are no matching messages");
|
||||
// interact with search view so that there are no matching messages
|
||||
await testUtils.dom.click($('.o_facet_remove'));
|
||||
await cpHelpers.editSearch(discuss, "abcd");
|
||||
await cpHelpers.validateSearch(discuss);
|
||||
|
||||
discuss.destroy();
|
||||
assert.strictEqual($('.o_searchview_facet').length, 1,
|
||||
"the searchview should have a facet");
|
||||
assert.strictEqual($('.o_facet_values').text().trim(), 'abcd',
|
||||
"the facet should be a search on 'abcd'");
|
||||
assert.containsNone(discuss, '.o_thread_message',
|
||||
"there should be no message after 2nd filter");
|
||||
assert.strictEqual(discuss.$('.o_thread_title').text().trim(),
|
||||
"No matches found",
|
||||
"should display that there are no matching messages");
|
||||
|
||||
discuss.on_detach_callback();
|
||||
discuss.destroy();
|
||||
|
||||
});
|
||||
|
||||
@@ -1147,6 +1150,7 @@ QUnit.test('mark all messages as read from Inbox', async function (assert) {
|
||||
|
||||
await testUtils.dom.click($markAllReadButton);
|
||||
|
||||
|
||||
markAllReadDef.then(function () {
|
||||
// immediately jump to end of the fadeout animation on messages
|
||||
$inbox = discuss.$('.o_mail_discuss_item[data-thread-id="mailbox_inbox"]');
|
||||
@@ -1964,33 +1968,38 @@ QUnit.test('save filter discuss', async function (assert) {
|
||||
}
|
||||
return this._super.apply(this,arguments);
|
||||
},
|
||||
intercepts: {
|
||||
create_filter: function (ev) {
|
||||
assert.deepEqual(
|
||||
JSON.parse(ev.data.filter.domain), [
|
||||
"|",
|
||||
["subject", "ilike", "she was born in a hurricane"],
|
||||
["body", "ilike", "she was born in a hurricane"]
|
||||
], 'The filter should have been saved with the right domain');
|
||||
env: {
|
||||
dataManager: {
|
||||
create_filter: async function (filter) {
|
||||
assert.deepEqual(
|
||||
JSON.parse(filter.domain), [
|
||||
"|",
|
||||
["subject", "ilike", "she was born in a hurricane"],
|
||||
["body", "ilike", "she was born in a hurricane"]
|
||||
], 'The filter should have been saved with the right domain');
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
// needed to handle events triggered by the controlPanelModel
|
||||
discuss.on_attach_callback();
|
||||
await testUtils.nextTick();
|
||||
|
||||
assert.containsOnce(discuss, '.o_searchview_input_container', 'search view input present');
|
||||
|
||||
$('.o_searchview_input').val("she was born in a hurricane").trigger('keyup');
|
||||
await testUtils.nextTick();
|
||||
await cpHelpers.editSearch(discuss, "she was born in a hurricane");
|
||||
|
||||
messageFetchCount = 1;
|
||||
$('.o_searchview_input').trigger($.Event('keydown', { which: $.ui.keyCode.ENTER }));
|
||||
await testUtils.nextTick();
|
||||
|
||||
await testUtils.dom.click(discuss.$('.o_favorites_menu_button'));
|
||||
await testUtils.dom.click(discuss.$('.o_add_favorite'));
|
||||
await cpHelpers.validateSearch(discuss);
|
||||
|
||||
await testUtils.fields.editInput(discuss.$('.o_favorite_name input'), 'War');
|
||||
await testUtils.dom.click(discuss.$('.o_save_favorite button'));
|
||||
await cpHelpers.toggleFavoriteMenu(discuss);
|
||||
await cpHelpers.toggleSaveFavorite(discuss);
|
||||
|
||||
await cpHelpers.editFavoriteName(discuss, "War");
|
||||
await cpHelpers.saveFavorite(discuss);
|
||||
|
||||
discuss.on_detach_callback();
|
||||
discuss.destroy();
|
||||
});
|
||||
|
||||
|
||||
@@ -91,7 +91,7 @@ QUnit.test('open a document thread in a thread window', async function (assert)
|
||||
assert.expect(6);
|
||||
|
||||
var messagingMenu = new MessagingMenu();
|
||||
testUtils.mock.addMockEnvironment(messagingMenu, {
|
||||
await testUtils.mock.addMockEnvironment(messagingMenu, {
|
||||
services: this.services,
|
||||
data: this.data,
|
||||
session: this.session,
|
||||
@@ -129,7 +129,7 @@ QUnit.test('expand a document thread window', async function (assert) {
|
||||
assert.expect(4);
|
||||
|
||||
var messagingMenu = new MessagingMenu();
|
||||
testUtils.mock.addMockEnvironment(messagingMenu, {
|
||||
await testUtils.mock.addMockEnvironment(messagingMenu, {
|
||||
services: this.services,
|
||||
data: this.data,
|
||||
session: this.session,
|
||||
@@ -177,7 +177,7 @@ QUnit.test('post messages in a document thread window', async function (assert)
|
||||
res_id: 1,
|
||||
};
|
||||
var messagingMenu = new MessagingMenu();
|
||||
testUtils.mock.addMockEnvironment(messagingMenu, {
|
||||
await testUtils.mock.addMockEnvironment(messagingMenu, {
|
||||
services: this.services,
|
||||
data: this.data,
|
||||
session: this.session,
|
||||
@@ -229,7 +229,7 @@ QUnit.test('post messages in a document thread window', async function (assert)
|
||||
QUnit.test('open, fold, unfold and close a document thread window', async function (assert) {
|
||||
assert.expect(20);
|
||||
var messagingMenu = new MessagingMenu();
|
||||
testUtils.mock.addMockEnvironment(messagingMenu, {
|
||||
await testUtils.mock.addMockEnvironment(messagingMenu, {
|
||||
services: this.services,
|
||||
data: this.data,
|
||||
session: this.session,
|
||||
@@ -285,7 +285,7 @@ QUnit.test('do not open thread window on fetch message failure', async function
|
||||
assert.expect(4);
|
||||
|
||||
var messagingMenu = new MessagingMenu();
|
||||
testUtils.addMockEnvironment(messagingMenu, {
|
||||
await testUtils.mock.addMockEnvironment(messagingMenu, {
|
||||
services: this.services,
|
||||
data: this.data,
|
||||
session: this.session,
|
||||
|
||||
@@ -9,11 +9,12 @@ var mailUtils = require('mail.utils');
|
||||
|
||||
var AbstractStorageService = require('web.AbstractStorageService');
|
||||
var Class = require('web.Class');
|
||||
var ControlPanelView = require('web.ControlPanelView');
|
||||
var RamStorage = require('web.RamStorage');
|
||||
const makeTestEnvironment = require('web.test_env');
|
||||
var testUtils = require('web.test_utils');
|
||||
var Widget = require('web.Widget');
|
||||
|
||||
const { prepareTarget, nextTick } = testUtils;
|
||||
/**
|
||||
* Test Utils
|
||||
*
|
||||
@@ -26,7 +27,7 @@ var Widget = require('web.Widget');
|
||||
* This is async due to mail_manager/mail_service that needs to be ready.
|
||||
*
|
||||
* @param {Object} params
|
||||
* @return {Promise} resolved with the discuss widget
|
||||
* @returns {Promise<Discuss>}
|
||||
*/
|
||||
async function createDiscuss(params) {
|
||||
var Parent = Widget.extend({
|
||||
@@ -37,8 +38,11 @@ async function createDiscuss(params) {
|
||||
'mail.message,false,search': '<search/>',
|
||||
};
|
||||
testUtils.mock.addMockEnvironment(parent, params);
|
||||
|
||||
const env = params.env || {};
|
||||
owl.Component.env = makeTestEnvironment(env);
|
||||
|
||||
var discuss = new Discuss(parent, params);
|
||||
var selector = params.debug ? 'body' : '#qunit-fixture';
|
||||
|
||||
// override 'destroy' of discuss so that it calls 'destroy' on the parent
|
||||
// instead, which is the parent of discuss and the mockServer.
|
||||
@@ -49,9 +53,9 @@ async function createDiscuss(params) {
|
||||
parent.destroy();
|
||||
};
|
||||
|
||||
return discuss.appendTo($(selector)).then(function () {
|
||||
return discuss;
|
||||
});
|
||||
await discuss.appendTo(prepareTarget(params.debug));
|
||||
await nextTick();
|
||||
return discuss;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -82,7 +82,7 @@ QUnit.test('preview of mail failure', async function (assert) {
|
||||
}];
|
||||
|
||||
var messagingMenu = new MessagingMenu();
|
||||
testUtils.mock.addMockEnvironment(messagingMenu, {
|
||||
await testUtils.mock.addMockEnvironment(messagingMenu, {
|
||||
data: this.data,
|
||||
services: this.services,
|
||||
});
|
||||
@@ -138,7 +138,7 @@ QUnit.test('preview grouped failures by document', async function (assert) {
|
||||
}];
|
||||
|
||||
var messagingMenu = new MessagingMenu();
|
||||
testUtils.mock.addMockEnvironment(messagingMenu, {
|
||||
await testUtils.mock.addMockEnvironment(messagingMenu, {
|
||||
data: this.data,
|
||||
services: this.services,
|
||||
});
|
||||
@@ -194,7 +194,7 @@ QUnit.test('preview grouped failures by document model', async function (assert)
|
||||
}];
|
||||
|
||||
var messagingMenu = new MessagingMenu();
|
||||
testUtils.mock.addMockEnvironment(messagingMenu, {
|
||||
await testUtils.mock.addMockEnvironment(messagingMenu, {
|
||||
data: this.data,
|
||||
services: this.services,
|
||||
});
|
||||
|
||||
@@ -105,7 +105,7 @@ QUnit.test('messaging menu widget: menu with no records', async function (assert
|
||||
assert.expect(1);
|
||||
|
||||
var messagingMenu = new MessagingMenu();
|
||||
testUtils.mock.addMockEnvironment(messagingMenu, {
|
||||
await testUtils.mock.addMockEnvironment(messagingMenu, {
|
||||
services: this.services,
|
||||
mockRPC: function (route, args) {
|
||||
if (args.method === 'message_fetch') {
|
||||
@@ -114,7 +114,7 @@ QUnit.test('messaging menu widget: menu with no records', async function (assert
|
||||
return this._super.apply(this, arguments);
|
||||
}
|
||||
});
|
||||
await messagingMenu.appendTo($('#qunit-fixture'));
|
||||
await messagingMenu.appendTo(testUtils.prepareTarget());
|
||||
await testUtils.dom.click(messagingMenu.$('.dropdown-toggle'));
|
||||
assert.hasClass(messagingMenu.$('.o_no_activity'),'o_no_activity', "should not have instance of widget");
|
||||
messagingMenu.destroy();
|
||||
@@ -126,7 +126,7 @@ QUnit.test('messaging menu widget: messaging not ready', async function (assert)
|
||||
const messagingReadyProm = testUtils.makeTestPromise();
|
||||
|
||||
const messagingMenu = new MessagingMenu();
|
||||
testUtils.mock.addMockEnvironment(messagingMenu, {
|
||||
await testUtils.mock.addMockEnvironment(messagingMenu, {
|
||||
services: this.services,
|
||||
async mockRPC(route, args) {
|
||||
if (route === '/mail/init_messaging') {
|
||||
@@ -142,7 +142,7 @@ QUnit.test('messaging menu widget: messaging not ready', async function (assert)
|
||||
return this._super(...arguments);
|
||||
},
|
||||
});
|
||||
await messagingMenu.appendTo($('#qunit-fixture'));
|
||||
await messagingMenu.appendTo(testUtils.prepareTarget());
|
||||
assert.verifySteps(['/mail/init_messaging:pending']);
|
||||
assert.ok(
|
||||
messagingMenu.el,
|
||||
@@ -170,11 +170,11 @@ QUnit.test('messaging menu widget: messaging not ready', async function (assert)
|
||||
QUnit.test('messaging menu widget: messaging menu with 1 record', async function (assert) {
|
||||
assert.expect(3);
|
||||
var messagingMenu = new MessagingMenu();
|
||||
testUtils.mock.addMockEnvironment(messagingMenu, {
|
||||
await testUtils.mock.addMockEnvironment(messagingMenu, {
|
||||
services: this.services,
|
||||
data: this.data,
|
||||
});
|
||||
await messagingMenu.appendTo($('#qunit-fixture'));
|
||||
await messagingMenu.appendTo(testUtils.prepareTarget());
|
||||
|
||||
await testUtils.dom.click(messagingMenu.$('.dropdown-toggle'));
|
||||
|
||||
@@ -197,14 +197,14 @@ QUnit.test('messaging menu widget: open inbox for needaction not linked to any d
|
||||
assert.expect(4);
|
||||
|
||||
var messagingMenu = new MessagingMenu();
|
||||
testUtils.mock.addMockEnvironment(messagingMenu, {
|
||||
await testUtils.mock.addMockEnvironment(messagingMenu, {
|
||||
services: this.services,
|
||||
data: this.data,
|
||||
session: {
|
||||
partner_id: 1,
|
||||
},
|
||||
});
|
||||
await messagingMenu.appendTo($('#qunit-fixture'));
|
||||
await messagingMenu.appendTo(testUtils.prepareTarget());
|
||||
|
||||
// Simulate received needaction message without associated document,
|
||||
// so that we have a message in inbox without a model and a resID
|
||||
@@ -272,12 +272,12 @@ QUnit.test("messaging menu widget: mark as read on thread preview", async functi
|
||||
}];
|
||||
|
||||
var messagingMenu = new MessagingMenu();
|
||||
testUtils.mock.addMockEnvironment(messagingMenu, {
|
||||
await testUtils.mock.addMockEnvironment(messagingMenu, {
|
||||
services: this.services,
|
||||
data: this.data,
|
||||
});
|
||||
|
||||
await messagingMenu.appendTo($('#qunit-fixture'));
|
||||
await messagingMenu.appendTo(testUtils.prepareTarget());
|
||||
await testUtils.dom.click(messagingMenu.$('.dropdown-toggle'));
|
||||
assert.hasClass(messagingMenu.$el,'o_mail_systray_item',
|
||||
'should be the instance of widget');
|
||||
@@ -335,14 +335,14 @@ QUnit.test('needaction messages in channels should appear, in addition to channe
|
||||
this.data['mail.message'].records = [needactionMessage, lastMessage];
|
||||
|
||||
var messagingMenu = new MessagingMenu();
|
||||
testUtils.mock.addMockEnvironment(messagingMenu, {
|
||||
await testUtils.mock.addMockEnvironment(messagingMenu, {
|
||||
services: this.services,
|
||||
data: this.data,
|
||||
session: {
|
||||
partner_id: partnerID,
|
||||
},
|
||||
});
|
||||
await messagingMenu.appendTo($('#qunit-fixture'));
|
||||
await messagingMenu.appendTo(testUtils.prepareTarget());
|
||||
|
||||
await testUtils.dom.click(messagingMenu.$('.dropdown-toggle'));
|
||||
|
||||
@@ -391,14 +391,15 @@ QUnit.test('preview of message on a document + mark as read', async function (as
|
||||
this.data['mail.message'].records.push(needactionMessage);
|
||||
|
||||
var messagingMenu = new MessagingMenu();
|
||||
testUtils.mock.addMockEnvironment(messagingMenu, {
|
||||
await testUtils.mock.addMockEnvironment(messagingMenu, {
|
||||
services: this.services,
|
||||
data: this.data,
|
||||
session: {
|
||||
partner_id: partnerID,
|
||||
},
|
||||
});
|
||||
await messagingMenu.appendTo($('#qunit-fixture'));
|
||||
await messagingMenu.appendTo(testUtils.prepareTarget());
|
||||
await testUtils.nextTick();
|
||||
assert.strictEqual(messagingMenu.$('.o_notification_counter').text(), '1',
|
||||
"should display a counter of 1 on the messaging menu icon");
|
||||
|
||||
@@ -427,11 +428,11 @@ QUnit.test('update messaging preview on receiving a new message in channel previ
|
||||
assert.expect(8);
|
||||
|
||||
var messagingMenu = new MessagingMenu();
|
||||
testUtils.mock.addMockEnvironment(messagingMenu, {
|
||||
await testUtils.mock.addMockEnvironment(messagingMenu, {
|
||||
services: this.services,
|
||||
data: this.data,
|
||||
});
|
||||
await messagingMenu.appendTo($('#qunit-fixture'));
|
||||
await messagingMenu.appendTo(testUtils.prepareTarget());
|
||||
|
||||
await testUtils.dom.click(messagingMenu.$('.dropdown-toggle'));
|
||||
|
||||
@@ -478,11 +479,11 @@ QUnit.test('new message of type "notification" are not considered as unread mess
|
||||
assert.expect(8);
|
||||
|
||||
const messagingMenu = new MessagingMenu();
|
||||
testUtils.mock.addMockEnvironment(messagingMenu, {
|
||||
await testUtils.mock.addMockEnvironment(messagingMenu, {
|
||||
data: this.data,
|
||||
services: this.services,
|
||||
});
|
||||
await messagingMenu.appendTo($('#qunit-fixture'));
|
||||
await messagingMenu.appendTo(testUtils.prepareTarget());
|
||||
// open messaging menu
|
||||
await testUtils.dom.click(messagingMenu.$('.dropdown-toggle'));
|
||||
assert.containsOnce(
|
||||
@@ -580,7 +581,7 @@ QUnit.test('preview of inbox message not linked to document + mark as read', asy
|
||||
this.data['mail.message'].records.concat(needactionMessages);
|
||||
|
||||
var messagingMenu = new MessagingMenu();
|
||||
testUtils.mock.addMockEnvironment(messagingMenu, {
|
||||
await testUtils.mock.addMockEnvironment(messagingMenu, {
|
||||
services: this.services,
|
||||
data: this.data,
|
||||
session: {
|
||||
@@ -593,7 +594,9 @@ QUnit.test('preview of inbox message not linked to document + mark as read', asy
|
||||
return this._super.apply(this, arguments);
|
||||
},
|
||||
});
|
||||
await messagingMenu.appendTo($('#qunit-fixture'));
|
||||
await messagingMenu.appendTo(testUtils.prepareTarget());
|
||||
await testUtils.nextTick();
|
||||
|
||||
assert.strictEqual(messagingMenu.$('.o_notification_counter').text(), '2',
|
||||
"should display a counter of 2 on the messaging menu icon");
|
||||
|
||||
@@ -673,14 +676,14 @@ QUnit.test('grouped preview for needaction messages linked to same document', as
|
||||
this.data.initMessaging.needaction_inbox_counter = 2;
|
||||
|
||||
var messagingMenu = new MessagingMenu();
|
||||
testUtils.mock.addMockEnvironment(messagingMenu, {
|
||||
await testUtils.mock.addMockEnvironment(messagingMenu, {
|
||||
services: this.services,
|
||||
data: this.data,
|
||||
session: {
|
||||
partner_id: 44,
|
||||
},
|
||||
});
|
||||
await messagingMenu.appendTo($('#qunit-fixture'));
|
||||
await messagingMenu.appendTo(testUtils.prepareTarget());
|
||||
|
||||
await testUtils.dom.click(messagingMenu.$('.dropdown-toggle'));
|
||||
var $previews = messagingMenu.$('.o_mail_preview');
|
||||
@@ -719,13 +722,13 @@ QUnit.test("messaging menu widget: channel seen notification", async function (a
|
||||
};
|
||||
|
||||
var messagingMenu = new MessagingMenu();
|
||||
testUtils.mock.addMockEnvironment(messagingMenu, {
|
||||
await testUtils.mock.addMockEnvironment(messagingMenu, {
|
||||
services: this.services,
|
||||
data: this.data,
|
||||
session: { partner_id: 3 },
|
||||
});
|
||||
|
||||
await messagingMenu.appendTo($('#qunit-fixture'));
|
||||
await messagingMenu.appendTo(testUtils.prepareTarget());
|
||||
await testUtils.dom.click(messagingMenu.$('.dropdown-toggle'));
|
||||
|
||||
assert.strictEqual(messagingMenu.$('.o_notification_counter').text(), '1',
|
||||
@@ -767,13 +770,13 @@ QUnit.test("messaging menu widget: no traceback when receiving channel_fetched n
|
||||
};
|
||||
|
||||
var messagingMenu = new MessagingMenu();
|
||||
testUtils.mock.addMockEnvironment(messagingMenu, {
|
||||
await testUtils.mock.addMockEnvironment(messagingMenu, {
|
||||
services: this.services,
|
||||
data: this.data,
|
||||
session: { partner_id: 3 },
|
||||
});
|
||||
|
||||
await messagingMenu.appendTo($('#qunit-fixture'));
|
||||
await messagingMenu.appendTo(testUtils.prepareTarget());
|
||||
await testUtils.dom.click(messagingMenu.$('.dropdown-toggle'));
|
||||
assert.containsOnce(messagingMenu, '.o_new_message',
|
||||
"should have button to open blank thread window");
|
||||
@@ -806,12 +809,12 @@ QUnit.test("messaging menu widget: preview with no message should be undated", a
|
||||
this.data['mail.message'].records[0].channel_ids = [];
|
||||
|
||||
var messagingMenu = new MessagingMenu();
|
||||
testUtils.mock.addMockEnvironment(messagingMenu, {
|
||||
await testUtils.mock.addMockEnvironment(messagingMenu, {
|
||||
services: this.services,
|
||||
data: this.data,
|
||||
});
|
||||
|
||||
await messagingMenu.appendTo($('#qunit-fixture'));
|
||||
await messagingMenu.appendTo(testUtils.prepareTarget());
|
||||
await testUtils.dom.click(messagingMenu.$('.dropdown-toggle'));
|
||||
|
||||
assert.strictEqual(messagingMenu.$('.o_mail_preview').length, 1,
|
||||
@@ -836,12 +839,12 @@ QUnit.test("messaging menu widget: sort dated previews before undated previews",
|
||||
this.data.initMessaging.channel_slots.direct_message = [dm];
|
||||
|
||||
var messagingMenu = new MessagingMenu();
|
||||
testUtils.mock.addMockEnvironment(messagingMenu, {
|
||||
await testUtils.mock.addMockEnvironment(messagingMenu, {
|
||||
services: this.services,
|
||||
data: this.data,
|
||||
});
|
||||
|
||||
await messagingMenu.appendTo($('#qunit-fixture'));
|
||||
await messagingMenu.appendTo(testUtils.prepareTarget());
|
||||
await testUtils.dom.click(messagingMenu.$('.dropdown-toggle'));
|
||||
|
||||
assert.strictEqual(messagingMenu.$('.o_mail_preview').length, 2,
|
||||
@@ -924,16 +927,19 @@ QUnit.test('global counter with channel previews', async function (assert) {
|
||||
this.data['mail.message'].records = messages;
|
||||
|
||||
var messagingMenu = new MessagingMenu();
|
||||
testUtils.mock.addMockEnvironment(messagingMenu, {
|
||||
await testUtils.mock.addMockEnvironment(messagingMenu, {
|
||||
services: this.services,
|
||||
data: this.data,
|
||||
session: {
|
||||
partner_id: 3,
|
||||
},
|
||||
});
|
||||
await messagingMenu.appendTo($('#qunit-fixture'));
|
||||
await messagingMenu.appendTo(testUtils.prepareTarget());
|
||||
await testUtils.nextTick();
|
||||
|
||||
assert.strictEqual(messagingMenu.$('.o_notification_counter').text(), '2',
|
||||
"should display a counter of 2 on the messaging menu icon");
|
||||
|
||||
await testUtils.dom.click(messagingMenu.$('.dropdown-toggle'));
|
||||
|
||||
assert.strictEqual(messagingMenu.$('.o_mail_preview').length, 3,
|
||||
@@ -965,7 +971,7 @@ QUnit.test('messaging menu widget: do not open chat window twice on preview clic
|
||||
var messageFetchDef = testUtils.makeTestPromise();
|
||||
|
||||
var messagingMenu = new MessagingMenu();
|
||||
testUtils.addMockEnvironment(messagingMenu, {
|
||||
await testUtils.mock.addMockEnvironment(messagingMenu, {
|
||||
services: this.services,
|
||||
data: this.data,
|
||||
session: { partner_id: 1 },
|
||||
@@ -991,7 +997,7 @@ QUnit.test('messaging menu widget: do not open chat window twice on preview clic
|
||||
return this._super.apply(this, arguments);
|
||||
},
|
||||
});
|
||||
await messagingMenu.appendTo($('#qunit-fixture'));
|
||||
await messagingMenu.appendTo(testUtils.prepareTarget());
|
||||
|
||||
// Opening chat window from messaging menu (pending from `messageFetchDef`)
|
||||
await testUtils.dom.click(messagingMenu.$('.dropdown-toggle'));
|
||||
@@ -1023,7 +1029,7 @@ QUnit.test('messaging menu widget: expand on thread preview', async function (as
|
||||
});
|
||||
|
||||
var messagingMenu = new MessagingMenu();
|
||||
testUtils.mock.addMockEnvironment(messagingMenu, {
|
||||
await testUtils.mock.addMockEnvironment(messagingMenu, {
|
||||
services: this.services,
|
||||
data: this.data,
|
||||
session: { partner_id: 44 },
|
||||
@@ -1036,7 +1042,7 @@ QUnit.test('messaging menu widget: expand on thread preview', async function (as
|
||||
},
|
||||
},
|
||||
});
|
||||
await messagingMenu.appendTo($('#qunit-fixture'));
|
||||
await messagingMenu.appendTo(testUtils.prepareTarget());
|
||||
|
||||
await testUtils.dom.click(messagingMenu.$('.dropdown-toggle'));
|
||||
var $preview = messagingMenu.$('.o_mail_preview');
|
||||
@@ -1062,7 +1068,7 @@ QUnit.test('messaging menu widget: click twice preview on slow message_fetch sho
|
||||
const messageFetchProm = testUtils.makeTestPromise();
|
||||
|
||||
const messagingMenu = new MessagingMenu();
|
||||
testUtils.addMockEnvironment(messagingMenu, {
|
||||
await testUtils.mock.addMockEnvironment(messagingMenu, {
|
||||
services: this.services,
|
||||
data: this.data,
|
||||
session: { partner_id: 1 },
|
||||
@@ -1086,7 +1092,7 @@ QUnit.test('messaging menu widget: click twice preview on slow message_fetch sho
|
||||
return this._super(...arguments);
|
||||
},
|
||||
});
|
||||
await messagingMenu.appendTo($('#qunit-fixture'));
|
||||
await messagingMenu.appendTo(testUtils.prepareTarget());
|
||||
|
||||
// Opening chat window 1st time from messaging menu (pending from `messageFetchDef`)
|
||||
await testUtils.dom.click(messagingMenu.$('.dropdown-toggle'));
|
||||
@@ -1113,11 +1119,11 @@ QUnit.test('no code injection in message body preview', async function (assert)
|
||||
`<p><script>throw new Error('CodeInjectionError');</script></p>`;
|
||||
|
||||
const messagingMenu = new MessagingMenu();
|
||||
testUtils.mock.addMockEnvironment(messagingMenu, {
|
||||
await testUtils.mock.addMockEnvironment(messagingMenu, {
|
||||
services: this.services,
|
||||
data: this.data,
|
||||
});
|
||||
await messagingMenu.appendTo($('#qunit-fixture'));
|
||||
await messagingMenu.appendTo(testUtils.prepareTarget());
|
||||
await testUtils.dom.click(messagingMenu.$('.dropdown-toggle'));
|
||||
assert.containsOnce(
|
||||
messagingMenu,
|
||||
|
||||
@@ -68,7 +68,7 @@
|
||||
<!-- tools -->
|
||||
<script type="text/javascript" src="/mail/static/src/js/tools/debug_manager.js"></script>
|
||||
<!-- filter menu -->
|
||||
<script type="text/javascript" src="/mail/static/src/js/filter_menu.js"></script>
|
||||
<script type="text/javascript" src="/mail/static/src/js/custom_filter_item.js"></script>
|
||||
<!-- utils -->
|
||||
<script type="text/javascript" src="/mail/static/src/js/utils.js"></script>
|
||||
<script type="text/javascript" src="/mail/static/src/js/activity.js"></script>
|
||||
|
||||
@@ -70,7 +70,7 @@ QUnit.test('messaging menu widget: rendering with OdooBot has a request', async
|
||||
assert.expect(5);
|
||||
|
||||
var messagingMenu = new MessagingMenu();
|
||||
testUtils.mock.addMockEnvironment(messagingMenu, {
|
||||
await testUtils.mock.addMockEnvironment(messagingMenu, {
|
||||
data: this.data,
|
||||
services: this.services,
|
||||
});
|
||||
@@ -99,7 +99,7 @@ QUnit.test('messaging menu widget: rendering without OdooBot has a request (deni
|
||||
window.Notification.permission = 'denied';
|
||||
|
||||
var messagingMenu = new MessagingMenu();
|
||||
testUtils.mock.addMockEnvironment(messagingMenu, {
|
||||
await testUtils.mock.addMockEnvironment(messagingMenu, {
|
||||
data: this.data,
|
||||
services: this.services,
|
||||
});
|
||||
@@ -122,7 +122,7 @@ QUnit.test('messaging menu widget: rendering without OdooBot has a request (acce
|
||||
window.Notification.permission = 'granted';
|
||||
|
||||
var messagingMenu = new MessagingMenu();
|
||||
testUtils.addMockEnvironment(messagingMenu, {
|
||||
await testUtils.mock.addMockEnvironment(messagingMenu, {
|
||||
data: this.data,
|
||||
services: this.services,
|
||||
});
|
||||
@@ -143,7 +143,7 @@ QUnit.test('messaging menu widget: respond to notification prompt', async functi
|
||||
assert.expect(4);
|
||||
|
||||
var messagingMenu = new MessagingMenu();
|
||||
testUtils.addMockEnvironment(messagingMenu, {
|
||||
await testUtils.mock.addMockEnvironment(messagingMenu, {
|
||||
data: this.data,
|
||||
services: this.services,
|
||||
});
|
||||
|
||||
@@ -101,7 +101,7 @@ var MrpBomReport = stock_report_generic.extend({
|
||||
var status = {
|
||||
cp_content: {
|
||||
$buttons: this.$buttonPrint,
|
||||
$searchview_buttons: this.$searchView
|
||||
$searchview: this.$searchView
|
||||
},
|
||||
};
|
||||
return this.updateControlPanel(status);
|
||||
@@ -132,7 +132,7 @@ var MrpBomReport = stock_report_generic.extend({
|
||||
var action = {
|
||||
'type': 'ir.actions.report',
|
||||
'report_type': 'qweb-pdf',
|
||||
'report_name': reportname,
|
||||
'report_name': reportname,
|
||||
'report_file': 'mrp.report_bom_structure',
|
||||
};
|
||||
return this.do_action(action).then(function (){
|
||||
|
||||
@@ -30,7 +30,7 @@ var QtyTagWidget = Widget.extend({
|
||||
|
||||
/**
|
||||
* Add a quantity when add(+) button clicked.
|
||||
*
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_onClickAddQty: function () {
|
||||
@@ -143,9 +143,8 @@ var GeneratePriceList = AbstractAction.extend(StandaloneFieldManagerMixin, {
|
||||
* @override
|
||||
*/
|
||||
start: function () {
|
||||
this.controlPanelProps.cp_content = this._renderComponent();
|
||||
return this._super.apply(this, arguments).then(() => {
|
||||
this._renderComponent();
|
||||
this.update_cp();
|
||||
this.$('.o_content').html(this.reportHtml);
|
||||
});
|
||||
},
|
||||
@@ -163,22 +162,6 @@ var GeneratePriceList = AbstractAction.extend(StandaloneFieldManagerMixin, {
|
||||
return _t('Pricelist Report');
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Public
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
update_cp: function () {
|
||||
this.updateControlPanel({
|
||||
cp_content: {
|
||||
$buttons: this.$buttonPrint,
|
||||
$searchview_buttons: this.$searchView,
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Private
|
||||
//--------------------------------------------------------------------------
|
||||
@@ -215,16 +198,17 @@ var GeneratePriceList = AbstractAction.extend(StandaloneFieldManagerMixin, {
|
||||
* @private
|
||||
*/
|
||||
_renderComponent: function () {
|
||||
this.$buttonPrint = $('<button>', {
|
||||
const $buttons = $('<button>', {
|
||||
class: 'btn btn-primary',
|
||||
text: _t("Print"),
|
||||
}).on('click', this._onClickPrint.bind(this));
|
||||
|
||||
this.$searchView = $(QWeb.render('product.report_pricelist_search'));
|
||||
this.many2one.appendTo(this.$searchView.find('.o_pricelist'));
|
||||
const $searchview = $(QWeb.render('product.report_pricelist_search'));
|
||||
this.many2one.appendTo($searchview.find('.o_pricelist'));
|
||||
|
||||
this.qtyTagWidget = new QtyTagWidget(this, this.context.quantities);
|
||||
this.qtyTagWidget.replace(this.$searchView.find('.o_product_qty'));
|
||||
this.qtyTagWidget.replace($searchview.find('.o_product_qty'));
|
||||
return { $buttons, $searchview };
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
@@ -28,8 +28,7 @@ var InventoryReportListController = ListController.extend({
|
||||
}
|
||||
var $buttonToDate = $(qweb.render('InventoryReport.Buttons'));
|
||||
$buttonToDate.on('click', this._onOpenWizard.bind(this));
|
||||
|
||||
$buttonToDate.prependTo($node.find('.o_list_buttons'));
|
||||
this.$buttons.prepend($buttonToDate);
|
||||
},
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@@ -9,12 +9,14 @@ odoo.define('web.AbstractAction', function (require) {
|
||||
*/
|
||||
|
||||
var ActionMixin = require('web.ActionMixin');
|
||||
var ControlPanelView = require('web.ControlPanelView');
|
||||
var ControlPanel = require('web.ControlPanel');
|
||||
var ControlPanelModel = require('web.ControlPanelModel');
|
||||
var Widget = require('web.Widget');
|
||||
const { ComponentWrapper } = require('web.OwlCompatibility');
|
||||
|
||||
var AbstractAction = Widget.extend(ActionMixin, {
|
||||
config: {
|
||||
ControlPanelView: ControlPanelView,
|
||||
ControlPanel: ControlPanel,
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -33,7 +35,7 @@ var AbstractAction = Widget.extend(ActionMixin, {
|
||||
* For example, the Discuss application adds the following line in its
|
||||
* constructor::
|
||||
*
|
||||
* this.controlPanelParams.modelName = 'mail.message';
|
||||
* this.controlPanelModelConfig.modelName = 'mail.message';
|
||||
*
|
||||
* @type boolean
|
||||
*/
|
||||
@@ -73,15 +75,25 @@ var AbstractAction = Widget.extend(ActionMixin, {
|
||||
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,
|
||||
};
|
||||
|
||||
if (this.hasControlPanel) {
|
||||
this.controlPanelModelConfig = {
|
||||
actionId: action.id,
|
||||
actionContext: action.context || {},
|
||||
actionDomain: action.domain || [],
|
||||
env: owl.Component.env,
|
||||
withSearchBar: this.withSearchBar,
|
||||
};
|
||||
|
||||
this.viewId = action.search_view_id && action.search_view_id[0];
|
||||
|
||||
this.controlPanelProps = {
|
||||
action,
|
||||
breadcrumbs: options && options.breadcrumbs,
|
||||
withSearchBar: this.withSearchBar,
|
||||
searchMenuTypes: this.searchMenuTypes,
|
||||
};
|
||||
}
|
||||
},
|
||||
/**
|
||||
* The willStart method is actually quite complicated if the client action
|
||||
@@ -89,40 +101,73 @@ var AbstractAction = Widget.extend(ActionMixin, {
|
||||
*
|
||||
* @override
|
||||
*/
|
||||
willStart: function () {
|
||||
var self = this;
|
||||
var proms = [this._super.apply(this, arguments)];
|
||||
willStart: async function () {
|
||||
const proms = [this._super(...arguments)];
|
||||
if (this.hasControlPanel) {
|
||||
var params = this.controlPanelParams;
|
||||
if (this.loadControlPanel) {
|
||||
proms.push(this
|
||||
.loadFieldView(params.modelName, params.context || {}, params.viewId, 'search')
|
||||
.then(function (fieldsView) {
|
||||
params.viewInfo = {
|
||||
arch: fieldsView.arch,
|
||||
fields: fieldsView.fields,
|
||||
};
|
||||
}));
|
||||
const { context, searchMenuTypes } = this.controlPanelProps;
|
||||
const { modelName } = this.controlPanelModelConfig;
|
||||
const options = { load_filters: searchMenuTypes.includes('favorite') };
|
||||
const args = [modelName, context || {}, this.viewId, 'search', options];
|
||||
const loadFieldViewPromise = this.loadFieldView(...args);
|
||||
const {arch, fields, favoriteFilters } = await loadFieldViewPromise;
|
||||
this.controlPanelModelConfig.viewInfo = {arch, fields, favoriteFilters };
|
||||
this.controlPanelProps.fields = fields;
|
||||
}
|
||||
return Promise.all(proms).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());
|
||||
});
|
||||
});
|
||||
this._controlPanelModel = new ControlPanelModel(this.controlPanelModelConfig);
|
||||
this.controlPanelProps.controlPanelModel = this._controlPanelModel;
|
||||
proms.push(this._controlPanelModel.isReady);
|
||||
}
|
||||
return Promise.all(proms);
|
||||
},
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
start: function () {
|
||||
if (this._controlPanel) {
|
||||
this._controlPanel.$el.prependTo(this.$el);
|
||||
start: async function () {
|
||||
await this._super(...arguments);
|
||||
if (this.hasControlPanel) {
|
||||
if ('title' in this.controlPanelProps) {
|
||||
this._setTitle(this.controlPanelProps.title);
|
||||
}
|
||||
this.controlPanelProps.title = this.getTitle();
|
||||
this._controlPanelWrapper = new ComponentWrapper(this, ControlPanel, this.controlPanelProps);
|
||||
await this._controlPanelWrapper.mount(this.el, { position: 'first-child' });
|
||||
|
||||
}
|
||||
return this._super.apply(this, arguments);
|
||||
},
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
destroy: function() {
|
||||
this._super.apply(this, arguments);
|
||||
ActionMixin.destroy.call(this);
|
||||
},
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
on_attach_callback: function () {
|
||||
ActionMixin.on_attach_callback.call(this);
|
||||
if (this.hasControlPanel) {
|
||||
this._controlPanelModel.on('search', this, this._onSearch);
|
||||
this._controlPanelModel.on('get-controller-query-params', this, this._onGetOwnedQueryParams);
|
||||
}
|
||||
},
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
on_detach_callback: function () {
|
||||
ActionMixin.on_detach_callback.call(this);
|
||||
if (this.hasControlPanel) {
|
||||
this._controlPanelModel.off('search', this);
|
||||
this._controlPanelModel.off('get-controller-query-params', this);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {Object} [searchQuery]
|
||||
*/
|
||||
_onSearch: function () {},
|
||||
});
|
||||
|
||||
return AbstractAction;
|
||||
|
||||
@@ -32,9 +32,7 @@ var _t = core._t;
|
||||
|
||||
var AbstractWebClient = Widget.extend(KeyboardNavigationMixin, {
|
||||
dependencies: ['notification'],
|
||||
events: _.extend({}, KeyboardNavigationMixin.events, {
|
||||
'click .o_search_options .dropdown-menu': '_onClickDropDownMenu',
|
||||
}),
|
||||
events: _.extend({}, KeyboardNavigationMixin.events),
|
||||
custom_events: {
|
||||
call_service: '_onCallService',
|
||||
clear_uncommitted_changes: function (e) {
|
||||
@@ -356,16 +354,6 @@ var AbstractWebClient = Widget.extend(KeyboardNavigationMixin, {
|
||||
const result = service[payload.method].apply(service, args);
|
||||
payload.callback(result);
|
||||
},
|
||||
/**
|
||||
* When clicking inside a dropdown to modify search options
|
||||
* prevents the bootstrap dropdown to close on itself
|
||||
*
|
||||
* @private
|
||||
* @param {Event} ev
|
||||
*/
|
||||
_onClickDropDownMenu: function (ev) {
|
||||
ev.stopPropagation();
|
||||
},
|
||||
/**
|
||||
* Whenever the connection is lost, we need to notify the user.
|
||||
*
|
||||
|
||||
@@ -877,7 +877,7 @@ var ActionManager = Widget.extend({
|
||||
/**
|
||||
* @private
|
||||
* @param {OdooEvent} ev
|
||||
* @param {OdooEvent} ev.data.controllerID
|
||||
* @param {string} ev.data.controllerID
|
||||
*/
|
||||
_onBreadcrumbClicked: function (ev) {
|
||||
ev.stopPropagation();
|
||||
|
||||
@@ -546,6 +546,7 @@ ActionManager.include({
|
||||
return Promise.resolve(controller.widget.willRestore()).then(function () {
|
||||
viewOptions = _.extend({}, viewOptions, {
|
||||
breadcrumbs: self._getBreadcrumbs(self.controllerStack.slice(0, index)),
|
||||
shouldUpdateControlPanel: true,
|
||||
});
|
||||
return controller.widget.reload(viewOptions).then(function () {
|
||||
return controller;
|
||||
@@ -708,15 +709,15 @@ ActionManager.include({
|
||||
*/
|
||||
_onSwitchView: function (ev) {
|
||||
ev.stopPropagation();
|
||||
var viewType = ev.data.view_type;
|
||||
var currentController = this.getCurrentController();
|
||||
const viewType = ev.data.view_type;
|
||||
const currentController = this.getCurrentController();
|
||||
if (currentController.jsID === ev.data.controllerID) {
|
||||
// only switch to the requested view if the controller that
|
||||
// triggered the request is the current controller
|
||||
var action = this.actions[currentController.actionID];
|
||||
var currentControllerState = currentController.widget.exportState();
|
||||
const action = this.actions[currentController.actionID];
|
||||
const currentControllerState = currentController.widget.exportState();
|
||||
action.controllerState = _.extend({}, action.controllerState, currentControllerState);
|
||||
var options = {
|
||||
const options = {
|
||||
controllerState: action.controllerState,
|
||||
currentId: ev.data.res_id,
|
||||
};
|
||||
|
||||
@@ -1,222 +1,240 @@
|
||||
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',
|
||||
"use strict";
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* 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
|
||||
* @extends WidgetAdapterMixin
|
||||
*/
|
||||
contentTemplate: null,
|
||||
|
||||
/**
|
||||
* Events built by and managed by Odoo Framework
|
||||
*
|
||||
* It is expected that any Widget Class implementing this mixin
|
||||
* will also implement the ParentedMixin which actually manages those
|
||||
*/
|
||||
custom_events: {
|
||||
get_controller_query_params: '_onGetControllerQueryParams',
|
||||
},
|
||||
/**
|
||||
* 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,
|
||||
const core = require('web.core');
|
||||
const { WidgetAdapterMixin } = require('web.OwlCompatibility');
|
||||
|
||||
/**
|
||||
* 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: '',
|
||||
const ActionMixin = Object.assign({}, WidgetAdapterMixin, {
|
||||
template: 'Action',
|
||||
|
||||
/**
|
||||
* @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 {Promise|undefined}
|
||||
*/
|
||||
willRestore: function () {},
|
||||
/**
|
||||
* 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,
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Public
|
||||
//--------------------------------------------------------------------------
|
||||
/**
|
||||
* Events built by and managed by Odoo Framework
|
||||
*
|
||||
* It is expected that any Widget Class implementing this mixin
|
||||
* will also implement the ParentedMixin which actually manages those
|
||||
*/
|
||||
custom_events: {
|
||||
get_controller_query_params: '_onGetOwnedQueryParams',
|
||||
},
|
||||
|
||||
/**
|
||||
* 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
|
||||
* promise is successfully resolved, then we can destroy the current action,
|
||||
* otherwise, we need to stop.
|
||||
*
|
||||
* @returns {Promise} resolved if the action can be removed, rejected
|
||||
* otherwise
|
||||
*/
|
||||
canBeRemoved: function () {
|
||||
return Promise.resolve();
|
||||
},
|
||||
/**
|
||||
* 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;
|
||||
},
|
||||
/**
|
||||
* Returns a serializable state that will be pushed in the URL by
|
||||
* the action manager, allowing the action to be restarted correctly
|
||||
* upon refresh. This function should be overriden to add extra information.
|
||||
* Note that some keys are reserved by the framework and will thus be
|
||||
* ignored ('action', 'active_id', 'active_ids' and 'title', for all
|
||||
* actions, and 'model' and 'view_type' for act_window actions).
|
||||
*
|
||||
* @returns {Object}
|
||||
*/
|
||||
getState: function () {
|
||||
return {};
|
||||
},
|
||||
/**
|
||||
* 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)
|
||||
/**
|
||||
* 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,
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Private
|
||||
//--------------------------------------------------------------------------
|
||||
/**
|
||||
* 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: '',
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {string} title
|
||||
*/
|
||||
_setTitle: function (title) {
|
||||
this._title = title;
|
||||
this.updateControlPanel({title: this._title}, {clear: false});
|
||||
},
|
||||
/**
|
||||
* 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 || {});
|
||||
},
|
||||
};
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
renderElement: function () {
|
||||
this._super.apply(this, arguments);
|
||||
if (this.contentTemplate) {
|
||||
const content = core.qweb.render(this.contentTemplate, { widget: this });
|
||||
this.$('.o_content').append(content);
|
||||
}
|
||||
},
|
||||
|
||||
return ActionMixin;
|
||||
/**
|
||||
* Called by the action manager when action is restored (typically, when
|
||||
* the user clicks on the action in the breadcrumb)
|
||||
*
|
||||
* @returns {Promise|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
|
||||
* promise is successfully resolved, then we can destroy the current action,
|
||||
* otherwise, we need to stop.
|
||||
*
|
||||
* @returns {Promise} resolved if the action can be removed, rejected
|
||||
* otherwise
|
||||
*/
|
||||
canBeRemoved: function () {
|
||||
return Promise.resolve();
|
||||
},
|
||||
|
||||
/**
|
||||
* 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 serializable state that will be pushed in the URL by
|
||||
* the action manager, allowing the action to be restarted correctly
|
||||
* upon refresh. This function should be overriden to add extra information.
|
||||
* Note that some keys are reserved by the framework and will thus be
|
||||
* ignored ('action', 'active_id', 'active_ids' and 'title', for all
|
||||
* actions, and 'model' and 'view_type' for act_window actions).
|
||||
*
|
||||
* @returns {Object}
|
||||
*/
|
||||
getState: 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) { },
|
||||
|
||||
/**
|
||||
* Method used to update the widget buttons state.
|
||||
*/
|
||||
updateButtons: function () { },
|
||||
|
||||
/**
|
||||
* The parameter newProps is used to update the props of
|
||||
* the controlPanelWrapper before render it. The key 'cp_content'
|
||||
* is not a prop of the control panel itself. One should if possible use
|
||||
* the slot mechanism.
|
||||
*
|
||||
* @param {Object} [newProps={}]
|
||||
* @returns {Promise}
|
||||
*/
|
||||
updateControlPanel: async function (newProps = {}) {
|
||||
if (!this.withControlPanel && !this.hasControlPanel) {
|
||||
return;
|
||||
}
|
||||
const props = Object.assign({}, newProps); // Work with a clean new object
|
||||
if ('title' in props) {
|
||||
this._setTitle(props.title);
|
||||
this.controlPanelProps.title = this.getTitle();
|
||||
delete props.title;
|
||||
}
|
||||
if ('cp_content' in props) {
|
||||
// cp_content has been updated: refresh it.
|
||||
this.controlPanelProps.cp_content = Object.assign({},
|
||||
this.controlPanelProps.cp_content,
|
||||
props.cp_content,
|
||||
);
|
||||
delete props.cp_content;
|
||||
}
|
||||
// Update props state
|
||||
Object.assign(this.controlPanelProps, props);
|
||||
return this._controlPanelWrapper.update(this.controlPanelProps);
|
||||
},
|
||||
|
||||
//---------------------------------------------------------------------
|
||||
// Private
|
||||
//---------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {string} title
|
||||
*/
|
||||
_setTitle: function (title) {
|
||||
this._title = title;
|
||||
},
|
||||
|
||||
//---------------------------------------------------------------------
|
||||
// Handlers
|
||||
//---------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* FIXME: this logic should be rethought
|
||||
*
|
||||
* Handles a context request: provides to the caller the state of the
|
||||
* current controller.
|
||||
*
|
||||
* @private
|
||||
* @param {function} callback used to send the requested state
|
||||
*/
|
||||
_onGetOwnedQueryParams: function (callback) {
|
||||
const state = this.getOwnedQueryParams();
|
||||
callback(state || {});
|
||||
},
|
||||
});
|
||||
|
||||
return ActionMixin;
|
||||
});
|
||||
|
||||
@@ -1,276 +0,0 @@
|
||||
odoo.define('web.Pager', function (require) {
|
||||
"use strict";
|
||||
|
||||
var utils = require('web.utils');
|
||||
var Widget = require('web.Widget');
|
||||
|
||||
var direction = {
|
||||
previous: -1,
|
||||
next: 1,
|
||||
};
|
||||
|
||||
var Pager = Widget.extend({
|
||||
template: "Pager",
|
||||
events: {
|
||||
'click .o_pager_next': '_onNext',
|
||||
'click .o_pager_previous': '_onPrevious',
|
||||
'click .o_pager_value': '_onEdit',
|
||||
},
|
||||
/**
|
||||
* The pager goes from 1 to size (included).
|
||||
* The current value is current_min if limit === 1
|
||||
* or the interval [current_min, current_min + limit[ if limit > 1
|
||||
*
|
||||
* @param {Widget} [parent] the parent widget
|
||||
* @param {int} [size] the total number of elements
|
||||
* @param {int} [current_min] the first element of the current_page
|
||||
* @param {int} [limit] the number of elements per page
|
||||
* @param {boolean} [options.can_edit] editable feature of the pager
|
||||
* @param {boolean} [options.single_page_hidden] (not) to display the pager
|
||||
* if only one page
|
||||
* @param {function} [options.validate] callback returning a Promise to
|
||||
* validate changes
|
||||
*/
|
||||
init: function (parent, size, current_min, limit, options) {
|
||||
this.state = {
|
||||
size: size,
|
||||
current_min: current_min,
|
||||
limit: limit,
|
||||
};
|
||||
Object.defineProperty(this.state, 'current_max', {
|
||||
get: function() {
|
||||
return Math.min(this.current_min + this.limit - 1, this.size);
|
||||
}
|
||||
});
|
||||
this.options = _.defaults({}, options, {
|
||||
can_edit: true, // editable
|
||||
single_page_hidden: false, // displayed even if there is a single page
|
||||
validate: function() {
|
||||
return Promise.resolve();
|
||||
},
|
||||
withAccessKey: true, // can be disabled, for example, for x2m widgets
|
||||
});
|
||||
this._super(parent);
|
||||
},
|
||||
/**
|
||||
* Renders the pager
|
||||
*
|
||||
* @returns {Promise}
|
||||
*/
|
||||
start: function () {
|
||||
this.$value = this.$('.o_pager_value');
|
||||
this.$limit = this.$('.o_pager_limit');
|
||||
this._render();
|
||||
return this._super();
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Public
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Disables the pager's arrows and the edition
|
||||
*/
|
||||
disable: function () {
|
||||
this.disabled = true;
|
||||
this._updateArrows();
|
||||
},
|
||||
/**
|
||||
* Enables the pager's arrows and the edition
|
||||
*/
|
||||
enable: function () {
|
||||
this.disabled = false;
|
||||
this._updateArrows();
|
||||
},
|
||||
/**
|
||||
* Executes the next action on the pager
|
||||
*/
|
||||
next: function () {
|
||||
this._changeSelection(direction.next);
|
||||
},
|
||||
/**
|
||||
* Executes the previous action on the pager
|
||||
*/
|
||||
previous: function () {
|
||||
this._changeSelection(direction.previous);
|
||||
},
|
||||
/**
|
||||
* Sets the state of the pager and renders it
|
||||
* @param {Object} [state] the values to update (size, current_min and limit)
|
||||
* @param {Object} [options]
|
||||
* @param {boolean} [options.notifyChange] set to true to make the pager
|
||||
* notify the environment that its state changed
|
||||
*/
|
||||
updateState: function (state, options) {
|
||||
_.extend(this.state, state);
|
||||
this._render();
|
||||
if (options && options.notifyChange) {
|
||||
this.trigger('pager_changed', _.clone(this.state));
|
||||
}
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Private
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Private function that updates the pager's state according to a pager action
|
||||
*
|
||||
* @param {int} [direction] the action (previous or next) on the pager
|
||||
*/
|
||||
_changeSelection: function (direction) {
|
||||
var self = this;
|
||||
this.options.validate().then(function() {
|
||||
var size = self.state.size;
|
||||
var current_min = self.state.current_min;
|
||||
var limit = self.state.limit;
|
||||
|
||||
// Compute the new current_min
|
||||
current_min = (current_min + limit*direction);
|
||||
if (current_min > size) {
|
||||
current_min = 1;
|
||||
} else if ((current_min < 1) && (limit === 1)) {
|
||||
current_min = size;
|
||||
} else if ((current_min < 1) && (limit > 1)) {
|
||||
current_min = size - ((size % limit) || limit) + 1;
|
||||
}
|
||||
|
||||
self.state.current_min = current_min;
|
||||
// The re-rendering of the pager must be done before the trigger of
|
||||
// event 'pager_changed' as the rendering may enable the pager
|
||||
// (and a common use is to disable the pager when this event is
|
||||
// triggered, and to re-enable it when the data have been reloaded)
|
||||
self._render();
|
||||
self.trigger('pager_changed', _.clone(self.state));
|
||||
});
|
||||
},
|
||||
/**
|
||||
* Private function that displays an input to edit the pager's state
|
||||
*/
|
||||
_edit: function () {
|
||||
if (this.options.can_edit) {
|
||||
var self = this;
|
||||
var $input = $('<input>', {class: 'o_input', type: 'text', value: this.$value.html()});
|
||||
|
||||
this.$value.html($input);
|
||||
$input.focus();
|
||||
|
||||
// Event handlers
|
||||
$input.click(function(ev) {
|
||||
ev.stopPropagation(); // ignore clicks on the input
|
||||
});
|
||||
$input.blur(function(ev) {
|
||||
self._save($(ev.target)); // save the state when leaving the input
|
||||
});
|
||||
$input.on('keydown', function (ev) {
|
||||
ev.stopPropagation();
|
||||
if (ev.which === $.ui.keyCode.ENTER) {
|
||||
self._save($(ev.target)); // save on enter
|
||||
} else if (ev.which === $.ui.keyCode.ESCAPE) {
|
||||
self._render(); // leave on escape
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Private function that renders the pager's state
|
||||
*/
|
||||
_render: function () {
|
||||
var size = this.state.size;
|
||||
var current_min = this.state.current_min;
|
||||
var current_max = this.state.current_max;
|
||||
|
||||
if (size === 0 || (this.options.single_page_hidden && this._singlePage())) {
|
||||
this.do_hide();
|
||||
} else {
|
||||
this.do_show();
|
||||
this._updateArrows();
|
||||
|
||||
var value = "" + current_min;
|
||||
if (this.state.limit > 1) {
|
||||
value += "-" + current_max;
|
||||
}
|
||||
this.$value.html(value);
|
||||
this.$limit.html(size);
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Private function that saves the state from the content of the input
|
||||
*
|
||||
* @param {jQuery} [$input] the jQuery element containing the new state
|
||||
*/
|
||||
_save: function ($input) {
|
||||
var self = this;
|
||||
this.options.validate().then(function() {
|
||||
var value = $input.val().split("-");
|
||||
var min = utils.confine(parseInt(value[0], 10), 1, self.state.size);
|
||||
var max = utils.confine(parseInt(value[1], 10), 1, self.state.size);
|
||||
|
||||
if (!isNaN(min)) {
|
||||
self.state.current_min = min;
|
||||
if (!isNaN(max)) {
|
||||
self.state.limit = utils.confine(max-min+1, 1, self.state.size);
|
||||
} else {
|
||||
// The state has been given as a single value -> set the limit to 1
|
||||
self.state.limit = 1;
|
||||
}
|
||||
self.trigger('pager_changed', _.clone(self.state));
|
||||
}
|
||||
// Render the pager's new state (removes the input)
|
||||
self._render();
|
||||
});
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @returns {boolean} true iff there is only one page
|
||||
*/
|
||||
_singlePage: function () {
|
||||
var state = this.state;
|
||||
return (1 === state.current_min) && (state.current_max === state.size);
|
||||
},
|
||||
/**
|
||||
* Updates the arrows' disable attribute: true iff the pager is disabed or
|
||||
* if there is only one page
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_updateArrows: function () {
|
||||
var disabled = this.disabled || this._singlePage();
|
||||
this.$('button').prop('disabled', disabled);
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Handlers
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {MouseEvent} event
|
||||
*/
|
||||
_onEdit: function (event) {
|
||||
event.stopPropagation();
|
||||
if (!this.disabled) {
|
||||
this._edit();
|
||||
}
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @param {MouseEvent} event
|
||||
*/
|
||||
_onNext: function (event) {
|
||||
event.stopPropagation();
|
||||
this.next();
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @param {MouseEvent} event
|
||||
*/
|
||||
_onPrevious: function (event) {
|
||||
event.stopPropagation();
|
||||
this.previous();
|
||||
},
|
||||
});
|
||||
|
||||
return Pager;
|
||||
|
||||
});
|
||||
@@ -1,219 +0,0 @@
|
||||
odoo.define('web.Sidebar', function (require) {
|
||||
"use strict";
|
||||
|
||||
var Context = require('web.Context');
|
||||
var core = require('web.core');
|
||||
var pyUtils = require('web.py_utils');
|
||||
var Widget = require('web.Widget');
|
||||
|
||||
var QWeb = core.qweb;
|
||||
var _t = core._t;
|
||||
|
||||
var Sidebar = Widget.extend({
|
||||
events: {
|
||||
"click a.dropdown-item": "_onDropdownClicked"
|
||||
},
|
||||
/**
|
||||
* @override
|
||||
*
|
||||
* @param {Object} options
|
||||
* @param {Object} options.items
|
||||
* @param {Object} options.sections
|
||||
* @param {Object} options.env
|
||||
* @param {Object} options.actions
|
||||
*
|
||||
*/
|
||||
init: function (parent, options) {
|
||||
this._super.apply(this, arguments);
|
||||
this.options = _.defaults(options || {}, {
|
||||
'editable': true
|
||||
});
|
||||
this.env = options.env;
|
||||
this.sections = options.sections || [
|
||||
{name: 'print', label: _t('Print')},
|
||||
{name: 'other', label: _t('Action')},
|
||||
];
|
||||
this.items = options.items || {
|
||||
print: [],
|
||||
other: [],
|
||||
};
|
||||
if (options.actions) {
|
||||
this._addToolbarActions(options.actions);
|
||||
}
|
||||
},
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
start: function () {
|
||||
this._super.apply(this, arguments);
|
||||
this.$el.addClass('btn-group');
|
||||
this._redraw();
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Public
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Update the env for the sidebar then rerender it.
|
||||
*
|
||||
* @param {Object} env
|
||||
*/
|
||||
updateEnv: function (env) {
|
||||
this.env = env;
|
||||
this._redraw();
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Private
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* For each item added to the section:
|
||||
*
|
||||
* ``label``
|
||||
* will be used as the item's name in the sidebar, can be html
|
||||
*
|
||||
* ``action``
|
||||
* descriptor for the action which will be executed, ``action`` and
|
||||
* ``callback`` should be exclusive
|
||||
*
|
||||
* ``callback``
|
||||
* function to call when the item is clicked in the sidebar, called
|
||||
* with the item descriptor as its first argument (so information
|
||||
* can be stored as additional keys on the object passed to
|
||||
* ``_addItems``)
|
||||
*
|
||||
* ``classname`` (optional)
|
||||
* ``@class`` set on the sidebar serialization of the item
|
||||
*
|
||||
* ``title`` (optional)
|
||||
* will be set as the item's ``@title`` (tooltip)
|
||||
*
|
||||
* @private
|
||||
* @param {String} sectionCode
|
||||
* @param {Array<{label, action | callback[, classname][, title]}>} items
|
||||
*/
|
||||
_addItems: function (sectionCode, items) {
|
||||
if (items) {
|
||||
this.items[sectionCode].unshift.apply(this.items[sectionCode], items);
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Method that will add the custom actions to the toolbar
|
||||
*
|
||||
* @private
|
||||
* @param {Object} toolbarActions
|
||||
*/
|
||||
_addToolbarActions: function (toolbarActions) {
|
||||
var self = this;
|
||||
_.each(['print','action','relate'], function (type) {
|
||||
if (type in toolbarActions) {
|
||||
var actions = toolbarActions[type];
|
||||
if (actions && actions.length) {
|
||||
var items = _.map(actions, function (action) {
|
||||
return {
|
||||
label: action.name,
|
||||
action: action,
|
||||
};
|
||||
});
|
||||
self._addItems(type === 'print' ? 'print' : 'other', items);
|
||||
}
|
||||
}
|
||||
});
|
||||
if ('other' in toolbarActions) {
|
||||
this._addItems('other', toolbarActions.other);
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Performs the action for the item clicked after getting the data
|
||||
* necessary with a trigger up
|
||||
*
|
||||
* @private
|
||||
* @param {Object} item
|
||||
*/
|
||||
_onItemActionClicked: function (item) {
|
||||
var self = this;
|
||||
this.trigger_up('sidebar_data_asked', {
|
||||
callback: function (env) {
|
||||
self.env = env;
|
||||
var activeIdsContext = {
|
||||
active_id: env.activeIds[0],
|
||||
active_ids: env.activeIds,
|
||||
active_model: env.model,
|
||||
select_all: !!env.selectAll,
|
||||
};
|
||||
if (env.domain) {
|
||||
activeIdsContext.active_domain = env.domain;
|
||||
}
|
||||
|
||||
var context = pyUtils.eval('context', new Context(env.context, activeIdsContext));
|
||||
self._rpc({
|
||||
route: '/web/action/load',
|
||||
params: {
|
||||
action_id: item.action.id,
|
||||
context: context,
|
||||
},
|
||||
}).then(function (result) {
|
||||
result.context = new Context(
|
||||
result.context || {}, activeIdsContext)
|
||||
.set_eval_context(context);
|
||||
result.flags = result.flags || {};
|
||||
result.flags.new_window = true;
|
||||
self.do_action(result, {
|
||||
on_close: function () {
|
||||
self.trigger_up('reload');
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
/**
|
||||
* Method that renders the sidebar when there is a data update
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_redraw: function () {
|
||||
this.$el.html(QWeb.render('Sidebar', {widget: this}));
|
||||
|
||||
// Hides Sidebar sections when item list is empty
|
||||
_.each(this.$('.o_dropdown'), function (el) {
|
||||
var $dropdown = $(el);
|
||||
if (!$dropdown.find('.dropdown-item').length) {
|
||||
$dropdown.hide();
|
||||
}
|
||||
});
|
||||
this.$("[title]").tooltip({
|
||||
delay: { show: 500, hide: 0}
|
||||
});
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Handlers
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Method triggered when the user clicks on a toolbar dropdown
|
||||
*
|
||||
* @private
|
||||
* @param {MouseEvent} event
|
||||
*/
|
||||
_onDropdownClicked: function (event) {
|
||||
var section = $(event.currentTarget).data('section');
|
||||
var index = $(event.currentTarget).data('index');
|
||||
var item = this.items[section][index];
|
||||
if (item.callback) {
|
||||
item.callback.apply(this, [item]);
|
||||
} else if (item.action) {
|
||||
this._onItemActionClicked(item);
|
||||
} else if (item.url) {
|
||||
return true;
|
||||
}
|
||||
event.preventDefault();
|
||||
},
|
||||
});
|
||||
|
||||
return Sidebar;
|
||||
|
||||
});
|
||||
@@ -0,0 +1,171 @@
|
||||
odoo.define('web.ActionMenus', function (require) {
|
||||
"use strict";
|
||||
|
||||
const Context = require('web.Context');
|
||||
const DropdownMenu = require('web.DropdownMenu');
|
||||
const pyUtils = require('web.py_utils');
|
||||
const Registry = require('web.Registry');
|
||||
|
||||
const { Component } = owl;
|
||||
|
||||
/**
|
||||
* Action menus (or Action/Print bar, previously called 'Sidebar')
|
||||
*
|
||||
* The side bar is the group of dropdown menus located on the left side of the
|
||||
* control panel. Its role is to display a list of items depending on the view
|
||||
* type and selected records and to execute a set of actions on active records.
|
||||
* It is made out of 2 dropdown menus: Print and Action.
|
||||
*
|
||||
* This component also provides a registry to use custom components in the ActionMenus's
|
||||
* Action menu.
|
||||
* @extends Component
|
||||
*/
|
||||
class ActionMenus extends Component {
|
||||
|
||||
mounted() {
|
||||
this._addTooltips();
|
||||
}
|
||||
|
||||
patched() {
|
||||
this._addTooltips();
|
||||
}
|
||||
|
||||
//---------------------------------------------------------------------
|
||||
// Getters
|
||||
//---------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @returns {Object[]}
|
||||
*/
|
||||
get actionItems() {
|
||||
// Callback based actions
|
||||
const callbackActions = (this.props.items.other || []).map(
|
||||
action => Object.assign({ key: `action-${action.description}` }, action)
|
||||
);
|
||||
// Action based actions
|
||||
const actionActions = this.props.items.action || [];
|
||||
const relateActions = this.props.items.relate || [];
|
||||
const formattedActions = [...actionActions, ...relateActions].map(
|
||||
action => ({ action, description: action.name, key: action.id })
|
||||
);
|
||||
// ActionMenus action registry components
|
||||
const registryActions = this.constructor.registry.values().map(
|
||||
({ Component, getProps }, index) => ({
|
||||
key: `registry-action-${index}`,
|
||||
Component,
|
||||
props: getProps(this.props),
|
||||
})
|
||||
);
|
||||
return [...callbackActions, ...formattedActions, ...registryActions];
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Object[]}
|
||||
*/
|
||||
get printItems() {
|
||||
const printActions = this.props.items.print || [];
|
||||
const printItems = printActions.map(
|
||||
action => ({ action, description: action.name, key: action.id })
|
||||
);
|
||||
return printItems;
|
||||
}
|
||||
|
||||
//---------------------------------------------------------------------
|
||||
// Private
|
||||
//---------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Add teh tooltips to the items
|
||||
* @private
|
||||
*/
|
||||
_addTooltips() {
|
||||
$(this.el.querySelectorAll('[title]')).tooltip({
|
||||
delay: { show: 500, hide: 0 }
|
||||
});
|
||||
}
|
||||
|
||||
//---------------------------------------------------------------------
|
||||
// Handlers
|
||||
//---------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Perform the action for the item clicked after getting the data
|
||||
* necessary with a trigger.
|
||||
* @private
|
||||
* @param {OwlEvent} ev
|
||||
*/
|
||||
async _executeAction(action) {
|
||||
const activeIdsContext = {
|
||||
active_id: this.props.activeIds[0],
|
||||
active_ids: this.props.activeIds,
|
||||
active_model: this.env.action.res_model,
|
||||
select_all: this.props.selectAll,
|
||||
};
|
||||
if (this.props.domain) {
|
||||
activeIdsContext.active_domain = this.props.domain;
|
||||
}
|
||||
|
||||
const context = pyUtils.eval('context', new Context(this.props.context, activeIdsContext));
|
||||
const result = await this.rpc({
|
||||
route: '/web/action/load',
|
||||
params: {
|
||||
action_id: action.id,
|
||||
context: context,
|
||||
},
|
||||
});
|
||||
result.context = new Context(result.context || {}, activeIdsContext)
|
||||
.set_eval_context(context);
|
||||
result.flags = result.flags || {};
|
||||
result.flags.new_window = true;
|
||||
this.trigger('do-action', {
|
||||
action: result,
|
||||
options: {
|
||||
on_close: () => this.trigger('reload'),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handler used to determine which way must be used to execute a selected
|
||||
* action: it will be either:
|
||||
* - a callback (function given by the view controller);
|
||||
* - an action ID (string);
|
||||
* - an URL (string).
|
||||
* @private
|
||||
* @param {OwlEvent} ev
|
||||
*/
|
||||
_onItemSelected(ev) {
|
||||
ev.stopPropagation();
|
||||
const { item } = ev.detail;
|
||||
if (item.callback) {
|
||||
item.callback([item]);
|
||||
} else if (item.action) {
|
||||
this._executeAction(item.action);
|
||||
} else if (item.url) {
|
||||
// Event has been prevented at its source: we need to redirect manually.
|
||||
this.env.services.navigate(item.url);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ActionMenus.registry = new Registry();
|
||||
|
||||
ActionMenus.components = { DropdownMenu };
|
||||
ActionMenus.props = {
|
||||
activeIds: { type: Array, element: [Number|String] }, // virtual IDs are strings.
|
||||
context: Object,
|
||||
domain: { type: Array, optional: 1 },
|
||||
items: {
|
||||
type: Object,
|
||||
shape: {
|
||||
action: { type: Array, optional: 1 },
|
||||
print: { type: Array, optional: 1 },
|
||||
other: { type: Array, optional: 1 },
|
||||
},
|
||||
},
|
||||
selectAll: { type: Boolean, optional: 1 },
|
||||
};
|
||||
ActionMenus.template = 'web.ActionMenus';
|
||||
|
||||
return ActionMenus;
|
||||
});
|
||||
@@ -82,7 +82,7 @@ odoo.define('web.CustomFileInput', function (require) {
|
||||
model: { type: String, optional: 1 },
|
||||
multi_upload: { type: Boolean, optional: 1 },
|
||||
};
|
||||
CustomFileInput.template = 'CustomFileInput';
|
||||
CustomFileInput.template = 'web.CustomFileInput';
|
||||
|
||||
return CustomFileInput;
|
||||
});
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
odoo.define('web.DatePickerOwl', function (require) {
|
||||
"use strict";
|
||||
|
||||
const field_utils = require('web.field_utils');
|
||||
const time = require('web.time');
|
||||
const { useAutofocus } = require('web.custom_hooks');
|
||||
|
||||
const { Component, hooks } = owl;
|
||||
const { useExternalListener, useRef, useState } = hooks;
|
||||
|
||||
let datePickerId = 0;
|
||||
|
||||
/**
|
||||
* Date picker
|
||||
*
|
||||
* This component exposes the API of the tempusdominus datepicker library.
|
||||
* As such, its template is a simple input that will open the TD datepicker
|
||||
* when clicked on. The component will also synchronize any user-input value
|
||||
* with the library widget and vice-vera.
|
||||
*
|
||||
* For further details regarding the implementation of the picker itself, please
|
||||
* refer to the official tempusdominus documentation (note: all props given
|
||||
* to this component will be passed as arguments to instantiate the picker widget).
|
||||
* @extends Component
|
||||
*/
|
||||
class DatePicker extends Component {
|
||||
constructor() {
|
||||
super(...arguments);
|
||||
|
||||
this.inputRef = useRef('input');
|
||||
this.state = useState({ warning: false });
|
||||
|
||||
this.datePickerId = `o_datepicker_${datePickerId++}`;
|
||||
this.typeOfDate = 'date';
|
||||
|
||||
useAutofocus();
|
||||
useExternalListener(window, 'scroll', this._onWindowScroll);
|
||||
}
|
||||
|
||||
mounted() {
|
||||
$(this.el).on('show.datetimepicker', this._onDateTimePickerShow.bind(this));
|
||||
$(this.el).on('hide.datetimepicker', this._onDateTimePickerHide.bind(this));
|
||||
$(this.el).on('error.datetimepicker', () => false);
|
||||
|
||||
this._datetimepicker(this.props);
|
||||
this.inputRef.el.value = this._formatDate(this.props.date);
|
||||
}
|
||||
|
||||
willUnmount() {
|
||||
this._datetimepicker('destroy');
|
||||
}
|
||||
|
||||
willUpdateProps(nextProps) {
|
||||
if (nextProps.date) {
|
||||
this._datetimepicker('date', nextProps.date);
|
||||
this.inputRef.el.value = this._formatDate(nextProps.date);
|
||||
}
|
||||
}
|
||||
|
||||
//---------------------------------------------------------------------
|
||||
// Private
|
||||
//---------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Handle bootstrap datetimepicker calls.
|
||||
* @private
|
||||
* @param {...any} args anything that will be passed to the datetimepicker function.
|
||||
*/
|
||||
_datetimepicker(...args) {
|
||||
$(this.el).datetimepicker(...args);
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {moment} date
|
||||
* @returns {string}
|
||||
*/
|
||||
_formatDate(date) {
|
||||
try {
|
||||
return field_utils.format[this.typeOfDate](date, null, { timezone: false });
|
||||
} catch (err) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {string|false} value
|
||||
* @returns {moment}
|
||||
*/
|
||||
_parseInput(inputValue) {
|
||||
try {
|
||||
return field_utils.parse[this.typeOfDate](inputValue, null, { timezone: false });
|
||||
} catch (err) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
//---------------------------------------------------------------------
|
||||
// Handlers
|
||||
//---------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Reacts to the datetimepicker being hidden
|
||||
* Used to unbind the scroll event from the datetimepicker
|
||||
* @private
|
||||
*/
|
||||
_onDateTimePickerHide() {
|
||||
const date = this._parseInput(this.inputRef.el.value);
|
||||
this.state.warning = date.format('YYYY-MM-DD') > moment().format('YYYY-MM-DD');
|
||||
this.trigger('datetime-changed', { date });
|
||||
}
|
||||
|
||||
/**
|
||||
* Reacts to the datetimepicker being shown
|
||||
* Could set/verify our widget value
|
||||
* And subsequently update the datetimepicker
|
||||
* @private
|
||||
*/
|
||||
_onDateTimePickerShow() {
|
||||
this.inputRef.el.select();
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
_onInputClick() {
|
||||
this._datetimepicker('toggle');
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
_onInputChange() {
|
||||
const date = this._parseInput(this.inputRef.el.value);
|
||||
if (date) {
|
||||
this.state.warning = date.format('YYYY-MM-DD') > moment().format('YYYY-MM-DD');
|
||||
this.trigger('datetime-changed', { date });
|
||||
} else {
|
||||
this.inputRef.el.value = this._formatDate(this.props.date);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
_onWindowScroll(ev) {
|
||||
if (ev.target !== this.inputRef.el) {
|
||||
this._datetimepicker('hide');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
DatePicker.defaultProps = {
|
||||
calendarWeeks: true,
|
||||
format: time.getLangDateFormat(),
|
||||
icons: {
|
||||
clear: 'fa fa-delete',
|
||||
close: 'fa fa-check primary',
|
||||
date: 'fa fa-calendar',
|
||||
down: 'fa fa-chevron-down',
|
||||
next: 'fa fa-chevron-right',
|
||||
previous: 'fa fa-chevron-left',
|
||||
time: 'fa fa-clock-o',
|
||||
today: 'fa fa-calendar-check-o',
|
||||
up: 'fa fa-chevron-up',
|
||||
},
|
||||
locale: moment.locale(),
|
||||
maxDate: moment({ y: 9999, M: 11, d: 31 }),
|
||||
minDate: moment({ y: 1900 }),
|
||||
useCurrent: false,
|
||||
widgetParent: 'body',
|
||||
};
|
||||
DatePicker.props = {
|
||||
// Actual date value
|
||||
date: moment,
|
||||
// Other props
|
||||
buttons: {
|
||||
type: Object,
|
||||
shape: {
|
||||
showClear: Boolean,
|
||||
showClose: Boolean,
|
||||
showToday: Boolean,
|
||||
},
|
||||
optional: 1,
|
||||
},
|
||||
calendarWeeks: Boolean,
|
||||
format: String,
|
||||
icons: {
|
||||
type: Object,
|
||||
shape: {
|
||||
clear: String,
|
||||
close: String,
|
||||
date: String,
|
||||
down: String,
|
||||
next: String,
|
||||
previous: String,
|
||||
time: String,
|
||||
today: String,
|
||||
up: String,
|
||||
},
|
||||
},
|
||||
keyBinds: { validate: kb => typeof kb === 'object' || kb === null, optional: 1 },
|
||||
locale: String,
|
||||
maxDate: moment,
|
||||
minDate: moment,
|
||||
readonly: { type: Boolean, optional: 1 },
|
||||
useCurrent: Boolean,
|
||||
widgetParent: String,
|
||||
};
|
||||
DatePicker.template = 'web.DatePicker';
|
||||
|
||||
/**
|
||||
* Date/time picker
|
||||
*
|
||||
* Similar to the DatePicker component, adding the handling of more specific
|
||||
* time values: hour-minute-second.
|
||||
*
|
||||
* Once again, refer to the tempusdominus documentation for implementation
|
||||
* details.
|
||||
* @extends DatePicker
|
||||
*/
|
||||
class DateTimePicker extends DatePicker {
|
||||
constructor() {
|
||||
super(...arguments);
|
||||
|
||||
this.typeOfDate = 'datetime';
|
||||
}
|
||||
}
|
||||
|
||||
DateTimePicker.defaultProps = Object.assign({}, DatePicker.defaultProps, {
|
||||
buttons: {
|
||||
showClear: false,
|
||||
showClose: true,
|
||||
showToday: false,
|
||||
},
|
||||
format: time.getLangDatetimeFormat(),
|
||||
});
|
||||
|
||||
return {
|
||||
DatePicker,
|
||||
DateTimePicker,
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,133 @@
|
||||
odoo.define('web.DropdownMenu', function (require) {
|
||||
"use strict";
|
||||
|
||||
const DropdownMenuItem = require('web.DropdownMenuItem');
|
||||
|
||||
const { Component, hooks } = owl;
|
||||
const { useExternalListener, useRef, useState } = hooks;
|
||||
|
||||
/**
|
||||
* Dropdown menu
|
||||
*
|
||||
* Generic component used to generate a list of interactive items. It uses some
|
||||
* bootstrap classes but most interactions are handled in here or in the dropdown
|
||||
* menu item class definition, including some keyboard navigation and escaping
|
||||
* system (click outside to close the dropdown).
|
||||
*
|
||||
* The layout of a dropdown menu is as following:
|
||||
* > a Button (always rendered) with a `title` and an optional `icon`;
|
||||
* > a Dropdown (rendered when open) containing a collection of given items.
|
||||
* These items must be objects and can have two shapes:
|
||||
* 1. item.Component & item.props > will instantiate the given Component with
|
||||
* the given props. Any additional key will be useless.
|
||||
* 2. any other shape > will instantiate a DropdownMenuItem with the item
|
||||
* object being its props. There is no props validation as this object
|
||||
* will be passed as-is when `selected` and can contain additional meta-keys
|
||||
* that will not affect the displayed item. For more information regarding
|
||||
* the behaviour of these items, @see DropdownMenuItem.
|
||||
* @extends Component
|
||||
*/
|
||||
class DropdownMenu extends Component {
|
||||
constructor() {
|
||||
super(...arguments);
|
||||
|
||||
this.dropdownMenu = useRef('dropdown');
|
||||
this.state = useState({ open: false });
|
||||
|
||||
this.symbol = this.env.device.isMobile ? 'fa fa-chevron-right float-right mt4' : false;
|
||||
|
||||
useExternalListener(window, 'click', this._onWindowClick);
|
||||
useExternalListener(window, 'keydown', this._onWindowKeydown);
|
||||
}
|
||||
|
||||
//---------------------------------------------------------------------
|
||||
// Getters
|
||||
//---------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Overriden in case we want to keep the caret style on the button in mobile.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
get displayCaret() {
|
||||
return !this.env.device.isMobile;
|
||||
}
|
||||
|
||||
/**
|
||||
* Meant to be overriden to provide the list of items to display.
|
||||
* @returns {Object[]}
|
||||
*/
|
||||
get items() {
|
||||
return this.props.items;
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {string}
|
||||
*/
|
||||
get title() {
|
||||
return this.props.title;
|
||||
}
|
||||
|
||||
//---------------------------------------------------------------------
|
||||
// Handlers
|
||||
//---------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {KeyboardEvent} ev
|
||||
*/
|
||||
_onButtonKeydown(ev) {
|
||||
switch (ev.key) {
|
||||
case 'ArrowLeft':
|
||||
case 'ArrowRight':
|
||||
case 'ArrowUp':
|
||||
case 'ArrowDown':
|
||||
const firstItem = this.el.querySelector('.dropdown-item');
|
||||
if (firstItem) {
|
||||
ev.preventDefault();
|
||||
firstItem.focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {OwlEvent} ev
|
||||
*/
|
||||
_onItemSelected(ev) { }
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
_onWindowClick(ev) {
|
||||
if (this.state.open && !this.el.contains(ev.target)) {
|
||||
this.state.open = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {KeyboardEvent} ev
|
||||
*/
|
||||
_onWindowKeydown(ev) {
|
||||
if (this.state.open && ev.key === 'Escape') {
|
||||
this.state.open = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
DropdownMenu.components = { DropdownMenuItem };
|
||||
DropdownMenu.defaultProps = { items: [] };
|
||||
DropdownMenu.props = {
|
||||
icon: { type: String, optional: 1 },
|
||||
items: {
|
||||
type: Array,
|
||||
element: Object,
|
||||
optional: 1,
|
||||
},
|
||||
title: { type: String, optional: 1 },
|
||||
};
|
||||
DropdownMenu.template = 'web.DropdownMenu';
|
||||
|
||||
return DropdownMenu;
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
odoo.define('web.DropdownMenuItem', function (require) {
|
||||
"use strict";
|
||||
|
||||
const { useListener } = require('web.custom_hooks');
|
||||
|
||||
const { Component, hooks } = owl;
|
||||
const { useExternalListener, useRef, useState } = hooks;
|
||||
|
||||
/**
|
||||
* Dropdown menu item
|
||||
*
|
||||
* Generic component instantiated by a dropdown menu (@see DropdownMenu) in
|
||||
* the absence of `Component` and `props` keys in a given item object.
|
||||
*
|
||||
* In its simplest form, a dropdown menu item will be given a description (optional,
|
||||
* but highly recommended) and will trigger a 'select-item' when clicked on.
|
||||
* Additionaly it can receive the following props:
|
||||
* - isActive: will add a `checked` symbol on the left side of the item
|
||||
* - removable: will add a `remove` trash icon on the right side of the item.
|
||||
* when clicked, will trigger a 'remove-item' event.
|
||||
* - options: will change the behaviour of the item ; instead of triggering
|
||||
* an event, the item will act as a nested dropdown menu and display
|
||||
* its given options. These will have the same definition as another
|
||||
* dropdown item but cannot have options of their own.
|
||||
*
|
||||
* It is recommended to extend this class when defining a Component which will
|
||||
* be put inside of a dropdown menu (@see CustomFilterItem as example).
|
||||
* @extends Component
|
||||
*/
|
||||
class DropdownMenuItem extends Component {
|
||||
constructor() {
|
||||
super(...arguments);
|
||||
|
||||
this.canBeOpened = Boolean(this.props.options && this.props.options.length);
|
||||
|
||||
this.fallbackFocusRef = useRef('fallback-focus');
|
||||
this.state = useState({ open: false });
|
||||
|
||||
useExternalListener(window, 'click', this._onWindowClick);
|
||||
useListener('keydown', this._onKeydown);
|
||||
}
|
||||
|
||||
//---------------------------------------------------------------------
|
||||
// Handlers
|
||||
//---------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {KeyboardEvent} ev
|
||||
*/
|
||||
_onKeydown(ev) {
|
||||
if (['INPUT', 'TEXTAREA'].includes(document.activeElement.tagName)) {
|
||||
return;
|
||||
}
|
||||
switch (ev.key) {
|
||||
case 'ArrowLeft':
|
||||
if (this.canBeOpened && this.state.open) {
|
||||
ev.preventDefault();
|
||||
if (this.fallbackFocusRef.el) {
|
||||
this.fallbackFocusRef.el.focus();
|
||||
}
|
||||
this.state.open = false;
|
||||
}
|
||||
break;
|
||||
case 'ArrowRight':
|
||||
if (this.canBeOpened && !this.state.open) {
|
||||
ev.preventDefault();
|
||||
this.state.open = true;
|
||||
}
|
||||
break;
|
||||
case 'Escape':
|
||||
ev.target.blur();
|
||||
if (this.canBeOpened && this.state.open) {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
if (this.fallbackFocusRef.el) {
|
||||
this.fallbackFocusRef.el.focus();
|
||||
}
|
||||
this.state.open = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
_onWindowClick(ev) {
|
||||
if (this.state.open && !this.el.contains(ev.target)) {
|
||||
this.state.open = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
DropdownMenuItem.template = 'web.DropdownMenuItem';
|
||||
|
||||
return DropdownMenuItem;
|
||||
});
|
||||
@@ -0,0 +1,221 @@
|
||||
odoo.define('web.Pager', function (require) {
|
||||
"use strict";
|
||||
|
||||
const { useAutofocus } = require('web.custom_hooks');
|
||||
|
||||
const { Component, hooks } = owl;
|
||||
const { useState } = hooks;
|
||||
|
||||
/**
|
||||
* Pager
|
||||
*
|
||||
* The pager goes from 1 to size (included).
|
||||
* The current value is currentMinimum if limit === 1 or the interval:
|
||||
* [currentMinimum, currentMinimum + limit[ if limit > 1].
|
||||
* The value can be manually changed by clicking on the pager value and giving
|
||||
* an input matching the pattern: min[,max] (in which the comma can be a dash
|
||||
* or a semicolon).
|
||||
* The pager also provides two buttons to quickly change the current page (next
|
||||
* or previous).
|
||||
* @extends Component
|
||||
*/
|
||||
class Pager extends Component {
|
||||
/**
|
||||
* @param {Object} [props]
|
||||
* @param {int} [props.size] the total number of elements
|
||||
* @param {int} [props.currentMinimum] the first element of the current_page
|
||||
* @param {int} [props.limit] the number of elements per page
|
||||
* @param {boolean} [props.editable] editable feature of the pager
|
||||
* @param {function} [props.validate] callback returning a Promise to
|
||||
* validate changes
|
||||
* @param {boolean} [props.withAccessKey] can be disabled, for example,
|
||||
* for x2m widgets
|
||||
*/
|
||||
constructor() {
|
||||
super(...arguments);
|
||||
|
||||
this.state = useState({
|
||||
disabled: false,
|
||||
editing: false,
|
||||
});
|
||||
|
||||
useAutofocus();
|
||||
}
|
||||
|
||||
async willUpdateProps(nextProps) {
|
||||
this.state.editing = false;
|
||||
if (
|
||||
this.props.currentMinimum !== nextProps.currentMinimum ||
|
||||
this.props.limit !== nextProps.limit ||
|
||||
this.props.size !== nextProps.size
|
||||
) {
|
||||
this.state.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
//---------------------------------------------------------------------
|
||||
// Getters
|
||||
//---------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @returns {number}
|
||||
*/
|
||||
get maximum() {
|
||||
return Math.min(this.props.currentMinimum + this.props.limit - 1, this.props.size);
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {boolean} true iff there is only one page
|
||||
*/
|
||||
get singlePage() {
|
||||
const { currentMinimum, size } = this.props;
|
||||
return (1 === currentMinimum) && (this.maximum === size);
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {number}
|
||||
*/
|
||||
get value() {
|
||||
return this.props.currentMinimum + (this.props.limit > 1 ? `-${this.maximum}` : '');
|
||||
}
|
||||
|
||||
//---------------------------------------------------------------------
|
||||
// Private
|
||||
//---------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Update the pager's state according to a pager action
|
||||
* @private
|
||||
* @param {number} [direction] the action (previous or next) on the pager
|
||||
*/
|
||||
async _changeSelection(direction) {
|
||||
try {
|
||||
await this.props.validate();
|
||||
} catch (err) {
|
||||
return;
|
||||
}
|
||||
const { limit, size } = this.props;
|
||||
|
||||
// Compute the new currentMinimum
|
||||
let currentMinimum = (this.props.currentMinimum + limit * direction);
|
||||
if (currentMinimum > size) {
|
||||
currentMinimum = 1;
|
||||
} else if ((currentMinimum < 1) && (limit === 1)) {
|
||||
currentMinimum = size;
|
||||
} else if ((currentMinimum < 1) && (limit > 1)) {
|
||||
currentMinimum = size - ((size % limit) || limit) + 1;
|
||||
}
|
||||
|
||||
// The re-rendering of the pager must be done before the trigger of
|
||||
// event 'pager-changed' as the rendering may enable the pager
|
||||
// (and a common use is to disable the pager when this event is
|
||||
// triggered, and to re-enable it when the data have been reloaded).
|
||||
this._updateAndDisable(currentMinimum, limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Save the state from the content of the input
|
||||
* @private
|
||||
* @param {string} value the new raw pager value
|
||||
* @returns {Promise}
|
||||
*/
|
||||
async _saveValue(value) {
|
||||
try {
|
||||
await this.props.validate();
|
||||
} catch (err) {
|
||||
return;
|
||||
}
|
||||
const [min, max] = value.trim().split(/\s*[\-\s,;]\s*/);
|
||||
|
||||
let currentMinimum = Math.max(Math.min(parseInt(min, 10), this.props.size), 1);
|
||||
let maximum = max ? Math.max(Math.min(parseInt(max, 10), this.props.size), 1) : min;
|
||||
|
||||
if (
|
||||
!isNaN(currentMinimum) &&
|
||||
!isNaN(maximum) &&
|
||||
currentMinimum <= maximum
|
||||
) {
|
||||
const limit = Math.max(maximum - currentMinimum) + 1;
|
||||
this._updateAndDisable(currentMinimum, limit);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {Object} payload
|
||||
*/
|
||||
_updateAndDisable(currentMinimum, limit) {
|
||||
if (
|
||||
currentMinimum !== this.props.currentMinimum ||
|
||||
limit !== this.props.limit
|
||||
) {
|
||||
this.state.disabled = true;
|
||||
this.trigger('pager-changed', { currentMinimum, limit });
|
||||
}
|
||||
}
|
||||
|
||||
//---------------------------------------------------------------------
|
||||
// Handlers
|
||||
//---------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
_onEdit() {
|
||||
if (
|
||||
this.props.editable && // editable
|
||||
!this.state.editing && // not already editing
|
||||
!this.state.disabled // not being changed already
|
||||
) {
|
||||
this.state.editing = true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {InputEvent} ev
|
||||
*/
|
||||
_onValueChange(ev) {
|
||||
this._saveValue(ev.currentTarget.value);
|
||||
if (!this.state.disabled) {
|
||||
ev.preventDefault();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {KeyboardEvent} ev
|
||||
*/
|
||||
_onValueKeydown(ev) {
|
||||
switch (ev.key) {
|
||||
case 'Enter':
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
this._saveValue(ev.currentTarget.value);
|
||||
break;
|
||||
case 'Escape':
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
this.state.editing = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Pager.defaultProps = {
|
||||
editable: true,
|
||||
validate: async () => { },
|
||||
withAccessKey: true,
|
||||
};
|
||||
Pager.props = {
|
||||
currentMinimum: { type: Number, optional: 1 },
|
||||
editable: Boolean,
|
||||
limit: { validate: l => !isNaN(l), optional: 1 },
|
||||
size: { type: Number, optional: 1 },
|
||||
validate: Function,
|
||||
withAccessKey: Boolean,
|
||||
};
|
||||
Pager.template = 'web.Pager';
|
||||
|
||||
return Pager;
|
||||
});
|
||||
@@ -0,0 +1,225 @@
|
||||
odoo.define('web.ControlPanel', function (require) {
|
||||
"use strict";
|
||||
|
||||
const ActionMenus = require('web.ActionMenus');
|
||||
const ControlPanelModel = require('web.ControlPanelModel');
|
||||
const FavoriteMenu = require('web.FavoriteMenu');
|
||||
const FilterMenu = require('web.FilterMenu');
|
||||
const GroupByMenu = require('web.GroupByMenu');
|
||||
const Pager = require('web.Pager');
|
||||
const SearchBar = require('web.SearchBar');
|
||||
const TimeRangeMenu = require('web.TimeRangeMenu');
|
||||
const { useModel } = require('web.model');
|
||||
|
||||
const { Component, hooks } = owl;
|
||||
const { useRef, useState, useSubEnv } = hooks;
|
||||
|
||||
/**
|
||||
* TODO: remove this whole mechanism as soon as `cp_content` is completely removed.
|
||||
* Extract the 'cp_content' key of the given props and return them as well as
|
||||
* the extracted content.
|
||||
* @param {Object} props
|
||||
* @returns {Object}
|
||||
*/
|
||||
function getAdditionalContent(props) {
|
||||
const additionalContent = {};
|
||||
if ('cp_content' in props) {
|
||||
const content = props.cp_content || {};
|
||||
if ('$buttons' in content) {
|
||||
additionalContent.buttons = content.$buttons;
|
||||
}
|
||||
if ('$searchview' in content) {
|
||||
additionalContent.searchView = content.$searchview;
|
||||
}
|
||||
if ('$pager' in content) {
|
||||
additionalContent.pager = content.$pager;
|
||||
}
|
||||
if ('$searchview_buttons' in content) {
|
||||
additionalContent.searchViewButtons = content.$searchview_buttons;
|
||||
}
|
||||
}
|
||||
return additionalContent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Control panel
|
||||
*
|
||||
* The control panel of the action|view. In its standard form, it is composed of
|
||||
* several sections/subcomponents. Here is a simplified graph representing the
|
||||
* action|view and its control panel:
|
||||
*
|
||||
* ┌ View Controller | Action ----------------------------------------------------------┐
|
||||
* | ┌ Control Panel ──────────────┬──────────────────────────────────────────────────┐ |
|
||||
* | │ ┌ Breadcrumbs ────────────┐ │ ┌ Search View ─────────────────────────────────┐ │ |
|
||||
* | │ │ [1] / [2] │ │ │ [3] [ ============== 4 =============== ] [5] │ │ |
|
||||
* | │ └─────────────────────────┘ │ └──────────────────────────────────────────────┘ │ |
|
||||
* | ├─────────────────────────────┼──────────────────────────────────────────────────┤ |
|
||||
* | │ ┌ Buttons ┐ ┌ ActionMenus ┐ │ ┌ Search Menus ─────┐ ┌ Pager ┐┌ View switcher ┐ │ |
|
||||
* | │ │ [6] │ │ [7] │ │ │ [8] [9] [10] [11] │ │ [12] ││ [13] │ │ |
|
||||
* | │ └─────────┘ └─────────────┘ │ └───────────────────┘ └───────┘└───────────────┘ │ |
|
||||
* | └─────────────────────────────┴──────────────────────────────────────────────────┘ |
|
||||
* | ┌ View Renderer | Action content ────────────────────────────────────────────────┐ |
|
||||
* | │ │ |
|
||||
* | │ ... │ |
|
||||
* | │ │ |
|
||||
* | │ │ |
|
||||
* | │ │ |
|
||||
* | └────────────────────────────────────────────────────────────────────────────────┘ |
|
||||
* └------------------------------------------------------------------------------------┘
|
||||
*
|
||||
* 1. Breadcrumbs: list of links composed by the `props.breadcrumbs` collection.
|
||||
* 2. Title: the title of the action|view. Can be empty and will yield 'Unnamed'.
|
||||
* 3. Search facets: a collection of facet components generated by the `ControlPanelModel`
|
||||
* and handled by the `SearchBar` component. @see SearchFacet
|
||||
* 4. SearchBar: @see SearchBar
|
||||
* 5. Advanced search: small buttons used to toggle the [Search Menus] section.
|
||||
* Will not be displayed if the allowed `props.searchMenuTypes` list is empty.
|
||||
* 6. Buttons: section in which the action|controller is meant to inject its control
|
||||
* buttons. The template provides a slot for this purpose.
|
||||
* 7. Action menus: @see ActionMenus
|
||||
* 8. Filter menu: @see FilterMenu
|
||||
* 9. Group by menu: @see GroupByMenu
|
||||
* 10. Time range menu: @see TimeRangeMenu
|
||||
* 11. Favorite menu: @see FavoriteMenu
|
||||
* 12. Pager: @see Pager
|
||||
* 13. View switcher buttons: list of buttons composed by the `props.views` collection.
|
||||
*
|
||||
* Subcomponents (especially in the [Search Menus] section) will call
|
||||
* the ControlPanelModel to get processed information about the current view|action.
|
||||
* @see ControlPanelModel for more details.
|
||||
*
|
||||
* Note: an additional temporary (and ugly) mechanic allows to inject a jQuery element
|
||||
* given in `props.cp_content` in a related section:
|
||||
* $buttons -> [Buttons]
|
||||
* $searchview -> [Search View]
|
||||
* $searchview_buttons -> [Search Menus]
|
||||
* $pager -> [Pager]
|
||||
* This system must be replaced by proper slot usage and the static template
|
||||
* inheritance mechanism when converting the views/actions.
|
||||
* @extends Component
|
||||
*/
|
||||
class ControlPanel extends Component {
|
||||
constructor() {
|
||||
super(...arguments);
|
||||
|
||||
this.additionalContent = getAdditionalContent(this.props);
|
||||
|
||||
useSubEnv({
|
||||
action: this.props.action,
|
||||
controlPanelModel: this.props.controlPanelModel,
|
||||
view: this.props.view,
|
||||
});
|
||||
|
||||
// Connect to the model
|
||||
// TODO: move this in enterprise whenever possible
|
||||
if (this.env.controlPanelModel) {
|
||||
this.model = useModel('controlPanelModel');
|
||||
}
|
||||
this.state = useState({
|
||||
displayDropdowns: true,
|
||||
});
|
||||
|
||||
// Reference hooks
|
||||
this.contentRefs = {
|
||||
buttons: useRef('buttons'),
|
||||
pager: useRef('pager'),
|
||||
searchView: useRef('searchView'),
|
||||
searchViewButtons: useRef('searchViewButtons'),
|
||||
};
|
||||
|
||||
this.fields = this._formatFields(this.props.fields);
|
||||
}
|
||||
|
||||
mounted() {
|
||||
this._attachAdditionalContent();
|
||||
}
|
||||
|
||||
patched() {
|
||||
this._attachAdditionalContent();
|
||||
}
|
||||
|
||||
async willUpdateProps(nextProps) {
|
||||
// Note: action and controlPanelModel are not likely to change during
|
||||
// the lifespan of a ControlPanel instance, so we only need to update
|
||||
// the view information.
|
||||
if ('view' in nextProps) {
|
||||
this.env.view = nextProps.view;
|
||||
}
|
||||
if ('fields' in nextProps) {
|
||||
this.fields = this._formatFields(nextProps.fields);
|
||||
}
|
||||
this.additionalContent = getAdditionalContent(nextProps);
|
||||
}
|
||||
|
||||
//---------------------------------------------------------------------
|
||||
// Private
|
||||
//---------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Attach additional content extracted from the props 'cp_content' key, if any.
|
||||
* @private
|
||||
*/
|
||||
_attachAdditionalContent() {
|
||||
for (const key in this.additionalContent) {
|
||||
if (this.additionalContent[key] && this.additionalContent[key].length) {
|
||||
const target = this.contentRefs[key].el;
|
||||
if (target) {
|
||||
target.innerHTML = "";
|
||||
target.append(...this.additionalContent[key]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Give `name` and `description` keys to the fields given to the control
|
||||
* panel.
|
||||
* @private
|
||||
* @param {Object} fields
|
||||
* @returns {Object}
|
||||
*/
|
||||
_formatFields(fields) {
|
||||
const formattedFields = {};
|
||||
for (const fieldName in fields) {
|
||||
formattedFields[fieldName] = Object.assign({
|
||||
description: fields[fieldName].string,
|
||||
name: fieldName,
|
||||
}, fields[fieldName]);
|
||||
}
|
||||
return formattedFields;
|
||||
}
|
||||
}
|
||||
|
||||
ControlPanel.components = {
|
||||
SearchBar,
|
||||
ActionMenus, Pager,
|
||||
FilterMenu, GroupByMenu, TimeRangeMenu, FavoriteMenu,
|
||||
};
|
||||
ControlPanel.defaultProps = {
|
||||
breadcrumbs: [],
|
||||
fields: {},
|
||||
searchMenuTypes: [],
|
||||
views: [],
|
||||
withBreadcrumbs: true,
|
||||
withSearchBar: true,
|
||||
};
|
||||
ControlPanel.props = {
|
||||
action: Object,
|
||||
breadcrumbs: Array,
|
||||
controlPanelModel: ControlPanelModel,
|
||||
cp_content: { type: Object, optional: 1 },
|
||||
fields: Object,
|
||||
pager: { validate: p => typeof p === 'object' || p === null, optional: 1 },
|
||||
searchMenuTypes: Array,
|
||||
actionMenus: { validate: s => typeof s === 'object' || s === null, optional: 1 },
|
||||
title: { type: String, optional: 1 },
|
||||
view: { type: Object, optional: 1 },
|
||||
views: Array,
|
||||
withBreadcrumbs: Boolean,
|
||||
withSearchBar: Boolean,
|
||||
};
|
||||
|
||||
ControlPanel.template = 'web.ControlPanel';
|
||||
|
||||
return ControlPanel;
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,40 @@
|
||||
odoo.define('web.ControlPanelX2Many', function (require) {
|
||||
|
||||
const ControlPanel = require('web.ControlPanel');
|
||||
|
||||
/**
|
||||
* Control panel (adaptation for x2many fields)
|
||||
*
|
||||
* Smaller version of the control panel with an abridged template (buttons and
|
||||
* pager only). We still extend the main version for the injection of `cp_content`
|
||||
* keys.
|
||||
* The pager of this control panel is only displayed if the amount of records
|
||||
* cannot be displayed in a single page.
|
||||
* @extends ControlPanel
|
||||
*/
|
||||
class ControlPanelX2Many extends ControlPanel {
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @returns {boolean}
|
||||
*/
|
||||
_shouldShowPager() {
|
||||
if (!this.props.pager || !this.props.pager.limit) {
|
||||
return false;
|
||||
}
|
||||
const { currentMinimum, limit, size } = this.props.pager;
|
||||
const maximum = Math.min(currentMinimum + limit - 1, size);
|
||||
const singlePage = (1 === currentMinimum) && (maximum === size);
|
||||
return !singlePage;
|
||||
}
|
||||
}
|
||||
|
||||
ControlPanelX2Many.defaultProps = {};
|
||||
ControlPanelX2Many.props = {
|
||||
cp_content: { type: Object, optional: 1 },
|
||||
pager: Object,
|
||||
};
|
||||
ControlPanelX2Many.template = 'web.ControlPanelX2Many';
|
||||
|
||||
return ControlPanelX2Many;
|
||||
});
|
||||
@@ -0,0 +1,153 @@
|
||||
odoo.define('web.CustomFavoriteItem', function (require) {
|
||||
"use strict";
|
||||
|
||||
const DropdownMenuItem = require('web.DropdownMenuItem');
|
||||
const FavoriteMenu = require('web.FavoriteMenu');
|
||||
const { useAutofocus } = require('web.custom_hooks');
|
||||
const { useModel } = require('web.model');
|
||||
|
||||
const { useRef } = owl.hooks;
|
||||
|
||||
let favoriteId = 0;
|
||||
|
||||
/**
|
||||
* Favorite generator menu
|
||||
*
|
||||
* This component is used to add a new favorite linked which will take every
|
||||
* information out of the current context and save it to a new `ir.filter`.
|
||||
*
|
||||
* There are 3 additional inputs to modify the filter:
|
||||
* - a text input (mandatory): the name of the favorite (must be unique)
|
||||
* - 'use by default' checkbox: if checked, the favorite will be the default
|
||||
* filter of the current model (and will bypass
|
||||
* any existing default filter). Cannot be checked
|
||||
* along with 'share with all users' checkbox.
|
||||
* - 'share with all users' checkbox: if checked, the favorite will be available
|
||||
* with all users instead of the current
|
||||
* one.Cannot be checked along with 'use
|
||||
* by default' checkbox.
|
||||
* Finally, there is a 'Save' button used to apply the current configuration
|
||||
* and save the context to a new filter.
|
||||
* @extends DropdownMenuItem
|
||||
*/
|
||||
class CustomFavoriteItem extends DropdownMenuItem {
|
||||
constructor() {
|
||||
super(...arguments);
|
||||
|
||||
const favId = favoriteId++;
|
||||
this.useByDefaultId = `o_favorite_use_by_default_${favId}`;
|
||||
this.shareAllUsersId = `o_favorite_share_all_users_${favId}`;
|
||||
|
||||
this.descriptionRef = useRef('description');
|
||||
this.model = useModel('controlPanelModel');
|
||||
this.interactive = true;
|
||||
Object.assign(this.state, {
|
||||
description: this.env.action.name || "",
|
||||
isDefault: false,
|
||||
isShared: false,
|
||||
});
|
||||
|
||||
useAutofocus();
|
||||
}
|
||||
|
||||
//---------------------------------------------------------------------
|
||||
// Private
|
||||
//---------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
_saveFavorite() {
|
||||
if (!this.state.description.length) {
|
||||
this.env.services.notification.notify({
|
||||
title: this.env._t("Error"),
|
||||
message: this.env._t("A name for your favorite filter is required."),
|
||||
type: 'danger',
|
||||
});
|
||||
return this.descriptionRef.el.focus();
|
||||
}
|
||||
const favorites = this.model.getFiltersOfType('favorite');
|
||||
if (favorites.some(f => f.description === this.state.description)) {
|
||||
this.env.services.notification.notify({
|
||||
title: this.env._t("Error"),
|
||||
message: this.env._t("Filter with same name already exists."),
|
||||
type: 'danger',
|
||||
});
|
||||
return this.descriptionRef.el.focus();
|
||||
}
|
||||
this.model.dispatch('createNewFavorite', {
|
||||
type: 'favorite',
|
||||
description: this.state.description,
|
||||
isDefault: this.state.isDefault,
|
||||
isShared: this.state.isShared,
|
||||
});
|
||||
// Reset state
|
||||
Object.assign(this.state, {
|
||||
description: this.env.action.name || "",
|
||||
isDefault: false,
|
||||
isShared: false,
|
||||
open: false,
|
||||
});
|
||||
}
|
||||
|
||||
//---------------------------------------------------------------------
|
||||
// Handlers
|
||||
//---------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {Event} ev change Event
|
||||
*/
|
||||
_onCheckboxChange(ev) {
|
||||
const { checked, id } = ev.target;
|
||||
if (this.useByDefaultId === id) {
|
||||
this.state.isDefault = checked;
|
||||
if (checked) {
|
||||
this.state.isShared = false;
|
||||
}
|
||||
} else {
|
||||
this.state.isShared = checked;
|
||||
if (checked) {
|
||||
this.state.isDefault = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {jQueryEvent} ev
|
||||
*/
|
||||
_onInputKeydown(ev) {
|
||||
switch (ev.key) {
|
||||
case 'Enter':
|
||||
ev.preventDefault();
|
||||
this._saveFavorite();
|
||||
break;
|
||||
case 'Escape':
|
||||
// Gives the focus back to the component.
|
||||
ev.preventDefault();
|
||||
ev.target.blur();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
//---------------------------------------------------------------------
|
||||
// Static
|
||||
//---------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @param {Object} env
|
||||
* @returns {boolean}
|
||||
*/
|
||||
static shouldBeDisplayed(env) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
CustomFavoriteItem.props = {};
|
||||
CustomFavoriteItem.template = 'web.CustomFavoriteItem';
|
||||
|
||||
FavoriteMenu.registry.add('favorite-generator-menu', CustomFavoriteItem, 0);
|
||||
|
||||
return CustomFavoriteItem;
|
||||
});
|
||||
@@ -0,0 +1,258 @@
|
||||
odoo.define('web.CustomFilterItem', function (require) {
|
||||
"use strict";
|
||||
|
||||
const { DatePicker, DateTimePicker } = require('web.DatePickerOwl');
|
||||
const Domain = require('web.Domain');
|
||||
const DropdownMenuItem = require('web.DropdownMenuItem');
|
||||
const { FIELD_OPERATORS, FIELD_TYPES } = require('web.searchUtils');
|
||||
const field_utils = require('web.field_utils');
|
||||
const { useModel } = require('web.model');
|
||||
|
||||
/**
|
||||
* Filter generator menu
|
||||
*
|
||||
* Component which purpose is to generate new filters from a set of inputs.
|
||||
* It is made of one or several `condition` objects that will be used to generate
|
||||
* filters which will be added in the filter menu. Each condition is composed
|
||||
* of 2, 3 or 4 inputs:
|
||||
*
|
||||
* 1. FIELD (select): the field used to form the base of the domain condition;
|
||||
*
|
||||
* 2. OPERATOR (select): the symbol determining the operator(s) of the domain
|
||||
* condition, linking the field to one or several value(s).
|
||||
* Some operators can have pre-defined values that will replace user inputs.
|
||||
* @see searchUtils for the list of operators.
|
||||
*
|
||||
* 3. [VALUE] (input|select): the value of the domain condition. it will be parsed
|
||||
* according to the selected field's type. Note that
|
||||
* it is optional as some operators have defined values.
|
||||
* The generated condition domain will be as following:
|
||||
* [
|
||||
* [field, operator, (operator_value|input_value)]
|
||||
* ]
|
||||
*
|
||||
* 4. [VALUE] (input): for now, only date-typed fields with the 'between' operator
|
||||
* allow for a second value. The given input values will then
|
||||
* be taken as the borders of the date range (between x and y)
|
||||
* and will be translated as the following domain form:
|
||||
* [
|
||||
* [date_field, '>=', x],
|
||||
* [date_field, '<=', y],
|
||||
* ]
|
||||
* @extends DropdownMenuItem
|
||||
*/
|
||||
class CustomFilterItem extends DropdownMenuItem {
|
||||
constructor() {
|
||||
super(...arguments);
|
||||
|
||||
this.model = useModel('controlPanelModel');
|
||||
|
||||
this.canBeOpened = true;
|
||||
this.state.conditions = [];
|
||||
// Format, filter and sort the fields props
|
||||
this.fields = Object.values(this.props.fields)
|
||||
.filter(field => this._validateField(field))
|
||||
.concat({ string: 'ID', type: 'id', name: 'id' })
|
||||
.sort(({ string: a }, { string: b }) => a > b ? 1 : a < b ? -1 : 0);
|
||||
|
||||
// Give access to constants variables to the template.
|
||||
this.DECIMAL_POINT = this.env._t.database.parameters.decimal_point;
|
||||
this.OPERATORS = FIELD_OPERATORS;
|
||||
this.FIELD_TYPES = FIELD_TYPES;
|
||||
|
||||
// Add default empty condition
|
||||
this._addDefaultCondition();
|
||||
}
|
||||
|
||||
//---------------------------------------------------------------------
|
||||
// Private
|
||||
//---------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Populate the conditions list with a default condition having as properties:
|
||||
* - the first available field
|
||||
* - the first available operator
|
||||
* - a null or empty array value
|
||||
* @private
|
||||
*/
|
||||
_addDefaultCondition() {
|
||||
const condition = {
|
||||
field: 0,
|
||||
operator: 0,
|
||||
};
|
||||
this._setDefaultValue(condition);
|
||||
this.state.conditions.push(condition);
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {Object} field
|
||||
* @returns {boolean}
|
||||
*/
|
||||
_validateField(field) {
|
||||
return !field.deprecated &&
|
||||
field.searchable &&
|
||||
FIELD_TYPES[field.type] &&
|
||||
field.name !== 'id';
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {Object} condition
|
||||
*/
|
||||
_setDefaultValue(condition) {
|
||||
const fieldType = this.fields[condition.field].type;
|
||||
const genericType = FIELD_TYPES[fieldType];
|
||||
const operator = FIELD_OPERATORS[genericType][condition.operator];
|
||||
switch (genericType) {
|
||||
case 'id':
|
||||
case 'number':
|
||||
condition.value = 0;
|
||||
break;
|
||||
case 'date':
|
||||
condition.value = [moment()];
|
||||
if (operator.symbol === 'between') {
|
||||
condition.value.push(moment());
|
||||
}
|
||||
break;
|
||||
case 'datetime':
|
||||
condition.value = [moment('00:00:00', 'hh:mm:ss')];
|
||||
if (operator.symbol === 'between') {
|
||||
condition.value.push(moment('23:59:59', 'hh:mm:ss'));
|
||||
}
|
||||
break;
|
||||
default:
|
||||
condition.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
//---------------------------------------------------------------------
|
||||
// Handlers
|
||||
//---------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Convert all conditions to prefilters.
|
||||
* @private
|
||||
*/
|
||||
_onApply() {
|
||||
const preFilters = this.state.conditions.map(condition => {
|
||||
const field = this.fields[condition.field];
|
||||
const type = this.FIELD_TYPES[field.type];
|
||||
const operator = this.OPERATORS[type][condition.operator];
|
||||
const descriptionArray = [field.string, operator.description];
|
||||
const domainArray = [];
|
||||
let domainValue;
|
||||
// Field type specifics
|
||||
if ('value' in operator) {
|
||||
domainValue = [operator.value];
|
||||
// No description to push here
|
||||
} else if (['date', 'datetime'].includes(type)) {
|
||||
domainValue = condition.value.map(
|
||||
val => field_utils.parse[type](val, { type }, { timezone: true })
|
||||
);
|
||||
const dateValue = condition.value.map(
|
||||
val => field_utils.format[type](val, { type }, { timezone: false })
|
||||
);
|
||||
descriptionArray.push(`"${dateValue.join(" " + this.env._t("and") + " ")}"`);
|
||||
} else {
|
||||
domainValue = [condition.value];
|
||||
descriptionArray.push(`"${condition.value}"`);
|
||||
}
|
||||
// Operator specifics
|
||||
if (operator.symbol === 'between') {
|
||||
domainArray.push(
|
||||
[field.name, '>=', domainValue[0]],
|
||||
[field.name, '<=', domainValue[1]]
|
||||
);
|
||||
} else {
|
||||
domainArray.push([field.name, operator.symbol, domainValue[0]]);
|
||||
}
|
||||
const preFilter = {
|
||||
description: descriptionArray.join(" "),
|
||||
domain: Domain.prototype.arrayToString(domainArray),
|
||||
type: 'filter',
|
||||
};
|
||||
return preFilter;
|
||||
});
|
||||
|
||||
this.model.dispatch('createNewFilters', preFilters);
|
||||
|
||||
// Reset state
|
||||
this.state.open = false;
|
||||
this.state.conditions = [];
|
||||
this._addDefaultCondition();
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {Object} condition
|
||||
* @param {number} valueIndex
|
||||
* @param {OwlEvent} ev
|
||||
*/
|
||||
_onDateChanged(condition, valueIndex, ev) {
|
||||
condition.value[valueIndex] = ev.detail.date;
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {Object} condition
|
||||
* @param {Event} ev
|
||||
*/
|
||||
_onFieldSelect(condition, ev) {
|
||||
Object.assign(condition, {
|
||||
field: ev.target.selectedIndex,
|
||||
operator: 0,
|
||||
});
|
||||
this._setDefaultValue(condition);
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {Object} condition
|
||||
* @param {Event} ev
|
||||
*/
|
||||
_onOperatorSelect(condition, ev) {
|
||||
condition.operator = ev.target.selectedIndex;
|
||||
this._setDefaultValue(condition);
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {Object} condition
|
||||
*/
|
||||
_onRemoveCondition(conditionIndex) {
|
||||
this.state.conditions.splice(conditionIndex, 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {Object} condition
|
||||
* @param {Event} ev
|
||||
*/
|
||||
_onValueInput(condition, ev) {
|
||||
const type = this.fields[condition.field].type;
|
||||
if (['float', 'integer', 'id'].includes(type)) {
|
||||
const previousValue = condition.value;
|
||||
const parser = field_utils.parse[type === 'float' ? 'float' : 'integer'];
|
||||
try {
|
||||
const parsed = parser(ev.target.value || 0);
|
||||
// Force parsed value in the input.
|
||||
ev.target.value = condition.value = (parsed || 0);
|
||||
} catch (err) {
|
||||
// Force previous value if non-parseable.
|
||||
ev.target.value = previousValue || 0;
|
||||
}
|
||||
} else {
|
||||
condition.value = ev.target.value || "";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
CustomFilterItem.components = { DatePicker, DateTimePicker };
|
||||
CustomFilterItem.props = {
|
||||
fields: Object,
|
||||
};
|
||||
CustomFilterItem.template = 'web.CustomFilterItem';
|
||||
|
||||
return CustomFilterItem;
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
odoo.define('web.CustomGroupByItem', function (require) {
|
||||
"use strict";
|
||||
|
||||
const DropdownMenuItem = require('web.DropdownMenuItem');
|
||||
const { useModel } = require('web.model');
|
||||
|
||||
/**
|
||||
* Group by generator menu
|
||||
*
|
||||
* Component used to generate new filters of type 'groupBy'. It is composed
|
||||
* of a button (used to toggle the rendering of the rest of the component) and
|
||||
* an input (select) used to choose a new field name which will be used as a
|
||||
* new groupBy value.
|
||||
* @extends DropdownMenuItem
|
||||
*/
|
||||
class CustomGroupByItem extends DropdownMenuItem {
|
||||
constructor() {
|
||||
super(...arguments);
|
||||
|
||||
this.canBeOpened = true;
|
||||
this.state.fieldName = this.props.fields[0].name;
|
||||
|
||||
this.model = useModel('controlPanelModel');
|
||||
}
|
||||
|
||||
//---------------------------------------------------------------------
|
||||
// Handlers
|
||||
//---------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
_onApply() {
|
||||
const field = this.props.fields.find(f => f.name === this.state.fieldName);
|
||||
this.model.dispatch('createNewGroupBy', field);
|
||||
this.state.open = false;
|
||||
}
|
||||
}
|
||||
|
||||
CustomGroupByItem.template = 'web.CustomGroupByItem';
|
||||
CustomGroupByItem.props = {
|
||||
fields: Array,
|
||||
};
|
||||
|
||||
return CustomGroupByItem;
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
odoo.define('web.FavoriteMenu', function (require) {
|
||||
"use strict";
|
||||
|
||||
const Dialog = require('web.OwlDialog');
|
||||
const DropdownMenu = require('web.DropdownMenu');
|
||||
const Registry = require('web.Registry');
|
||||
const { useModel } = require('web.model');
|
||||
|
||||
/**
|
||||
* 'Favorites' menu
|
||||
*
|
||||
* Simple rendering of the filters of type `favorites` given by the control panel
|
||||
* model. It uses most of the behaviours implemented by the dropdown menu Component,
|
||||
* with the addition of a submenu registry used to display additional components.
|
||||
* Only the favorite generator (@see CustomFavoriteItem) is registered in
|
||||
* the `web` module.
|
||||
* @see DropdownMenu for additional details.
|
||||
* @extends DropdownMenu
|
||||
*/
|
||||
class FavoriteMenu extends DropdownMenu {
|
||||
constructor() {
|
||||
super(...arguments);
|
||||
|
||||
this.model = useModel('controlPanelModel');
|
||||
this.state.deletedFavorite = false;
|
||||
}
|
||||
|
||||
//---------------------------------------------------------------------
|
||||
// Getters
|
||||
//---------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
get items() {
|
||||
const favorites = this.model.getFiltersOfType('favorite');
|
||||
const registryMenus = this.constructor.registry.values().reduce(
|
||||
(menus, Component) => {
|
||||
if (Component.shouldBeDisplayed(this.env)) {
|
||||
menus.push({
|
||||
key: Component.name,
|
||||
Component,
|
||||
});
|
||||
}
|
||||
return menus;
|
||||
},
|
||||
[]
|
||||
);
|
||||
return [...favorites, ...registryMenus];
|
||||
}
|
||||
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
get title() {
|
||||
return this.env._t("Favorites");
|
||||
}
|
||||
|
||||
//---------------------------------------------------------------------
|
||||
// Handlers
|
||||
//---------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {OwlEvent} ev
|
||||
*/
|
||||
_onItemRemoved(ev) {
|
||||
const favorite = this.items.find(fav => fav.id === ev.detail.item.id);
|
||||
this.state.deletedFavorite = favorite;
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {OwlEvent} ev
|
||||
*/
|
||||
_onItemSelected(ev) {
|
||||
ev.stopPropagation();
|
||||
this.model.dispatch('toggleFilter', ev.detail.item.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
async _onRemoveFavorite() {
|
||||
await this.model.dispatch('deleteFavorite', this.state.deletedFavorite.id);
|
||||
this.state.deletedFavorite = false;
|
||||
}
|
||||
}
|
||||
|
||||
FavoriteMenu.registry = new Registry();
|
||||
|
||||
FavoriteMenu.components = Object.assign({}, DropdownMenu.components, {
|
||||
Dialog,
|
||||
});
|
||||
FavoriteMenu.defaultProps = Object.assign({}, DropdownMenu.defaultProps, {
|
||||
icon: 'fa fa-star',
|
||||
});
|
||||
FavoriteMenu.template = 'web.FavoriteMenu';
|
||||
|
||||
return FavoriteMenu;
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
odoo.define('web.FilterMenu', function (require) {
|
||||
"use strict";
|
||||
|
||||
const DropdownMenu = require('web.DropdownMenu');
|
||||
const CustomFilterItem = require('web.CustomFilterItem');
|
||||
const { useModel } = require('web.model');
|
||||
|
||||
/**
|
||||
* 'Filters' menu
|
||||
*
|
||||
* Simple rendering of the filters of type `filter` given by the control panel
|
||||
* model. It uses most of the behaviours implemented by the dropdown menu Component,
|
||||
* with the addition of a filter generator (@see CustomFilterItem).
|
||||
* @see DropdownMenu for additional details.
|
||||
* @extends DropdownMenu
|
||||
*/
|
||||
class FilterMenu extends DropdownMenu {
|
||||
|
||||
constructor() {
|
||||
super(...arguments);
|
||||
|
||||
this.model = useModel('controlPanelModel');
|
||||
}
|
||||
|
||||
//---------------------------------------------------------------------
|
||||
// Getters
|
||||
//---------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
get items() {
|
||||
return this.model.getFiltersOfType('filter');
|
||||
}
|
||||
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
get title() {
|
||||
return this.env._t("Filters");
|
||||
}
|
||||
|
||||
//---------------------------------------------------------------------
|
||||
// Handlers
|
||||
//---------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {OwlEvent} ev
|
||||
*/
|
||||
_onItemSelected(ev) {
|
||||
ev.stopPropagation();
|
||||
const { item, option } = ev.detail;
|
||||
if (option) {
|
||||
this.model.dispatch('toggleFilterWithOptions', item.id, option.id);
|
||||
} else {
|
||||
this.model.dispatch('toggleFilter', item.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
FilterMenu.components = Object.assign({}, DropdownMenu.components, {
|
||||
CustomFilterItem,
|
||||
});
|
||||
FilterMenu.defaultProps = Object.assign({}, DropdownMenu.defaultProps, {
|
||||
icon: 'fa fa-filter',
|
||||
});
|
||||
FilterMenu.props = Object.assign({}, DropdownMenu.props, {
|
||||
fields: Object,
|
||||
});
|
||||
FilterMenu.template = 'web.FilterMenu';
|
||||
|
||||
return FilterMenu;
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
odoo.define('web.GroupByMenu', function (require) {
|
||||
"use strict";
|
||||
|
||||
const { GROUPABLE_TYPES } = require('web.searchUtils');
|
||||
const DropdownMenu = require('web.DropdownMenu');
|
||||
const CustomGroupByItem = require('web.CustomGroupByItem');
|
||||
const { useModel } = require('web.model');
|
||||
|
||||
/**
|
||||
* 'Group by' menu
|
||||
*
|
||||
* Simple rendering of the filters of type `groupBy` given by the control panel
|
||||
* model. It uses most of the behaviours implemented by the dropdown menu Component,
|
||||
* with the addition of a groupBy filter generator (@see CustomGroupByItem).
|
||||
* @see DropdownMenu for additional details.
|
||||
* @extends DropdownMenu
|
||||
*/
|
||||
class GroupByMenu extends DropdownMenu {
|
||||
|
||||
constructor() {
|
||||
super(...arguments);
|
||||
|
||||
this.fields = Object.values(this.props.fields)
|
||||
.filter(field => this._validateField(field))
|
||||
.sort(({ string: a }, { string: b }) => a > b ? 1 : a < b ? -1 : 0);
|
||||
|
||||
this.model = useModel('controlPanelModel');
|
||||
}
|
||||
|
||||
//---------------------------------------------------------------------
|
||||
// Getters
|
||||
//---------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
get items() {
|
||||
return this.model.getFiltersOfType('groupBy');
|
||||
}
|
||||
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
get title() {
|
||||
return this.env._t("Group By");
|
||||
}
|
||||
|
||||
//---------------------------------------------------------------------
|
||||
// Private
|
||||
//---------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {Object} field
|
||||
* @returns {boolean}
|
||||
*/
|
||||
_validateField(field) {
|
||||
return field.sortable &&
|
||||
field.name !== "id" &&
|
||||
GROUPABLE_TYPES.includes(field.type);
|
||||
}
|
||||
|
||||
//---------------------------------------------------------------------
|
||||
// Handlers
|
||||
//---------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {OwlEvent} ev
|
||||
*/
|
||||
_onItemSelected(ev) {
|
||||
ev.stopPropagation();
|
||||
const { item, option } = ev.detail;
|
||||
if (option) {
|
||||
this.model.dispatch('toggleFilterWithOptions', item.id, option.id);
|
||||
} else {
|
||||
this.model.dispatch('toggleFilter', item.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
GroupByMenu.components = Object.assign({}, DropdownMenu.components, {
|
||||
CustomGroupByItem,
|
||||
});
|
||||
GroupByMenu.defaultProps = Object.assign({}, DropdownMenu.defaultProps, {
|
||||
icon: 'fa fa-bars',
|
||||
});
|
||||
GroupByMenu.props = Object.assign({}, DropdownMenu.props, {
|
||||
fields: Object,
|
||||
});
|
||||
GroupByMenu.template = 'web.GroupByMenu';
|
||||
|
||||
return GroupByMenu;
|
||||
});
|
||||
@@ -0,0 +1,480 @@
|
||||
odoo.define('web.SearchBar', function (require) {
|
||||
"use strict";
|
||||
|
||||
const Domain = require('web.Domain');
|
||||
const field_utils = require('web.field_utils');
|
||||
const { useAutofocus } = require('web.custom_hooks');
|
||||
const { useModel } = require('web.model');
|
||||
|
||||
const CHAR_FIELDS = ['char', 'html', 'many2many', 'many2one', 'one2many', 'text'];
|
||||
const { Component, hooks } = owl;
|
||||
const { useExternalListener, useRef, useState } = hooks;
|
||||
|
||||
let sourceId = 0;
|
||||
|
||||
/**
|
||||
* Search bar
|
||||
*
|
||||
* This component has two main roles:
|
||||
* 1) Display the current search facets
|
||||
* 2) Create new search filters using an input and an autocompletion values
|
||||
* generator.
|
||||
*
|
||||
* For the first bit, the core logic can be found in the XML template of this
|
||||
* component, searchfacet components or in the ControlPanelModel itself.
|
||||
*
|
||||
* The autocompletion mechanic works with transient subobjects called 'sources'.
|
||||
* Sources contain the information that will be used to generate new search facets.
|
||||
* A source is generated either:
|
||||
* a. From an undetermined user input: the user will give a string and select
|
||||
* a field from the autocompletion dropdown > this will search the selected
|
||||
* field records with the given pattern (with an 'ilike' operator);
|
||||
* b. From a given selection: when given an input by the user, the searchbar
|
||||
* will pre-fetch 'many2one' field records matching the input value and filter
|
||||
* 'select' fields with the same value. If the user clicks on one of these
|
||||
* fetched/filtered values, it will generate a matching search facet targeting
|
||||
* records having this exact value.
|
||||
* @extends Component
|
||||
*/
|
||||
class SearchBar extends Component {
|
||||
constructor() {
|
||||
super(...arguments);
|
||||
|
||||
this.focusOnUpdate = useAutofocus();
|
||||
this.inputRef = useRef('search-input');
|
||||
this.model = useModel('controlPanelModel');
|
||||
this.state = useState({
|
||||
sources: [],
|
||||
focusedItem: 0,
|
||||
inputValue: "",
|
||||
});
|
||||
|
||||
this.autoCompleteSources = this.model.getFiltersOfType('field').map(
|
||||
filter => this._createSource(filter)
|
||||
);
|
||||
this.noResultItem = [null, this.env._t("(no result)")];
|
||||
|
||||
useExternalListener(window, 'click', this._onWindowClick);
|
||||
useExternalListener(window, 'keydown', this._onWindowKeydown);
|
||||
}
|
||||
|
||||
mounted() {
|
||||
// 'search' will always patch the search bar, 'focus' will never.
|
||||
this.env.controlPanelModel.on('search', this, this.focusOnUpdate);
|
||||
this.env.controlPanelModel.on('focus-control-panel', this, () => {
|
||||
this.inputRef.el.focus();
|
||||
});
|
||||
}
|
||||
|
||||
willUnmount() {
|
||||
this.env.controlPanelModel.off('search', this);
|
||||
this.env.controlPanelModel.off('focus-control-panel', this);
|
||||
}
|
||||
|
||||
//---------------------------------------------------------------------
|
||||
// Private
|
||||
//---------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
_closeAutoComplete() {
|
||||
this.state.sources = [];
|
||||
this.state.focusedItem = 0;
|
||||
this.focusOnUpdate();
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {Object} filter
|
||||
* @returns {Object}
|
||||
*/
|
||||
_createSource(filter) {
|
||||
const source = {
|
||||
active: true,
|
||||
description: filter.description,
|
||||
fieldName: filter.fieldName,
|
||||
filterId: filter.id,
|
||||
filterOperator: filter.operator,
|
||||
id: sourceId ++,
|
||||
operator: CHAR_FIELDS.includes(filter.fieldType) ? 'ilike' : '=',
|
||||
parent: false,
|
||||
};
|
||||
switch (filter.fieldType) {
|
||||
case 'selection':
|
||||
source.active = false;
|
||||
source.selection = this.props.fields[filter.fieldName].selection;
|
||||
break;
|
||||
case 'boolean':
|
||||
source.active = false;
|
||||
source.selection = [
|
||||
[true, this.env._t("Yes")],
|
||||
[false, this.env._t("No")],
|
||||
];
|
||||
break;
|
||||
case 'many2one':
|
||||
source.expand = true;
|
||||
source.expanded = false;
|
||||
if (filter.domain) {
|
||||
source.domain = filter.domain;
|
||||
}
|
||||
}
|
||||
return source;
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {Object} source
|
||||
* @param {[any, string]} values
|
||||
* @param {boolean} [active=true]
|
||||
*/
|
||||
_createSubSource(source, [value, label], active = true) {
|
||||
const subSource = {
|
||||
active,
|
||||
fieldName: source.fieldName,
|
||||
filterId: source.filterId,
|
||||
filterOperator: source.filterOperator,
|
||||
id: sourceId ++,
|
||||
label,
|
||||
operator: '=',
|
||||
parent: source,
|
||||
value,
|
||||
};
|
||||
return subSource;
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {Object} source
|
||||
* @param {boolean} shouldExpand
|
||||
*/
|
||||
async _expandSource(source, shouldExpand) {
|
||||
source.expanded = shouldExpand;
|
||||
if (shouldExpand) {
|
||||
let args = source.domain;
|
||||
if (typeof args === 'string') {
|
||||
try {
|
||||
args = Domain.prototype.stringToArray(args);
|
||||
} catch (err) {
|
||||
args = [];
|
||||
}
|
||||
}
|
||||
const { context, relation } = this.props.fields[source.fieldName];
|
||||
const results = await this.rpc({
|
||||
kwargs: {
|
||||
args,
|
||||
context,
|
||||
limit: 8,
|
||||
name: this.state.inputValue.trim(),
|
||||
},
|
||||
method: 'name_search',
|
||||
model: relation,
|
||||
});
|
||||
const options = results.map(result => this._createSubSource(source, result));
|
||||
const parentIndex = this.state.sources.indexOf(source);
|
||||
if (!options.length) {
|
||||
options.push(this._createSubSource(source, this.noResultItem, false));
|
||||
}
|
||||
this.state.sources.splice(parentIndex + 1, 0, ...options);
|
||||
} else {
|
||||
this.state.sources = this.state.sources.filter(src => src.parent !== source);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {string} query
|
||||
*/
|
||||
_filterSources(query) {
|
||||
return this.autoCompleteSources.reduce(
|
||||
(sources, source) => {
|
||||
// Field selection or boolean.
|
||||
if (source.selection) {
|
||||
const options = [];
|
||||
source.selection.forEach(result => {
|
||||
if (fuzzy.test(query, result[1].toLowerCase())) {
|
||||
options.push(this._createSubSource(source, result));
|
||||
}
|
||||
});
|
||||
if (options.length) {
|
||||
sources.push(source, ...options);
|
||||
}
|
||||
// Any other type.
|
||||
} else if (this._validateSource(query, source)) {
|
||||
sources.push(source);
|
||||
}
|
||||
// Fold any expanded item.
|
||||
if (source.expanded) {
|
||||
source.expanded = false;
|
||||
}
|
||||
return sources;
|
||||
},
|
||||
[]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Focus the search facet at the designated index if any.
|
||||
* @private
|
||||
*/
|
||||
_focusFacet(index) {
|
||||
const facets = this.el.getElementsByClassName('o_searchview_facet');
|
||||
if (facets.length) {
|
||||
facets[index].focus();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to parse the given rawValue according to the type of the given
|
||||
* source field type. The returned formatted value is the one that will
|
||||
* supposedly be sent to the server.
|
||||
* @private
|
||||
* @param {string} rawValue
|
||||
* @param {Object} source
|
||||
* @returns {string}
|
||||
*/
|
||||
_parseWithSource(rawValue, source) {
|
||||
const { type } = this.props.fields[source.fieldName];
|
||||
const parser = field_utils.parse[type];
|
||||
let parsedValue;
|
||||
if (['date', 'datetime'].includes(type)) {
|
||||
const parsedDate = parser(rawValue, { type }, { timezone: true });
|
||||
const dateFormat = type === 'datetime' ? 'YYYY-MM-DD HH:mm:ss' : 'YYYY-MM-DD';
|
||||
const momentValue = moment(parsedDate, dateFormat);
|
||||
if (!momentValue.isValid()) {
|
||||
throw new Error('Invalid date');
|
||||
}
|
||||
parsedValue = parsedDate.toJSON();
|
||||
} else {
|
||||
parsedValue = parser(rawValue);
|
||||
}
|
||||
return parsedValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {Object} source
|
||||
*/
|
||||
_selectSource(source) {
|
||||
// Inactive sources are:
|
||||
// - Selection sources
|
||||
// - "no result" items
|
||||
if (source.active) {
|
||||
const labelValue = source.label || this.state.inputValue;
|
||||
this.model.dispatch('addAutoCompletionValues', {
|
||||
filterId: source.filterId,
|
||||
value: source.value || this._parseWithSource(labelValue, source),
|
||||
label: labelValue,
|
||||
operator: source.filterOperator || source.operator,
|
||||
});
|
||||
}
|
||||
this.state.inputValue = "";
|
||||
this.inputRef.el.value = "";
|
||||
this._closeAutoComplete();
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {string} query
|
||||
* @param {Object} source
|
||||
* @returns {boolean}
|
||||
*/
|
||||
_validateSource(query, source) {
|
||||
try {
|
||||
this._parseWithSource(query, source);
|
||||
} catch (err) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
//---------------------------------------------------------------------
|
||||
// Handlers
|
||||
//---------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {Object} facet
|
||||
* @param {number} facetIndex
|
||||
* @param {KeyboardEvent} ev
|
||||
*/
|
||||
_onFacetKeydown(facet, facetIndex, ev) {
|
||||
switch (ev.key) {
|
||||
case 'ArrowLeft':
|
||||
if (facetIndex === 0) {
|
||||
this.inputRef.el.focus();
|
||||
} else {
|
||||
this._focusFacet(facetIndex - 1);
|
||||
}
|
||||
break;
|
||||
case 'ArrowRight':
|
||||
const facets = this.el.getElementsByClassName('o_searchview_facet');
|
||||
if (facetIndex === facets.length - 1) {
|
||||
this.inputRef.el.focus();
|
||||
} else {
|
||||
this._focusFacet(facetIndex + 1);
|
||||
}
|
||||
break;
|
||||
case 'Backspace':
|
||||
this._onFacetRemove(facet);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {Object} facet
|
||||
*/
|
||||
_onFacetRemove(facet) {
|
||||
this.model.dispatch('deactivateGroup', facet.groupId);
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {KeyboardEvent} ev
|
||||
*/
|
||||
_onSearchKeydown(ev) {
|
||||
if (ev.isComposing) {
|
||||
// This case happens with an IME for example: we let it handle all key events.
|
||||
return;
|
||||
}
|
||||
const currentItem = this.state.sources[this.state.focusedItem] || {};
|
||||
switch (ev.key) {
|
||||
case 'ArrowDown':
|
||||
ev.preventDefault();
|
||||
if (Object.keys(this.state.sources).length) {
|
||||
let nextIndex = this.state.focusedItem + 1;
|
||||
if (nextIndex >= this.state.sources.length) {
|
||||
nextIndex = 0;
|
||||
}
|
||||
this.state.focusedItem = nextIndex;
|
||||
} else {
|
||||
this.env.bus.trigger('focus-view');
|
||||
}
|
||||
break;
|
||||
case 'ArrowLeft':
|
||||
if (currentItem.expanded) {
|
||||
// Priority 1: fold expanded item.
|
||||
ev.preventDefault();
|
||||
this._expandSource(currentItem, false);
|
||||
} else if (currentItem.parent) {
|
||||
// Priority 2: focus parent item.
|
||||
ev.preventDefault();
|
||||
this.state.focusedItem = this.state.sources.indexOf(currentItem.parent);
|
||||
// Priority 3: Do nothing (navigation inside text).
|
||||
} else if (ev.target.selectionStart === 0) {
|
||||
const facets = this.model.getFacets();
|
||||
// Priority 4: navigate to rightmost facet.
|
||||
this._focusFacet(facets.length - 1);
|
||||
}
|
||||
break;
|
||||
case 'ArrowRight':
|
||||
if (ev.target.selectionStart === this.state.inputValue.length) {
|
||||
// Priority 1: Do nothing (navigation inside text).
|
||||
if (currentItem.expand) {
|
||||
// Priority 2: go to first child or expand item.
|
||||
ev.preventDefault();
|
||||
if (currentItem.expanded) {
|
||||
this.state.focusedItem ++;
|
||||
} else {
|
||||
this._expandSource(currentItem, true);
|
||||
}
|
||||
} else if (ev.target.selectionStart === this.state.inputValue.length) {
|
||||
// Priority 3: navigate to leftmost facet.
|
||||
this._focusFacet(0);
|
||||
}
|
||||
}
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
ev.preventDefault();
|
||||
let previousIndex = this.state.focusedItem - 1;
|
||||
if (previousIndex < 0) {
|
||||
previousIndex = this.state.sources.length - 1;
|
||||
}
|
||||
this.state.focusedItem = previousIndex;
|
||||
break;
|
||||
case 'Backspace':
|
||||
if (!this.state.inputValue.length) {
|
||||
const facets = this.model.getFacets();
|
||||
if (facets.length) {
|
||||
this._onFacetRemove(facets[facets.length - 1]);
|
||||
}
|
||||
}
|
||||
break;
|
||||
case 'Enter':
|
||||
if (!this.state.inputValue.length) {
|
||||
this.model.dispatch('search');
|
||||
break;
|
||||
}
|
||||
/* falls through */
|
||||
case 'Tab':
|
||||
if (this.state.inputValue.length) {
|
||||
this._selectSource(currentItem);
|
||||
}
|
||||
break;
|
||||
case 'Escape':
|
||||
if (this.state.sources.length) {
|
||||
this._closeAutoComplete();
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {InputEvent} ev
|
||||
*/
|
||||
_onSearchInput(ev) {
|
||||
this.state.inputValue = ev.target.value;
|
||||
const wasVisible = this.state.sources.length;
|
||||
const query = this.state.inputValue.trim().toLowerCase();
|
||||
if (query.length) {
|
||||
this.state.sources = this._filterSources(query);
|
||||
} else if (wasVisible) {
|
||||
this._closeAutoComplete();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Only handled if the user has moved its cursor at least once after the
|
||||
* results are loaded and displayed.
|
||||
* @private
|
||||
* @param {number} resultIndex
|
||||
*/
|
||||
_onSourceMousemove(resultIndex) {
|
||||
this.state.focusedItem = resultIndex;
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
_onWindowClick(ev) {
|
||||
if (this.state.sources.length && !this.el.contains(ev.target)) {
|
||||
this._closeAutoComplete();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {KeyboardEvent} ev
|
||||
*/
|
||||
_onWindowKeydown(ev) {
|
||||
if (ev.key === 'Escape' && this.state.sources.length) {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
this._closeAutoComplete();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SearchBar.defaultProps = {
|
||||
fields: {},
|
||||
};
|
||||
SearchBar.props = {
|
||||
fields: Object,
|
||||
};
|
||||
SearchBar.template = 'web.SearchBar';
|
||||
|
||||
return SearchBar;
|
||||
});
|
||||
@@ -0,0 +1,161 @@
|
||||
odoo.define('web.searchUtils', function (require) {
|
||||
"use strict";
|
||||
|
||||
const { _lt } = require('web.core');
|
||||
|
||||
// Filter menu parameters
|
||||
const FIELD_OPERATORS = {
|
||||
boolean: [
|
||||
{ symbol: "=", description: _lt("is true"), value: true },
|
||||
{ symbol: "!=", description: _lt("is false"), value: true },
|
||||
],
|
||||
char: [
|
||||
{ symbol: "ilike", description:_lt("contains") },
|
||||
{ symbol: "not ilike", description: _lt("doesn't contain") },
|
||||
{ symbol: "=", description: _lt("is equal to") },
|
||||
{ symbol: "!=", description: _lt("is not equal to") },
|
||||
{ symbol: "!=", description: _lt("is set"), value: false },
|
||||
{ symbol: "=", description: _lt("is not set"), value: false },
|
||||
],
|
||||
date: [
|
||||
{ symbol: "=", description: _lt("is equal to") },
|
||||
{ symbol: "!=", description: _lt("is not equal to") },
|
||||
{ symbol: ">", description: _lt("is after") },
|
||||
{ symbol: "<", description: _lt("is before") },
|
||||
{ symbol: ">=", description: _lt("is after or equal to") },
|
||||
{ symbol: "<=", description: _lt("is before or equal to") },
|
||||
{ symbol: "between", description: _lt("is between") },
|
||||
{ symbol: "!=", description: _lt("is set"), value: false },
|
||||
{ symbol: "=", description: _lt("is not set"), value: false },
|
||||
],
|
||||
datetime: [
|
||||
{ symbol: "between", description: _lt("is between") },
|
||||
{ symbol: "=", description: _lt("is equal to") },
|
||||
{ symbol: "!=", description: _lt("is not equal to") },
|
||||
{ symbol: ">", description: _lt("is after") },
|
||||
{ symbol: "<", description: _lt("is before") },
|
||||
{ symbol: ">=", description: _lt("is after or equal to") },
|
||||
{ symbol: "<=", description: _lt("is before or equal to") },
|
||||
{ symbol: "!=", description: _lt("is set"), value: false },
|
||||
{ symbol: "=", description: _lt("is not set"), value: false },
|
||||
],
|
||||
id: [
|
||||
{ symbol: "=", description:_lt("is")},
|
||||
],
|
||||
number: [
|
||||
{ symbol: "=", description: _lt("is equal to") },
|
||||
{ symbol: "!=", description: _lt("is not equal to") },
|
||||
{ symbol: ">", description: _lt("greater than") },
|
||||
{ symbol: "<", description: _lt("less than") },
|
||||
{ symbol: ">=", description: _lt("greater than or equal to") },
|
||||
{ symbol: "<=", description: _lt("less than or equal to") },
|
||||
{ symbol: "!=", description: _lt("is set"), value: false },
|
||||
{ symbol: "=", description: _lt("is not set"), value: false },
|
||||
],
|
||||
selection: [
|
||||
{ symbol: "=", description:_lt("is")},
|
||||
{ symbol: "!=", description: _lt("is not") },
|
||||
{ symbol: "!=", description: _lt("is set"), value: false },
|
||||
{ symbol: "=", description: _lt("is not set"), value: false },
|
||||
],
|
||||
};
|
||||
const FIELD_TYPES = {
|
||||
boolean: 'boolean',
|
||||
char: 'char',
|
||||
date: 'date',
|
||||
datetime: 'datetime',
|
||||
float: 'number',
|
||||
id: 'id',
|
||||
integer: 'number',
|
||||
html: 'char',
|
||||
many2many: 'char',
|
||||
many2one: 'char',
|
||||
monetary: 'number',
|
||||
one2many: 'char',
|
||||
text: 'char',
|
||||
selection: 'selection',
|
||||
};
|
||||
const DEFAULT_PERIOD = 'this_month';
|
||||
const MONTH_OPTIONS = {
|
||||
this_month: { id: 'this_month', groupNumber: 1, format: 'MMMM', addParam: {}, setParam: {}, granularity: 'month' },
|
||||
last_month: { id: 'last_month', groupNumber: 1, format: 'MMMM', addParam: { months: -1 }, setParam: {}, granularity: 'month' },
|
||||
antepenultimate_month: { id: 'antepenultimate_month', groupNumber: 1, format: 'MMMM', addParam: { months: -2 }, setParam: {}, granularity: 'month' },
|
||||
};
|
||||
const QUARTER_OPTIONS = {
|
||||
fourth_quarter: { id: 'fourth_quarter', groupNumber: 1, description: _lt("Q4"), addParam: {}, setParam: { quarter: 4 }, granularity: 'quarter' },
|
||||
third_quarter: { id: 'third_quarter', groupNumber: 1, description: _lt("Q3"), addParam: {}, setParam: { quarter: 3 }, granularity: 'quarter' },
|
||||
second_quarter: { id: 'second_quarter', groupNumber: 1, description: _lt("Q2"), addParam: {}, setParam: { quarter: 2 }, granularity: 'quarter' },
|
||||
first_quarter: { id: 'first_quarter', groupNumber: 1, description: _lt("Q1"), addParam: {}, setParam: { quarter: 1 }, granularity: 'quarter' },
|
||||
};
|
||||
const YEAR_OPTIONS = {
|
||||
this_year: { id: 'this_year', groupNumber: 2, format: 'YYYY', addParam: {}, setParam: {}, granularity: 'year' },
|
||||
last_year: { id: 'last_year', groupNumber: 2, format: 'YYYY', addParam: { years: -1 }, setParam: {}, granularity: 'year' },
|
||||
antepenultimate_year: { id: 'antepenultimate_year', groupNumber: 2, format: 'YYYY', addParam: { years: -2 }, setParam: {}, granularity: 'year' },
|
||||
};
|
||||
const OPTION_GENERATORS = Object.assign({}, MONTH_OPTIONS, QUARTER_OPTIONS, YEAR_OPTIONS);
|
||||
|
||||
function rankPeriod(oId) {
|
||||
return Object.keys(OPTION_GENERATORS).indexOf(oId);
|
||||
}
|
||||
|
||||
// GroupBy menu parameters
|
||||
const GROUPABLE_TYPES = ['many2one', 'char', 'boolean', 'selection', 'date', 'datetime', 'integer'];
|
||||
const DEFAULT_INTERVAL = 'month';
|
||||
const INTERVAL_OPTIONS = {
|
||||
year: { description: _lt("Year"), id: 'year', groupNumber: 1 },
|
||||
quarter: { description: _lt("Quarter"), id: 'quarter', groupNumber: 1 },
|
||||
month: { description: _lt("Month"), id: 'month', groupNumber: 1 },
|
||||
week: { description: _lt("Week"), id: 'week', groupNumber: 1 },
|
||||
day: { description: _lt("Day"), id: 'day', groupNumber: 1 }
|
||||
};
|
||||
function rankInterval(oId) {
|
||||
return Object.keys(INTERVAL_OPTIONS).indexOf(oId);
|
||||
}
|
||||
|
||||
// TimeRange menu parameters
|
||||
const TIME_RANGE_OPTIONS = {
|
||||
last_7_days: { description: _lt("Last 7 Days"), id: 'last_7_days', groupNumber: 1 },
|
||||
last_30_days: { description: _lt("Last 30 Days"), id: 'last_30_days', groupNumber: 1 },
|
||||
last_365_days: { description: _lt("Last 365 Days"), id: 'last_365_days', groupNumber: 1 },
|
||||
last_5_years: { description: _lt("Last 5 Years"), id: 'last_5_years', groupNumber: 1 },
|
||||
today: { description: _lt("Today"), id: 'today', groupNumber: 2 },
|
||||
this_week: { description: _lt("This Week"), id: 'this_week', groupNumber: 2 },
|
||||
this_month: { description: _lt("This Month"), id: 'this_month', groupNumber: 2 },
|
||||
this_quarter: { description: _lt("This Quarter"), id: 'this_quarter', groupNumber: 2 },
|
||||
this_year: { description: _lt("This Year"), id: 'this_year', groupNumber: 2 },
|
||||
yesterday: { description: _lt("Yesterday"), id: 'yesterday', groupNumber: 3 },
|
||||
last_week: { description: _lt("Last Week"), id: 'last_week', groupNumber: 3 },
|
||||
last_month: { description: _lt("Last Month"), id: 'last_month', groupNumber: 3 },
|
||||
last_quarter: { description: _lt("Last Quarter"), id: 'last_quarter', groupNumber: 3 },
|
||||
last_year: { description: _lt("Last Year"), id: 'last_year', groupNumber: 3 },
|
||||
};
|
||||
const COMPARISON_TIME_RANGE_OPTIONS = {
|
||||
previous_period: { description: _lt("Previous Period"), id: 'previous_period' },
|
||||
previous_year: { description: _lt("Previous Year"), id: 'previous_year' },
|
||||
};
|
||||
|
||||
// Search bar
|
||||
const FACET_ICONS = {
|
||||
filter: 'fa-filter',
|
||||
groupBy: 'fa-bars',
|
||||
favorite: 'fa-star',
|
||||
timeRange: 'fa-calendar',
|
||||
};
|
||||
|
||||
return {
|
||||
COMPARISON_TIME_RANGE_OPTIONS,
|
||||
DEFAULT_INTERVAL,
|
||||
DEFAULT_PERIOD,
|
||||
FACET_ICONS,
|
||||
FIELD_OPERATORS,
|
||||
FIELD_TYPES,
|
||||
GROUPABLE_TYPES,
|
||||
INTERVAL_OPTIONS,
|
||||
OPTION_GENERATORS,
|
||||
TIME_RANGE_OPTIONS,
|
||||
YEAR_OPTIONS,
|
||||
|
||||
rankInterval,
|
||||
rankPeriod,
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,109 @@
|
||||
odoo.define('web.TimeRangeMenu', function (require) {
|
||||
"use strict";
|
||||
|
||||
const { COMPARISON_TIME_RANGE_OPTIONS, DEFAULT_PERIOD, TIME_RANGE_OPTIONS } = require('web.searchUtils');
|
||||
const DropdownMenu = require('web.DropdownMenu');
|
||||
const { useModel } = require('web.model');
|
||||
|
||||
// Used to provide unique ids to its template elements.
|
||||
let timeRangeMenuId = 0;
|
||||
|
||||
/**
|
||||
* 'Time ranges' menu
|
||||
*
|
||||
* Component used to create a time range from a field, a given range and optionally
|
||||
* another field to make a comparison.
|
||||
*
|
||||
* The component template overrides the dropdownmenu to keep the basic behaviours
|
||||
* (opening/closing, layout). The template itself iis a set of labels/inputs
|
||||
* used to select the field and range. There is also a checkbox used to determine
|
||||
* whether to render the comparison range field selection (input).
|
||||
* @extends DropdownMenu
|
||||
*/
|
||||
class TimeRangeMenu extends DropdownMenu {
|
||||
constructor() {
|
||||
super(...arguments);
|
||||
this.model = useModel('controlPanelModel');
|
||||
|
||||
this.domId = timeRangeMenuId++;
|
||||
this.fields = Object.values(this.props.fields)
|
||||
.filter(field => this._validateField(field));
|
||||
this.periodOptions = TIME_RANGE_OPTIONS;
|
||||
this.comparisonTimeRangeOptions = COMPARISON_TIME_RANGE_OPTIONS;
|
||||
this.periodGroups = Object.values(this.periodOptions).reduce((acc, o) => {
|
||||
if (!acc.includes(o.groupNumber)) {
|
||||
acc.push(o.groupNumber);
|
||||
}
|
||||
return acc;
|
||||
}, []);
|
||||
const activeTimeRange = this.model.getFiltersOfType('timeRange').find(
|
||||
timeRange => timeRange.isActive
|
||||
);
|
||||
Object.assign(this.state, {
|
||||
fieldName: this.fields[0] && this.fields[0].name,
|
||||
rangeId: DEFAULT_PERIOD,
|
||||
}, activeTimeRange);
|
||||
}
|
||||
|
||||
//---------------------------------------------------------------------
|
||||
// Getters
|
||||
//---------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
get title() {
|
||||
return this.env._t("Time Ranges");
|
||||
}
|
||||
|
||||
//---------------------------------------------------------------------
|
||||
// Private
|
||||
//---------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {Object} field
|
||||
* @returns {boolean}
|
||||
*/
|
||||
_validateField(field) {
|
||||
return field.sortable &&
|
||||
['date', 'datetime'].includes(field.type);
|
||||
}
|
||||
|
||||
//---------------------------------------------------------------------
|
||||
// Handlers
|
||||
//---------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
_onApply() {
|
||||
this.model.dispatch('activateTimeRange',
|
||||
this.state.fieldName,
|
||||
this.state.rangeId,
|
||||
this.state.comparisonRangeId
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
_onCheckboxClick() {
|
||||
if (!this.state.comparisonRangeId) {
|
||||
this.state.comparisonRangeId = 'previous_period'; // default
|
||||
} else {
|
||||
delete this.state.comparisonRangeId;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TimeRangeMenu.defaultProps = Object.assign({}, DropdownMenu.defaultProps, {
|
||||
icon: 'fa fa-calendar',
|
||||
});
|
||||
TimeRangeMenu.props = Object.assign({}, DropdownMenu.props, {
|
||||
fields: Object,
|
||||
});
|
||||
TimeRangeMenu.template = 'web.TimeRangeMenu';
|
||||
|
||||
return TimeRangeMenu;
|
||||
});
|
||||
@@ -5,35 +5,37 @@ odoo.define('web.custom_hooks', function () {
|
||||
const { onMounted, onPatched, onWillUnmount } = hooks;
|
||||
|
||||
/**
|
||||
* Returns a function which purpose is to focus the given selector on the next
|
||||
* repaint (mount or patch). Its default selector is the first element having
|
||||
* an `autofocus' attribute. Text selection will be set at the end of the value
|
||||
* if the target is a text element. The action is lost if no element was found.
|
||||
*
|
||||
* @returns {Function}
|
||||
* Focus a given selector as soon as it appears in the DOM and if it was not
|
||||
* displayed before. If the selected target is an input|textarea, set the selection
|
||||
* at the end.
|
||||
* @param {Object} [params]
|
||||
* @param {string} [params.selector='autofocus'] default: select the first element
|
||||
* with an `autofocus` attribute.
|
||||
* @returns {Function} function that forces the focus on the next update if visible.
|
||||
*/
|
||||
function useFocusOnUpdate() {
|
||||
const component = Component.current;
|
||||
component.__willFocus = null;
|
||||
|
||||
function _focusSelector() {
|
||||
if (component.__willFocus) {
|
||||
const target = component.el.querySelector(component.__willFocus);
|
||||
if (target) {
|
||||
target.focus();
|
||||
if (['INPUT', 'TEXTAREA'].includes(target.tagName)) {
|
||||
target.selectionStart = target.selectionEnd = target.value.length;
|
||||
}
|
||||
function useAutofocus(params = {}) {
|
||||
const comp = Component.current;
|
||||
// Prevent autofocus in mobile
|
||||
if (comp.env.device.isMobile) {
|
||||
return () => {};
|
||||
}
|
||||
const selector = params.selector || '[autofocus]';
|
||||
let target = null;
|
||||
function autofocus() {
|
||||
const prevTarget = target;
|
||||
target = comp.el.querySelector(selector);
|
||||
if (target && target !== prevTarget) {
|
||||
target.focus();
|
||||
if (['INPUT', 'TEXTAREA'].includes(target.tagName)) {
|
||||
target.selectionStart = target.selectionEnd = target.value.length;
|
||||
}
|
||||
component.__willFocus = null;
|
||||
}
|
||||
}
|
||||
onMounted(autofocus);
|
||||
onPatched(autofocus);
|
||||
|
||||
onMounted(_focusSelector);
|
||||
onPatched(_focusSelector);
|
||||
|
||||
return function focusOnUpdate(selector = '[autofocus]') {
|
||||
component.__willFocus = selector;
|
||||
return function focusOnUpdate() {
|
||||
target = null;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -110,7 +112,7 @@ odoo.define('web.custom_hooks', function () {
|
||||
}
|
||||
|
||||
return {
|
||||
useFocusOnUpdate,
|
||||
useAutofocus,
|
||||
useListener,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -31,6 +31,8 @@ odoo.define('web.OwlDialog', function () {
|
||||
* should be rendered.
|
||||
* @param {string} [props.size='large'] 'extra-large', 'large', 'medium'
|
||||
* or 'small'.
|
||||
* @param {string} [props.stopClicks=true] whether the dialog should stop
|
||||
* the clicks propagation outside of itself.
|
||||
* @param {string} [props.subtitle='']
|
||||
* @param {string} [props.title='Odoo']
|
||||
* @param {boolean} [props.technical=true] If set to false, the modal will have
|
||||
@@ -71,7 +73,7 @@ odoo.define('web.OwlDialog', function () {
|
||||
this._removeTooltips();
|
||||
}
|
||||
|
||||
async willUnmount() {
|
||||
willUnmount() {
|
||||
this.env.bus.off('close_dialogs', this, this._close);
|
||||
|
||||
this._removeTooltips();
|
||||
@@ -129,6 +131,16 @@ odoo.define('web.OwlDialog', function () {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
_onClick(ev) {
|
||||
if (this.props.stopClicks) {
|
||||
ev.stopPropagation();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
@@ -151,7 +163,7 @@ odoo.define('web.OwlDialog', function () {
|
||||
$(this.mainButton)
|
||||
.tooltip({
|
||||
delay: { show: 200, hide: 0 },
|
||||
title: () => this.env.qweb.renderToString('DialogButton.tooltip', {
|
||||
title: () => this.env.qweb.renderToString('web.DialogButton.tooltip', {
|
||||
title: this.mainButton.innerText.toUpperCase(),
|
||||
}),
|
||||
trigger: 'manual',
|
||||
@@ -235,21 +247,23 @@ odoo.define('web.OwlDialog', function () {
|
||||
renderFooter: true,
|
||||
renderHeader: true,
|
||||
size: 'large',
|
||||
stopClicks: true,
|
||||
technical: true,
|
||||
title: "Odoo",
|
||||
};
|
||||
Dialog.props = {
|
||||
backdrop: { validate: b => ['static', true, false].includes(b), optional: 1 },
|
||||
backdrop: { validate: b => ['static', true, false].includes(b) },
|
||||
contentClass: { type: String, optional: 1 },
|
||||
fullscreen: { type: Boolean, optional: 1 },
|
||||
renderFooter: { type: Boolean, optional: 1 },
|
||||
renderHeader: { type: Boolean, optional: 1 },
|
||||
size: { validate: s => ['extra-large', 'large', 'medium', 'small'].includes(s), optional: 1 },
|
||||
renderFooter: Boolean,
|
||||
renderHeader: Boolean,
|
||||
size: { validate: s => ['extra-large', 'large', 'medium', 'small'].includes(s) },
|
||||
stopClicks: Boolean,
|
||||
subtitle: { type: String, optional: 1 },
|
||||
technical: { type: Boolean, optional: 1 },
|
||||
title: { type: String, optional: 1 },
|
||||
technical: Boolean,
|
||||
title: String,
|
||||
};
|
||||
Dialog.template = 'OwlDialog';
|
||||
Dialog.template = 'web.OwlDialog';
|
||||
|
||||
return Dialog;
|
||||
});
|
||||
|
||||
@@ -628,6 +628,28 @@ var utils = {
|
||||
'expires=' + new Date(new Date().getTime() + ttl*1000).toGMTString()
|
||||
].join(';');
|
||||
},
|
||||
/**
|
||||
* Returns a string formatted using given values.
|
||||
* If the value is an object, its keys will replace `%(key)s` expressions.
|
||||
* If the values are a set of strings, they will replace `%s` expressions.
|
||||
* If no value is given, the string will not be formatted.
|
||||
*
|
||||
* @param {string} string
|
||||
* @param {(Object|...string)} values
|
||||
*/
|
||||
sprintf: function (string, ...values) {
|
||||
if (values.length === 1 && typeof values[0] === 'object') {
|
||||
const valuesDict = values[0];
|
||||
for (const value in valuesDict) {
|
||||
string = string.replace(`%(${value})s`, valuesDict[value]);
|
||||
}
|
||||
} else {
|
||||
for (const value of values) {
|
||||
string = string.replace(/%s/, value);
|
||||
}
|
||||
}
|
||||
return string;
|
||||
},
|
||||
/**
|
||||
* Sort an array in place, keeping the initial order for identical values.
|
||||
*
|
||||
|
||||
@@ -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 ControlPanelView = require('web.ControlPanelView');
|
||||
const ControlPanelX2Many = require('web.ControlPanelX2Many');
|
||||
var core = require('web.core');
|
||||
var data = require('web.data');
|
||||
var Dialog = require('web.Dialog');
|
||||
@@ -26,7 +26,7 @@ const Domain = require('web.Domain');
|
||||
var KanbanRecord = require('web.KanbanRecord');
|
||||
var KanbanRenderer = require('web.KanbanRenderer');
|
||||
var ListRenderer = require('web.ListRenderer');
|
||||
var Pager = require('web.Pager');
|
||||
const { ComponentWrapper, WidgetAdapterMixin } = require('web.OwlCompatibility');
|
||||
|
||||
var _t = core._t;
|
||||
var _lt = core._lt;
|
||||
@@ -963,7 +963,7 @@ var KanbanFieldMany2One = AbstractField.extend({
|
||||
// X2Many widgets
|
||||
//------------------------------------------------------------------------------
|
||||
|
||||
var FieldX2Many = AbstractField.extend({
|
||||
var FieldX2Many = AbstractField.extend(WidgetAdapterMixin, {
|
||||
tagName: 'div',
|
||||
custom_events: _.extend({}, AbstractField.prototype.custom_events, {
|
||||
add_record: '_onAddRecord',
|
||||
@@ -980,6 +980,7 @@ var FieldX2Many = AbstractField.extend({
|
||||
navigation_move: '_onNavigationMove',
|
||||
save_optional_fields: '_onSaveOrLoadOptionalFields',
|
||||
load_optional_fields: '_onSaveOrLoadOptionalFields',
|
||||
pager_changed: '_onPagerChanged',
|
||||
}),
|
||||
|
||||
// We need to trigger the reset on every changes to be aware of the parent changes
|
||||
@@ -1006,6 +1007,21 @@ var FieldX2Many = AbstractField.extend({
|
||||
this.isMany2Many = this.field.type === 'many2many' || this.attrs.widget === 'many2many';
|
||||
this.activeActions = {};
|
||||
this.recordParams = {fieldName: this.name, viewType: this.viewType};
|
||||
// The limit is fixed so it cannot be changed by adding/removing lines in
|
||||
// the widget. It will only change through a hard reload or when manually
|
||||
// changing the pager (see _onPagerChanged).
|
||||
this.pagingState = {
|
||||
currentMinimum: this.value.offset + 1,
|
||||
limit: this.value.limit,
|
||||
size: this.value.count,
|
||||
validate: () => {
|
||||
// TODO: we should have some common method in the basic renderer...
|
||||
return this.view.arch.tag === 'tree' ?
|
||||
this.renderer.unselectRow() :
|
||||
Promise.resolve();
|
||||
},
|
||||
withAccessKey: false,
|
||||
};
|
||||
var arch = this.view && this.view.arch;
|
||||
if (arch) {
|
||||
this.activeActions.create = arch.attrs.create ?
|
||||
@@ -1024,8 +1040,21 @@ var FieldX2Many = AbstractField.extend({
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
start: function () {
|
||||
return this._renderControlPanel().then(this._super.bind(this));
|
||||
start: async function () {
|
||||
const _super = this._super.bind(this);
|
||||
if (this.view) {
|
||||
this._renderButtons();
|
||||
this._controlPanelWrapper = new ComponentWrapper(this, ControlPanelX2Many, {
|
||||
cp_content: { $buttons: this.$buttons },
|
||||
pager: this.pagingState,
|
||||
});
|
||||
await this._controlPanelWrapper.mount(this.el, { position: 'first-child' });
|
||||
}
|
||||
return _super(...arguments);
|
||||
},
|
||||
destroy: function () {
|
||||
WidgetAdapterMixin.destroy.call(this);
|
||||
this._super();
|
||||
},
|
||||
/**
|
||||
* For the list renderer to properly work, it must know if it is in the DOM,
|
||||
@@ -1033,6 +1062,7 @@ var FieldX2Many = AbstractField.extend({
|
||||
*/
|
||||
on_attach_callback: function () {
|
||||
this.isInDOM = true;
|
||||
WidgetAdapterMixin.on_attach_callback.call(this);
|
||||
if (this.renderer) {
|
||||
this.renderer.on_attach_callback();
|
||||
}
|
||||
@@ -1042,6 +1072,10 @@ var FieldX2Many = AbstractField.extend({
|
||||
*/
|
||||
on_detach_callback: function () {
|
||||
this.isInDOM = false;
|
||||
WidgetAdapterMixin.on_detach_callback.call(this);
|
||||
if (this.renderer) {
|
||||
this.renderer.on_detach_callback();
|
||||
}
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
@@ -1116,7 +1150,10 @@ var FieldX2Many = AbstractField.extend({
|
||||
}
|
||||
}
|
||||
return this._super.apply(this, arguments).then(() => {
|
||||
this._updateControlPanel();
|
||||
if (this.view) {
|
||||
this._renderButtons();
|
||||
this._updateControlPanel();
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
@@ -1249,8 +1286,8 @@ var FieldX2Many = AbstractField.extend({
|
||||
addTrashIcon: this._hasTrashIcon(),
|
||||
columnInvisibleFields: this.currentColInvisibleFields,
|
||||
keepWidths: true,
|
||||
}).then(function () {
|
||||
self.pager.updateState({ size: self.value.count });
|
||||
}).then(() => {
|
||||
this._updateControlPanel({ size: this.value.count });
|
||||
});
|
||||
}
|
||||
var arch = this.view.arch;
|
||||
@@ -1302,59 +1339,6 @@ var FieldX2Many = AbstractField.extend({
|
||||
return this._super();
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Instanciates a control panel with the appropriate buttons and a pager.
|
||||
* Prepends the control panel's $el to this widget's $el.
|
||||
*
|
||||
* @private
|
||||
* @returns {Promise}
|
||||
*/
|
||||
_renderControlPanel: function () {
|
||||
if (!this.view) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
var self = this;
|
||||
var defs = [];
|
||||
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,
|
||||
validate: function () {
|
||||
var isList = self.view.arch.tag === 'tree';
|
||||
// TODO: we should have some common method in the basic renderer...
|
||||
return isList ? self.renderer.unselectRow() : Promise.resolve();
|
||||
},
|
||||
});
|
||||
this.pager.on('pager_changed', this, function (new_state) {
|
||||
self.trigger_up('load', {
|
||||
id: self.value.id,
|
||||
limit: new_state.limit,
|
||||
offset: new_state.current_min - 1,
|
||||
on_success: function (value) {
|
||||
self.value = value;
|
||||
self._render();
|
||||
},
|
||||
});
|
||||
});
|
||||
this._renderButtons();
|
||||
defs.push(this.pager.appendTo($('<div>'))); // start the pager
|
||||
defs.push(cpDef);
|
||||
return Promise.all(defs).then(function () {
|
||||
self._controlPanel.updateContents({
|
||||
cp_content: {
|
||||
$buttons: self.$buttons,
|
||||
$pager: self.pager.$el,
|
||||
}
|
||||
});
|
||||
});
|
||||
},
|
||||
/**
|
||||
* Renders the buttons and sets this.$buttons.
|
||||
*
|
||||
@@ -1395,8 +1379,8 @@ var FieldX2Many = AbstractField.extend({
|
||||
} else {
|
||||
self.renderer.setRowMode(recordID, 'readonly').then(resolve);
|
||||
}
|
||||
}).then(function () {
|
||||
self.pager.updateState({ size: self.value.count });
|
||||
}).then(async function () {
|
||||
self._updateControlPanel({ size: self.value.count });
|
||||
var newEval = self._evalColumnInvisibleFields();
|
||||
if (!_.isEqual(self.currentColInvisibleFields, newEval)) {
|
||||
self.currentColInvisibleFields = newEval;
|
||||
@@ -1409,18 +1393,17 @@ var FieldX2Many = AbstractField.extend({
|
||||
/**
|
||||
* Re-renders buttons and updates the control panel. This method is called
|
||||
* when the widget is reset, as the available buttons might have changed.
|
||||
* The only mutable element in X2Many fields will be the pager.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_updateControlPanel: function () {
|
||||
if (this._controlPanel) {
|
||||
this._renderButtons();
|
||||
const params = {
|
||||
cp_content: {
|
||||
$buttons: this.$buttons,
|
||||
}
|
||||
_updateControlPanel: function (pagingState) {
|
||||
if (this._controlPanelWrapper) {
|
||||
const newProps = {
|
||||
cp_content: { $buttons: this.$buttons },
|
||||
pager: Object.assign(this.pagingState, pagingState),
|
||||
};
|
||||
this._controlPanel.updateContents(params, { clear: false });
|
||||
return this._controlPanelWrapper.update(newProps);
|
||||
}
|
||||
},
|
||||
/**
|
||||
@@ -1576,6 +1559,30 @@ var FieldX2Many = AbstractField.extend({
|
||||
_onOpenRecord: function () {
|
||||
// to implement
|
||||
},
|
||||
/**
|
||||
* We re-render the pager immediately with the new event values to allow
|
||||
* it to request another pager change while another one is still ongoing.
|
||||
* @see field_manager_mixin for concurrency handling.
|
||||
*
|
||||
* @private
|
||||
* @param {OdooEvent} ev
|
||||
*/
|
||||
_onPagerChanged: function (ev) {
|
||||
ev.stopPropagation();
|
||||
const { currentMinimum, limit } = ev.data;
|
||||
this._updateControlPanel({ currentMinimum, limit });
|
||||
this.trigger_up('load', {
|
||||
id: this.value.id,
|
||||
limit,
|
||||
offset: currentMinimum - 1,
|
||||
on_success: value => {
|
||||
this.value = value;
|
||||
this.pagingState.limit = value.limit;
|
||||
this.pagingState.size = value.count;
|
||||
this._render();
|
||||
},
|
||||
});
|
||||
},
|
||||
/**
|
||||
* Called when the renderer ask to save a line (the user tries to leave it)
|
||||
* -> Nothing is to "save" here, the model was already notified of the line
|
||||
@@ -1777,7 +1784,7 @@ var FieldOne2Many = FieldX2Many.extend({
|
||||
*/
|
||||
reset: function (record, ev) {
|
||||
var self = this;
|
||||
return this._super.apply(this, arguments).then(function () {
|
||||
return this._super.apply(this, arguments).then(() => {
|
||||
if (ev && ev.target === self && ev.data.changes && self.view.arch.tag === 'tree') {
|
||||
if (ev.data.changes[self.name] && ev.data.changes[self.name].operation === 'CREATE') {
|
||||
var index = 0;
|
||||
@@ -1792,7 +1799,7 @@ var FieldOne2Many = FieldX2Many.extend({
|
||||
// have 3 records, and we click on add, we will see the
|
||||
// 4 records on the same page, but we do not want a
|
||||
// pager.
|
||||
self.pager.updateState({ size: self.value.count - 1});
|
||||
self._updateControlPanel({ size: self.value.count - 1 });
|
||||
}
|
||||
var newID = self.value.data[index].id;
|
||||
self.renderer.editRecord(newID);
|
||||
@@ -1832,7 +1839,7 @@ var FieldOne2Many = FieldX2Many.extend({
|
||||
*/
|
||||
_renderButtons: function () {
|
||||
if (this.activeActions.create) {
|
||||
this._super.apply(this, arguments);
|
||||
return this._super(...arguments);
|
||||
}
|
||||
},
|
||||
/**
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
odoo.define('web.web_client', function (require) {
|
||||
"use strict";
|
||||
|
||||
const env = require('web.env');
|
||||
const session = require("web.session");
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
odoo.define('web.model', function () {
|
||||
"use strict";
|
||||
|
||||
const { Component, core } = owl;
|
||||
const { EventBus, Observer } = core;
|
||||
|
||||
// TODO: remove this if made available in owl.utils
|
||||
function partitionBy(arr, fn) {
|
||||
let lastGroup = false;
|
||||
let lastValue;
|
||||
return arr.reduce((acc, cur) => {
|
||||
let curVal = fn(cur);
|
||||
if (lastGroup) {
|
||||
if (curVal === lastValue) {
|
||||
lastGroup.push(cur);
|
||||
}
|
||||
else {
|
||||
lastGroup = false;
|
||||
}
|
||||
}
|
||||
if (!lastGroup) {
|
||||
lastGroup = [cur];
|
||||
acc.push(lastGroup);
|
||||
}
|
||||
lastValue = curVal;
|
||||
return acc;
|
||||
}, []);
|
||||
}
|
||||
|
||||
/**
|
||||
* Model
|
||||
*
|
||||
* The purpose of the class Model and the associated hook useModel
|
||||
* is to offer something similar to an owl store but with no automatic
|
||||
* notification (and rendering) of components when the 'state' used in the model
|
||||
* would change. Instead, one should call the __notifyComponents function whenever
|
||||
* it is useful to alert registered component. Nevertheless,
|
||||
* when calling a method throught the dispatch method, a notifcation
|
||||
* does take place automatically, and registered components (via useModel) are rendered.
|
||||
*
|
||||
* it is highly expected that this class will change in a near future. We don't have
|
||||
* the necessary hindsight to be sure its actual form is good.
|
||||
* @extends EventBus
|
||||
*/
|
||||
class Model extends EventBus {
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.rev = 1;
|
||||
this.mapping = {}; // could be a weak map
|
||||
}
|
||||
|
||||
//---------------------------------------------------------------------
|
||||
// Public
|
||||
//---------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Call the base model method with given name with the arguments
|
||||
* determined by the dispatch extra arguments.
|
||||
*
|
||||
* @param {string} action
|
||||
* @param {...any} args
|
||||
* @returns {Promise<any>}
|
||||
*/
|
||||
async dispatch(action, ...args) {
|
||||
const result = await this[action](...args);
|
||||
// TODO try to put it in promise.resolve()
|
||||
this._dispatch(...arguments);
|
||||
let rev = this.rev;
|
||||
await Promise.resolve();
|
||||
if (rev === this.rev) {
|
||||
await this._notifyComponents();
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
//---------------------------------------------------------------------
|
||||
// Private
|
||||
//---------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {string} action
|
||||
* @param {...any} args
|
||||
*/
|
||||
_dispatch(action, ...args) { }
|
||||
|
||||
/**
|
||||
* see Context method in owl.js for explanation
|
||||
* @private
|
||||
*/
|
||||
async _notifyComponents() {
|
||||
const rev = ++this.rev;
|
||||
const subscriptions = this.subscriptions.update;
|
||||
const groups = partitionBy(subscriptions, s => (s.owner ? s.owner.__owl__.depth : -1));
|
||||
for (let group of groups) {
|
||||
const proms = group.map(sub => sub.callback.call(sub.owner, rev));
|
||||
Component.scheduler.flush();
|
||||
await Promise.all(proms);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This is more or less the hook 'useContextWithCB' from owl only slightly simplified.
|
||||
*
|
||||
* @param {string} modelName
|
||||
*/
|
||||
function useModel(modelName) {
|
||||
const component = Component.current;
|
||||
const model = component.env[modelName];
|
||||
if (!(model instanceof Model)) {
|
||||
throw new Error(`No Model found when connecting '${component.constructor.name}'`);
|
||||
}
|
||||
|
||||
const mapping = model.mapping;
|
||||
const __owl__ = component.__owl__;
|
||||
const componentId = __owl__.id;
|
||||
if (!__owl__.observer) {
|
||||
__owl__.observer = new Observer();
|
||||
__owl__.observer.notifyCB = component.render.bind(component);
|
||||
}
|
||||
const currentCB = __owl__.observer.notifyCB;
|
||||
__owl__.observer.notifyCB = function () {
|
||||
if (model.rev > mapping[componentId]) {
|
||||
return;
|
||||
}
|
||||
currentCB();
|
||||
};
|
||||
mapping[componentId] = 0;
|
||||
const renderFn = __owl__.renderFn;
|
||||
__owl__.renderFn = function (comp, params) {
|
||||
mapping[componentId] = model.rev;
|
||||
return renderFn(comp, params);
|
||||
};
|
||||
|
||||
model.on('update', component, async modelRev => {
|
||||
if (mapping[componentId] < modelRev) {
|
||||
mapping[componentId] = modelRev;
|
||||
await component.render();
|
||||
}
|
||||
});
|
||||
|
||||
const __destroy = component.__destroy;
|
||||
component.__destroy = parent => {
|
||||
model.off("update", component);
|
||||
__destroy.call(component, parent);
|
||||
};
|
||||
|
||||
return model;
|
||||
}
|
||||
|
||||
return {
|
||||
Model,
|
||||
useModel,
|
||||
};
|
||||
});
|
||||
@@ -37,7 +37,7 @@ var ReportAction = AbstractAction.extend({
|
||||
start: function () {
|
||||
var self = this;
|
||||
this.iframe = this.$('iframe')[0];
|
||||
return Promise.all([this._super.apply(this, arguments), session.is_bound]).then(function () {
|
||||
return Promise.all([this._super.apply(this, arguments), session.is_bound]).then(async function () {
|
||||
var web_base_url = session['web.base.url'];
|
||||
var trusted_host = utils.get_host_from_url(web_base_url);
|
||||
var trusted_protocol = utils.get_protocol_from_url(web_base_url);
|
||||
@@ -46,7 +46,11 @@ var ReportAction = AbstractAction.extend({
|
||||
self.$buttons = $(QWeb.render('report.client_action.ControlButtons', {}));
|
||||
self.$buttons.on('click', '.o_report_print', self.on_click_print);
|
||||
|
||||
self._update_control_panel();
|
||||
await self.updateControlPanel({
|
||||
cp_content: {
|
||||
$buttons: self.$buttons,
|
||||
},
|
||||
});
|
||||
|
||||
// Load the report in the iframe. Note that we use a relative URL.
|
||||
self.iframe.src = self.report_url;
|
||||
@@ -54,7 +58,11 @@ var ReportAction = AbstractAction.extend({
|
||||
},
|
||||
|
||||
do_show: function () {
|
||||
this._update_control_panel();
|
||||
this.updateControlPanel({
|
||||
cp_content: {
|
||||
$buttons: this.$buttons,
|
||||
},
|
||||
});
|
||||
return this._super.apply(this, arguments);
|
||||
},
|
||||
|
||||
@@ -69,14 +77,6 @@ var ReportAction = AbstractAction.extend({
|
||||
$(window).off('message', this.on_message_received);
|
||||
},
|
||||
|
||||
_update_control_panel: function () {
|
||||
this.updateControlPanel({
|
||||
cp_content: {
|
||||
$buttons: this.$buttons,
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Event handler of the message post. We only handle them if they're from
|
||||
* `web.base.url` host and protocol and if they're part of `AUTHORIZED_MESSAGES`.
|
||||
|
||||
@@ -99,26 +99,28 @@ return core.Class.extend({
|
||||
},
|
||||
model: model,
|
||||
method: 'load_views',
|
||||
}).then(function (result) {
|
||||
}).then(async result => {
|
||||
// Freeze the fields dict as it will be shared between views and
|
||||
// no one should edit it
|
||||
utils.deepFreeze(result.fields);
|
||||
|
||||
// Insert views into the fields_views cache
|
||||
_.each(views_descr, function (view_descr) {
|
||||
for (const view_descr of views_descr) {
|
||||
var toolbar = options.toolbar && view_descr[1] !== 'search';
|
||||
var fv_key = self._gen_key(model, view_descr[0], view_descr[1], toolbar, context);
|
||||
var fvg = result.fields_views[view_descr[1]];
|
||||
if (view_descr[1] === 'search') {
|
||||
fvg.favoriteFilters = result.filters;
|
||||
}
|
||||
fvg.viewFields = fvg.fields;
|
||||
fvg.fields = result.fields;
|
||||
self._cache.fields_views[fv_key] = Promise.resolve(fvg);
|
||||
});
|
||||
}
|
||||
|
||||
// Insert filters, if any, into the filters cache
|
||||
if (result.filters) {
|
||||
self._cache.filters[filters_key] = Promise.resolve(result.filters);
|
||||
}
|
||||
|
||||
return result.fields_views;
|
||||
}, this._invalidate.bind(this, this._cache.views, key));
|
||||
}
|
||||
|
||||
@@ -245,7 +245,7 @@ DebugManager.include({
|
||||
action: this._action,
|
||||
can_edit: this._can_edit_views,
|
||||
controller: this._controller,
|
||||
controlPanelView: this._controller && this._controller._controlPanel,
|
||||
withControlPanel: this._controller && this._controller.withControlPanel,
|
||||
manager: this,
|
||||
view: this._controller && _.findWhere(this._action.views, {
|
||||
type: this._controller.viewType,
|
||||
|
||||
@@ -216,8 +216,8 @@
|
||||
function testFilters() {
|
||||
if (appsMenusOnly === true) return Promise.resolve();
|
||||
var filterProm = Promise.resolve();
|
||||
// var $filters = $('div.o_control_panel div.btn-group.o_dropdown > ul.o_filters_menu > li:not(.o_add_custom_filter)');
|
||||
var $filters = $('.o_filters_menu > .o_menu_item:not(.d-none)');
|
||||
// var $filters = $('div.o_control_panel div.btn-group.o_dropdown > ul.o_filter_menu > li:not(.o_add_custom_filter)');
|
||||
var $filters = $('.o_filter_menu > .o_menu_item:not(.d-none)');
|
||||
console.log("Testing " + $filters.length + " filters");
|
||||
var filter_ids = _.compact(_.map($filters, function(f) { return f.dataset.id}));
|
||||
filter_ids.forEach(function(filter_id){
|
||||
|
||||
@@ -15,86 +15,93 @@ odoo.define('web.AbstractController', function (require) {
|
||||
var ActionMixin = require('web.ActionMixin');
|
||||
var ajax = require('web.ajax');
|
||||
var concurrency = require('web.concurrency');
|
||||
var config = require('web.config');
|
||||
var core = require('web.core');
|
||||
const { ComponentWrapper } = require('web.OwlCompatibility');
|
||||
const ControlPanel = require('web.ControlPanel');
|
||||
var mvc = require('web.mvc');
|
||||
var { WidgetAdapterMixin } = require('web.OwlCompatibility');
|
||||
|
||||
var session = require('web.session');
|
||||
|
||||
var QWeb = core.qweb;
|
||||
|
||||
var AbstractController = mvc.Controller.extend(ActionMixin, WidgetAdapterMixin, {
|
||||
var AbstractController = mvc.Controller.extend(ActionMixin, {
|
||||
custom_events: _.extend({}, ActionMixin.custom_events, {
|
||||
navigation_move: '_onNavigationMove',
|
||||
open_record: '_onOpenRecord',
|
||||
search: '_onSearch',
|
||||
switch_view: '_onSwitchView',
|
||||
search_panel_domain_updated: '_onSearchPanelDomainUpdated',
|
||||
switch_view: '_onSwitchView',
|
||||
}),
|
||||
events: {
|
||||
'click a[type="action"]': '_onActionClicked',
|
||||
},
|
||||
|
||||
/**
|
||||
* @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 {boolean} params.isMultiRecord
|
||||
* @param {Object} param
|
||||
* @param {Object[]} params.actionViews
|
||||
* @param {string} params.activeActions
|
||||
* @param {string} params.bannerRoute
|
||||
* @param {Array[]} params.controlPanelDomain
|
||||
* @param {ControlPanelModel} [params.controlPanelModel]
|
||||
* @param {Object} [params.controlPanelProps]
|
||||
* @param {string} params.controllerID an id to ease the communication with
|
||||
* upstream components
|
||||
* @param {string} params.displayName
|
||||
* @param {Object} params.initialState
|
||||
* @param {string} params.modelName
|
||||
* @param {string} [params.searchPanel]
|
||||
* @param {string} params.viewType
|
||||
* @param {boolean} [params.withControlPanel]
|
||||
* @param {boolean} [params.withSearchPanel]
|
||||
*/
|
||||
init: function (parent, model, renderer, params) {
|
||||
this._super.apply(this, arguments);
|
||||
this._controlPanel = params.controlPanel;
|
||||
this._title = params.displayName;
|
||||
this.modelName = params.modelName;
|
||||
this.activeActions = params.activeActions;
|
||||
this.controllerID = params.controllerID;
|
||||
this.initialState = params.initialState;
|
||||
this.bannerRoute = params.bannerRoute;
|
||||
this.isMultiRecord = params.isMultiRecord;
|
||||
this.actionViews = params.actionViews;
|
||||
this.viewType = params.viewType;
|
||||
// use a DropPrevious to correctly handle concurrent updates
|
||||
this.dp = new concurrency.DropPrevious();
|
||||
|
||||
this.withControlPanel = params.withControlPanel;
|
||||
if (this.withControlPanel) {
|
||||
this.controlPanelProps = params.controlPanelProps;
|
||||
this._controlPanelModel = params.controlPanelModel;
|
||||
}
|
||||
|
||||
this.withSearchPanel = params.withSearchPanel && params.searchPanel;
|
||||
// the following attributes are used when there is a searchPanel
|
||||
this._searchPanel = params.searchPanel;
|
||||
if (this.withSearchPanel) {
|
||||
this._searchPanel = params.searchPanel;
|
||||
}
|
||||
this.controlPanelDomain = params.controlPanelDomain || [];
|
||||
this.searchPanelDomain = this._searchPanel ? this._searchPanel.getDomain() : [];
|
||||
},
|
||||
|
||||
/**
|
||||
* Simply renders and updates the url.
|
||||
*
|
||||
* @returns {Promise}
|
||||
*/
|
||||
start: function () {
|
||||
var self = this;
|
||||
if (this._searchPanel) {
|
||||
start: async function () {
|
||||
if (this.withSearchPanel) {
|
||||
this.$('.o_content')
|
||||
.addClass('o_controller_with_searchpanel')
|
||||
.prepend(this._searchPanel.$el);
|
||||
}
|
||||
|
||||
this.$el.addClass('o_view_controller');
|
||||
|
||||
return this._super.apply(this, arguments).then(function () {
|
||||
var prom;
|
||||
if (self._controlPanel) {
|
||||
// render the ControlPanel elements (buttons, pager, sidebar...)
|
||||
prom = self._renderControlPanelElements().then(function (elements) {
|
||||
self.controlPanelElements = elements;
|
||||
self._controlPanel.$el.prependTo(self.$el);
|
||||
});
|
||||
}
|
||||
return Promise.resolve(prom);
|
||||
}).then(function () {
|
||||
return self._update(self.initialState);
|
||||
});
|
||||
this.renderButtons();
|
||||
const promises = [this._super(...arguments)];
|
||||
if (this.withControlPanel) {
|
||||
this._updateControlPanelProps(this.initialState);
|
||||
this._controlPanelWrapper = new ComponentWrapper(this, ControlPanel, this.controlPanelProps);
|
||||
this._controlPanelWrapper.env.bus.on('focus-view', this, () => this.renderer.giveFocus());
|
||||
promises.push(this._controlPanelWrapper.mount(this.el, { position: 'first-child' }));
|
||||
}
|
||||
await Promise.all(promises);
|
||||
await this._update(this.initialState, { shouldUpdateControlPanel: false });
|
||||
this.updateButtons();
|
||||
},
|
||||
/**
|
||||
* @override
|
||||
@@ -103,34 +110,33 @@ var AbstractController = mvc.Controller.extend(ActionMixin, WidgetAdapterMixin,
|
||||
if (this.$buttons) {
|
||||
this.$buttons.off();
|
||||
}
|
||||
if (this.controlPanelElements && this.controlPanelElements.$switch_buttons) {
|
||||
this.controlPanelElements.$switch_buttons.off();
|
||||
}
|
||||
ActionMixin.destroy.call(this);
|
||||
this._super.apply(this, arguments);
|
||||
WidgetAdapterMixin.destroy.call(this, ...arguments);
|
||||
},
|
||||
/**
|
||||
* Called each time the controller is attached into the DOM.
|
||||
*/
|
||||
on_attach_callback: function () {
|
||||
if (this._controlPanel) {
|
||||
this._controlPanel.on_attach_callback();
|
||||
}
|
||||
if (this._searchPanel) {
|
||||
ActionMixin.on_attach_callback.call(this);
|
||||
if (this.withSearchPanel) {
|
||||
this._searchPanel.on_attach_callback();
|
||||
}
|
||||
if (this.withControlPanel) {
|
||||
this._controlPanelModel.on('search', this, this._onSearch);
|
||||
this._controlPanelModel.on('get-controller-query-params', this, this._onGetOwnedQueryParams);
|
||||
}
|
||||
this.renderer.on_attach_callback();
|
||||
WidgetAdapterMixin.on_attach_callback.call(this, ...arguments);
|
||||
},
|
||||
/**
|
||||
* Called each time the controller is detached from the DOM.
|
||||
*/
|
||||
on_detach_callback: function () {
|
||||
if (this._controlPanel) {
|
||||
this._controlPanel.on_detach_callback();
|
||||
ActionMixin.on_detach_callback.call(this);
|
||||
if (this.withControlPanel) {
|
||||
this._controlPanelModel.off('search', this);
|
||||
this._controlPanelModel.off('get-controller-query-params', this);
|
||||
}
|
||||
this.renderer.on_detach_callback();
|
||||
WidgetAdapterMixin.on_detach_callback.call(this, ...arguments);
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
@@ -174,10 +180,10 @@ var AbstractController = mvc.Controller.extend(ActionMixin, WidgetAdapterMixin,
|
||||
*/
|
||||
exportState: function () {
|
||||
var state = {};
|
||||
if (this._controlPanel) {
|
||||
state.cpState = this._controlPanel.exportState();
|
||||
if (this.withControlPanel) {
|
||||
state.cpState = this._controlPanelModel.exportState();
|
||||
}
|
||||
if (this._searchPanel) {
|
||||
if (this.withSearchPanel) {
|
||||
state.spState = this._searchPanel.exportState();
|
||||
}
|
||||
return state;
|
||||
@@ -203,21 +209,20 @@ var AbstractController = mvc.Controller.extend(ActionMixin, WidgetAdapterMixin,
|
||||
/**
|
||||
* Short helper method to reload the view
|
||||
*
|
||||
* @param {Object} [params] This object will simply be given to the update
|
||||
* @param {Object} [params={}] This object will simply be given to the update
|
||||
* @returns {Promise}
|
||||
*/
|
||||
reload: async function (params) {
|
||||
params = params || {};
|
||||
var searchPanelUpdateProm;
|
||||
var controllerState = params.controllerState || {};
|
||||
var cpState = controllerState.cpState;
|
||||
if (this._controlPanel && cpState) {
|
||||
await this._controlPanel.importState(cpState).then(function (searchQuery) {
|
||||
params = _.extend({}, params, searchQuery);
|
||||
});
|
||||
reload: async function (params = {}) {
|
||||
let searchPanelUpdateProm;
|
||||
const controllerState = params.controllerState || {};
|
||||
const cpState = controllerState.cpState;
|
||||
if (this.withControlPanel && cpState) {
|
||||
this._controlPanelModel.importState(cpState);
|
||||
const searchQuery = this._controlPanelModel.getQuery();
|
||||
params = Object.assign({}, params, searchQuery);
|
||||
}
|
||||
var postponeRendering = false;
|
||||
if (this._searchPanel) {
|
||||
let postponeRendering = false;
|
||||
if (this.withSearchPanel) {
|
||||
this.controlPanelDomain = params.domain || this.controlPanelDomain;
|
||||
if (controllerState.spState) {
|
||||
this._searchPanel.importState(controllerState.spState);
|
||||
@@ -234,28 +239,6 @@ var AbstractController = mvc.Controller.extend(ActionMixin, WidgetAdapterMixin,
|
||||
return this.renderer._render();
|
||||
}
|
||||
},
|
||||
/**
|
||||
* For views that require a pager, this method will be called to allow the
|
||||
* controller to instantiate and render a pager. Note that in theory, the
|
||||
* controller can actually render whatever he wants in the pager zone. If
|
||||
* your view does not want a pager, just let this method empty.
|
||||
*
|
||||
* @param {jQuery Node} $node
|
||||
* @return {Promise}
|
||||
*/
|
||||
renderPager: function ($node) {
|
||||
return Promise.resolve();
|
||||
},
|
||||
/**
|
||||
* Same as renderPager, but for the 'sidebar' zone (the zone with the menu
|
||||
* dropdown in the control panel next to the buttons)
|
||||
*
|
||||
* @param {jQuery Node} $node
|
||||
* @return {Promise}
|
||||
*/
|
||||
renderSidebar: function ($node) {
|
||||
return Promise.resolve();
|
||||
},
|
||||
/**
|
||||
* This is the main entry point for the controller. Changes from the search
|
||||
* view arrive in this method, and internal changes can sometimes also call
|
||||
@@ -271,34 +254,21 @@ var AbstractController = mvc.Controller.extend(ActionMixin, WidgetAdapterMixin,
|
||||
*
|
||||
* @returns {Promise}
|
||||
*/
|
||||
update: function (params, options) {
|
||||
var self = this;
|
||||
var shouldReload = (options && 'reload' in options) ? options.reload : true;
|
||||
var def = shouldReload ? this.model.reload(this.handle, params) : Promise.resolve();
|
||||
// we check here that the updateIndex of the control panel hasn't changed
|
||||
// between the start of the update request and the moment the controller
|
||||
// asks the control panel to update itself ; indeed, it could happen that
|
||||
// another action/controller is executed during this one reloads itself,
|
||||
// and if that one finishes first, it replaces this controller in the DOM,
|
||||
// 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._controlPanel && this._controlPanel.updateIndex;
|
||||
return this.dp.add(def).then(function (handle) {
|
||||
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.updateRendererState(state, params)).then(function () {
|
||||
if (self._controlPanel && cpUpdateIndex !== self._controlPanel.updateIndex) {
|
||||
return;
|
||||
}
|
||||
self.renderer.setLocalState(localState);
|
||||
return self._update(state, params);
|
||||
});
|
||||
});
|
||||
update: async function (params, options = {}) {
|
||||
const shouldReload = 'reload' in options ? options.reload : true;
|
||||
if (shouldReload) {
|
||||
this.handle = await this.dp.add(this.model.reload(this.handle, params));
|
||||
}
|
||||
const localState = this.renderer.getLocalState();
|
||||
const state = this.model.get(this.handle);
|
||||
const promises = [
|
||||
this.updateRendererState(state, params).then(() => {
|
||||
this.renderer.setLocalState(localState);
|
||||
}),
|
||||
this._update(state, params),
|
||||
];
|
||||
await this.dp.add(Promise.all(promises));
|
||||
this.updateButtons();
|
||||
},
|
||||
/**
|
||||
* Update the state of the renderer (handle both Widget and Component
|
||||
@@ -318,6 +288,16 @@ var AbstractController = mvc.Controller.extend(ActionMixin, WidgetAdapterMixin,
|
||||
// Private
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
|
||||
/**
|
||||
* Meant to be overriden to return a proper object.
|
||||
* @private
|
||||
* @param {Object} [state]
|
||||
* @return {(Object|null)}
|
||||
*/
|
||||
_getPagingInfo: function (state) {
|
||||
return null;
|
||||
},
|
||||
/**
|
||||
* Return the current search domain. This is the searchDomain used to update
|
||||
* the searchpanel. It returns the domain coming from the controlpanel. This
|
||||
@@ -330,6 +310,15 @@ var AbstractController = mvc.Controller.extend(ActionMixin, WidgetAdapterMixin,
|
||||
_getSearchDomain: function () {
|
||||
return this.controlPanelDomain;
|
||||
},
|
||||
/**
|
||||
* Meant to be overriden to return a proper object.
|
||||
* @private
|
||||
* @param {Object} [state]
|
||||
* @return {(Object|null)}
|
||||
*/
|
||||
_getActionMenuItems: function (state) {
|
||||
return null;
|
||||
},
|
||||
/**
|
||||
* This method is the way a view can notifies the outside world that
|
||||
* something has changed. The main use for this is to update the url, for
|
||||
@@ -360,107 +349,33 @@ var AbstractController = mvc.Controller.extend(ActionMixin, WidgetAdapterMixin,
|
||||
* @private
|
||||
* @returns {Promise}
|
||||
*/
|
||||
_renderBanner: function () {
|
||||
_renderBanner: async function () {
|
||||
if (this.bannerRoute !== undefined) {
|
||||
var self = this;
|
||||
return this.dp
|
||||
.add(this._rpc({route: this.bannerRoute}))
|
||||
.then(function (response) {
|
||||
if (!response.html) {
|
||||
self.$el.removeClass('o_has_banner');
|
||||
return Promise.resolve();
|
||||
}
|
||||
self.$el.addClass('o_has_banner');
|
||||
var $banner = $(response.html);
|
||||
// we should only display one banner at a time
|
||||
if (self._$banner && self._$banner.remove) {
|
||||
self._$banner.remove();
|
||||
}
|
||||
// Css and js are moved to <head>
|
||||
var defs = [];
|
||||
$('link[rel="stylesheet"]', $banner).each(function (i, link) {
|
||||
defs.push(ajax.loadCSS(link.href));
|
||||
link.remove();
|
||||
});
|
||||
$('script[type="text/javascript"]', $banner).each(function (i, js) {
|
||||
defs.push(ajax.loadJS(js.src));
|
||||
js.remove();
|
||||
});
|
||||
return Promise.all(defs).then(function () {
|
||||
$banner.insertBefore(self.$('> .o_content'));
|
||||
self._$banner = $banner;
|
||||
});
|
||||
});
|
||||
}
|
||||
return Promise.resolve();
|
||||
},
|
||||
/**
|
||||
* Renders the control elements (buttons, pager and sidebar) of the current
|
||||
* view.
|
||||
*
|
||||
* @private
|
||||
* @returns {Promise<Object>} resolved with an object containing the control
|
||||
* panel jQuery elements
|
||||
*/
|
||||
_renderControlPanelElements: function () {
|
||||
var self = this;
|
||||
var elements = {
|
||||
$buttons: $('<div>'),
|
||||
$sidebar: $('<div>'),
|
||||
$pager: $('<div>'),
|
||||
};
|
||||
|
||||
this.renderButtons(elements.$buttons);
|
||||
var sidebarProm = this.renderSidebar(elements.$sidebar);
|
||||
var pagerProm = this.renderPager(elements.$pager);
|
||||
|
||||
return Promise.all([sidebarProm, pagerProm]).then(function () {
|
||||
// remove the unnecessary outer div
|
||||
elements = _.mapObject(elements, function ($node) {
|
||||
return $node && $node.contents();
|
||||
const response = await this._rpc({route: this.bannerRoute});
|
||||
if (!response.html) {
|
||||
this.$el.removeClass('o_has_banner');
|
||||
return Promise.resolve();
|
||||
}
|
||||
this.$el.addClass('o_has_banner');
|
||||
var $banner = $(response.html);
|
||||
// we should only display one banner at a time
|
||||
if (this._$banner && this._$banner.remove) {
|
||||
this._$banner.remove();
|
||||
}
|
||||
// Css and js are moved to <head>
|
||||
var defs = [];
|
||||
$('link[rel="stylesheet"]', $banner).each(function (i, link) {
|
||||
defs.push(ajax.loadCSS(link.href));
|
||||
link.remove();
|
||||
});
|
||||
elements.$switch_buttons = self._renderSwitchButtons();
|
||||
|
||||
return elements;
|
||||
});
|
||||
},
|
||||
/**
|
||||
* Renders the switch buttons and binds listeners on them.
|
||||
*
|
||||
* @private
|
||||
* @returns {jQuery}
|
||||
*/
|
||||
_renderSwitchButtons: function () {
|
||||
var self = this;
|
||||
var views = _.filter(this.actionViews, {multiRecord: this.isMultiRecord});
|
||||
|
||||
if (views.length <= 1) {
|
||||
return $();
|
||||
$('script[type="text/javascript"]', $banner).each(function (i, js) {
|
||||
defs.push(ajax.loadJS(js.src));
|
||||
js.remove();
|
||||
});
|
||||
await Promise.all(defs);
|
||||
$banner.insertBefore(this.$('> .o_content'));
|
||||
this._$banner = $banner;
|
||||
}
|
||||
|
||||
var template = config.device.isMobile ? 'ControlPanel.SwitchButtons.Mobile' : 'ControlPanel.SwitchButtons';
|
||||
var $switchButtons = $(QWeb.render(template, {
|
||||
viewType: this.viewType,
|
||||
views: views,
|
||||
}));
|
||||
// create bootstrap tooltips
|
||||
_.each(views, function (view) {
|
||||
$switchButtons.filter('.o_cp_switch_' + view.type).tooltip();
|
||||
});
|
||||
// add onclick event listener
|
||||
var $switchButtonsFiltered = config.device.isMobile ? $switchButtons.find('button') : $switchButtons.filter('button');
|
||||
$switchButtonsFiltered.click(_.debounce(function (event) {
|
||||
var viewType = $(event.target).data('view-type');
|
||||
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) {
|
||||
var activeView = _.findWhere(views, {type: this.viewType});
|
||||
$switchButtons.find('.o_switch_view_button_icon').addClass('fa fa-lg ' + activeView.icon);
|
||||
}
|
||||
|
||||
return $switchButtons;
|
||||
},
|
||||
/**
|
||||
* @override
|
||||
@@ -483,21 +398,57 @@ var AbstractController = mvc.Controller.extend(ActionMixin, WidgetAdapterMixin,
|
||||
*
|
||||
* @private
|
||||
* @param {Object} state the state given by the model
|
||||
* @param {Object} [params]
|
||||
* @param {Object[]} [params.breadcrumbs]
|
||||
* @param {Object} [params={}]
|
||||
* @param {Object} [params.shouldUpdateControlPanel]
|
||||
* @returns {Promise}
|
||||
*/
|
||||
_update: function (state, params) {
|
||||
// AAB: update the control panel -> this will be moved elsewhere at some point
|
||||
var cpContent = _.extend({}, this.controlPanelElements);
|
||||
this.updateControlPanel({
|
||||
breadcrumbs: params && params.breadcrumbs,
|
||||
cp_content: cpContent,
|
||||
if (!this.$buttons) {
|
||||
this.renderButtons();
|
||||
}
|
||||
const promises = [this._renderBanner()];
|
||||
if (this.withControlPanel && params.shouldUpdateControlPanel !== false) {
|
||||
this._updateControlPanelProps(state);
|
||||
if (params.breadcrumbs) {
|
||||
this.controlPanelProps.breadcrumbs = params.breadcrumbs;
|
||||
}
|
||||
promises.push(this.updateControlPanel());
|
||||
}
|
||||
this._pushState();
|
||||
return Promise.all(promises);
|
||||
},
|
||||
/**
|
||||
* Can be used to update the key 'cp_content'. This method is called in start and _update methods.
|
||||
*
|
||||
* @private
|
||||
* @param {Object} state the state given by the model
|
||||
*/
|
||||
_updateControlPanelProps(state) {
|
||||
if (!this.controlPanelProps.cp_content) {
|
||||
this.controlPanelProps.cp_content = {};
|
||||
}
|
||||
if (this.$buttons) {
|
||||
this.controlPanelProps.cp_content.$buttons = this.$buttons;
|
||||
}
|
||||
Object.assign(this.controlPanelProps, {
|
||||
actionMenus: this._getActionMenuItems(state),
|
||||
pager: this._getPagingInfo(state),
|
||||
title: this.getTitle(),
|
||||
});
|
||||
|
||||
this._pushState();
|
||||
return this._renderBanner();
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @param {Object} state
|
||||
* @param {Object} newProps
|
||||
* @returns {Promise}
|
||||
*/
|
||||
_updatePaging: async function (state, newProps) {
|
||||
const pagingInfo = this._getPagingInfo(state);
|
||||
if (pagingInfo) {
|
||||
Object.assign(pagingInfo, newProps);
|
||||
return this.updateControlPanel({ pager: pagingInfo });
|
||||
}
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
@@ -574,7 +525,7 @@ var AbstractController = mvc.Controller.extend(ActionMixin, WidgetAdapterMixin,
|
||||
switch (ev.data.direction) {
|
||||
case 'up':
|
||||
ev.stopPropagation();
|
||||
this._controlPanel.focusSearchBar();
|
||||
this._controlPanelModel.trigger('focus-control-panel');
|
||||
break;
|
||||
case 'down':
|
||||
ev.stopPropagation();
|
||||
@@ -608,17 +559,13 @@ var AbstractController = mvc.Controller.extend(ActionMixin, WidgetAdapterMixin,
|
||||
},
|
||||
/**
|
||||
* 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.
|
||||
* environment needs to be updated with the new domain, context, groupby,...
|
||||
*
|
||||
* @private
|
||||
* @param {OdooEvent} ev
|
||||
* @param {Array[]} ev.data.domain
|
||||
* @param {Object} ev.data.context
|
||||
* @param {string[]} ev.data.groupby
|
||||
* @param {Object} searchQuery
|
||||
*/
|
||||
_onSearch: function (ev) {
|
||||
ev.stopPropagation();
|
||||
this.reload(_.extend({offset: 0, groupsOffset: 0}, ev.data));
|
||||
_onSearch: function (searchQuery) {
|
||||
this.reload(_.extend({ offset: 0, groupsOffset: 0 }, searchQuery));
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
|
||||
@@ -26,11 +26,13 @@ odoo.define('web.AbstractView', function (require) {
|
||||
var AbstractModel = require('web.AbstractModel');
|
||||
var AbstractRenderer = require('web.AbstractRenderer');
|
||||
var AbstractController = require('web.AbstractController');
|
||||
var ControlPanelView = require('web.ControlPanelView');
|
||||
const ControlPanelModel = require('web.ControlPanelModel');
|
||||
var mvc = require('web.mvc');
|
||||
var SearchPanel = require('web.SearchPanel');
|
||||
var viewUtils = require('web.viewUtils');
|
||||
|
||||
const { Component } = owl;
|
||||
|
||||
var Factory = mvc.Factory;
|
||||
|
||||
var AbstractView = Factory.extend({
|
||||
@@ -83,7 +85,7 @@ var AbstractView = Factory.extend({
|
||||
* @param {string} [params.displayName]
|
||||
* @param {Array[]} [params.domain=[]]
|
||||
* @param {Object[]} [params.dynamicFilters] transmitted to the
|
||||
* ControlPanelView
|
||||
* ControlPanel
|
||||
* @param {number[]} [params.ids]
|
||||
* @param {boolean} [params.isEmbedded=false]
|
||||
* @param {Object} [params.searchQuery={}]
|
||||
@@ -143,9 +145,10 @@ var AbstractView = Factory.extend({
|
||||
controllerID: params.controllerID,
|
||||
displayName: params.displayName,
|
||||
isEmbedded: isEmbedded,
|
||||
isMultiRecord: this.multi_record,
|
||||
modelName: params.modelName,
|
||||
viewType: this.viewType,
|
||||
withControlPanel: this.withControlPanel,
|
||||
withSearchPanel: this.withSearchPanel,
|
||||
};
|
||||
|
||||
var controllerState = params.controllerState || {};
|
||||
@@ -159,10 +162,6 @@ var AbstractView = Factory.extend({
|
||||
res_id: currentId,
|
||||
res_ids: controllerState.resIds || params.ids || (currentId ? [currentId] : undefined),
|
||||
};
|
||||
// default_order is like:
|
||||
// 'name,id desc'
|
||||
// but we need it like:
|
||||
// [{name: 'id', asc: false}, {name: 'name', asc: true}]
|
||||
var defaultOrder = this.arch.attrs.default_order;
|
||||
if (defaultOrder) {
|
||||
this.loadParams.orderedBy = _.map(defaultOrder.split(','), function (order) {
|
||||
@@ -174,26 +173,50 @@ var AbstractView = Factory.extend({
|
||||
this._updateMVCParams(params.searchQuery);
|
||||
}
|
||||
|
||||
this.controlPanelParams = {
|
||||
action: action,
|
||||
activateDefaultFavorite: params.activateDefaultFavorite,
|
||||
dynamicFilters: params.dynamicFilters,
|
||||
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,
|
||||
};
|
||||
this.searchPanelParams = {
|
||||
defaultNoFilter: params.searchPanelDefaultNoFilter,
|
||||
fields: this.fields,
|
||||
model: this.loadParams.modelName,
|
||||
state: controllerState.spState,
|
||||
};
|
||||
if (this.withControlPanel) {
|
||||
this.controlPanelModelConfig = {
|
||||
env: Component.env,
|
||||
actionId: action.id,
|
||||
actionContext: Object.assign({}, this.loadParams.context || {}),
|
||||
actionDomain: this.loadParams.domain || [],
|
||||
modelName: params.modelName,
|
||||
// control initialization
|
||||
activateDefaultFavorite: params.activateDefaultFavorite,
|
||||
dynamicFilters: params.dynamicFilters,
|
||||
viewInfo: params.controlPanelFieldsView,
|
||||
withSearchBar: params.withSearchBar,
|
||||
// used to avoid timeRanges in query
|
||||
searchMenuTypes: params.searchMenuTypes,
|
||||
// avoid work to initialize
|
||||
importedState: controllerState.cpState,
|
||||
};
|
||||
|
||||
const controlPanelModel = new ControlPanelModel(this.controlPanelModelConfig);
|
||||
|
||||
const controlPanelProps = {
|
||||
action,
|
||||
breadcrumbs: params.breadcrumbs,
|
||||
controlPanelModel,
|
||||
fields: this.fields,
|
||||
searchMenuTypes: params.searchMenuTypes,
|
||||
view: this.fieldsView,
|
||||
views: action.views && action.views.filter(v => v.multiRecord === this.multi_record),
|
||||
withBreadcrumbs: params.withBreadcrumbs,
|
||||
withSearchBar: params.withSearchBar,
|
||||
};
|
||||
this.controllerParams.controlPanelModel = controlPanelModel;
|
||||
this.controllerParams.controlPanelProps = controlPanelProps;
|
||||
}
|
||||
|
||||
if (this.withSearchPanel) {
|
||||
this.searchPanelParams = {
|
||||
arch: (params.controlPanelFieldsView || {}).arch,
|
||||
defaultNoFilter: params.searchPanelDefaultNoFilter,
|
||||
fields: this.fields,
|
||||
model: this.loadParams.modelName,
|
||||
state: controllerState.spState,
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
@@ -203,41 +226,36 @@ var AbstractView = Factory.extend({
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
getController: function (parent) {
|
||||
var self = this;
|
||||
var cpDef = this.withControlPanel && this._createControlPanel(parent);
|
||||
var spDef;
|
||||
getController: async function (parent) {
|
||||
const _super = this._super.bind(this);
|
||||
if (this.withControlPanel) {
|
||||
await this.controllerParams.controlPanelModel.isReady;
|
||||
const query = this.controllerParams.controlPanelModel.getQuery();
|
||||
this._updateMVCParams(query);
|
||||
}
|
||||
let searchPanel = false;
|
||||
if (this.withSearchPanel) {
|
||||
var spProto = this.config.SearchPanel.prototype;
|
||||
var viewInfo = this.controlPanelParams.viewInfo;
|
||||
var searchPanelParams = spProto.computeSearchPanelParams(viewInfo, this.viewType);
|
||||
const spProto = this.config.SearchPanel.prototype;
|
||||
const { arch, fields } = this.searchPanelParams;
|
||||
const searchPanelParams = spProto.computeSearchPanelParams(arch, fields, this.viewType);
|
||||
if (searchPanelParams.sections) {
|
||||
this.searchPanelParams.sections = searchPanelParams.sections;
|
||||
this.rendererParams.withSearchPanel = true;
|
||||
spDef = Promise.resolve(cpDef).then(this._createSearchPanel.bind(this, parent, searchPanelParams));
|
||||
searchPanel = await this._createSearchPanel(parent, searchPanelParams);
|
||||
}
|
||||
}
|
||||
|
||||
var _super = this._super.bind(this);
|
||||
return Promise.all([cpDef, spDef]).then(function ([controlPanel, searchPanel]) {
|
||||
// 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();
|
||||
var prom = _super(parent);
|
||||
prom.then(function (controller) {
|
||||
if (controlPanel) {
|
||||
controlPanel.setParent(controller);
|
||||
}
|
||||
if (searchPanel) {
|
||||
searchPanel.setParent(controller);
|
||||
}
|
||||
if (modelParent) {
|
||||
// if we already add a model, restore its parent
|
||||
self.model.setParent(modelParent);
|
||||
}
|
||||
});
|
||||
return prom;
|
||||
});
|
||||
// get the parent of the model if it already exists, as _super will
|
||||
// set the new controller as parent, which we don't want
|
||||
const modelParent = this.model && this.model.getParent();
|
||||
const controller = await _super(...arguments);
|
||||
if (searchPanel) {
|
||||
searchPanel.setParent(controller);
|
||||
}
|
||||
if (modelParent) {
|
||||
// if we already add a model, restore its parent
|
||||
this.model.setParent(modelParent);
|
||||
}
|
||||
return controller;
|
||||
},
|
||||
/**
|
||||
* Ensures that only one instance of AbstractModel is created
|
||||
@@ -264,25 +282,6 @@ var AbstractView = Factory.extend({
|
||||
// Private
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Instantiates and starts a ControlPanelController.
|
||||
*
|
||||
* @private
|
||||
* @param {Widget} parent
|
||||
* @returns {Promise<ControlPanelController>} resolved when the controlPanel
|
||||
* is ready
|
||||
*/
|
||||
_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 () {
|
||||
self._updateMVCParams(controlPanel.getSearchQuery());
|
||||
return controlPanel;
|
||||
});
|
||||
});
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @param {Widget} parent
|
||||
@@ -373,33 +372,23 @@ var AbstractView = Factory.extend({
|
||||
* @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 {Object} [searchQuery.timeRanges]
|
||||
* @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,
|
||||
comparisonField: timeRangeMenuData.comparisonField,
|
||||
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;
|
||||
this.loadParams.orderedBy = Array.isArray(searchQuery.orderedBy) && searchQuery.orderedBy.length ?
|
||||
searchQuery.orderedBy :
|
||||
this.loadParams.orderedBy;
|
||||
if (searchQuery.timeRanges) {
|
||||
this.loadParams.timeRanges = searchQuery.timeRanges;
|
||||
this.rendererParams.timeRanges = searchQuery.timeRanges;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -11,7 +11,6 @@ var AbstractController = require('web.AbstractController');
|
||||
var core = require('web.core');
|
||||
var Dialog = require('web.Dialog');
|
||||
var FieldManagerMixin = require('web.FieldManagerMixin');
|
||||
var Pager = require('web.Pager');
|
||||
var TranslationDialog = require('web.TranslationDialog');
|
||||
|
||||
var _t = core._t;
|
||||
@@ -19,12 +18,12 @@ var _t = core._t;
|
||||
var BasicController = AbstractController.extend(FieldManagerMixin, {
|
||||
custom_events: _.extend({}, AbstractController.prototype.custom_events, FieldManagerMixin.custom_events, {
|
||||
discard_changes: '_onDiscardChanges',
|
||||
pager_changed: '_onPagerChanged',
|
||||
reload: '_onReload',
|
||||
resequence_records: '_onResequenceRecords',
|
||||
set_dirty: '_onSetDirty',
|
||||
load_optional_fields: '_onLoadOptionalFields',
|
||||
save_optional_fields: '_onSaveOptionalFields',
|
||||
sidebar_data_asked: '_onSidebarDataAsked',
|
||||
translate: '_onTranslate',
|
||||
}),
|
||||
/**
|
||||
@@ -52,12 +51,11 @@ var BasicController = AbstractController.extend(FieldManagerMixin, {
|
||||
* @override
|
||||
* @returns {Promise}
|
||||
*/
|
||||
start: function () {
|
||||
start: async function () {
|
||||
// add classname to reflect the (absence of) access rights (used to
|
||||
// correctly display the nocontent helper)
|
||||
this.$el.toggleClass('o_cannot_create', !this.activeActions.create);
|
||||
return this._super.apply(this, arguments)
|
||||
.then(this._updateEnv.bind(this));
|
||||
await this._super(...arguments);
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
@@ -148,35 +146,6 @@ var BasicController = AbstractController.extend(FieldManagerMixin, {
|
||||
isDirty: function (recordID) {
|
||||
return this.model.isDirty(recordID || this.handle);
|
||||
},
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
renderPager: function ($node, options) {
|
||||
var self = this;
|
||||
var params = this._getPagerParams();
|
||||
this.pager = new Pager(this, params.size, params.current_min, params.limit, options);
|
||||
|
||||
this.pager.on('pager_changed', this, function (newState) {
|
||||
this.pager.disable();
|
||||
var data = this.model.get(this.handle, {raw: true});
|
||||
var limitChanged = (data.limit !== newState.limit);
|
||||
var reloadParams;
|
||||
if (data.groupedBy && data.groupedBy.length) {
|
||||
reloadParams = {groupsLimit: newState.limit, groupsOffset: newState.current_min - 1};
|
||||
} else {
|
||||
reloadParams = {limit: newState.limit, offset: newState.current_min - 1};
|
||||
}
|
||||
this.reload(reloadParams).then(function () {
|
||||
// reset the scroll position to the top on page changed only
|
||||
if (!limitChanged) {
|
||||
self.trigger_up('scrollTo', {top: 0});
|
||||
}
|
||||
}).then(this.pager.enable.bind(this.pager));
|
||||
});
|
||||
return this.pager.appendTo($node).then(function () {
|
||||
self._updatePager(); // to force proper visibility
|
||||
});
|
||||
},
|
||||
/**
|
||||
* Saves the record whose ID is given if necessary (@see _saveRecord).
|
||||
*
|
||||
@@ -218,13 +187,9 @@ var BasicController = AbstractController.extend(FieldManagerMixin, {
|
||||
* @override
|
||||
* @returns {Promise}
|
||||
*/
|
||||
update: function (params, options) {
|
||||
var self = this;
|
||||
update: async function (params, options) {
|
||||
this.mode = params.mode || this.mode;
|
||||
return this._super(params, options).then(function () {
|
||||
self._updateEnv();
|
||||
self._updatePager();
|
||||
});
|
||||
return this._super(params, options);
|
||||
},
|
||||
/**
|
||||
* @override
|
||||
@@ -486,44 +451,32 @@ var BasicController = AbstractController.extend(FieldManagerMixin, {
|
||||
return viewIdentifier;
|
||||
},
|
||||
/**
|
||||
* Return the params (current_min, limit and size) to pass to the pager,
|
||||
* Return the params (currentMinimum, limit and size) to pass to the pager,
|
||||
* according to the current state.
|
||||
*
|
||||
* @private
|
||||
* @returns {Object}
|
||||
*/
|
||||
_getPagerParams: function () {
|
||||
var state = this.model.get(this.handle, {raw: true});
|
||||
var isGrouped = state.groupedBy && state.groupedBy.length;
|
||||
_getPagingInfo: function (state) {
|
||||
const isGrouped = state.groupedBy && state.groupedBy.length;
|
||||
return {
|
||||
current_min: (isGrouped ? state.groupsOffset : state.offset) + 1,
|
||||
currentMinimum: (isGrouped ? state.groupsOffset : state.offset) + 1,
|
||||
limit: isGrouped ? state.groupsLimit : state.limit,
|
||||
size: isGrouped ? state.groupsCount : state.count,
|
||||
};
|
||||
},
|
||||
/**
|
||||
* Returns the new sidebar env
|
||||
* Return the new actionMenus props.
|
||||
*
|
||||
* @override
|
||||
* @private
|
||||
* @return {Object} the new sidebar env
|
||||
*/
|
||||
_getSidebarEnv: function () {
|
||||
_getActionMenuItems: function (state) {
|
||||
return {
|
||||
context: this.model.get(this.handle).getContext(),
|
||||
activeIds: this.getSelectedIds(),
|
||||
model: this.modelName,
|
||||
context: state.getContext(),
|
||||
};
|
||||
},
|
||||
/**
|
||||
* Determine whether or not the pager must be displayed (probably depending
|
||||
* on the current state). Controllers must override this to implement their
|
||||
* own logic.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_isPagerVisible: function () {
|
||||
return true;
|
||||
},
|
||||
/**
|
||||
* Sort function used to sort the fields by names, to compute the optional fields keys
|
||||
*
|
||||
@@ -633,24 +586,16 @@ var BasicController = AbstractController.extend(FieldManagerMixin, {
|
||||
* and notifies the component chain (by bubbling an event up)
|
||||
*
|
||||
* @private
|
||||
* @param {Object} [newProps={}]
|
||||
*/
|
||||
_updateEnv: function () {
|
||||
var env = this.model.get(this.handle, {env: true});
|
||||
if (this.sidebar) {
|
||||
var sidebarEnv = this._getSidebarEnv();
|
||||
this.sidebar.updateEnv(sidebarEnv);
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Update the pager with the current state.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_updatePager: function () {
|
||||
if (this.pager) {
|
||||
this.pager.updateState(this._getPagerParams());
|
||||
this.pager.do_toggle(this._isPagerVisible());
|
||||
}
|
||||
_updateControlPanel: function (newProps = {}) {
|
||||
const state = this.model.get(this.handle);
|
||||
const props = Object.assign(newProps, {
|
||||
actionMenus: this._getActionMenuItems(state),
|
||||
pager: this._getPagingInfo(state),
|
||||
title: this.getTitle(),
|
||||
});
|
||||
return this.updateControlPanel(props);
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
@@ -693,6 +638,27 @@ var BasicController = AbstractController.extend(FieldManagerMixin, {
|
||||
}
|
||||
FieldManagerMixin._onFieldChanged.apply(this, arguments);
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @param {OdooEvent} ev
|
||||
*/
|
||||
_onPagerChanged: async function (ev) {
|
||||
ev.stopPropagation();
|
||||
const { currentMinimum, limit } = ev.data;
|
||||
const state = this.model.get(this.handle, { raw: true });
|
||||
const reloadParams = state.groupedBy && state.groupedBy.length ? {
|
||||
groupsLimit: limit,
|
||||
groupsOffset: currentMinimum - 1,
|
||||
} : {
|
||||
limit,
|
||||
offset: currentMinimum - 1,
|
||||
};
|
||||
await this.reload(reloadParams);
|
||||
// reset the scroll position to the top on page changed only
|
||||
if (state.limit === limit) {
|
||||
this.trigger_up('scrollTo', { top: 0 });
|
||||
}
|
||||
},
|
||||
/**
|
||||
* When a reload event triggers up, we need to reload the full view.
|
||||
* For example, after a form view dialog saved some data.
|
||||
@@ -735,24 +701,19 @@ var BasicController = AbstractController.extend(FieldManagerMixin, {
|
||||
* @param {string} ev.data.handleField
|
||||
*/
|
||||
_onResequenceRecords: function (ev) {
|
||||
var self = this;
|
||||
|
||||
this.trigger_up('mutexify', {
|
||||
action: function () {
|
||||
var state = self.model.get(self.handle);
|
||||
var resIDs = _.map(ev.data.recordIds, function (recordID) {
|
||||
return _.findWhere(state.data, {id: recordID}).res_id;
|
||||
});
|
||||
var options = {
|
||||
action: async () => {
|
||||
let state = this.model.get(this.handle);
|
||||
const resIDs = ev.data.recordIds
|
||||
.map(recordID => state.data.find(d => d.id === recordID).res_id);
|
||||
const options = {
|
||||
offset: ev.data.offset,
|
||||
field: ev.data.handleField,
|
||||
};
|
||||
return self.model.resequence(self.modelName, resIDs, self.handle, options)
|
||||
.then(function () {
|
||||
self._updateEnv();
|
||||
state = self.model.get(self.handle);
|
||||
return self.renderer.updateState(state, {noRender: true});
|
||||
});
|
||||
await this.model.resequence(this.modelName, resIDs, this.handle, options);
|
||||
this._updateControlPanel();
|
||||
state = this.model.get(this.handle);
|
||||
return this.renderer.updateState(state, { noRender: true });
|
||||
},
|
||||
});
|
||||
},
|
||||
@@ -797,17 +758,6 @@ var BasicController = AbstractController.extend(FieldManagerMixin, {
|
||||
ev.stopPropagation(); // prevent other controllers from handling this request
|
||||
this.model.setDirty(ev.data.dataPointID);
|
||||
},
|
||||
/**
|
||||
* Handler used to get all the data necessary when a custom action is
|
||||
* performed through the sidebar.
|
||||
*
|
||||
* @private
|
||||
* @param {OdooEvent} ev
|
||||
*/
|
||||
_onSidebarDataAsked: function (ev) {
|
||||
var sidebarEnv = this._getSidebarEnv();
|
||||
ev.data.callback(sidebarEnv);
|
||||
},
|
||||
/**
|
||||
* open the translation view for the current field
|
||||
*
|
||||
|
||||
@@ -4798,7 +4798,7 @@ var BasicModel = AbstractModel.extend({
|
||||
element.context = options.context;
|
||||
}
|
||||
if (options.orderedBy !== undefined) {
|
||||
element.orderedBy = options.orderedBy || element.orderedBy;
|
||||
element.orderedBy = (options.orderedBy.length && options.orderedBy) || element.orderedBy;
|
||||
}
|
||||
if (options.domain !== undefined) {
|
||||
element.domain = options.domain;
|
||||
|
||||
@@ -49,12 +49,26 @@ var BasicRenderer = AbstractRenderer.extend(WidgetAdapterMixin, {
|
||||
* Called each time the renderer is attached into the DOM.
|
||||
*/
|
||||
on_attach_callback: function () {
|
||||
for (const handle in this.allFieldWidgets) {
|
||||
this.allFieldWidgets[handle].forEach(widget => {
|
||||
if (widget.on_attach_callback) {
|
||||
widget.on_attach_callback();
|
||||
}
|
||||
});
|
||||
}
|
||||
WidgetAdapterMixin.on_attach_callback.call(this);
|
||||
},
|
||||
/**
|
||||
* Called each time the renderer is detached from the DOM.
|
||||
*/
|
||||
on_detach_callback: function () {
|
||||
for (const handle in this.allFieldWidgets) {
|
||||
this.allFieldWidgets[handle].forEach(widget => {
|
||||
if (widget.on_detach_callback) {
|
||||
widget.on_detach_callback();
|
||||
}
|
||||
});
|
||||
}
|
||||
WidgetAdapterMixin.on_detach_callback.call(this);
|
||||
},
|
||||
|
||||
|
||||
@@ -37,6 +37,9 @@ var CalendarController = AbstractController.extend({
|
||||
updateRecord: '_onUpdateRecord',
|
||||
viewUpdated: '_onViewUpdated',
|
||||
}),
|
||||
events: _.extend({}, AbstractController.prototype.events, {
|
||||
today_button_click: '_onTodayButtonClicked'
|
||||
}),
|
||||
/**
|
||||
* @override
|
||||
* @param {Widget} parent
|
||||
@@ -60,34 +63,16 @@ var CalendarController = AbstractController.extend({
|
||||
// The quickCreating attribute ensures that we don't do several create
|
||||
this.quickCreating = false;
|
||||
},
|
||||
/**
|
||||
* Overrides to unbind handler on the control panel mobile 'Today' button.
|
||||
*
|
||||
* @override
|
||||
*/
|
||||
destroy: function () {
|
||||
this._super.apply(this, arguments);
|
||||
if (this.$todayButton) {
|
||||
this.$todayButton.off();
|
||||
}
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Public
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @override
|
||||
* @returns {string}
|
||||
*/
|
||||
getTitle: function () {
|
||||
return this._title;
|
||||
},
|
||||
/**
|
||||
* Render the buttons according to the CalendarView.buttons template and
|
||||
* add listeners on it. Set this.$buttons with the produced jQuery element
|
||||
*
|
||||
* @param {jQueryElement} [$node] a jQuery node where the rendered buttons
|
||||
* @param {jQuery} [$node] a jQuery node where the rendered buttons
|
||||
* should be inserted. $node may be undefined, in which case the Calendar
|
||||
* inserts them into this.options.$buttons or into a div of its template
|
||||
*/
|
||||
@@ -120,25 +105,6 @@ var CalendarController = AbstractController.extend({
|
||||
this.$('.o_calendar_buttons').replaceWith(this.$buttons);
|
||||
}
|
||||
},
|
||||
/**
|
||||
* In mobile, we want to display a special 'Today' button on the bottom
|
||||
* right corner of the control panel. This is the pager area, and as there
|
||||
* is no pager in Calendar views, we fool the system by defining a fake
|
||||
* pager (which is actually our button) such that it will be inserted in the
|
||||
* desired place.
|
||||
*
|
||||
* @todo get rid of this hack once the ControlPanel layout will be reworked
|
||||
*
|
||||
* @param {jQueryElement} $node the button should be appended to this
|
||||
* element to be displayed in the bottom right corner of the control panel
|
||||
*/
|
||||
renderPager: function ($node) {
|
||||
if (config.device.isMobile) {
|
||||
this.$todayButton = $(QWeb.render('CalendarView.TodayButtonMobile'));
|
||||
this.$todayButton.on('click', this._move.bind(this, 'today'));
|
||||
$node.append(this.$todayButton);
|
||||
}
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Private
|
||||
@@ -424,7 +390,14 @@ var CalendarController = AbstractController.extend({
|
||||
event.data.data.on_save = self.quick.destroy.bind(self.quick);
|
||||
self._onOpenCreate(event.data);
|
||||
self.quickCreating = false;
|
||||
})
|
||||
});
|
||||
},
|
||||
/**
|
||||
* In mobile, we display a special 'Today' button on the bottom right corner
|
||||
* of the control panel. Its click events are handled here.
|
||||
*/
|
||||
_onTodayButtonClicked: function () {
|
||||
this._move('today');
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
@@ -446,7 +419,8 @@ var CalendarController = AbstractController.extend({
|
||||
this.$buttons.find('.active').removeClass('active');
|
||||
this.$buttons.find('.o_calendar_button_' + this.mode).addClass('active');
|
||||
}
|
||||
this._setTitle(this.displayName + ' (' + event.data.title + ')');
|
||||
const title = `${this.displayName} (${event.data.title})`;
|
||||
return this.updateControlPanel({ title });
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -1,269 +0,0 @@
|
||||
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',
|
||||
reload: '_onReload',
|
||||
reset: '_onReset',
|
||||
},
|
||||
|
||||
/**
|
||||
* @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();
|
||||
},
|
||||
/**
|
||||
* Called when the control panel is remove form the DOM.
|
||||
*/
|
||||
on_detach_callback: function () {
|
||||
this.renderer.on_detach_callback();
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Public
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @see ControlPanelModel (exportState)
|
||||
* @returns {Object}
|
||||
*/
|
||||
exportState: function () {
|
||||
return this.model.exportState();
|
||||
},
|
||||
/**
|
||||
* Called by the abstract controller to give focus to the searchbar
|
||||
*/
|
||||
focusSearchBar: function () {
|
||||
if (this.renderer.searchBar) {
|
||||
this.renderer.searchBar.focus();
|
||||
}
|
||||
},
|
||||
/**
|
||||
* 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 {Promise<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 Promise.all(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 {Promise}
|
||||
*/
|
||||
_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 || this.renderer.getLastFacet();
|
||||
if (group) {
|
||||
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();
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @param {OdooEvent} ev
|
||||
*/
|
||||
_onReload: function (ev) {
|
||||
ev.stopPropagation();
|
||||
this.trigger_up('search', this.model.getQuery());
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @param {OdooEvent} ev
|
||||
*/
|
||||
_onReset: function (ev) {
|
||||
ev.stopPropagation();
|
||||
var state = this.model.get();
|
||||
this.renderer.updateState(state);
|
||||
},
|
||||
});
|
||||
|
||||
return ControlPanelController;
|
||||
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,387 +0,0 @@
|
||||
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 .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.isMobile = config.device.isMobile;
|
||||
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 Promise.all([superDef, searchDef]).then(function () {
|
||||
self._setSearchMenusVisibility();
|
||||
});
|
||||
},
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
on_attach_callback: function () {
|
||||
this._focusSearchInput();
|
||||
},
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
on_detach_callback: function () {
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Public
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @returns {Object|undefined}
|
||||
*/
|
||||
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 {Promise}
|
||||
*/
|
||||
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.isMobileDevice) {
|
||||
// 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});
|
||||
});
|
||||
}
|
||||
|
||||
var secondLast = index === length - 2;
|
||||
if (secondLast) {
|
||||
$bc.attr('accessKey', 'b');
|
||||
}
|
||||
|
||||
return $bc;
|
||||
},
|
||||
/**
|
||||
* Renderer the search bar and the search menus
|
||||
*
|
||||
* @private
|
||||
* @returns {Promise}
|
||||
*/
|
||||
_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 Promise.all(defs).then(this._focusSearchInput.bind(this));
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @returns {Promise}
|
||||
*/
|
||||
_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,
|
||||
filterFields: this.state.filterFields,
|
||||
});
|
||||
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 {Promise}
|
||||
*/
|
||||
_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 {Promise[]}
|
||||
*/
|
||||
_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
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Toggle the search menus visibility.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_onMore: function () {
|
||||
this.displaySearchMenu = !this.displaySearchMenu;
|
||||
this._setSearchMenusVisibility();
|
||||
},
|
||||
});
|
||||
|
||||
return ControlPanelRenderer;
|
||||
|
||||
});
|
||||
@@ -1,343 +0,0 @@
|
||||
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 Domain = require('web.Domain');
|
||||
|
||||
var DEFAULT_INTERVAL = controlPanelViewParameters.DEFAULT_INTERVAL;
|
||||
var DEFAULT_PERIOD = controlPanelViewParameters.DEFAULT_PERIOD;
|
||||
var INTERVAL_OPTIONS = controlPanelViewParameters.INTERVAL_OPTIONS;
|
||||
const OPTION_GENERATORS = controlPanelViewParameters.OPTION_GENERATORS;
|
||||
|
||||
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.title] the name of the action, to display in the
|
||||
* breadcrumb
|
||||
* @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 {Object[]} [params.dynamicFilters=[]] filters to add to the
|
||||
* search (in addition to those described in the arch), each filter being
|
||||
* an object with keys 'description' (what is displayed in the searchbar)
|
||||
* and 'domain'
|
||||
*/
|
||||
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.referenceMoment = moment();
|
||||
|
||||
const setDescriptions = (options) => {
|
||||
return options.map(o => {
|
||||
const oClone = JSON.parse(JSON.stringify(o));
|
||||
const description = o.description ?
|
||||
o.description.toString () :
|
||||
this.referenceMoment.clone().add(o.addParam).format(o.format);
|
||||
return _.extend(oClone, {description: description});
|
||||
});
|
||||
};
|
||||
const process = (options) => {
|
||||
return options.map(o => {
|
||||
const date = this.referenceMoment.clone().set(o.setParam).add(o.addParam);
|
||||
delete o.addParam;
|
||||
o.setParam[o.granularity] = date[o.granularity]();
|
||||
o.defaultYear = date.year();
|
||||
return o;
|
||||
});
|
||||
}
|
||||
|
||||
this.optionGenerators = process(setDescriptions(OPTION_GENERATORS));
|
||||
this.intervalOptions = setDescriptions(INTERVAL_OPTIONS);
|
||||
|
||||
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.title = params.title;
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
// add a filter group with the dynamic filters, if any
|
||||
if (params.dynamicFilters && params.dynamicFilters.length) {
|
||||
var dynamicFiltersGroup = params.dynamicFilters.map(function (filter) {
|
||||
return {
|
||||
description: filter.description,
|
||||
domain: JSON.stringify(filter.domain),
|
||||
isDefault: true,
|
||||
type: 'filter',
|
||||
};
|
||||
});
|
||||
this.loadParams.groups.unshift(dynamicFiltersGroup);
|
||||
}
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// 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 (attrs.invisible) {
|
||||
filter.invisible = true;
|
||||
}
|
||||
if (filter.type === 'filter') {
|
||||
if (filter.isDefault) {
|
||||
filter.defaultRank = -5;
|
||||
}
|
||||
filter.domain = attrs.domain;
|
||||
filter.context = pyUtils.eval('context', attrs.context);
|
||||
if (attrs.date) {
|
||||
filter.fieldName = attrs.date;
|
||||
filter.fieldType = this.fields[attrs.date].type;
|
||||
filter.hasOptions = true;
|
||||
filter.options = this.optionGenerators;
|
||||
filter.defaultOptionId = attrs.default_period ||
|
||||
DEFAULT_PERIOD;
|
||||
filter.currentOptionIds = new Set();
|
||||
filter.basicDomains = this._getDateFilterBasicDomains(filter);
|
||||
}
|
||||
} else if (filter.type === 'groupBy') {
|
||||
if (filter.isDefault) {
|
||||
const val = this.searchDefaults[attrs.name];
|
||||
filter.defaultRank = typeof val === 'number' ? val : 100;
|
||||
}
|
||||
filter.fieldName = attrs.fieldName;
|
||||
filter.fieldType = this.fields[attrs.fieldName].type;
|
||||
if (_.contains(['date', 'datetime'], filter.fieldType)) {
|
||||
filter.hasOptions = true;
|
||||
filter.options = this.intervalOptions;
|
||||
filter.defaultOptionId = attrs.defaultInterval ||
|
||||
DEFAULT_INTERVAL;
|
||||
filter.currentOptionIds = new Set();
|
||||
}
|
||||
} else if (filter.type === 'field') {
|
||||
if (filter.isDefault) {
|
||||
filter.defaultRank = -10;
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Constructs an object containing various domains based on this.referenceMoment and
|
||||
* the field associated with the provided date filter.
|
||||
*
|
||||
* @private
|
||||
* @param {Object} filter
|
||||
* @returns {Object}
|
||||
*/
|
||||
_getDateFilterBasicDomains: function (filter) {
|
||||
const _constructBasicDomain = (y, o) => {
|
||||
const setParam = Object.assign({}, y.setParam, o ? o.setParam : {});
|
||||
const granularity = o ? o.granularity : y.granularity;
|
||||
const date = this.referenceMoment.clone().set(setParam);
|
||||
let leftBound = date.clone().startOf(granularity).locale('en');
|
||||
let rightBound = date.clone().endOf(granularity).locale('en');
|
||||
|
||||
if (filter.fieldType === 'date') {
|
||||
leftBound = leftBound.format("YYYY-MM-DD");
|
||||
rightBound = rightBound.format("YYYY-MM-DD");
|
||||
} else {
|
||||
leftBound = leftBound.utc().format("YYYY-MM-DD HH:mm:ss");
|
||||
rightBound = rightBound.utc().format("YYYY-MM-DD HH:mm:ss");
|
||||
}
|
||||
const domain = Domain.prototype.arrayToString([
|
||||
'&',
|
||||
[filter.fieldName, ">=", leftBound],
|
||||
[filter.fieldName, "<=", rightBound]
|
||||
]);
|
||||
|
||||
let description;
|
||||
if (o) {
|
||||
description = o.description + " " + y.description;
|
||||
} else {
|
||||
description = y.description;
|
||||
}
|
||||
|
||||
return { domain, description };
|
||||
};
|
||||
|
||||
const domains = {};
|
||||
this.optionGenerators.filter(y => y.groupId === 2).forEach(y => {
|
||||
domains[y.optionId] = _constructBasicDomain(y);
|
||||
this.optionGenerators.filter(y => y.groupId === 1).forEach(o => {
|
||||
domains[y.optionId + "__" + o.optionId] = _constructBasicDomain(y, o);
|
||||
});
|
||||
});
|
||||
return domains;
|
||||
},
|
||||
/**
|
||||
* Parse the arch of a 'search' view.
|
||||
*
|
||||
* @private
|
||||
* @param {Object} arch arch with root node <search>
|
||||
*/
|
||||
_parseSearchArch: function (arch) {
|
||||
var self = this;
|
||||
// a searchview arch may contain a 'searchpanel' node, but this isn't
|
||||
// the concern of the ControlPanelView (the SearchPanel will handle it).
|
||||
// Ideally, this code should whitelist the tags to take into account
|
||||
// instead of blacklisting the others, but with the current (messy)
|
||||
// structure of a searchview arch, it's way simpler to do it that way.
|
||||
var children = arch.children.filter(function (child) {
|
||||
return child.tag !== 'searchpanel';
|
||||
});
|
||||
var preFilters = _.flatten(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;
|
||||
|
||||
});
|
||||
@@ -1,34 +0,0 @@
|
||||
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,122 +0,0 @@
|
||||
odoo.define('web.MobileControlPanelRenderer', function (require) {
|
||||
"use strict";
|
||||
|
||||
var config = require('web.config');
|
||||
var ControlPanelRenderer = require('web.ControlPanelRenderer');
|
||||
|
||||
if (!config.device.isMobile) {
|
||||
return;
|
||||
}
|
||||
|
||||
ControlPanelRenderer.include({
|
||||
events:_.extend({}, ControlPanelRenderer.prototype.events, {
|
||||
'click .o_mobile_search_close, .o_mobile_search_show_result, .o_toggle_searchview_full': '_toggleMobileSearchView',
|
||||
'click .o_enable_searchview': '_toggleMobileQuickSearchView',
|
||||
'click .o_toggle_searchview_full': '_onOpenMobileSearchView',
|
||||
'click .o_mobile_search_clear_facets': '_onEmptyAll',
|
||||
'show.bs.dropdown .o_mobile_search_filter .o_dropdown': '_onDropdownToggle',
|
||||
'hide.bs.dropdown .o_mobile_search_filter .o_dropdown': '_onDropdownToggle',
|
||||
}),
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Private
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Overridden to correctly place submenu inside the mobile section.
|
||||
*
|
||||
* @private
|
||||
* @override
|
||||
*/
|
||||
_getSubMenusPlace: function () {
|
||||
return this.$('.o_mobile_search_filter');
|
||||
},
|
||||
/**
|
||||
* @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._renderSearchviewInput();
|
||||
});
|
||||
},
|
||||
_renderSearchviewInput: function () {
|
||||
if (this.$('.o_toggle_searchview_full').is(':visible') && !this.$('.o_mobile_search').is(':visible')) {
|
||||
this.$('.o_toggle_searchview_full').toggleClass('btn-secondary', !!this.state.query.length);
|
||||
this.searchBar.$el.detach().insertAfter(this.$('.o_mobile_search'));
|
||||
} else {
|
||||
this.searchBar.$el.detach().insertAfter(this.$('.o_mobile_search_header'));
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Toggles mobile search view screen.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_toggleMobileSearchView: function () {
|
||||
this.$('.o_mobile_search').toggleClass('o_hidden');
|
||||
this._renderSearchviewInput();
|
||||
},
|
||||
/**
|
||||
* Toggles mobile quick search view on screen.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_toggleMobileQuickSearchView: function () {
|
||||
this.$('.o_cp_searchview').toggleClass('o_searchview_quick');
|
||||
this.$('.breadcrumb').toggleClass('o_hidden',
|
||||
this.$('.o_cp_searchview').hasClass('o_searchview_quick'));
|
||||
this.$('.o_toggle_searchview_full')
|
||||
.toggleClass('o_hidden')
|
||||
.toggleClass('btn-secondary', !!this.state.query.length);
|
||||
this._renderSearchviewInput();
|
||||
this.$('.o_enable_searchview').toggleClass("fa-search").toggleClass("fa-close");
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Handlers
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Toggles the little arrow in main buttons.
|
||||
*
|
||||
* @private
|
||||
* @param {BootstrapEvent} ev
|
||||
*/
|
||||
_onDropdownToggle: function (ev) {
|
||||
$(ev.currentTarget).find('.fa-chevron-right').toggleClass('fa-chevron-down');
|
||||
},
|
||||
/**
|
||||
* Clears all filters from the search view.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_onEmptyAll: function () {
|
||||
this.trigger_up('search_bar_cleared');
|
||||
},
|
||||
/**
|
||||
* Opens the mobile search view screen.
|
||||
*
|
||||
* @private
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
_onOpenMobileSearchView: function (ev) {
|
||||
if (ev.target === this.el) {
|
||||
this._toggleMobileSearchView();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
});
|
||||
@@ -1,177 +0,0 @@
|
||||
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',
|
||||
'click input[type="checkbox"]': '_onCheckboxClick',
|
||||
'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
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @param {Object} params
|
||||
*/
|
||||
update: function (params) {
|
||||
this.favorites = params.favorites;
|
||||
},
|
||||
/**
|
||||
* 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();
|
||||
this.trigger_up('favorite_submenu_toggled');
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Handlers
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Additional behavior when a checkbox is clicked upon
|
||||
* Namely, some checkbox are mutually exclusive
|
||||
* this function allows this
|
||||
*
|
||||
* @private
|
||||
* @param {jQueryEvent} ev
|
||||
*/
|
||||
_onCheckboxClick: function (ev) {
|
||||
function mutuallyExclusive (elem) {
|
||||
if (!elem.id) return false;
|
||||
return ['use_by_default', 'share_all_users'].some(function (str) {
|
||||
return elem.id.indexOf(str);
|
||||
});
|
||||
}
|
||||
var $checkboxes = this.$('input[type="checkbox"]');
|
||||
var currentCheckBox = ev.target;
|
||||
|
||||
if (mutuallyExclusive(currentCheckBox)) {
|
||||
for (var i=0; i < $checkboxes.length; i++) {
|
||||
var checkbox = $checkboxes[i];
|
||||
if (checkbox !== currentCheckBox && mutuallyExclusive(checkbox)) {
|
||||
checkbox.checked = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
/**
|
||||
* @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,80 +0,0 @@
|
||||
odoo.define('web.controlPanelViewParameters', function (require) {
|
||||
"use strict";
|
||||
|
||||
var core = require('web.core');
|
||||
|
||||
var _lt = core._lt;
|
||||
|
||||
// for FilterMenu
|
||||
const DEFAULT_PERIOD = 'this_month';
|
||||
const 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 },
|
||||
{ description: _lt('Last 5 Years'), optionId: 'last_5_years', groupId: 1 },
|
||||
{ description: _lt('Today'), optionId: 'today', groupId: 2 },
|
||||
{ description: _lt('This Week'), optionId: 'this_week', groupId: 2 },
|
||||
{ description: _lt('This Month'), optionId: 'this_month', groupId: 2 },
|
||||
{ description: _lt('This Quarter'), optionId: 'this_quarter', groupId: 2 },
|
||||
{ description: _lt('This Year'), optionId: 'this_year', groupId: 2 },
|
||||
{ description: _lt('Yesterday'), optionId: 'yesterday', groupId: 3 },
|
||||
{ description: _lt('Last Week'), optionId: 'last_week', groupId: 3 },
|
||||
{ description: _lt('Last Month'), optionId: 'last_month', groupId: 3 },
|
||||
{ description: _lt('Last Quarter'), optionId: 'last_quarter', groupId: 3 },
|
||||
{ description: _lt('Last Year'), optionId: 'last_year', groupId: 3 },
|
||||
];
|
||||
const MONTH_OPTIONS = [
|
||||
{ optionId: 'this_month', groupId: 1, format: 'MMMM', addParam: {}, setParam: {}, granularity: 'month' },
|
||||
{ optionId: 'last_month', groupId: 1, format: 'MMMM', addParam: { months: -1 }, setParam: {}, granularity: 'month' },
|
||||
{ optionId: 'antepenultimate_month', groupId: 1, format: 'MMMM', addParam: { months: -2 }, setParam: {}, granularity: 'month' }
|
||||
]
|
||||
const QUARTER_OPTIONS = [
|
||||
{ optionId: 'fourth_quarter', groupId: 1, description: _lt("Q4"), addParam: {}, setParam: { quarter: 4 }, granularity: 'quarter' },
|
||||
{ optionId: 'third_quarter', groupId: 1, description: _lt("Q3"), addParam: {}, setParam: { quarter: 3 }, granularity: 'quarter' },
|
||||
{ optionId: 'second_quarter', groupId: 1, description: _lt("Q2"), addParam: {}, setParam: { quarter: 2 }, granularity: 'quarter' },
|
||||
{ optionId: 'first_quarter', groupId: 1, description: _lt("Q1"), addParam: {}, setParam: { quarter: 1 }, granularity: 'quarter' }
|
||||
]
|
||||
const YEAR_OPTIONS = [
|
||||
{ optionId: 'this_year', groupId: 2, format: 'YYYY', addParam: {}, setParam: {}, granularity: 'year' },
|
||||
{ optionId: 'last_year', groupId: 2, format: 'YYYY', addParam: { years: -1 }, setParam: {}, granularity: 'year' },
|
||||
{ optionId: 'antepenultimate_year', groupId: 2, format: 'YYYY', addParam: { years: -2 }, setParam: {}, granularity: 'year' },
|
||||
];
|
||||
const OPTION_GENERATORS = [...MONTH_OPTIONS, ...QUARTER_OPTIONS, ...YEAR_OPTIONS];
|
||||
|
||||
// for GroupBy menu
|
||||
const GROUPABLE_TYPES = ['many2one', 'char', 'boolean', 'selection', 'date', 'datetime', 'integer'];
|
||||
const DEFAULT_INTERVAL = 'month';
|
||||
const INTERVAL_OPTIONS = [
|
||||
{ description: _lt('Year'), optionId: 'year', groupId: 1 },
|
||||
{ description: _lt('Quarter'), optionId: 'quarter', groupId: 1 },
|
||||
{ description: _lt('Month'), optionId: 'month', groupId: 1 },
|
||||
{ description: _lt('Week'), optionId: 'week', groupId: 1 },
|
||||
{ description: _lt('Day'), optionId: 'day', groupId: 1 },
|
||||
];
|
||||
function rank(oId) {
|
||||
return INTERVAL_OPTIONS.findIndex(o => o.optionId === oId);
|
||||
}
|
||||
|
||||
// for TimeRangeMenu
|
||||
const DEFAULT_TIMERANGE = DEFAULT_PERIOD;
|
||||
const TIME_RANGE_OPTIONS = PERIOD_OPTIONS;
|
||||
const COMPARISON_TIME_RANGE_OPTIONS = [
|
||||
{ description: _lt('Previous Period'), optionId: 'previous_period' },
|
||||
{ description: _lt('Previous Year'), optionId: 'previous_year' }
|
||||
];
|
||||
|
||||
return {
|
||||
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,
|
||||
OPTION_GENERATORS: OPTION_GENERATORS,
|
||||
PERIOD_OPTIONS: PERIOD_OPTIONS,
|
||||
TIME_RANGE_OPTIONS: TIME_RANGE_OPTIONS,
|
||||
YEAR_OPTIONS: YEAR_OPTIONS,
|
||||
rank: rank,
|
||||
};
|
||||
|
||||
});
|
||||
@@ -1,134 +0,0 @@
|
||||
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({
|
||||
custom_events: _.extend({}, DropdownMenu.prototype.custom_events, {
|
||||
favorite_submenu_toggled: '_onSubMenuToggled',
|
||||
}),
|
||||
/**
|
||||
* @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,
|
||||
};
|
||||
var superProm = this._super.apply(this, arguments);
|
||||
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);
|
||||
});
|
||||
return superProm;
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Public
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
update: function (items) {
|
||||
this._super.apply(this, arguments);
|
||||
_.invoke(this.subMenus, 'update', { favorites: this.items });
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Private
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
_closeSubMenus: function () {
|
||||
_.invoke(this.subMenus, 'closeMenu');
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Handlers
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @override
|
||||
* @private
|
||||
*/
|
||||
_onBootstrapClose: function () {
|
||||
this._super.apply(this, arguments);
|
||||
this._closeSubMenus();
|
||||
},
|
||||
/**
|
||||
* Reacts to a submenu being toggled
|
||||
*
|
||||
* When a submenu is toggled, it has changed the position
|
||||
* and size of the Favorite's dropdown. This method
|
||||
* repositions the current dropdown
|
||||
*
|
||||
* @private
|
||||
* @param {OdooEvent} ev
|
||||
*
|
||||
*/
|
||||
_onSubMenuToggled: function (ev) {
|
||||
ev.stopPropagation();
|
||||
this.$dropdownReference.dropdown('update');
|
||||
},
|
||||
/**
|
||||
* @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;
|
||||
|
||||
});
|
||||
@@ -1,8 +0,0 @@
|
||||
odoo.define('web.favorites_submenus_registry', function (require) {
|
||||
"use strict";
|
||||
|
||||
var Registry = require('web.Registry');
|
||||
|
||||
return new Registry();
|
||||
|
||||
});
|
||||
@@ -1,186 +0,0 @@
|
||||
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 _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 () {
|
||||
var superProm = this._super.apply(this, arguments);
|
||||
this.$menu.addClass('o_filters_menu');
|
||||
this._renderGeneratorMenu();
|
||||
return superProm;
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Private
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Add a proposition inside the custom filter edition menu.
|
||||
*
|
||||
* @private
|
||||
* @returns {Promise}
|
||||
*/
|
||||
_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);
|
||||
return 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),
|
||||
};
|
||||
});
|
||||
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();
|
||||
}
|
||||
this.$dropdownReference.dropdown('update');
|
||||
},
|
||||
/**
|
||||
* 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;
|
||||
|
||||
});
|
||||
@@ -1,206 +0,0 @@
|
||||
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
|
||||
* }
|
||||
* @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;
|
||||
options = options || {};
|
||||
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 && name !== "id" && _.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 && !options.noSymbol ? '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.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 () {
|
||||
var superProm = this._super.apply(this, arguments);
|
||||
this.$menu.addClass('o_group_by_menu');
|
||||
this._renderGeneratorMenu();
|
||||
return superProm;
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// 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.currentOptionIds = new Set([]);
|
||||
}
|
||||
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');
|
||||
this.$dropdownReference.dropdown('update');
|
||||
},
|
||||
/**
|
||||
* @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;
|
||||
|
||||
});
|
||||
@@ -1,147 +0,0 @@
|
||||
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
|
||||
* @param {Promise}
|
||||
*/
|
||||
_addGroupByMenu: function ($node, groupableFields) {
|
||||
this.sortedFieldNames = Object.keys(groupableFields).sort();
|
||||
this.groupableFields = groupableFields;
|
||||
const groupBys = this._getGroupBys(this.model.get().groupBy);
|
||||
this.groupByMenu = new GroupByMenu(this, groupBys, this.groupableFields, {noSymbol: true});
|
||||
return this.groupByMenu.insertAfter($node.find('div:first'));
|
||||
},
|
||||
/**
|
||||
* This method puts the active groupBys in a convenient form.
|
||||
*
|
||||
* @private
|
||||
* @param {string[]} activeGroupBys
|
||||
* returns {Object[]} groupBysNormalized
|
||||
*/
|
||||
_normalizeActiveGroupBys: function (activeGroupBys) {
|
||||
return activeGroupBys.map(gb => {
|
||||
const fieldName = gb.split(':')[0];
|
||||
const field = this.groupableFields[fieldName];
|
||||
const ngb = {fieldName: fieldName};
|
||||
if (_.contains(['date', 'datetime'], field.type)) {
|
||||
ngb.interval = gb.split(':')[1] || controlPanelViewParameters.DEFAULT_INTERVAL;
|
||||
}
|
||||
return ngb;
|
||||
});
|
||||
},
|
||||
/**
|
||||
* 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) {
|
||||
const groupBysNormalized = this._normalizeActiveGroupBys(activeGroupBys);
|
||||
return this.sortedFieldNames.map(fieldName => {
|
||||
const field = this.groupableFields[fieldName];
|
||||
const groupByActivity = groupBysNormalized.filter(gb => (gb.fieldName === fieldName));
|
||||
const groupBy = {
|
||||
id: fieldName,
|
||||
isActive: groupByActivity.length ? true : false,
|
||||
description: field.string,
|
||||
};
|
||||
if (_.contains(['date', 'datetime'], field.type)) {
|
||||
groupBy.hasOptions = true;
|
||||
groupBy.options = controlPanelViewParameters.INTERVAL_OPTIONS;
|
||||
groupBy.currentOptionIds = groupByActivity.length ?
|
||||
new Set(groupByActivity.map(gb => gb.interval)) :
|
||||
new Set([]);
|
||||
}
|
||||
return groupBy;
|
||||
});
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Handlers
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {OdooEvent} ev
|
||||
*/
|
||||
_onItemOptionClicked: function (ev) {
|
||||
const fieldName = ev.data.id;
|
||||
const optionId = ev.data.optionId;
|
||||
const activeGroupBys = this.model.get().groupBy;
|
||||
const groupBysNormalized = this._normalizeActiveGroupBys(activeGroupBys);
|
||||
const index = groupBysNormalized.findIndex(ngb =>
|
||||
ngb.fieldName === fieldName && ngb.interval === optionId);
|
||||
if (index === -1) {
|
||||
activeGroupBys.push(fieldName + ':' + optionId);
|
||||
} else {
|
||||
activeGroupBys.splice(index, 1);
|
||||
}
|
||||
this._setGroupby(activeGroupBys);
|
||||
this.groupByMenu.update(this._getGroupBys(activeGroupBys));
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @param {OdooEvent} ev
|
||||
*/
|
||||
_onMenuItemClicked: function (ev) {
|
||||
const fieldName = ev.data.id;
|
||||
const activeGroupBys = this.model.get().groupBy;
|
||||
const groupByFieldNames = activeGroupBys.map(gb => gb.split(':')[0]);
|
||||
const 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;
|
||||
|
||||
});
|
||||
@@ -1,260 +0,0 @@
|
||||
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, {
|
||||
'compositionend .o_searchview_input': '_onCompositionendInput',
|
||||
'compositionstart .o_searchview_input': '_onCompositionstartInput',
|
||||
'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.filterFields = params.filterFields;
|
||||
|
||||
this.autoCompleteSources = [];
|
||||
this.searchFacets = [];
|
||||
this._isInputComposing = false;
|
||||
},
|
||||
/**
|
||||
* @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 Promise.all(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 = this.autoCompleteSources.map(function (source) {
|
||||
return source.getAutocompletionValues(req.term);
|
||||
});
|
||||
Promise.all(defs).then(function (result) {
|
||||
var resultCleaned = _(result).chain()
|
||||
.compact()
|
||||
.flatten(true)
|
||||
.value();
|
||||
callback(resultCleaned);
|
||||
});
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @param {Object} facet
|
||||
* @returns {Promise}
|
||||
*/
|
||||
_renderFacet: function (facet) {
|
||||
var searchFacet = new SearchFacet(this, facet);
|
||||
this.searchFacets.push(searchFacet);
|
||||
return searchFacet.insertBefore(this.$('input'));
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @returns {Promise}
|
||||
*/
|
||||
_setupAutoCompletion: function () {
|
||||
var self = this;
|
||||
this._setupAutoCompletionWidgets();
|
||||
this.autoComplete = new AutoComplete(this, {
|
||||
$input: this.$('input'),
|
||||
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));
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Handlers
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {Event} e
|
||||
* @param {Object} ui
|
||||
* @param {Object} ui.item selected completion item
|
||||
*/
|
||||
_onAutoCompleteSelected: function (e, ui) {
|
||||
e.preventDefault();
|
||||
var facet = ui.item.facet;
|
||||
if (!facet) {
|
||||
// this happens when selecting "(no result)" item
|
||||
this.trigger_up('reset');
|
||||
return;
|
||||
}
|
||||
var filter = facet.filter;
|
||||
if (filter.type === 'field') {
|
||||
var values = filter.autoCompleteValues;
|
||||
values.push(facet.values[0]);
|
||||
this.trigger_up('autocompletion_filter', {
|
||||
filterId: filter.id,
|
||||
autoCompleteValues: values,
|
||||
});
|
||||
} else {
|
||||
this.trigger_up('autocompletion_filter', {
|
||||
filterId: filter.id,
|
||||
});
|
||||
}
|
||||
},
|
||||
/**
|
||||
* @rivate
|
||||
* @param {CompositionEvent} ev
|
||||
*/
|
||||
_onCompositionendInput: function () {
|
||||
this._isInputComposing = false;
|
||||
},
|
||||
/**
|
||||
* @rivate
|
||||
* @param {CompositionEvent} ev
|
||||
*/
|
||||
_onCompositionstartInput: function () {
|
||||
this._isInputComposing = true;
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @param {KeyEvent} e
|
||||
*/
|
||||
_onKeydown: function (e) {
|
||||
if (this._isInputComposing) {
|
||||
return;
|
||||
}
|
||||
switch(e.which) {
|
||||
case $.ui.keyCode.LEFT:
|
||||
this._focusPreceding();
|
||||
e.preventDefault();
|
||||
break;
|
||||
case $.ui.keyCode.RIGHT:
|
||||
this._focusFollowing();
|
||||
e.preventDefault();
|
||||
break;
|
||||
case $.ui.keyCode.DOWN:
|
||||
// if the searchbar dropdown is closed, try to focus the renderer
|
||||
const $dropdown = this.$('.o_searchview_autocomplete:visible');
|
||||
if (!$dropdown.length) {
|
||||
this.trigger_up('navigation_move', { direction: 'down' });
|
||||
e.preventDefault();
|
||||
}
|
||||
break;
|
||||
case $.ui.keyCode.BACKSPACE:
|
||||
if (this.$input.val() === '') {
|
||||
this.trigger_up('facet_removed');
|
||||
}
|
||||
break;
|
||||
case $.ui.keyCode.ENTER:
|
||||
if (this.$input.val() === '') {
|
||||
this.trigger_up('reload');
|
||||
}
|
||||
break;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return SearchBar;
|
||||
|
||||
});
|
||||
-485
@@ -1,485 +0,0 @@
|
||||
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 {Promise<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 Promise.resolve(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 Promise.resolve([{
|
||||
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 Promise.resolve(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 Promise.resolve(); }
|
||||
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 Promise.resolve(null); }
|
||||
return Promise.resolve([{
|
||||
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 Promise.resolve(null);
|
||||
}
|
||||
|
||||
var m = moment(v, t === 'datetime' ? 'YYYY-MM-DD HH:mm:ss' : 'YYYY-MM-DD');
|
||||
if (!m.isValid()) { return Promise.resolve(null); }
|
||||
var dateString = field_utils.format[t](m, {type: t});
|
||||
var label = this._getAutocompletionLabel(dateString);
|
||||
var facet = this._getFacetValue(dateString, m.toDate());
|
||||
return Promise.resolve([{
|
||||
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 Promise.resolve(null); }
|
||||
var label = this._getAutocompletionLabel(value);
|
||||
return Promise.resolve([{
|
||||
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,
|
||||
};
|
||||
|
||||
});
|
||||
-37
@@ -1,37 +0,0 @@
|
||||
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);
|
||||
|
||||
});
|
||||
@@ -1,172 +0,0 @@
|
||||
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',
|
||||
'compositionend': '_onCompositionend',
|
||||
'compositionstart': '_onCompositionstart',
|
||||
'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();
|
||||
this._isComposing = false;
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// 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) {
|
||||
if (filter.type === 'filter') {
|
||||
const optionDescriptions = [];
|
||||
const sortFunction = (o1, o2) =>
|
||||
filter.options.findIndex(o => o.optionId === o1) - filter.options.findIndex(o => o.optionId === o2);
|
||||
const p = _.partition([...filter.currentOptionIds], optionId =>
|
||||
filter.options.find(o => o.optionId === optionId).groupId === 1);
|
||||
const yearIds = p[1].sort(sortFunction);
|
||||
const otherOptionIds = p[0].sort(sortFunction);
|
||||
// the following case corresponds to years selected only
|
||||
if (otherOptionIds.length === 0) {
|
||||
yearIds.forEach(yearId => {
|
||||
const d = filter.basicDomains[yearId];
|
||||
optionDescriptions.push(d.description);
|
||||
});
|
||||
} else {
|
||||
otherOptionIds.forEach(optionId => {
|
||||
yearIds.forEach(yearId => {
|
||||
const d = filter.basicDomains[yearId + '__' + optionId];
|
||||
optionDescriptions.push(d.description);
|
||||
});
|
||||
});
|
||||
}
|
||||
description += ': ' + optionDescriptions.join('/');
|
||||
} else {
|
||||
description = description += ': ' +
|
||||
filter.options.find(o => o.optionId === filter.optionId).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
|
||||
* @param {CompositionEvent} ev
|
||||
*/
|
||||
_onCompositionend: function (ev) {
|
||||
this._isComposing = false;
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @param {CompositionEvent} ev
|
||||
*/
|
||||
_onCompositionstart: function (ev) {
|
||||
this._isComposing = true;
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
_onFacetRemove: function () {
|
||||
this.trigger_up('facet_removed', {group: this.facet});
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @param {KeyboardEvent} ev
|
||||
*/
|
||||
_onKeydown: function (ev) {
|
||||
if (this._isComposing) {
|
||||
return;
|
||||
}
|
||||
switch (ev.which) {
|
||||
case $.ui.keyCode.BACKSPACE:
|
||||
this.trigger_up('facet_removed', {group: this.facet});
|
||||
break;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return SearchFacet;
|
||||
|
||||
});
|
||||
@@ -1,418 +0,0 @@
|
||||
odoo.define('web.search_filters', function (require) {
|
||||
"use strict";
|
||||
|
||||
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({
|
||||
template: 'SearchView.extended_search.proposition',
|
||||
events: {
|
||||
'change .o_searchview_extended_prop_field': 'changed',
|
||||
'change .o_searchview_extended_prop_op': 'operator_changed',
|
||||
'click .o_searchview_extended_delete_prop': function (e) {
|
||||
e.stopPropagation();
|
||||
this.trigger_up('remove_proposition');
|
||||
},
|
||||
'keyup .o_searchview_extended_prop_value': function (ev) {
|
||||
if (ev.which === $.ui.keyCode.ENTER) {
|
||||
this.trigger_up('confirm_proposition');
|
||||
}
|
||||
},
|
||||
},
|
||||
/**
|
||||
* @override
|
||||
* @param fields
|
||||
*/
|
||||
init: function (parent, fields) {
|
||||
this._super(parent);
|
||||
this.fields = _(fields).chain()
|
||||
.map(function (val, key) { return _.extend({}, val, {'name': key}); })
|
||||
.filter(function (field) { return !field.deprecated && field.searchable; })
|
||||
.sortBy(function (field) {return field.string;})
|
||||
.value();
|
||||
this.attrs = {_: _, fields: this.fields, selected: null};
|
||||
this.value = null;
|
||||
},
|
||||
start: function () {
|
||||
var parent = this._super();
|
||||
parent.then(this.proxy('changed'));
|
||||
return parent;
|
||||
},
|
||||
changed: function () {
|
||||
var nval = this.$(".o_searchview_extended_prop_field").val();
|
||||
if(this.attrs.selected === null || this.attrs.selected === undefined || nval !== this.attrs.selected.name) {
|
||||
this.select_field(_.detect(this.fields, function (x) {return x.name === nval;}));
|
||||
}
|
||||
},
|
||||
operator_changed: function (e) {
|
||||
this.value.show_inputs($(e.target));
|
||||
},
|
||||
/**
|
||||
* Selects the provided field object
|
||||
*
|
||||
* @param field a field descriptor object (as returned by fields_get, augmented by the field name)
|
||||
*/
|
||||
select_field: function (field) {
|
||||
var self = this;
|
||||
if(this.attrs.selected !== null && this.attrs.selected !== undefined) {
|
||||
this.value.destroy();
|
||||
this.value = null;
|
||||
this.$('.o_searchview_extended_prop_op').html('');
|
||||
}
|
||||
this.attrs.selected = field;
|
||||
if(field === null || field === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
var type = field.type;
|
||||
var Field = search_filters_registry.getAny([type, "char"]);
|
||||
|
||||
this.value = new Field(this, field);
|
||||
_.each(this.value.operators, function (operator) {
|
||||
$('<option>', {value: operator.value})
|
||||
.text(String(operator.text))
|
||||
.appendTo(self.$('.o_searchview_extended_prop_op'));
|
||||
});
|
||||
var $value_loc = this.$('.o_searchview_extended_prop_value').show().empty();
|
||||
this.value.appendTo($value_loc);
|
||||
|
||||
},
|
||||
get_filter: function () {
|
||||
if (this.attrs.selected === null || this.attrs.selected === undefined) {
|
||||
return null;
|
||||
}
|
||||
var field = this.attrs.selected,
|
||||
op_select = this.$('.o_searchview_extended_prop_op')[0],
|
||||
operator = op_select.options[op_select.selectedIndex];
|
||||
|
||||
return {
|
||||
attrs: {
|
||||
domain: this.value.get_domain(field, operator),
|
||||
string: this.value.get_label(field, operator),
|
||||
},
|
||||
children: [],
|
||||
tag: 'filter',
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
var Field = Widget.extend({
|
||||
init: function (parent, field) {
|
||||
this._super(parent);
|
||||
this.field = field;
|
||||
},
|
||||
get_label: function (field, operator) {
|
||||
var format;
|
||||
switch (operator.value) {
|
||||
case '∃': case '∄': format = _t('%(field)s %(operator)s'); break;
|
||||
default: format = _t('%(field)s %(operator)s "%(value)s"'); break;
|
||||
}
|
||||
return this.format_label(format, field, operator);
|
||||
},
|
||||
format_label: function (format, field, operator) {
|
||||
return _.str.sprintf(format, {
|
||||
field: field.string,
|
||||
// According to spec, HTMLOptionElement#label should return
|
||||
// HTMLOptionElement#text when not defined/empty, but it does
|
||||
// not in older Webkit (between Safari 5.1.5 and Chrome 17) and
|
||||
// Gecko (pre Firefox 7) browsers, so we need a manual fallback
|
||||
// for those
|
||||
operator: operator.label || operator.text,
|
||||
value: this
|
||||
});
|
||||
},
|
||||
get_domain: function (field, operator) {
|
||||
switch (operator.value) {
|
||||
case '∃': return [[field.name, '!=', false]];
|
||||
case '∄': return [[field.name, '=', false]];
|
||||
default: return [[field.name, operator.value, this.get_value()]];
|
||||
}
|
||||
},
|
||||
show_inputs: function ($operator) {
|
||||
var $value = this.$el.parent();
|
||||
switch ($operator.val()) {
|
||||
case '∃':
|
||||
case '∄':
|
||||
$value.hide();
|
||||
break;
|
||||
default:
|
||||
$value.show();
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Returns a human-readable version of the value, in case the "logical"
|
||||
* and the "semantic" values of a field differ (as for selection fields,
|
||||
* for instance).
|
||||
*
|
||||
* The default implementation simply returns the value itself.
|
||||
*
|
||||
* @return {String} human-readable version of the value
|
||||
*/
|
||||
toString: function () {
|
||||
return this.get_value();
|
||||
}
|
||||
});
|
||||
|
||||
var Char = Field.extend({
|
||||
tagName: 'input',
|
||||
className: 'o_input',
|
||||
attributes: {
|
||||
type: 'text'
|
||||
},
|
||||
operators: [
|
||||
{value: "ilike", text: _lt("contains")},
|
||||
{value: "not ilike", text: _lt("doesn't contain")},
|
||||
{value: "=", text: _lt("is equal to")},
|
||||
{value: "!=", text: _lt("is not equal to")},
|
||||
{value: "∃", text: _lt("is set")},
|
||||
{value: "∄", text: _lt("is not set")}
|
||||
],
|
||||
get_value: function () {
|
||||
return this.$el.val();
|
||||
}
|
||||
});
|
||||
|
||||
var DateTime = Field.extend({
|
||||
tagName: 'span',
|
||||
serverFormat: 'YYYY-MM-DD HH:mm:ss',
|
||||
attributes: {
|
||||
type: 'datetime'
|
||||
},
|
||||
operators: [
|
||||
{value: "between", text: _lt("is between")},
|
||||
{value: "=", text: _lt("is equal to")},
|
||||
{value: "!=", text: _lt("is not equal to")},
|
||||
{value: ">", text: _lt("is after")},
|
||||
{value: "<", text: _lt("is before")},
|
||||
{value: ">=", text: _lt("is after or equal to")},
|
||||
{value: "<=", text: _lt("is before or equal to")},
|
||||
{value: "∃", text: _lt("is set")},
|
||||
{value: "∄", text: _lt("is not set")}
|
||||
],
|
||||
/**
|
||||
* Gets the value of the datepicker
|
||||
*
|
||||
* @public
|
||||
* @param {Integer} [index] The datepicker's index.
|
||||
* 0 for the lower boundary (default)
|
||||
* 1 for the higher boundary
|
||||
*
|
||||
* @return {Moment} The value in UTC
|
||||
*/
|
||||
get_value: function (index) {
|
||||
// retrieve the datepicker value
|
||||
var value = this["datewidget_" + (index || 0)].getValue().clone();
|
||||
// convert to utc
|
||||
return value.add(-this.getSession().getTZOffset(value), 'minutes');
|
||||
},
|
||||
get_domain: function (field, operator) {
|
||||
switch (operator.value) {
|
||||
case '∃':
|
||||
return [[field.name, '!=', false]];
|
||||
case '∄':
|
||||
return [[field.name, '=', false]];
|
||||
case 'between':
|
||||
return [
|
||||
[field.name, '>=', this._formatMomentToServer(this.get_value(0))],
|
||||
[field.name, '<=', this._formatMomentToServer(this.get_value(1))]
|
||||
];
|
||||
default:
|
||||
return [[field.name, operator.value, this._formatMomentToServer(this.get_value())]];
|
||||
}
|
||||
},
|
||||
show_inputs: function ($operator) {
|
||||
this._super.apply(this, arguments);
|
||||
|
||||
if ($operator.val() === "between") {
|
||||
this.datewidget_1.do_show();
|
||||
} else {
|
||||
this.datewidget_1.do_hide();
|
||||
}
|
||||
},
|
||||
toString: function () {
|
||||
var str = field_utils.format[this.attributes.type](this.get_value(), {type: this.attributes.type});
|
||||
// the second datewidget might have been hidden because the operator has changed
|
||||
var date_1_value = this.datewidget_1 && !this.datewidget_1.$el.hasClass('o_hidden') && this.get_value(1);
|
||||
if (date_1_value) {
|
||||
str += _lt(" and ") + field_utils.format[this.attributes.type](date_1_value, {type: this.attributes.type});
|
||||
}
|
||||
return str;
|
||||
},
|
||||
start: function () {
|
||||
return Promise.all([
|
||||
this._super.apply(this, arguments),
|
||||
this._create_new_widget("datewidget_0", '00:00:00', 'hh:mm:ss'),
|
||||
this._create_new_widget("datewidget_1", '23:59:59', 'hh:mm:ss'),
|
||||
]).then(() => {
|
||||
if (this.operators[0].value !== "between") {
|
||||
this.datewidget_1.do_hide();
|
||||
}
|
||||
});
|
||||
},
|
||||
_create_new_widget: function (name, ...time) {
|
||||
this[name] = new (this._get_widget_class())(this);
|
||||
return this[name].appendTo(this.$el).then((function () {
|
||||
this[name].setValue(moment(...time));
|
||||
}).bind(this));
|
||||
},
|
||||
_get_widget_class: function () {
|
||||
return datepicker.DateTimeWidget;
|
||||
},
|
||||
/**
|
||||
* Transform a Moment in a server acceptable format
|
||||
*
|
||||
* @private
|
||||
* @param {Moment} momentValue The moment to get the string for
|
||||
*
|
||||
* @return {String} Represents the value in UTC
|
||||
*/
|
||||
_formatMomentToServer: function (momentValue) {
|
||||
return momentValue.locale('en').format(this.serverFormat);
|
||||
},
|
||||
});
|
||||
|
||||
var Date = DateTime.extend({
|
||||
serverFormat: 'YYYY-MM-DD',
|
||||
attributes: {
|
||||
type: 'date'
|
||||
},
|
||||
operators: [
|
||||
{value: "=", text: _lt("is equal to")},
|
||||
{value: "!=", text: _lt("is not equal to")},
|
||||
{value: ">", text: _lt("is after")},
|
||||
{value: "<", text: _lt("is before")},
|
||||
{value: ">=", text: _lt("is after or equal to")},
|
||||
{value: "<=", text: _lt("is before or equal to")},
|
||||
{value: "between", text: _lt("is between")},
|
||||
{value: "∃", text: _lt("is set")},
|
||||
{value: "∄", text: _lt("is not set")}
|
||||
],
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
get_value: function (index) {
|
||||
// retrieve the datepicker value
|
||||
var value = this["datewidget_" + (index || 0)].getValue();
|
||||
return value && value.clone();
|
||||
},
|
||||
_get_widget_class: function () {
|
||||
return datepicker.DateWidget;
|
||||
},
|
||||
});
|
||||
|
||||
var Integer = Field.extend({
|
||||
tagName: 'input',
|
||||
className: 'o_input',
|
||||
attributes: {
|
||||
type: 'number',
|
||||
value: '0',
|
||||
},
|
||||
operators: [
|
||||
{value: "=", text: _lt("is equal to")},
|
||||
{value: "!=", text: _lt("is not equal to")},
|
||||
{value: ">", text: _lt("greater than")},
|
||||
{value: "<", text: _lt("less than")},
|
||||
{value: ">=", text: _lt("greater than or equal to")},
|
||||
{value: "<=", text: _lt("less than or equal to")},
|
||||
{value: "∃", text: _lt("is set")},
|
||||
{value: "∄", text: _lt("is not set")}
|
||||
],
|
||||
toString: function () {
|
||||
return this.$el.val();
|
||||
},
|
||||
get_value: function () {
|
||||
try {
|
||||
var val =this.$el.val();
|
||||
return field_utils.parse.integer(val === "" ? 0 : val);
|
||||
} catch (e) {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
var Id = Integer.extend({
|
||||
operators: [{value: "=", text: _lt("is")}]
|
||||
});
|
||||
|
||||
var Float = Field.extend({
|
||||
template: 'SearchView.extended_search.proposition.float',
|
||||
operators: [
|
||||
{value: "=", text: _lt("is equal to")},
|
||||
{value: "!=", text: _lt("is not equal to")},
|
||||
{value: ">", text: _lt("greater than")},
|
||||
{value: "<", text: _lt("less than")},
|
||||
{value: ">=", text: _lt("greater than or equal to")},
|
||||
{value: "<=", text: _lt("less than or equal to")},
|
||||
{value: "∃", text: _lt("is set")},
|
||||
{value: "∄", text: _lt("is not set")}
|
||||
],
|
||||
init: function (parent) {
|
||||
this._super(parent);
|
||||
this.decimal_point = _t.database.parameters.decimal_point;
|
||||
},
|
||||
toString: function () {
|
||||
return this.$el.val();
|
||||
},
|
||||
get_value: function () {
|
||||
try {
|
||||
var val =this.$el.val();
|
||||
return field_utils.parse.float(val === "" ? 0.0 : val);
|
||||
} catch (e) {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
var Selection = Field.extend({
|
||||
template: 'SearchView.extended_search.proposition.selection',
|
||||
operators: [
|
||||
{value: "=", text: _lt("is")},
|
||||
{value: "!=", text: _lt("is not")},
|
||||
{value: "∃", text: _lt("is set")},
|
||||
{value: "∄", text: _lt("is not set")}
|
||||
],
|
||||
toString: function () {
|
||||
var select = this.$el[0];
|
||||
var option = select.options[select.selectedIndex];
|
||||
return option.label || option.text;
|
||||
},
|
||||
get_value: function () {
|
||||
return this.$el.val();
|
||||
}
|
||||
});
|
||||
|
||||
var Boolean = Field.extend({
|
||||
tagName: 'span',
|
||||
operators: [
|
||||
{value: "=", text: _lt("is true")},
|
||||
{value: "!=", text: _lt("is false")}
|
||||
],
|
||||
get_label: function (field, operator) {
|
||||
return this.format_label(
|
||||
_t('%(field)s %(operator)s'), field, operator);
|
||||
},
|
||||
get_value: function () {
|
||||
return true;
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
Boolean: Boolean,
|
||||
Char: Char,
|
||||
Date: Date,
|
||||
DateTime: DateTime,
|
||||
ExtendedSearchProposition: ExtendedSearchProposition,
|
||||
Field: Field,
|
||||
Float: Float,
|
||||
Id: Id,
|
||||
Integer: Integer,
|
||||
Selection: Selection,
|
||||
};
|
||||
|
||||
});
|
||||
@@ -1,30 +0,0 @@
|
||||
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);
|
||||
});
|
||||
@@ -1,106 +0,0 @@
|
||||
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;
|
||||
|
||||
});
|
||||
@@ -5,7 +5,6 @@ var BasicController = require('web.BasicController');
|
||||
var core = require('web.core');
|
||||
var Dialog = require('web.Dialog');
|
||||
var dialogs = require('web.view_dialogs');
|
||||
var Sidebar = require('web.Sidebar');
|
||||
|
||||
var _t = core._t;
|
||||
var qweb = core.qweb;
|
||||
@@ -24,7 +23,7 @@ var FormController = BasicController.extend({
|
||||
/**
|
||||
* @override
|
||||
*
|
||||
* @param {boolean} params.hasSidebar
|
||||
* @param {boolean} params.hasActionMenus
|
||||
* @param {Object} params.toolbarActions
|
||||
*/
|
||||
init: function (parent, model, renderer, params) {
|
||||
@@ -34,7 +33,7 @@ var FormController = BasicController.extend({
|
||||
this.disableAutofocus = params.disableAutofocus;
|
||||
this.footerToButtons = params.footerToButtons;
|
||||
this.defaultButtons = params.defaultButtons;
|
||||
this.hasSidebar = params.hasSidebar;
|
||||
this.hasActionMenus = params.hasActionMenus;
|
||||
this.toolbarActions = params.toolbarActions || {};
|
||||
},
|
||||
/**
|
||||
@@ -88,10 +87,9 @@ var FormController = BasicController.extend({
|
||||
* for the new record.
|
||||
* @returns {Promise}
|
||||
*/
|
||||
createRecord: function (parentID) {
|
||||
var self = this;
|
||||
var record = this.model.get(this.handle, {raw: true});
|
||||
return this.model.load({
|
||||
createRecord: async function (parentID) {
|
||||
const record = this.model.get(this.handle, { raw: true });
|
||||
const handle = await this.model.load({
|
||||
context: record.getContext(),
|
||||
fields: record.fields,
|
||||
fieldsInfo: record.fieldsInfo,
|
||||
@@ -100,15 +98,14 @@ var FormController = BasicController.extend({
|
||||
res_ids: record.res_ids,
|
||||
type: 'record',
|
||||
viewType: 'form',
|
||||
}).then(function (handle) {
|
||||
self.handle = handle;
|
||||
self._updateEnv();
|
||||
return self._setMode('edit');
|
||||
});
|
||||
this.handle = handle;
|
||||
this._updateControlPanel();
|
||||
return this._setMode('edit');
|
||||
},
|
||||
/**
|
||||
* Returns the current res_id, wrapped in a list. This is only used by the
|
||||
* sidebar (and the debugmanager)
|
||||
* action menus (and the debugmanager)
|
||||
*
|
||||
* @override
|
||||
*
|
||||
@@ -142,35 +139,34 @@ var FormController = BasicController.extend({
|
||||
* have to use them instead of the standard buttons.
|
||||
*
|
||||
* @override method from AbstractController
|
||||
* @param {jQueryElement} $node
|
||||
* @param {jQuery} [$node]
|
||||
*/
|
||||
renderButtons: function ($node) {
|
||||
var $footer = this.footerToButtons ? this.renderer.$('footer') : null;
|
||||
var $footer = this.footerToButtons ? this.renderer.$el && this.renderer.$('footer') : null;
|
||||
var mustRenderFooterButtons = $footer && $footer.length;
|
||||
if (!this.defaultButtons && !mustRenderFooterButtons) {
|
||||
return;
|
||||
if ((this.defaultButtons && !this.$buttons) || mustRenderFooterButtons) {
|
||||
this.$buttons = $('<div/>');
|
||||
if (mustRenderFooterButtons) {
|
||||
this.$buttons.append($footer);
|
||||
} else {
|
||||
this.$buttons.append(qweb.render("FormView.buttons", {widget: this}));
|
||||
this.$buttons.on('click', '.o_form_button_edit', this._onEdit.bind(this));
|
||||
this.$buttons.on('click', '.o_form_button_create', this._onCreate.bind(this));
|
||||
this.$buttons.on('click', '.o_form_button_save', this._onSave.bind(this));
|
||||
this.$buttons.on('click', '.o_form_button_cancel', this._onDiscard.bind(this));
|
||||
this._assignSaveCancelKeyboardBehavior(this.$buttons.find('.o_form_buttons_edit'));
|
||||
this.$buttons.find('.o_form_buttons_edit').tooltip({
|
||||
delay: {show: 200, hide:0},
|
||||
title: function(){
|
||||
return qweb.render('SaveCancelButton.tooltip');
|
||||
},
|
||||
trigger: 'manual',
|
||||
});
|
||||
}
|
||||
}
|
||||
this.$buttons = $('<div/>');
|
||||
if (mustRenderFooterButtons) {
|
||||
this.$buttons.append($footer);
|
||||
|
||||
} else {
|
||||
this.$buttons.append(qweb.render("FormView.buttons", {widget: this}));
|
||||
this.$buttons.on('click', '.o_form_button_edit', this._onEdit.bind(this));
|
||||
this.$buttons.on('click', '.o_form_button_create', this._onCreate.bind(this));
|
||||
this.$buttons.on('click', '.o_form_button_save', this._onSave.bind(this));
|
||||
this.$buttons.on('click', '.o_form_button_cancel', this._onDiscard.bind(this));
|
||||
this._assignSaveCancelKeyboardBehavior(this.$buttons.find('.o_form_buttons_edit'));
|
||||
this.$buttons.find('.o_form_buttons_edit').tooltip({
|
||||
delay: {show: 200, hide:0},
|
||||
title: function(){
|
||||
return qweb.render('SaveCancelButton.tooltip');
|
||||
},
|
||||
trigger: 'manual',
|
||||
});
|
||||
this._updateButtons();
|
||||
if (this.$buttons && $node) {
|
||||
this.$buttons.appendTo($node);
|
||||
}
|
||||
this.$buttons.appendTo($node);
|
||||
},
|
||||
/**
|
||||
* The form view has to prevent a click on the pager if the form is dirty
|
||||
@@ -180,71 +176,58 @@ var FormController = BasicController.extend({
|
||||
* @param {Object} options
|
||||
* @returns {Promise}
|
||||
*/
|
||||
renderPager: function ($node, options) {
|
||||
options = _.extend({}, options, {
|
||||
_getPagingInfo: function () {
|
||||
// Only display the pager if we are not on a new record.
|
||||
if (this.model.isNew(this.handle)) {
|
||||
return null;
|
||||
}
|
||||
return Object.assign(this._super(...arguments), {
|
||||
validate: this.canBeDiscarded.bind(this),
|
||||
});
|
||||
return this._super($node, options);
|
||||
},
|
||||
/**
|
||||
* Instantiate and render the sidebar if a sidebar is requested
|
||||
* Sets this.sidebar
|
||||
* @param {jQuery} [$node] a jQuery node where the sidebar should be
|
||||
* inserted
|
||||
* @return {Promise}
|
||||
* @override
|
||||
* @private
|
||||
**/
|
||||
renderSidebar: function ($node) {
|
||||
var self = this;
|
||||
if (this.hasSidebar) {
|
||||
var otherItems = [];
|
||||
const activeField = this.model.getActiveField(this.initialState);
|
||||
const activeFieldValue = this.initialState.data[activeField];
|
||||
if (this.archiveEnabled && activeField) {
|
||||
var classname = "o_sidebar_item_archive" + (activeFieldValue ? "" : " o_hidden")
|
||||
otherItems.push({
|
||||
label: _t("Archive"),
|
||||
callback: function () {
|
||||
Dialog.confirm(self, _t("Are you sure that you want to archive this record?"), {
|
||||
confirm_callback: self._toggleArchiveState.bind(self, true),
|
||||
_getActionMenuItems: function () {
|
||||
if (!this.hasActionMenus || this.mode === 'edit') {
|
||||
return null;
|
||||
}
|
||||
const props = this._super(...arguments);
|
||||
const activeField = this.model.getActiveField(this.initialState);
|
||||
const otherActionItems = [];
|
||||
if (this.archiveEnabled && activeField) {
|
||||
if (this.initialState.data[activeField]) {
|
||||
otherActionItems.push({
|
||||
description: _t("Archive"),
|
||||
callback: () => {
|
||||
Dialog.confirm(this, _t("Are you sure that you want to archive this record?"), {
|
||||
confirm_callback: () => this._toggleArchiveState(true),
|
||||
});
|
||||
},
|
||||
classname: classname,
|
||||
});
|
||||
classname = "o_sidebar_item_unarchive" + (activeFieldValue ? " o_hidden" : "")
|
||||
otherItems.push({
|
||||
label: _t("Unarchive"),
|
||||
callback: this._toggleArchiveState.bind(this, false),
|
||||
classname: classname,
|
||||
} else {
|
||||
otherActionItems.push({
|
||||
description: _t("Unarchive"),
|
||||
callback: () => this._toggleArchiveState(false),
|
||||
});
|
||||
}
|
||||
if (this.is_action_enabled('delete')) {
|
||||
otherItems.push({
|
||||
label: _t('Delete'),
|
||||
callback: this._onDeleteRecord.bind(this),
|
||||
});
|
||||
}
|
||||
if (this.is_action_enabled('create') && this.is_action_enabled('duplicate')) {
|
||||
otherItems.push({
|
||||
label: _t('Duplicate'),
|
||||
callback: this._onDuplicateRecord.bind(this),
|
||||
});
|
||||
}
|
||||
this.sidebar = new Sidebar(this, {
|
||||
editable: this.is_action_enabled('edit'),
|
||||
viewType: 'form',
|
||||
env: {
|
||||
context: this.model.get(this.handle).getContext(),
|
||||
activeIds: this.getSelectedIds(),
|
||||
model: this.modelName,
|
||||
},
|
||||
actions: _.extend(this.toolbarActions, {other: otherItems}),
|
||||
});
|
||||
return this.sidebar.appendTo($node).then(function() {
|
||||
// Show or hide the sidebar according to the view mode
|
||||
self._updateSidebar();
|
||||
}
|
||||
if (this.activeActions.create && this.activeActions.duplicate) {
|
||||
otherActionItems.push({
|
||||
description: _t("Duplicate"),
|
||||
callback: () => this._onDuplicateRecord(this),
|
||||
});
|
||||
}
|
||||
return Promise.resolve();
|
||||
if (this.activeActions.delete) {
|
||||
otherActionItems.push({
|
||||
description: _t("Delete"),
|
||||
callback: () => this._onDeleteRecord(this),
|
||||
});
|
||||
}
|
||||
return Object.assign(props, {
|
||||
items: Object.assign(this.toolbarActions, { other: otherActionItems }),
|
||||
});
|
||||
},
|
||||
/**
|
||||
* Show a warning message if the user modified a translated field. For each
|
||||
@@ -252,32 +235,29 @@ var FormController = BasicController.extend({
|
||||
*
|
||||
* @override
|
||||
*/
|
||||
saveRecord: function () {
|
||||
var self = this;
|
||||
return this._super.apply(this, arguments).then(function (changedFields) {
|
||||
// the title could have been changed
|
||||
self._setTitle(self.getTitle());
|
||||
self._updateEnv();
|
||||
saveRecord: async function () {
|
||||
const changedFields = await this._super(...arguments);
|
||||
// the title could have been changed
|
||||
this._updateControlPanel();
|
||||
|
||||
if (_t.database.multi_lang && changedFields.length) {
|
||||
// need to make sure changed fields that should be translated
|
||||
// are displayed with an alert
|
||||
var fields = self.renderer.state.fields;
|
||||
var data = self.renderer.state.data;
|
||||
var alertFields = {};
|
||||
for (var k = 0; k < changedFields.length; k++) {
|
||||
var field = fields[changedFields[k]];
|
||||
var fieldData = data[changedFields[k]];
|
||||
if (field.translate && fieldData) {
|
||||
alertFields[changedFields[k]] = field;
|
||||
}
|
||||
}
|
||||
if (!_.isEmpty(alertFields)) {
|
||||
self.renderer.updateAlertFields(alertFields);
|
||||
if (_t.database.multi_lang && changedFields.length) {
|
||||
// need to make sure changed fields that should be translated
|
||||
// are displayed with an alert
|
||||
var fields = this.renderer.state.fields;
|
||||
var data = this.renderer.state.data;
|
||||
var alertFields = {};
|
||||
for (var k = 0; k < changedFields.length; k++) {
|
||||
var field = fields[changedFields[k]];
|
||||
var fieldData = data[changedFields[k]];
|
||||
if (field.translate && fieldData) {
|
||||
alertFields[changedFields[k]] = field;
|
||||
}
|
||||
}
|
||||
return changedFields;
|
||||
});
|
||||
if (!_.isEmpty(alertFields)) {
|
||||
this.renderer.updateAlertFields(alertFields);
|
||||
}
|
||||
}
|
||||
return changedFields;
|
||||
},
|
||||
/**
|
||||
* Overrides to force the viewType to 'form', so that we ensure that the
|
||||
@@ -285,12 +265,32 @@ var FormController = BasicController.extend({
|
||||
*
|
||||
* @override
|
||||
*/
|
||||
update: function (params, options) {
|
||||
update: async 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);
|
||||
await this._super(params, options);
|
||||
this.autofocus();
|
||||
},
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
updateButtons: function () {
|
||||
if (!this.$buttons) {
|
||||
return;
|
||||
}
|
||||
if (this.footerToButtons) {
|
||||
var $footer = this.renderer.$el && this.renderer.$('footer');
|
||||
if ($footer && $footer.length) {
|
||||
this.$buttons.empty().append($footer);
|
||||
}
|
||||
}
|
||||
var edit_mode = (this.mode === 'edit');
|
||||
this.$buttons.find('.o_form_buttons_edit')
|
||||
.toggleClass('o_hidden', !edit_mode);
|
||||
this.$buttons.find('.o_form_buttons_view')
|
||||
.toggleClass('o_hidden', edit_mode);
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
@@ -412,15 +412,6 @@ var FormController = BasicController.extend({
|
||||
this._super.apply(this, arguments);
|
||||
this.renderer.enableButtons();
|
||||
},
|
||||
/**
|
||||
* Only display the pager if we are not on a new record.
|
||||
*
|
||||
* @override
|
||||
* @private
|
||||
*/
|
||||
_isPagerVisible: function () {
|
||||
return !this.model.isNew(this.handle);
|
||||
},
|
||||
/**
|
||||
* Hook method, called when record(s) has been deleted.
|
||||
*
|
||||
@@ -464,74 +455,6 @@ var FormController = BasicController.extend({
|
||||
}
|
||||
return this._super.apply(this, arguments);
|
||||
},
|
||||
/**
|
||||
* Updates the controller's title according to the new state
|
||||
*
|
||||
* @override
|
||||
* @private
|
||||
* @param {Object} state
|
||||
* @returns {Promise}
|
||||
*/
|
||||
_update: function () {
|
||||
var self = this;
|
||||
|
||||
return this._super.apply(this, arguments).then(function() {
|
||||
var title = self.getTitle();
|
||||
self._setTitle(title);
|
||||
self._updateButtons();
|
||||
self._updateSidebar();
|
||||
self.autofocus();
|
||||
});
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
_updateButtons: function () {
|
||||
if (this.$buttons) {
|
||||
if (this.footerToButtons) {
|
||||
var $footer = this.renderer.$('footer');
|
||||
if ($footer.length) {
|
||||
this.$buttons.empty().append($footer);
|
||||
}
|
||||
}
|
||||
var edit_mode = (this.mode === 'edit');
|
||||
this.$buttons.find('.o_form_buttons_edit')
|
||||
.toggleClass('o_hidden', !edit_mode);
|
||||
this.$buttons.find('.o_form_buttons_view')
|
||||
.toggleClass('o_hidden', edit_mode);
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Show or hide the sidebar according to the actual_mode
|
||||
* @private
|
||||
*/
|
||||
_updateSidebar: function () {
|
||||
if (this.sidebar) {
|
||||
this.sidebar.do_toggle(this.mode === 'readonly');
|
||||
// Hide/Show Archive/Unarchive dropdown items
|
||||
// We could have toggled the o_hidden class on the
|
||||
// item theirselves, but the items are redrawed
|
||||
// at each update, based on the initial definition
|
||||
var archive_item = _.find(this.sidebar.items.other, function(item) {
|
||||
return item.classname && item.classname.includes('o_sidebar_item_archive')
|
||||
})
|
||||
var unarchive_item = _.find(this.sidebar.items.other, function(item) {
|
||||
return item.classname && item.classname.includes('o_sidebar_item_unarchive')
|
||||
})
|
||||
const state = this.model.get(this.handle, {raw: true});
|
||||
const activeField = this.model.getActiveField(state);
|
||||
const activeFieldValue = this.renderer.state.data[activeField];
|
||||
if (archive_item && unarchive_item) {
|
||||
if (activeFieldValue) {
|
||||
archive_item.classname = 'o_sidebar_item_archive';
|
||||
unarchive_item.classname = 'o_sidebar_item_unarchive o_hidden';
|
||||
} else {
|
||||
archive_item.classname = 'o_sidebar_item_archive o_hidden';
|
||||
unarchive_item.classname = 'o_sidebar_item_unarchive';
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Handlers
|
||||
@@ -623,18 +546,15 @@ var FormController = BasicController.extend({
|
||||
this._discardChanges();
|
||||
},
|
||||
/**
|
||||
* Called when the user clicks on 'Duplicate Record' in the sidebar
|
||||
* Called when the user clicks on 'Duplicate Record' in the action menus
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_onDuplicateRecord: function () {
|
||||
var self = this;
|
||||
this.model.duplicateRecord(this.handle)
|
||||
.then(function (handle) {
|
||||
self.handle = handle;
|
||||
self._updateEnv();
|
||||
self._setMode('edit');
|
||||
});
|
||||
_onDuplicateRecord: async function () {
|
||||
const handle = await this.model.duplicateRecord(this.handle);
|
||||
this.handle = handle;
|
||||
this._updateControlPanel();
|
||||
this._setMode('edit');
|
||||
},
|
||||
/**
|
||||
* Called when the user wants to edit the current record -> @see _setMode
|
||||
@@ -777,7 +697,7 @@ var FormController = BasicController.extend({
|
||||
});
|
||||
},
|
||||
/**
|
||||
* Called when clicking on 'Archive' or 'Unarchive' in the sidebar.
|
||||
* Called when clicking on 'Archive' or 'Unarchive' in the action menus.
|
||||
*
|
||||
* @private
|
||||
* @param {boolean} archive
|
||||
|
||||
@@ -49,9 +49,6 @@ var FormRenderer = BasicRenderer.extend({
|
||||
*/
|
||||
on_attach_callback: function () {
|
||||
this._isInDom = true;
|
||||
_.forEach(this.allFieldWidgets, function (widgets){
|
||||
_.invoke(widgets, 'on_attach_callback');
|
||||
});
|
||||
this._super.apply(this, arguments);
|
||||
},
|
||||
/**
|
||||
|
||||
@@ -25,19 +25,19 @@ var FormView = BasicView.extend({
|
||||
* @override
|
||||
*/
|
||||
init: function (viewInfo, params) {
|
||||
var hasSidebar = params.hasSidebar;
|
||||
var hasActionMenus = params.hasActionMenus;
|
||||
this._super.apply(this, arguments);
|
||||
|
||||
var mode = params.mode || (params.currentId ? 'readonly' : 'edit');
|
||||
this.loadParams.type = 'record';
|
||||
|
||||
// this is kind of strange, but the param object is modified by
|
||||
// AbstractView, so we only need to use its hasSidebar value if it was
|
||||
// AbstractView, so we only need to use its hasActionMenus value if it was
|
||||
// not already present in the beginning of this method
|
||||
if (hasSidebar === undefined) {
|
||||
hasSidebar = params.hasSidebar;
|
||||
if (hasActionMenus === undefined) {
|
||||
hasActionMenus = params.hasActionMenus;
|
||||
}
|
||||
this.controllerParams.hasSidebar = hasSidebar;
|
||||
this.controllerParams.hasActionMenus = hasActionMenus;
|
||||
this.controllerParams.disableAutofocus = params.disable_autofocus;
|
||||
this.controllerParams.toolbarActions = viewInfo.toolbar;
|
||||
this.controllerParams.footerToButtons = params.footerToButtons;
|
||||
@@ -75,7 +75,7 @@ var FormView = BasicView.extend({
|
||||
params.withControlPanel = !(inDialog || inline);
|
||||
params.footerToButtons = inDialog;
|
||||
params.hasSearchView = inDialog ? false : params.hasSearchView;
|
||||
params.hasSidebar = !inDialog && !inline;
|
||||
params.hasActionMenus = !inDialog && !inline;
|
||||
params.searchMenuTypes = inDialog ? [] : params.searchMenuTypes;
|
||||
if (inDialog || inline || fullscreen) {
|
||||
params.mode = 'edit';
|
||||
|
||||
@@ -1,16 +1,30 @@
|
||||
odoo.define('web.GraphController', function (require) {
|
||||
"use strict";
|
||||
|
||||
/*---------------------------------------------------------
|
||||
* Odoo Graph view
|
||||
*---------------------------------------------------------*/
|
||||
|
||||
var core = require('web.core');
|
||||
var AbstractController = require('web.AbstractController');
|
||||
var GroupByMenuMixin = require('web.GroupByMenuMixin');
|
||||
const AbstractController = require('web.AbstractController');
|
||||
const { ComponentWrapper } = require('web.OwlCompatibility');
|
||||
const DropdownMenu = require('web.DropdownMenu');
|
||||
const { DEFAULT_INTERVAL, INTERVAL_OPTIONS } = require('web.searchUtils');
|
||||
const { qweb } = require('web.core');
|
||||
|
||||
var qweb = core.qweb;
|
||||
class CarretDropdownMenu extends DropdownMenu {
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
get displayCaret() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
var GraphController = AbstractController.extend({
|
||||
custom_events: _.extend({}, AbstractController.prototype.custom_events, {
|
||||
item_selected: '_onItemSelected',
|
||||
}),
|
||||
|
||||
var GraphController = AbstractController.extend(GroupByMenuMixin,{
|
||||
/**
|
||||
* @override
|
||||
* @param {Widget} parent
|
||||
@@ -22,12 +36,12 @@ var GraphController = AbstractController.extend(GroupByMenuMixin,{
|
||||
* @param {string[]} params.groupableFields,
|
||||
*/
|
||||
init: function (parent, model, renderer, params) {
|
||||
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.withButtons = params.withButtons;
|
||||
|
||||
// this parameter determines what is the list of fields
|
||||
// that may be used within the groupby menu available when
|
||||
@@ -75,6 +89,18 @@ var GraphController = AbstractController.extend(GroupByMenuMixin,{
|
||||
}
|
||||
};
|
||||
},
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
reload: async function () {
|
||||
const promises = [this._super(...arguments)];
|
||||
if (this.withButtons) {
|
||||
const state = this.model.get();
|
||||
this.measures.forEach(m => m.isActive = m.fieldName === state.measure);
|
||||
promises.push(this.measureMenu.update({ items: this.measures }));
|
||||
}
|
||||
return Promise.all(promises);
|
||||
},
|
||||
/**
|
||||
* Render the buttons according to the GraphView.buttons and
|
||||
* add listeners on it.
|
||||
@@ -85,86 +111,62 @@ var GraphController = AbstractController.extend(GroupByMenuMixin,{
|
||||
* nothing
|
||||
*/
|
||||
renderButtons: function ($node) {
|
||||
if ($node) {
|
||||
var context = {
|
||||
measures: _.sortBy(_.pairs(_.omit(this.measures, '__count__')), function (x) { return x[1].string.toLowerCase(); }),
|
||||
};
|
||||
this.$buttons = $(qweb.render('GraphView.buttons', context));
|
||||
this.$measureList = this.$buttons.find('.o_graph_measures_list');
|
||||
this.$buttons.find('button').tooltip();
|
||||
this.$buttons.click(this._onButtonClick.bind(this));
|
||||
this._updateButtons();
|
||||
this.$buttons.appendTo($node);
|
||||
if (this.isEmbedded) {
|
||||
this._addGroupByMenu($node, this.groupableFields).then(function(){
|
||||
var groupByButton = $node.find('.o_dropdown_toggler_btn');
|
||||
groupByButton.removeClass("o_dropdown_toggler_btn btn btn-secondary dropdown-toggle");
|
||||
groupByButton.addClass("btn dropdown-toggle btn-outline-secondary");
|
||||
});
|
||||
}
|
||||
this.$buttons = $(qweb.render('GraphView.buttons'));
|
||||
this.$buttons.find('button').tooltip();
|
||||
this.$buttons.click(ev => this._onButtonClick(ev));
|
||||
|
||||
if (this.withButtons) {
|
||||
const actionsContainer = this.$buttons[0];
|
||||
const promises = [];
|
||||
const state = this.model.get();
|
||||
const fragment = document.createDocumentFragment();
|
||||
// Instantiate and append MeasureMenu
|
||||
this.measures.forEach(m => m.isActive = m.fieldName === state.measure);
|
||||
this.measureMenu = new ComponentWrapper(this, CarretDropdownMenu, {
|
||||
title: "Measures",
|
||||
items: this.measures,
|
||||
});
|
||||
promises.push(this.measureMenu.mount(fragment).then(() => {
|
||||
actionsContainer.appendChild(this.measureMenu.el);
|
||||
this.measureMenu.el.classList.add('o_graph_measures_list');
|
||||
}));
|
||||
if ($node) {
|
||||
if (this.isEmbedded) {
|
||||
// Instantiate and append GroupBy menu
|
||||
this.groupByMenu = new ComponentWrapper(this, CarretDropdownMenu, {
|
||||
title: "Group By",
|
||||
icon: 'fa fa-bars',
|
||||
items: this._getGroupBys(state.groupBy),
|
||||
});
|
||||
promises.push(this.groupByMenu.mount(fragment).then(() => {
|
||||
actionsContainer.appendChild(this.groupByMenu.el);
|
||||
this.groupByMenu.el.classList.add('o_group_by_menu');
|
||||
}));
|
||||
}
|
||||
this.$buttons.appendTo($node);
|
||||
}
|
||||
Promise.all(promises).then(() => {
|
||||
// Similar behaviour for all buttons
|
||||
const buttons = actionsContainer.querySelectorAll('.o_dropdown_toggler_btn');
|
||||
for (const button of buttons) {
|
||||
button.classList.remove('o_dropdown_toggler_btn', 'btn-secondary');
|
||||
if (this.isEmbedded) {
|
||||
button.classList.add('btn-outline-secondary');
|
||||
} else {
|
||||
button.classList.add('btn-primary');
|
||||
button.tabIndex = 0;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Private
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/*
|
||||
* override
|
||||
*
|
||||
* @private
|
||||
* @param {string[]} groupBy
|
||||
*/
|
||||
_setGroupby: function (groupBy) {
|
||||
this.update({groupBy: groupBy});
|
||||
},
|
||||
/**
|
||||
* @todo remove this and directly calls update. Update should be overridden
|
||||
* and modified to call _updateButtons
|
||||
* Makes sure that the buttons in the control panel matches the current
|
||||
* state (so, correct active buttons and stuff like that).
|
||||
*
|
||||
* @private
|
||||
*
|
||||
* @param {'pie'|'line'|'bar'} mode
|
||||
* @override
|
||||
*/
|
||||
_setMode: function (mode) {
|
||||
this.update({mode: mode});
|
||||
this._updateButtons();
|
||||
},
|
||||
/**
|
||||
* @todo same as _setMode
|
||||
*
|
||||
* @param {string} measure should be a valid (and aggregatable) field name
|
||||
*/
|
||||
_setMeasure: function (measure) {
|
||||
var self = this;
|
||||
this.update({measure: measure}).then(function () {
|
||||
self._updateButtons();
|
||||
});
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
*
|
||||
* @param {boolean} stacked
|
||||
*/
|
||||
_toggleStackMode: function (stacked) {
|
||||
this.update({stacked: stacked});
|
||||
this._updateButtons();
|
||||
},
|
||||
/**
|
||||
* override
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_update: function () {
|
||||
this._updateButtons();
|
||||
return this._super.apply(this, arguments);
|
||||
},
|
||||
/**
|
||||
* makes sure that the buttons in the control panel matches the current
|
||||
* state (so, correct active buttons and stuff like that)
|
||||
*/
|
||||
_updateButtons: function () {
|
||||
updateButtons: function () {
|
||||
if (!this.$buttons) {
|
||||
return;
|
||||
}
|
||||
@@ -178,9 +180,60 @@ var GraphController = AbstractController.extend(GroupByMenuMixin,{
|
||||
.data('stacked', state.stacked)
|
||||
.toggleClass('active', state.stacked)
|
||||
.toggleClass('o_hidden', state.mode !== 'bar');
|
||||
_.each(this.$measureList.find('.dropdown-item'), function (item) {
|
||||
var $item = $(item);
|
||||
$item.toggleClass('selected', $item.data('field') === state.measure);
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Private
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Returns the items used by the Group By menu in embedded mode.
|
||||
*
|
||||
* @private
|
||||
* @param {string[]} activeGroupBys
|
||||
* @returns {Object[]}
|
||||
*/
|
||||
_getGroupBys(activeGroupBys) {
|
||||
const normalizedGroupBys = this._normalizeActiveGroupBys(activeGroupBys);
|
||||
const groupBys = Object.keys(this.groupableFields).map(fieldName => {
|
||||
const field = this.groupableFields[fieldName];
|
||||
const groupByActivity = normalizedGroupBys.filter(gb => gb.fieldName === fieldName);
|
||||
const groupBy = {
|
||||
id: fieldName,
|
||||
isActive: Boolean(groupByActivity.length),
|
||||
description: field.string,
|
||||
itemType: 'groupBy',
|
||||
};
|
||||
if (['date', 'datetime'].includes(field.type)) {
|
||||
groupBy.hasOptions = true;
|
||||
const activeOptionIds = groupByActivity.map(gb => gb.interval);
|
||||
groupBy.options = Object.values(INTERVAL_OPTIONS).map(o => {
|
||||
return Object.assign({}, o, { isActive: activeOptionIds.includes(o.id) });
|
||||
});
|
||||
}
|
||||
return groupBy;
|
||||
}).sort((gb1, gb2) => {
|
||||
return gb1.description.localeCompare(gb2.description);
|
||||
});
|
||||
return groupBys;
|
||||
},
|
||||
|
||||
/**
|
||||
* This method puts the active groupBys in a convenient form.
|
||||
*
|
||||
* @private
|
||||
* @param {string[]} activeGroupBys
|
||||
* @returns {Object[]} normalizedGroupBys
|
||||
*/
|
||||
_normalizeActiveGroupBys(activeGroupBys) {
|
||||
return activeGroupBys.map(groupBy => {
|
||||
const fieldName = groupBy.split(':')[0];
|
||||
const field = this.groupableFields[fieldName];
|
||||
const normalizedGroupBy = { fieldName };
|
||||
if (['date', 'datetime'].includes(field.type)) {
|
||||
normalizedGroupBy.interval = groupBy.split(':')[1] || DEFAULT_INTERVAL;
|
||||
}
|
||||
return normalizedGroupBy;
|
||||
});
|
||||
},
|
||||
|
||||
@@ -196,18 +249,51 @@ var GraphController = AbstractController.extend(GroupByMenuMixin,{
|
||||
*/
|
||||
_onButtonClick: function (ev) {
|
||||
var $target = $(ev.target);
|
||||
var field;
|
||||
if ($target.hasClass('o_graph_button')) {
|
||||
if (_.contains(['bar','line', 'pie'], $target.data('mode'))) {
|
||||
this._setMode($target.data('mode'));
|
||||
this.update({ mode: $target.data('mode') });
|
||||
} else if ($target.data('mode') === 'stack') {
|
||||
this._toggleStackMode(!$target.data('stacked'));
|
||||
this.update({ stacked: !$target.data('stacked') });
|
||||
}
|
||||
} else if ($target.parents('.o_graph_measures_list').length) {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
field = $target.data('field');
|
||||
this._setMeasure(field);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {OdooEvent} ev
|
||||
*/
|
||||
_onItemSelected(ev) {
|
||||
const item = ev.data.item;
|
||||
if (this.isEmbedded && item.itemType === 'groupBy') {
|
||||
const fieldName = item.id;
|
||||
const optionId = ev.data.option && ev.data.option.id;
|
||||
const activeGroupBys = this.model.get().groupBy;
|
||||
if (optionId) {
|
||||
const normalizedGroupBys = this._normalizeActiveGroupBys(activeGroupBys);
|
||||
const index = normalizedGroupBys.findIndex(ngb =>
|
||||
ngb.fieldName === fieldName && ngb.interval === optionId);
|
||||
if (index === -1) {
|
||||
activeGroupBys.push(fieldName + ':' + optionId);
|
||||
} else {
|
||||
activeGroupBys.splice(index, 1);
|
||||
}
|
||||
} else {
|
||||
const groupByFieldNames = activeGroupBys.map(gb => gb.split(':')[0]);
|
||||
const indexOfGroupby = groupByFieldNames.indexOf(fieldName);
|
||||
if (indexOfGroupby === -1) {
|
||||
activeGroupBys.push(fieldName);
|
||||
} else {
|
||||
activeGroupBys.splice(indexOfGroupby, 1);
|
||||
}
|
||||
}
|
||||
this.update({ groupBy: activeGroupBys });
|
||||
this.groupByMenu.update({
|
||||
items: this._getGroupBys(activeGroupBys),
|
||||
});
|
||||
} else if (item.itemType === 'measure') {
|
||||
this.update({ measure: item.fieldName });
|
||||
this.measures.forEach(m => m.isActive = m.fieldName === item.fieldName);
|
||||
this.measureMenu.update({ items: this.measures });
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@ odoo.define('web.GraphModel', function (require) {
|
||||
"use strict";
|
||||
|
||||
var core = require('web.core');
|
||||
const { DEFAULT_INTERVAL, rank } = require('web.controlPanelViewParameters');
|
||||
const { DEFAULT_INTERVAL, rankInterval } = require('web.searchUtils');
|
||||
|
||||
var _t = core._t;
|
||||
|
||||
@@ -35,10 +35,7 @@ return AbstractModel.extend({
|
||||
* @returns {Object}
|
||||
*/
|
||||
get: function () {
|
||||
var self = this;
|
||||
return _.extend({}, this.chart, {
|
||||
comparisonFieldIndex: self._getComparisonFieldIndex(),
|
||||
});
|
||||
return _.extend({}, this.chart);
|
||||
},
|
||||
/**
|
||||
* Initial loading.
|
||||
@@ -47,21 +44,16 @@ return AbstractModel.extend({
|
||||
* should be done by the graphView I think.
|
||||
*
|
||||
* @param {Object} params
|
||||
* @param {boolean} params.compare
|
||||
* @param {Object} params.context
|
||||
* @param {Object} params.fields
|
||||
* @param {string[]} params.comparisonTimeRange
|
||||
* @param {string[]} params.domain
|
||||
* @param {string[]} params.groupBys a list of valid field names
|
||||
* @param {string[]} params.groupedBy a list of valid field names
|
||||
* @param {boolean} params.stacked
|
||||
* @param {string[]} params.timeRange
|
||||
* @param {string} params.comparisonField
|
||||
* @param {string} params.comparisonTimeRangeDescription
|
||||
* @param {string} params.measure a valid field name
|
||||
* @param {'pie'|'bar'|'line'} params.mode
|
||||
* @param {string} params.modelName
|
||||
* @param {string} params.timeRangeDescription
|
||||
* @param {Object} params.timeRanges
|
||||
* @returns {Promise} The promise does not return a handle, we don't need
|
||||
* to keep track of various entities.
|
||||
*/
|
||||
@@ -70,11 +62,7 @@ return AbstractModel.extend({
|
||||
this.initialGroupBys = groupBys;
|
||||
this.fields = params.fields;
|
||||
this.modelName = params.modelName;
|
||||
this.chart = {
|
||||
comparisonField: params.comparisonField,
|
||||
comparisonTimeRange: params.comparisonTimeRange,
|
||||
comparisonTimeRangeDescription: params.comparisonTimeRangeDescription,
|
||||
compare: params.compare,
|
||||
this.chart = Object.assign({
|
||||
context: params.context,
|
||||
dataPoints: [],
|
||||
domain: params.domain,
|
||||
@@ -83,11 +71,12 @@ return AbstractModel.extend({
|
||||
mode: params.context.graph_mode || params.mode,
|
||||
origins: [],
|
||||
stacked: params.stacked,
|
||||
timeRange: params.timeRange,
|
||||
timeRangeDescription: params.timeRangeDescription,
|
||||
};
|
||||
this.chart.processedGroupBy = this._processGroupBy(this.chart.groupBy);
|
||||
return this._loadGraph(this._getDomains());
|
||||
timeRanges: params.timeRanges,
|
||||
});
|
||||
|
||||
this._computeDerivedParams();
|
||||
|
||||
return this._loadGraph();
|
||||
},
|
||||
/**
|
||||
* Reload data. It is similar to the load function. Note that we ignore the
|
||||
@@ -104,6 +93,7 @@ return AbstractModel.extend({
|
||||
* @param {string[]} [params.groupBy]
|
||||
* @param {string} [params.measure] a valid field name
|
||||
* @param {string} [params.mode] one of 'bar', 'pie', 'line'
|
||||
* @param {Object} [params.timeRanges]
|
||||
* @returns {Promise}
|
||||
*/
|
||||
reload: function (handle, params) {
|
||||
@@ -112,22 +102,6 @@ return AbstractModel.extend({
|
||||
this.chart.groupBy = params.context.graph_groupbys || this.chart.groupBy;
|
||||
this.chart.measure = params.context.graph_measure || this.chart.measure;
|
||||
this.chart.mode = params.context.graph_mode || this.chart.mode;
|
||||
var timeRangeMenuData = params.context.timeRangeMenuData;
|
||||
if (timeRangeMenuData) {
|
||||
this.chart.comparisonField = timeRangeMenuData.comparisonField || undefined;
|
||||
this.chart.comparisonTimeRange = timeRangeMenuData.comparisonTimeRange || [];
|
||||
this.chart.compare = this.chart.comparisonTimeRange.length > 0;
|
||||
this.chart.comparisonTimeRangeDescription = timeRangeMenuData.comparisonTimeRangeDescription;
|
||||
this.chart.timeRange = timeRangeMenuData.timeRange || [];
|
||||
this.chart.timeRangeDescription = timeRangeMenuData.timeRangeDescription;
|
||||
} else {
|
||||
this.chart.comparisonField = undefined;
|
||||
this.chart.comparisonTimeRange = [];
|
||||
this.chart.compare = false;
|
||||
this.chart.comparisonTimeRangeDescription = undefined;
|
||||
this.chart.timeRange = [];
|
||||
this.chart.timeRangeDescription = undefined;
|
||||
}
|
||||
}
|
||||
if ('domain' in params) {
|
||||
this.chart.domain = params.domain;
|
||||
@@ -138,7 +112,12 @@ return AbstractModel.extend({
|
||||
if ('measure' in params) {
|
||||
this.chart.measure = params.measure;
|
||||
}
|
||||
this.chart.processedGroupBy = this._processGroupBy(this.chart.groupBy);
|
||||
if ('timeRanges' in params) {
|
||||
this.chart.timeRanges = params.timeRanges;
|
||||
}
|
||||
|
||||
this._computeDerivedParams();
|
||||
|
||||
if ('mode' in params) {
|
||||
this.chart.mode = params.mode;
|
||||
return Promise.resolve();
|
||||
@@ -147,7 +126,7 @@ return AbstractModel.extend({
|
||||
this.chart.stacked = params.stacked;
|
||||
return Promise.resolve();
|
||||
}
|
||||
return this._loadGraph(this._getDomains());
|
||||
return this._loadGraph();
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
@@ -155,28 +134,34 @@ return AbstractModel.extend({
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Compute this.chart.processedGroupBy, this.chart.domains, this.chart.origins,
|
||||
* and this.chart.comparisonFieldIndex.
|
||||
* Those parameters are determined by this.chart.timeRanges, this.chart.groupBy, and this.chart.domain.
|
||||
*
|
||||
* @private
|
||||
* @returns {number}
|
||||
*/
|
||||
_getComparisonFieldIndex: function () {
|
||||
var groupBys = this.chart.processedGroupBy.map(function (gb) {
|
||||
return gb.split(":")[0];
|
||||
});
|
||||
return groupBys.indexOf(this.chart.comparisonField);
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @returns {Array[]}
|
||||
*/
|
||||
_getDomains: function () {
|
||||
var domains = [this.chart.domain.concat(this.chart.timeRange)];
|
||||
this.chart.origins = [this.chart.timeRangeDescription || ""];
|
||||
if (this.chart.compare) {
|
||||
domains.push(this.chart.domain.concat(this.chart.comparisonTimeRange));
|
||||
this.chart.origins.push(this.chart.comparisonTimeRangeDescription);
|
||||
_computeDerivedParams: function () {
|
||||
this.chart.processedGroupBy = this._processGroupBy(this.chart.groupBy);
|
||||
|
||||
const { range, rangeDescription, comparisonRange, comparisonRangeDescription, fieldName } = this.chart.timeRanges;
|
||||
if (range) {
|
||||
this.chart.domains = [this.chart.domain.concat(range)];
|
||||
this.chart.origins = [rangeDescription];
|
||||
if (comparisonRange) {
|
||||
this.chart.domains.push(this.chart.domain.concat(comparisonRange));
|
||||
this.chart.origins.push(comparisonRangeDescription);
|
||||
}
|
||||
const groupBys = this.chart.processedGroupBy.map(function (gb) {
|
||||
return gb.split(":")[0];
|
||||
});
|
||||
this.chart.comparisonFieldIndex = groupBys.indexOf(fieldName);
|
||||
} else {
|
||||
this.chart.domains = [this.chart.domain];
|
||||
this.chart.origins = [""];
|
||||
this.chart.comparisonFieldIndex = -1;
|
||||
}
|
||||
return domains;
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch and process graph data. It is basically a(some) read_group(s)
|
||||
* with correct fields for each domain. We have to do some light processing
|
||||
@@ -184,10 +169,9 @@ return AbstractModel.extend({
|
||||
* with an aggregation function, such as my_date:week.
|
||||
*
|
||||
* @private
|
||||
* @param {Array[]} domains
|
||||
* @returns {Promise}
|
||||
*/
|
||||
_loadGraph: function (domains) {
|
||||
_loadGraph: function () {
|
||||
var self = this;
|
||||
this.chart.dataPoints = [];
|
||||
var groupBy = this.chart.processedGroupBy;
|
||||
@@ -205,10 +189,10 @@ return AbstractModel.extend({
|
||||
}
|
||||
|
||||
var context = _.extend({fill_temporal: true}, this.chart.context);
|
||||
var defs = [];
|
||||
|
||||
domains.forEach(function (domain, originIndex) {
|
||||
defs.push(self._rpc({
|
||||
var proms = [];
|
||||
this.chart.domains.forEach(function (domain, originIndex) {
|
||||
proms.push(self._rpc({
|
||||
model: self.modelName,
|
||||
method: 'read_group',
|
||||
context: context,
|
||||
@@ -218,7 +202,7 @@ return AbstractModel.extend({
|
||||
lazy: false,
|
||||
}).then(self._processData.bind(self, originIndex)));
|
||||
});
|
||||
return Promise.all(defs);
|
||||
return Promise.all(proms);
|
||||
},
|
||||
/**
|
||||
* Since read_group is insane and returns its result on different keys
|
||||
@@ -286,7 +270,7 @@ return AbstractModel.extend({
|
||||
}
|
||||
if (groupBysMap.has(fieldName)) {
|
||||
const registeredInterval = groupBysMap.get(fieldName);
|
||||
if (rank(registeredInterval) < rank(interval)) {
|
||||
if (rankInterval(registeredInterval) < rankInterval(interval)) {
|
||||
groupBysMap.set(fieldName, interval);
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -16,8 +16,8 @@ var GraphRenderer = require('web.GraphRenderer');
|
||||
var _t = core._t;
|
||||
var _lt = core._lt;
|
||||
|
||||
var controlPanelViewParameters = require('web.controlPanelViewParameters');
|
||||
var GROUPABLE_TYPES = controlPanelViewParameters.GROUPABLE_TYPES;
|
||||
var searchUtils = require('web.searchUtils');
|
||||
var GROUPABLE_TYPES = searchUtils.GROUPABLE_TYPES;
|
||||
|
||||
var GraphView = AbstractView.extend({
|
||||
display_name: _lt('Graph'),
|
||||
@@ -39,54 +39,81 @@ var GraphView = AbstractView.extend({
|
||||
init: function (viewInfo, params) {
|
||||
this._super.apply(this, arguments);
|
||||
|
||||
var self = this;
|
||||
var measure;
|
||||
var groupBys = [];
|
||||
var measures = {__count__: {string: _t("Count"), type: "integer"}};
|
||||
var groupableFields = {};
|
||||
this.fields.__count__ = {string: _t("Count"), type: "integer"};
|
||||
const additionalMeasures = params.additionalMeasures || [];
|
||||
let measure;
|
||||
const measures = {};
|
||||
const measureStrings = {};
|
||||
const groupBys = [];
|
||||
const groupableFields = {};
|
||||
this.fields.__count__ = { string: _t("Count"), type: 'integer' };
|
||||
|
||||
var measureString = {};
|
||||
|
||||
this.arch.children.forEach(function (field) {
|
||||
var fieldName = field.attrs.name;
|
||||
this.arch.children.forEach(field => {
|
||||
let fieldName = field.attrs.name;
|
||||
if (fieldName === "id") {
|
||||
return;
|
||||
}
|
||||
var interval = field.attrs.interval;
|
||||
const interval = field.attrs.interval;
|
||||
if (interval) {
|
||||
fieldName = fieldName + ':' + interval;
|
||||
}
|
||||
if (field.attrs.type === 'measure') {
|
||||
const { string } = this.fields[fieldName];
|
||||
measure = fieldName;
|
||||
measures[fieldName] = self.fields[fieldName];
|
||||
measures[fieldName] = {
|
||||
description: string,
|
||||
fieldName,
|
||||
groupNumber: 0,
|
||||
isActive: false,
|
||||
itemType: 'measure',
|
||||
};
|
||||
} else {
|
||||
groupBys.push(fieldName);
|
||||
}
|
||||
if (field.attrs.string) {
|
||||
measureString[fieldName] = field.attrs.string;
|
||||
measureStrings[fieldName] = field.attrs.string;
|
||||
}
|
||||
});
|
||||
|
||||
_.each(this.fields, function (field, name) {
|
||||
for (const name in this.fields) {
|
||||
const field = this.fields[name];
|
||||
if (name !== 'id' && field.store === true) {
|
||||
if (_.contains(['integer', 'float', 'monetary'], field.type) ||
|
||||
_.contains(params.additionalMeasures, name)) {
|
||||
measures[name] = field;
|
||||
if (
|
||||
['integer', 'float', 'monetary'].includes(field.type) ||
|
||||
additionalMeasures.includes(name)
|
||||
) {
|
||||
measures[name] = {
|
||||
description: field.string,
|
||||
fieldName: name,
|
||||
groupNumber: 0,
|
||||
isActive: false,
|
||||
itemType: 'measure',
|
||||
};
|
||||
}
|
||||
if (_.contains(GROUPABLE_TYPES, field.type)) {
|
||||
if (GROUPABLE_TYPES.includes(field.type)) {
|
||||
groupableFields[name] = field;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
_.each(measureString, function (string, name) {
|
||||
}
|
||||
for (const name in measureStrings) {
|
||||
if (measures[name]) {
|
||||
measures[name].string = string;
|
||||
measures[name].description = measureStrings[name];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
this.controllerParams.measures = measures;
|
||||
const sortedMeasures = Object.values(measures).sort((a, b) => {
|
||||
const descA = a.description.toLowerCase();
|
||||
const descB = b.description.toLowerCase();
|
||||
return descA > descB ? 1 : descA < descB ? -1 : 0;
|
||||
});
|
||||
const countMeasure = {
|
||||
description: _t("Count"),
|
||||
fieldName: '__count__',
|
||||
groupNumber: 1,
|
||||
isActive: false,
|
||||
itemType: 'measure',
|
||||
};
|
||||
this.controllerParams.withButtons = params.withButtons !== false;
|
||||
this.controllerParams.measures = [...sortedMeasures, countMeasure];
|
||||
this.controllerParams.groupableFields = groupableFields;
|
||||
this.rendererParams.fields = this.fields;
|
||||
this.rendererParams.title = this.arch.attrs.title; // TODO: use attrs.string instead
|
||||
|
||||
@@ -58,21 +58,35 @@ var KanbanController = BasicController.extend({
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @param {jQueryElement} $node
|
||||
* @returns {Promise}
|
||||
* @param {jQuery} [$node]
|
||||
*/
|
||||
renderButtons: function ($node) {
|
||||
if (this.hasButtons && this.is_action_enabled('create')) {
|
||||
this.$buttons = $(qweb.render(this.buttons_template, {
|
||||
btnClass: 'btn-primary',
|
||||
widget: this,
|
||||
}));
|
||||
this.$buttons.on('click', 'button.o-kanban-button-new', this._onButtonNew.bind(this));
|
||||
this.$buttons.on('keydown', this._onButtonsKeyDown.bind(this));
|
||||
this._updateButtons();
|
||||
return Promise.resolve(this.$buttons.appendTo($node));
|
||||
if (!this.hasButtons || !this.is_action_enabled('create')) {
|
||||
return;
|
||||
}
|
||||
return Promise.resolve();
|
||||
this.$buttons = $(qweb.render(this.buttons_template, {
|
||||
btnClass: 'btn-primary',
|
||||
widget: this,
|
||||
}));
|
||||
this.$buttons.on('click', 'button.o-kanban-button-new', this._onButtonNew.bind(this));
|
||||
this.$buttons.on('keydown', this._onButtonsKeyDown.bind(this));
|
||||
if ($node) {
|
||||
this.$buttons.appendTo($node);
|
||||
}
|
||||
},
|
||||
/**
|
||||
* In grouped mode, set 'Create' button as btn-secondary if there is no column
|
||||
* (except if we can't create new columns)
|
||||
*
|
||||
* @override
|
||||
*/
|
||||
updateButtons: function () {
|
||||
if (!this.$buttons) {
|
||||
return;
|
||||
}
|
||||
var state = this.model.get(this.handle, {raw: true});
|
||||
var createHidden = this.is_action_enabled('group_create') && state.isGroupedByM2ONoColumn;
|
||||
this.$buttons.find('.o-kanban-button-new').toggleClass('o_hidden', createHidden);
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
@@ -100,9 +114,11 @@ var KanbanController = BasicController.extend({
|
||||
* @override
|
||||
* @private
|
||||
*/
|
||||
_isPagerVisible: function () {
|
||||
var state = this.model.get(this.handle, {raw: true});
|
||||
return !!(state.count && !state.groupedBy.length);
|
||||
_getPagingInfo: function (state) {
|
||||
if (!(state.count && !state.groupedBy.length)) {
|
||||
return null;
|
||||
}
|
||||
return this._super(...arguments);
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
@@ -185,33 +201,7 @@ var KanbanController = BasicController.extend({
|
||||
*/
|
||||
_resequenceRecords: function (column_id, ids) {
|
||||
var self = this;
|
||||
return this.model.resequence(this.modelName, ids, column_id).then(function () {
|
||||
self._updateEnv();
|
||||
});
|
||||
},
|
||||
/**
|
||||
* Overrides to update the control panel buttons when the state is updated.
|
||||
*
|
||||
* @override
|
||||
* @private
|
||||
*/
|
||||
_update: function () {
|
||||
this._updateButtons();
|
||||
return this._super.apply(this, arguments);
|
||||
},
|
||||
/**
|
||||
* In grouped mode, set 'Create' button as btn-secondary if there is no column
|
||||
* (except if we can't create new columns)
|
||||
*
|
||||
* @private
|
||||
* @override from abstract controller
|
||||
*/
|
||||
_updateButtons: function () {
|
||||
if (this.$buttons) {
|
||||
var state = this.model.get(this.handle, {raw: true});
|
||||
var createHidden = this.is_action_enabled('group_create') && state.isGroupedByM2ONoColumn;
|
||||
this.$buttons.find('.o-kanban-button-new').toggleClass('o_hidden', createHidden);
|
||||
}
|
||||
return this.model.resequence(this.modelName, ids, column_id);
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
@@ -236,7 +226,6 @@ var KanbanController = BasicController.extend({
|
||||
}).then(function () {
|
||||
return self.update({}, {reload: false});
|
||||
}).then(function () {
|
||||
self._updateButtons();
|
||||
self.renderer.quickCreateToggleFold();
|
||||
self.renderer.trigger_up("quick_create_column_created");
|
||||
});
|
||||
@@ -389,7 +378,6 @@ var KanbanController = BasicController.extend({
|
||||
this.model.loadMore(column.db_id).then(function (db_id) {
|
||||
var data = self.model.get(db_id);
|
||||
self.renderer.updateColumn(db_id, data);
|
||||
self._updateEnv();
|
||||
});
|
||||
},
|
||||
/**
|
||||
@@ -412,7 +400,6 @@ var KanbanController = BasicController.extend({
|
||||
// function that updates the kanban view once the record has been added
|
||||
// it receives the local id of the created record in arguments
|
||||
var update = function (db_id) {
|
||||
self._updateEnv();
|
||||
|
||||
var columnState = self.model.getColumn(db_id);
|
||||
var state = self.model.get(self.handle);
|
||||
@@ -459,9 +446,7 @@ var KanbanController = BasicController.extend({
|
||||
*/
|
||||
_onResequenceColumn: function (ev) {
|
||||
var self = this;
|
||||
this._resequenceColumns(ev.data.ids).then(function () {
|
||||
self._updateEnv();
|
||||
});
|
||||
this._resequenceColumns(ev.data.ids);
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
@@ -481,7 +466,6 @@ var KanbanController = BasicController.extend({
|
||||
return self.renderer.updateColumn(db_id, data, options);
|
||||
})
|
||||
.then(function () {
|
||||
self._updateEnv();
|
||||
if (ev.data.onSuccess) {
|
||||
ev.data.onSuccess();
|
||||
}
|
||||
@@ -520,7 +504,6 @@ var KanbanController = BasicController.extend({
|
||||
prom.then(function (dbID) {
|
||||
var data = self.model.get(dbID);
|
||||
self.renderer.updateColumn(dbID, data);
|
||||
self._updateEnv();
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user