[MERGE] *: rewrite search panel with OWL
The first aim of the present work consists in the conversion of the SearchPanel widget to a OWL component. Along the way we have introduced many changes/novelties in the js framework, most notably in the class Model (module addons/web/static/src/js/model.js). Here is a more detailed account of what has been done: - Model (and ModelExtension): refactoring of the Model class to support multiple extensions. The purpose is to build a model having the appropriate extensions (or plugins) for a given situation. The control panel model has now become an 'extension' (ModelExtension instance) of a 'search model' (Model instance). That extension manages the filters/groubys/favorites statuses and is added only when a control panel is used by a view/action. The search model itself generates the domain, contexts, groupbys,... using the different parts coming from the added extensions. That model is meant to replace the view model in the long term. It might however need some improvements to hold such a role (partial interface rendering, etc.). - SearchPanel: the previous search panel widget has been divided in 2 parts: 1. a SearchPanel part: a component whose responsibilities are now to render the search panel data given by its corresponding model extension, display some rendering logic (expanded categories, checked groups) and dispatch the events triggered on it. 2. a SearchPanelModelExtension part: a model extension that holds all the search logic of the search panel. Its responsibilities are calling the server to update its data and to provide them to the search panel component and the other search extensions via the search model. - LunchModelExtension: conversion of the LunchModel to a proper model extension. The lunch "location" logic (locationId and userId) has been transferred to a new model extension (LunchModelExtension) which provides an additional domain part to the global query. - Registry: before this commit, any value could be added to a registry and the class needed to be overridden to assert that an added value would have some properties. Now, a registry can be instantiated with a given predicate function that all added values must "pass" to be registered (default: all values are accepted). This has been introduced here in order to be able to control the type of extensions added to a given model. Task ID: 2228968 closes odoo/odoo#49899 Related: odoo/enterprise#10096 Signed-off-by: Aaron Bohy (aab) <aab@odoo.com> Co-authored-by: Julien Mougenot <jum@odoo.com> Co-authored-by: Mathieu Duckerts-Antoine <dam@odoo.com>
This commit is contained in:
co-authored by
Julien Mougenot
Mathieu Duckerts-Antoine
commit
6c29ed3d42
@@ -3,7 +3,7 @@ odoo.define('base_import.ImportMenu', function (require) {
|
||||
|
||||
const DropdownMenuItem = require('web.DropdownMenuItem');
|
||||
const FavoriteMenu = require('web.FavoriteMenu');
|
||||
const { useModel } = require('web.model');
|
||||
const { useModel } = require('web/static/src/js/model.js');
|
||||
|
||||
/**
|
||||
* Import Records menu
|
||||
@@ -15,7 +15,7 @@ odoo.define('base_import.ImportMenu', function (require) {
|
||||
class ImportMenu extends DropdownMenuItem {
|
||||
constructor() {
|
||||
super(...arguments);
|
||||
this.model = useModel('controlPanelModel');
|
||||
this.model = useModel('searchModel');
|
||||
}
|
||||
|
||||
//---------------------------------------------------------------------
|
||||
@@ -30,8 +30,8 @@ odoo.define('base_import.ImportMenu', function (require) {
|
||||
type: 'ir.actions.client',
|
||||
tag: 'import',
|
||||
params: {
|
||||
model: this.model.modelName,
|
||||
context: this.model.actionContext,
|
||||
model: this.model.config.modelName,
|
||||
context: this.model.config.context,
|
||||
}
|
||||
};
|
||||
this.trigger('do-action', {action: action});
|
||||
|
||||
@@ -5,7 +5,6 @@ odoo.define('board.AddToBoardMenu', function (require) {
|
||||
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');
|
||||
|
||||
@@ -48,7 +47,7 @@ odoo.define('board.AddToBoardMenu', function (require) {
|
||||
* @private
|
||||
*/
|
||||
async _addToBoard() {
|
||||
const searchQuery = this.env.controlPanelModel.getQuery();
|
||||
const searchQuery = this.env.searchModel.get('query');
|
||||
const context = new Context(this.env.action.context);
|
||||
context.add(searchQuery.context);
|
||||
context.add({
|
||||
@@ -61,7 +60,7 @@ odoo.define('board.AddToBoardMenu', function (require) {
|
||||
});
|
||||
}
|
||||
let controllerQueryParams;
|
||||
this.env.controlPanelModel.trigger('get-controller-query-params', params => {
|
||||
this.env.searchModel.trigger('get-controller-query-params', params => {
|
||||
controllerQueryParams = params || {};
|
||||
});
|
||||
controllerQueryParams.context = controllerQueryParams.context || {};
|
||||
|
||||
@@ -840,7 +840,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,
|
||||
|
||||
@@ -23,7 +23,7 @@ odoo.define('board.AddToGoogleSpreadsheetMenu', function (require) {
|
||||
* @private
|
||||
*/
|
||||
async _onAddToSpreadsheet() {
|
||||
const searchQuery = this.env.controlPanelModel.getQuery();
|
||||
const searchQuery = this.env.model.get('query');
|
||||
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);
|
||||
|
||||
@@ -27,7 +27,6 @@ var LunchControllerCommon = {
|
||||
*/
|
||||
init: function () {
|
||||
this._super.apply(this, arguments);
|
||||
this.userId = null;
|
||||
this.editMode = false;
|
||||
this.updated = false;
|
||||
this.widgetData = null;
|
||||
@@ -60,29 +59,15 @@ var LunchControllerCommon = {
|
||||
},
|
||||
});
|
||||
},
|
||||
_fetchWidgetData: function () {
|
||||
var self = this;
|
||||
|
||||
return this._rpc({
|
||||
_fetchWidgetData: async function () {
|
||||
this.widgetData = await this._rpc({
|
||||
route: '/lunch/infos',
|
||||
params: {
|
||||
user_id: this.userId,
|
||||
user_id: this.searchModel.get('userId'),
|
||||
context: this.context,
|
||||
},
|
||||
}).then(function (data) {
|
||||
self.widgetData = data;
|
||||
return self.model._updateLocation(data.user_location[0]);
|
||||
});
|
||||
},
|
||||
/**
|
||||
* Override to add the location domain (coming from the LunchWidget)
|
||||
* to the searchDomain (coming from the controlPanel).
|
||||
*
|
||||
* @override
|
||||
*/
|
||||
_getViewDomain: async function () {
|
||||
return this.model.getLocationDomain();
|
||||
},
|
||||
/**
|
||||
* Renders and appends the lunch banner widget.
|
||||
*
|
||||
@@ -140,28 +125,14 @@ var LunchControllerCommon = {
|
||||
});
|
||||
},
|
||||
_onLocationChanged: function (ev) {
|
||||
var self = this;
|
||||
|
||||
ev.stopPropagation();
|
||||
|
||||
this._rpc({
|
||||
route: '/lunch/user_location_set',
|
||||
params: {
|
||||
user_id: this.userId,
|
||||
location_id: ev.data.locationId,
|
||||
context: this.context,
|
||||
},
|
||||
}).then(function () {
|
||||
self.model._updateLocation(ev.data.locationId).then(function () {
|
||||
self.reload();
|
||||
});
|
||||
});
|
||||
this.searchModel.dispatch('setLocationId', ev.data.locationId);
|
||||
},
|
||||
_onOpenWizard: function (ev) {
|
||||
var self = this;
|
||||
ev.stopPropagation();
|
||||
|
||||
var ctx = this.userId ? {default_user_id: this.userId} : {};
|
||||
var ctx = this.searchModel.get('userId') ? {default_user_id: this.searchModel.get('userId')} : {};
|
||||
|
||||
var options = {
|
||||
on_close: function () {
|
||||
@@ -197,7 +168,7 @@ var LunchControllerCommon = {
|
||||
this._rpc({
|
||||
route: '/lunch/pay',
|
||||
params: {
|
||||
user_id: this.userId,
|
||||
user_id: this.searchModel.get('userId'),
|
||||
context: this.context,
|
||||
},
|
||||
}).then(function (isPaid) {
|
||||
@@ -224,13 +195,7 @@ var LunchControllerCommon = {
|
||||
},
|
||||
_onUserChanged: function (ev) {
|
||||
ev.stopPropagation();
|
||||
|
||||
var self = this;
|
||||
|
||||
this.userId = ev.data.userId;
|
||||
this.model._updateUser(ev.data.userId).then(function () {
|
||||
self.reload();
|
||||
});
|
||||
this.searchModel.dispatch('updateUserId', ev.data.userId);
|
||||
},
|
||||
_onUnlinkOrder: function (ev) {
|
||||
var self = this;
|
||||
@@ -239,7 +204,7 @@ var LunchControllerCommon = {
|
||||
this._rpc({
|
||||
route: '/lunch/trash',
|
||||
params: {
|
||||
user_id: this.userId,
|
||||
user_id: this.searchModel.get('userId'),
|
||||
context: this.context,
|
||||
},
|
||||
}).then(function () {
|
||||
|
||||
@@ -2,7 +2,6 @@ odoo.define('lunch.LunchKanbanView', function (require) {
|
||||
"use strict";
|
||||
|
||||
var LunchKanbanController = require('lunch.LunchKanbanController');
|
||||
var LunchModel = require('lunch.LunchModel');
|
||||
var LunchKanbanRenderer = require('lunch.LunchKanbanRenderer');
|
||||
|
||||
var core = require('web.core');
|
||||
@@ -14,21 +13,16 @@ var _lt = core._lt;
|
||||
var LunchKanbanView = KanbanView.extend({
|
||||
config: _.extend({}, KanbanView.prototype.config, {
|
||||
Controller: LunchKanbanController,
|
||||
Model: LunchModel,
|
||||
Renderer: LunchKanbanRenderer,
|
||||
}),
|
||||
display_name: _lt('Lunch Kanban'),
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Private
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
_getViewDomain: function (parent) {
|
||||
const model = this.getModel(parent);
|
||||
return model.getLocationDomain();
|
||||
_createSearchModel(params, extraExtensions = {}) {
|
||||
Object.assign(extraExtensions, { Lunch: {} });
|
||||
return this._super(params, extraExtensions);
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@ odoo.define('lunch.LunchListView', function (require) {
|
||||
"use strict";
|
||||
|
||||
var LunchListController = require('lunch.LunchListController');
|
||||
var LunchModel = require('lunch.LunchModel');
|
||||
var LunchListRenderer = require('lunch.LunchListRenderer');
|
||||
|
||||
var core = require('web.core');
|
||||
@@ -14,7 +13,6 @@ var _lt = core._lt;
|
||||
var LunchListView = ListView.extend({
|
||||
config: _.extend({}, ListView.prototype.config, {
|
||||
Controller: LunchListController,
|
||||
Model: LunchModel,
|
||||
Renderer: LunchListRenderer,
|
||||
}),
|
||||
display_name: _lt('Lunch List'),
|
||||
@@ -26,10 +24,11 @@ var LunchListView = ListView.extend({
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
_getViewDomain: function (parent) {
|
||||
const model = this.getModel(parent);
|
||||
return model.getLocationDomain();
|
||||
_createSearchModel(params, extraExtensions = {}) {
|
||||
Object.assign(extraExtensions, { Lunch: {} });
|
||||
return this._super(params, extraExtensions);
|
||||
},
|
||||
|
||||
});
|
||||
|
||||
view_registry.add('lunch_list', LunchListView);
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
odoo.define("lunch/static/src/js/lunch_model_extension.js", function (require) {
|
||||
"use strict";
|
||||
|
||||
const ActionModel = require("web/static/src/js/views/action_model.js");
|
||||
|
||||
class LunchModelExtension extends ActionModel.Extension {
|
||||
|
||||
//---------------------------------------------------------------------
|
||||
// Public
|
||||
//---------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @override
|
||||
* @returns {any}
|
||||
*/
|
||||
get(property) {
|
||||
switch (property) {
|
||||
case "domain": return this.getDomain();
|
||||
case "userId": return this.state.userId;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
async load() {
|
||||
await this._updateLocationId();
|
||||
}
|
||||
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
prepareState() {
|
||||
Object.assign(this.state, {
|
||||
locationId: null,
|
||||
userId: null,
|
||||
});
|
||||
}
|
||||
|
||||
//---------------------------------------------------------------------
|
||||
// Actions / Getters
|
||||
//---------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @returns {Array[] | null}
|
||||
*/
|
||||
getDomain() {
|
||||
if (this.state.locationId) {
|
||||
return [["is_available_at", "in", [this.state.locationId]]];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} locationId
|
||||
* @returns {Promise}
|
||||
*/
|
||||
async setLocationId(locationId) {
|
||||
this.state.locationId = locationId;
|
||||
await this.env.services.rpc({
|
||||
route: "/lunch/user_location_set",
|
||||
params: {
|
||||
context: this.env.session.user_context,
|
||||
location_id: this.state.locationId,
|
||||
user_id: this.state.userId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} userId
|
||||
* @returns {Promise}
|
||||
*/
|
||||
async updateUserId(userId) {
|
||||
this.state.userId = userId;
|
||||
await this._updateLocationId();
|
||||
}
|
||||
|
||||
//---------------------------------------------------------------------
|
||||
// Private
|
||||
//---------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @returns {Promise}
|
||||
*/
|
||||
async _updateLocationId() {
|
||||
this.state.locationId = await this.env.services.rpc({
|
||||
route: "/lunch/user_location_get",
|
||||
params: {
|
||||
context: this.env.session.user_context,
|
||||
user_id: this.state.userId,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
ActionModel.registry.add("Lunch", LunchModelExtension, 20);
|
||||
|
||||
return LunchModelExtension;
|
||||
});
|
||||
@@ -7,7 +7,6 @@
|
||||
<link rel="stylesheet" type="text/scss" href="/lunch/static/src/scss/lunch_list.scss"/>
|
||||
|
||||
<script type="text/javascript" src="/lunch/static/src/js/lunch_controller_common.js"></script>
|
||||
<script type="text/javascript" src="/lunch/static/src/js/lunch_model.js"></script>
|
||||
<script type="text/javascript" src="/lunch/static/src/js/lunch_widget.js"></script>
|
||||
<script type="text/javascript" src="/lunch/static/src/js/lunch_mobile.js"></script>
|
||||
<script type="text/javascript" src="/lunch/static/src/js/lunch_payment_dialog.js"></script>
|
||||
@@ -15,6 +14,7 @@
|
||||
<script type="text/javascript" src="/lunch/static/src/js/lunch_kanban_controller.js"></script>
|
||||
<script type="text/javascript" src="/lunch/static/src/js/lunch_kanban_renderer.js"></script>
|
||||
<script type="text/javascript" src="/lunch/static/src/js/lunch_kanban_record.js"></script>
|
||||
<script type="text/javascript" src="/lunch/static/src/js/lunch_model_extension.js"></script>
|
||||
<script type="text/javascript" src="/lunch/static/src/js/lunch_list_view.js"></script>
|
||||
<script type="text/javascript" src="/lunch/static/src/js/lunch_list_controller.js"></script>
|
||||
<script type="text/javascript" src="/lunch/static/src/js/lunch_list_renderer.js"></script>
|
||||
|
||||
@@ -49,7 +49,7 @@ const DiscussWidget = AbstractAction.extend({
|
||||
// control panel attributes
|
||||
this.action = action;
|
||||
this.actionManager = parent;
|
||||
this.controlPanelModelConfig.modelName = 'mail.message';
|
||||
this.searchModelConfig.modelName = 'mail.message';
|
||||
this.discuss = undefined;
|
||||
this.options = options;
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@ This module provides the core of the Odoo Web Client.
|
||||
"static/src/xml/pivot.xml",
|
||||
"static/src/xml/rainbow_man.xml",
|
||||
"static/src/xml/report.xml",
|
||||
"static/src/xml/search_panel.xml",
|
||||
"static/src/xml/web_calendar.xml",
|
||||
],
|
||||
'bootstrap': True, # load translations for login screen
|
||||
|
||||
@@ -9,8 +9,8 @@ odoo.define('web.AbstractAction', function (require) {
|
||||
*/
|
||||
|
||||
var ActionMixin = require('web.ActionMixin');
|
||||
const ActionModel = require('web/static/src/js/views/action_model.js');
|
||||
var ControlPanel = require('web.ControlPanel');
|
||||
var ControlPanelModel = require('web.ControlPanelModel');
|
||||
var Widget = require('web.Widget');
|
||||
const { ComponentWrapper } = require('web.OwlCompatibility');
|
||||
|
||||
@@ -35,7 +35,7 @@ var AbstractAction = Widget.extend(ActionMixin, {
|
||||
* For example, the Discuss application adds the following line in its
|
||||
* constructor::
|
||||
*
|
||||
* this.controlPanelModelConfig.modelName = 'mail.message';
|
||||
* this.searchModelConfig.modelName = 'mail.message';
|
||||
*
|
||||
* @type boolean
|
||||
*/
|
||||
@@ -76,12 +76,16 @@ var AbstractAction = Widget.extend(ActionMixin, {
|
||||
this._super(parent);
|
||||
this._title = action.display_name || action.name;
|
||||
|
||||
this.searchModelConfig = {
|
||||
context: Object.assign({}, action.context),
|
||||
domain: action.domain || [],
|
||||
env: owl.Component.env,
|
||||
searchMenuTypes: this.searchMenuTypes,
|
||||
};
|
||||
this.extensions = {};
|
||||
if (this.hasControlPanel) {
|
||||
this.controlPanelModelConfig = {
|
||||
this.extensions.ControlPanel = {
|
||||
actionId: action.id,
|
||||
actionContext: action.context || {},
|
||||
actionDomain: action.domain || [],
|
||||
env: owl.Component.env,
|
||||
withSearchBar: this.withSearchBar,
|
||||
};
|
||||
|
||||
@@ -102,23 +106,36 @@ var AbstractAction = Widget.extend(ActionMixin, {
|
||||
* @override
|
||||
*/
|
||||
willStart: async function () {
|
||||
const proms = [this._super(...arguments)];
|
||||
const superPromise = this._super(...arguments);
|
||||
if (this.hasControlPanel) {
|
||||
if (this.loadControlPanel) {
|
||||
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 };
|
||||
const { context, modelName } = this.searchModelConfig;
|
||||
const options = { load_filters: this.searchMenuTypes.includes('favorite') };
|
||||
const { arch, fields, favoriteFilters } = await this.loadFieldView(
|
||||
modelName,
|
||||
context || {},
|
||||
this.viewId,
|
||||
'search',
|
||||
options
|
||||
);
|
||||
const archs = { search: arch };
|
||||
const { ControlPanel: controlPanelInfo } = ActionModel.extractArchInfo(archs);
|
||||
Object.assign(this.extensions.ControlPanel, {
|
||||
archNodes: controlPanelInfo.children,
|
||||
favoriteFilters,
|
||||
fields,
|
||||
});
|
||||
this.controlPanelProps.fields = fields;
|
||||
}
|
||||
this._controlPanelModel = new ControlPanelModel(this.controlPanelModelConfig);
|
||||
this.controlPanelProps.controlPanelModel = this._controlPanelModel;
|
||||
proms.push(this._controlPanelModel.isReady);
|
||||
}
|
||||
return Promise.all(proms);
|
||||
this.searchModel = new ActionModel(this.extensions, this.searchModelConfig);
|
||||
if (this.hasControlPanel) {
|
||||
this.controlPanelProps.searchModel = this.searchModel;
|
||||
}
|
||||
return Promise.all([
|
||||
superPromise,
|
||||
this.searchModel.load(),
|
||||
]);
|
||||
},
|
||||
/**
|
||||
* @override
|
||||
@@ -147,9 +164,9 @@ var AbstractAction = Widget.extend(ActionMixin, {
|
||||
*/
|
||||
on_attach_callback: function () {
|
||||
ActionMixin.on_attach_callback.call(this);
|
||||
this.searchModel.on('search', this, this._onSearch);
|
||||
if (this.hasControlPanel) {
|
||||
this._controlPanelModel.on('search', this, this._onSearch);
|
||||
this._controlPanelModel.on('get-controller-query-params', this, this._onGetOwnedQueryParams);
|
||||
this.searchModel.on('get-controller-query-params', this, this._onGetOwnedQueryParams);
|
||||
}
|
||||
},
|
||||
/**
|
||||
@@ -157,9 +174,9 @@ var AbstractAction = Widget.extend(ActionMixin, {
|
||||
*/
|
||||
on_detach_callback: function () {
|
||||
ActionMixin.on_detach_callback.call(this);
|
||||
this.searchModel.off('search', this);
|
||||
if (this.hasControlPanel) {
|
||||
this._controlPanelModel.off('search', this);
|
||||
this._controlPanelModel.off('get-controller-query-params', this);
|
||||
this.searchModel.off('get-controller-query-params', this);
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -488,6 +488,7 @@ ActionManager.include({
|
||||
// can't switch to an unknown view
|
||||
return Promise.reject();
|
||||
}
|
||||
|
||||
var currentController = this.getCurrentController();
|
||||
var index;
|
||||
if (currentController.actionID !== action.jsID) {
|
||||
@@ -546,7 +547,7 @@ ActionManager.include({
|
||||
return Promise.resolve(controller.widget.willRestore()).then(function () {
|
||||
viewOptions = _.extend({}, viewOptions, {
|
||||
breadcrumbs: self._getBreadcrumbs(self.controllerStack.slice(0, index)),
|
||||
shouldUpdateControlPanel: true,
|
||||
shouldUpdateSearchComponents: true,
|
||||
});
|
||||
return controller.widget.reload(viewOptions).then(function () {
|
||||
return controller;
|
||||
|
||||
@@ -3,7 +3,7 @@ odoo.define("web.ComparisonMenu", function (require) {
|
||||
|
||||
const DropdownMenu = require("web.DropdownMenu");
|
||||
const { FACET_ICONS } = require("web.searchUtils");
|
||||
const { useModel } = require("web.model");
|
||||
const { useModel } = require("web/static/src/js/model.js");
|
||||
|
||||
/**
|
||||
* "Comparison" menu
|
||||
@@ -15,7 +15,7 @@ odoo.define("web.ComparisonMenu", function (require) {
|
||||
class ComparisonMenu extends DropdownMenu {
|
||||
constructor() {
|
||||
super(...arguments);
|
||||
this.model = useModel("controlPanelModel");
|
||||
this.model = useModel('searchModel');
|
||||
}
|
||||
|
||||
//---------------------------------------------------------------------
|
||||
@@ -33,7 +33,7 @@ odoo.define("web.ComparisonMenu", function (require) {
|
||||
* @override
|
||||
*/
|
||||
get items() {
|
||||
return this.model.getFiltersOfType("comparison");
|
||||
return this.model.get('filters', f => f.type === 'comparison');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -2,15 +2,15 @@ odoo.define('web.ControlPanel', function (require) {
|
||||
"use strict";
|
||||
|
||||
const ActionMenus = require('web.ActionMenus');
|
||||
const ControlPanelModel = require('web.ControlPanelModel');
|
||||
const ComparisonMenu = require('web.ComparisonMenu');
|
||||
const ActionModel = require('web/static/src/js/views/action_model.js');
|
||||
const FavoriteMenu = require('web.FavoriteMenu');
|
||||
const FilterMenu = require('web.FilterMenu');
|
||||
const GroupByMenu = require('web.GroupByMenu');
|
||||
const patchMixin = require('web.patchMixin');
|
||||
const Pager = require('web.Pager');
|
||||
const SearchBar = require('web.SearchBar');
|
||||
const { useModel } = require('web.model');
|
||||
const { useModel } = require('web/static/src/js/model.js');
|
||||
|
||||
const { Component, hooks } = owl;
|
||||
const { useRef, useSubEnv } = hooks;
|
||||
@@ -105,14 +105,14 @@ odoo.define('web.ControlPanel', function (require) {
|
||||
|
||||
useSubEnv({
|
||||
action: this.props.action,
|
||||
controlPanelModel: this.props.controlPanelModel,
|
||||
searchModel: this.props.searchModel,
|
||||
view: this.props.view,
|
||||
});
|
||||
|
||||
// Connect to the model
|
||||
// TODO: move this in enterprise whenever possible
|
||||
if (this.env.controlPanelModel) {
|
||||
this.model = useModel('controlPanelModel');
|
||||
if (this.env.searchModel) {
|
||||
this.model = useModel('searchModel');
|
||||
}
|
||||
|
||||
// Reference hooks
|
||||
@@ -135,7 +135,7 @@ odoo.define('web.ControlPanel', function (require) {
|
||||
}
|
||||
|
||||
async willUpdateProps(nextProps) {
|
||||
// Note: action and controlPanelModel are not likely to change during
|
||||
// Note: action and searchModel 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) {
|
||||
@@ -185,6 +185,7 @@ odoo.define('web.ControlPanel', function (require) {
|
||||
return formattedFields;
|
||||
}
|
||||
}
|
||||
ControlPanel.modelExtension = "ControlPanel";
|
||||
|
||||
ControlPanel.components = {
|
||||
SearchBar,
|
||||
@@ -202,7 +203,7 @@ odoo.define('web.ControlPanel', function (require) {
|
||||
ControlPanel.props = {
|
||||
action: Object,
|
||||
breadcrumbs: Array,
|
||||
controlPanelModel: ControlPanelModel,
|
||||
searchModel: ActionModel,
|
||||
cp_content: { type: Object, optional: 1 },
|
||||
fields: Object,
|
||||
pager: { validate: p => typeof p === 'object' || p === null, optional: 1 },
|
||||
@@ -214,7 +215,6 @@ odoo.define('web.ControlPanel', function (require) {
|
||||
withBreadcrumbs: Boolean,
|
||||
withSearchBar: Boolean,
|
||||
};
|
||||
|
||||
ControlPanel.template = 'web.ControlPanel';
|
||||
|
||||
return patchMixin(ControlPanel);
|
||||
|
||||
+278
-353
@@ -1,17 +1,17 @@
|
||||
odoo.define('web.ControlPanelModel', function (require) {
|
||||
odoo.define("web/static/src/js/control_panel/control_panel_model_extension.js", function (require) {
|
||||
"use strict";
|
||||
|
||||
const ActionModel = require("web/static/src/js/views/action_model.js");
|
||||
const Domain = require('web.Domain');
|
||||
const { Model } = require('web.model');
|
||||
const { parseArch } = require('web.viewUtils');
|
||||
const pyUtils = require('web.py_utils');
|
||||
|
||||
const { DEFAULT_INTERVAL, DEFAULT_PERIOD, FACET_ICONS,
|
||||
const { DEFAULT_INTERVAL, DEFAULT_PERIOD,
|
||||
getComparisonOptions, getIntervalOptions, getPeriodOptions,
|
||||
constructDateDomain, rankInterval, yearSelected } = require('web.searchUtils');
|
||||
|
||||
const FAVORITE_PRIVATE_GROUP = 1;
|
||||
const FAVORITE_SHARED_GROUP = 2;
|
||||
const DISABLE_FAVORITE = "search_disable_custom_filters";
|
||||
|
||||
let filterId = 1;
|
||||
let groupId = 1;
|
||||
@@ -140,54 +140,100 @@ odoo.define('web.ControlPanelModel', function (require) {
|
||||
* The query elements indicates what are the active filters and 'how' they are active.
|
||||
* The key groupId has been added for simplicity. It could have been removed from query elements
|
||||
* since the information is available on the corresponding filters.
|
||||
* @extends Model
|
||||
* @extends ActionModel.Extension
|
||||
*/
|
||||
class ControlPanelModel extends Model {
|
||||
class ControlPanelModelExtension extends ActionModel.Extension {
|
||||
/**
|
||||
* @param {Object} config
|
||||
* @param {(string|number)} config.actionId
|
||||
* @param {Object} config.env
|
||||
* @param {string} config.modelName
|
||||
* @param {Object} [config.importedState]
|
||||
* @param {Array[]} [config.actionDomain=[]]
|
||||
* @param {Object} [config.actionContext={}]
|
||||
* @param {Object} [config.context={}]
|
||||
* @param {Object[]} [config.archNodes=[]]
|
||||
* @param {Object[]} [config.dynamicFilters=[]]
|
||||
* @param {string[]} [config.searchMenuTypes=[]]
|
||||
* @param {Object} [config.viewInfo={}]
|
||||
* @param {Object} [config.favoriteFilters={}]
|
||||
* @param {Object} [config.fields={}]
|
||||
* @param {boolean} [config.withSearchBar=true]
|
||||
*
|
||||
*/
|
||||
constructor(config) {
|
||||
super();
|
||||
constructor() {
|
||||
super(...arguments);
|
||||
|
||||
this._setProperties(config);
|
||||
|
||||
if (this.withSearchBar) {
|
||||
if (config.importedState) {
|
||||
this.importState(config.importedState);
|
||||
} else {
|
||||
this._prepareInitialState();
|
||||
this.actionContext = Object.assign({}, this.config.context);
|
||||
this.searchMenuTypes = this.config.searchMenuTypes || [];
|
||||
this.favoriteFilters = this.config.favoriteFilters || [];
|
||||
this.fields = this.config.fields || {};
|
||||
this.searchDefaults = [];
|
||||
for (const key in this.actionContext) {
|
||||
const match = /^search_default_(.*)$/.exec(key);
|
||||
if (match) {
|
||||
const val = this.actionContext[key];
|
||||
if (val) {
|
||||
this.searchDefaults[match[1]] = val;
|
||||
}
|
||||
delete this.actionContext[key];
|
||||
}
|
||||
}
|
||||
this.labelPromises = [];
|
||||
|
||||
this.isReady = Promise.all(this.labelPromises);
|
||||
}
|
||||
|
||||
//---------------------------------------------------------------------
|
||||
// Getters
|
||||
//---------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @returns {(Object | undefined)}
|
||||
*/
|
||||
get activeComparison() {
|
||||
return this.state.query.find(queryElem => queryElem.type === 'comparison');
|
||||
this.referenceMoment = moment();
|
||||
this.optionGenerators = getPeriodOptions(this.referenceMoment);
|
||||
this.intervalOptions = getIntervalOptions();
|
||||
this.comparisonOptions = getComparisonOptions();
|
||||
}
|
||||
|
||||
//---------------------------------------------------------------------
|
||||
// Public
|
||||
//---------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @override
|
||||
* @returns {any}
|
||||
*/
|
||||
get(property, ...args) {
|
||||
switch (property) {
|
||||
case "context": return this.getContext();
|
||||
case "domain": return this.getDomain();
|
||||
case "facets": return this._getFacets();
|
||||
case "filters": return this._getFilters(...args);
|
||||
case "groupBy": return this.getGroupBy();
|
||||
case "orderedBy": return this.getOrderedBy();
|
||||
case "timeRanges": return this.getTimeRanges();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
async load() {
|
||||
await Promise.all(this.labelPromises);
|
||||
}
|
||||
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
prepareState() {
|
||||
Object.assign(this.state, {
|
||||
filters: {},
|
||||
query: [],
|
||||
});
|
||||
if (this.config.withSearchBar !== false) {
|
||||
this._addFilters();
|
||||
this._activateDefaultFilters();
|
||||
}
|
||||
}
|
||||
|
||||
//---------------------------------------------------------------------
|
||||
// Actions / Getters
|
||||
//---------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @returns {Object | undefined}
|
||||
*/
|
||||
get activeComparison() {
|
||||
return this.state.query.find(queryElem => queryElem.type === 'comparison');
|
||||
}
|
||||
|
||||
/**
|
||||
* Activate a filter of type 'field' with given filterId with
|
||||
* 'autocompleteValues' value, label, and operator.
|
||||
@@ -321,117 +367,59 @@ odoo.define('web.ControlPanelModel', function (require) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the state of the control panel model (the filters and the
|
||||
* current query). This state can then be used in an other control panel
|
||||
* model (with same key modelName). See importedState.
|
||||
* @returns {Object}
|
||||
*/
|
||||
exportState() {
|
||||
return JSON.parse(JSON.stringify(this.state));
|
||||
}
|
||||
|
||||
/**
|
||||
* Called by search bar to render the facets
|
||||
* @returns {Object[]}
|
||||
*/
|
||||
getFacets() {
|
||||
const accept = type => {
|
||||
if (['groupBy', 'comparison'].includes(type) && !this.searchMenuTypes.includes(type)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
getContext() {
|
||||
const groups = this._getGroups();
|
||||
const facets = [];
|
||||
for (const group of groups) {
|
||||
const { activities, type, id } = group;
|
||||
if (accept(type)) {
|
||||
const facet = {
|
||||
groupId: id,
|
||||
separator: type === 'groupBy' ? ">" : this.env._t("or"),
|
||||
values: this._getFacetDescriptions(activities, type),
|
||||
};
|
||||
const icon = FACET_ICONS[type];
|
||||
if (icon) {
|
||||
facet.icon = icon;
|
||||
} else {
|
||||
facet.title = activities[0].filter.description;
|
||||
}
|
||||
facets.push(facet);
|
||||
}
|
||||
}
|
||||
return facets;
|
||||
return this._getContext(groups);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return an array containing enriched copies of the filters of the provided type.
|
||||
* @param {string} type
|
||||
* @returns {Object[]}
|
||||
* @returns {Array[]}
|
||||
*/
|
||||
getFiltersOfType(type) {
|
||||
const filters = Object.values(this.state.filters).reduce(
|
||||
(filters, filter) => {
|
||||
if (filter.type === type && !filter.invisible) {
|
||||
const filterQueryElements = this.state.query.filter(
|
||||
queryElem => queryElem.filterId === filter.id
|
||||
);
|
||||
const enrichedFilter = this._enrichFilterCopy(filter, filterQueryElements);
|
||||
if (enrichedFilter) {
|
||||
filters.push(enrichedFilter);
|
||||
}
|
||||
}
|
||||
return filters;
|
||||
},
|
||||
[]
|
||||
);
|
||||
if (type === 'favorite') {
|
||||
filters.sort((f1, f2) => f1.groupNumber - f2.groupNumber);
|
||||
getDomain() {
|
||||
const groups = this._getGroups();
|
||||
const userContext = this.env.session.user_context;
|
||||
try {
|
||||
return Domain.prototype.stringToArray(this._getDomain(groups), userContext);
|
||||
} catch (err) {
|
||||
throw new Error(
|
||||
`${this.env._t("Control panel model extension failed to evaluate domain")}:/n${JSON.stringify(err)}`
|
||||
);
|
||||
}
|
||||
return filters;
|
||||
}
|
||||
|
||||
/**
|
||||
* Principal objects used by controllers/models to fetch data.
|
||||
* @returns {Object} An object called search query with keys domain, groupBy,
|
||||
* context, orderedBy, and (optionally) timeRanges.
|
||||
* @returns {string[]}
|
||||
*/
|
||||
getQuery() {
|
||||
getGroupBy() {
|
||||
const groups = this._getGroups();
|
||||
return this._getGroupBy(groups);
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {string[]}
|
||||
*/
|
||||
getOrderedBy() {
|
||||
const groups = this._getGroups();
|
||||
return this._getOrderedBy(groups);
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Object}
|
||||
*/
|
||||
getTimeRanges() {
|
||||
const requireEvaluation = true;
|
||||
const groups = this._getGroups();
|
||||
const query = {
|
||||
context: this._getContext(groups),
|
||||
domain: this._getDomain(groups, requireEvaluation),
|
||||
orderedBy: this._getOrderedBy(groups)
|
||||
};
|
||||
if (this.searchMenuTypes.includes('groupBy')) {
|
||||
const groupBy = this._getGroupBy(groups);
|
||||
query.groupBy = groupBy;
|
||||
} else {
|
||||
query.groupBy = [];
|
||||
}
|
||||
if (this.searchMenuTypes.includes('comparison')) {
|
||||
const timeRanges = this._getTimeRanges(requireEvaluation);
|
||||
query.timeRanges = timeRanges || {};
|
||||
}
|
||||
return query;
|
||||
return this._getTimeRanges(requireEvaluation);
|
||||
}
|
||||
|
||||
/**
|
||||
* Allow to reuse the state of a previous control panel model with same modelName.
|
||||
* This is mainly used when switching views.
|
||||
* @param {Object} state
|
||||
* Used to call dispatch and trigger a 'search'.
|
||||
*/
|
||||
importState(state) {
|
||||
Object.assign(this.state, state);
|
||||
search() {
|
||||
/* ... */
|
||||
}
|
||||
|
||||
/**
|
||||
* This function won't do anything: its purpose is to call the dispatch
|
||||
* method to trigger a 'search' event + reload the components.
|
||||
*/
|
||||
search() { }
|
||||
|
||||
/**
|
||||
* Activate/Deactivate a filter of type 'comparison' with provided id.
|
||||
* At most one filter of type 'comparison' can be activated at every time.
|
||||
@@ -514,27 +502,6 @@ odoo.define('web.ControlPanelModel', function (require) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* TODO: the way it is done could be improved, but the actual state of the
|
||||
* searchView doesn't allow to do much better.
|
||||
*
|
||||
* Update the domain of the search view by adding and/or removing filters.
|
||||
* @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 {number[]} filtersToRemove list of filter ids to remove
|
||||
* (previously added ones)
|
||||
* @returns {number[]} list of added filter ids (to pass as filtersToRemove
|
||||
* for a further call to this function)
|
||||
*/
|
||||
updateFilters(newFilters, filtersToRemove) {
|
||||
const newFilterIdS = this.createNewFilters(newFilters);
|
||||
this.state.query = this.state.query.filter(
|
||||
queryElem => !filtersToRemove.includes(queryElem.filterId)
|
||||
);
|
||||
return newFilterIdS;
|
||||
}
|
||||
|
||||
//---------------------------------------------------------------------
|
||||
// Private
|
||||
//---------------------------------------------------------------------
|
||||
@@ -573,9 +540,10 @@ odoo.define('web.ControlPanelModel', function (require) {
|
||||
/**
|
||||
* This function populates the 'filters' object at initialization.
|
||||
* The filters come from:
|
||||
* - config.viewInfo.arch (types 'comparison', 'filter', 'groupBy', 'field'),
|
||||
* - config.archNodes (types 'comparison', 'filter', 'groupBy', 'field'),
|
||||
* - config.dynamicFilters (type 'filter'),
|
||||
* - config.viewInfo.favoriteFilters (type 'favorite'),
|
||||
* - config.favoriteFilters (type 'favorite'),
|
||||
* - code itself (type 'timeRange')
|
||||
* @private
|
||||
*/
|
||||
_addFilters() {
|
||||
@@ -610,12 +578,12 @@ odoo.define('web.ControlPanelModel', function (require) {
|
||||
* Returns the active comparison timeRanges object.
|
||||
* @private
|
||||
* @param {Object} comparisonFilter
|
||||
* @returns {Object | undefined}
|
||||
* @returns {Object | null}
|
||||
*/
|
||||
_computeTimeRanges(comparisonFilter) {
|
||||
const { filterId } = this.activeComparison;
|
||||
if (filterId !== comparisonFilter.id) {
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
const { dateFilterId, comparisonOptionId } = comparisonFilter;
|
||||
const {
|
||||
@@ -674,11 +642,12 @@ odoo.define('web.ControlPanelModel', function (require) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Add filters of type 'filter' determined by the key array this.dynamicFilters.
|
||||
* Add filters of type 'filter' determined by the key array dynamicFilters.
|
||||
* @private
|
||||
*/
|
||||
_createGroupOfDynamicFilters() {
|
||||
const pregroup = this.dynamicFilters.map(filter => {
|
||||
const dynamicFilters = this.config.dynamicFilters || [];
|
||||
const pregroup = dynamicFilters.map(filter => {
|
||||
return {
|
||||
description: filter.description,
|
||||
domain: JSON.stringify(filter.domain),
|
||||
@@ -690,14 +659,17 @@ odoo.define('web.ControlPanelModel', function (require) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Add filters of type 'favorite' determined by array this.favoriteFilters.
|
||||
* Add filters of type 'favorite' determined by the array this.favoriteFilters.
|
||||
* @private
|
||||
*/
|
||||
_createGroupOfFavorites() {
|
||||
const activateFavorite = DISABLE_FAVORITE in this.actionContext ?
|
||||
!this.actionContext[DISABLE_FAVORITE] :
|
||||
true;
|
||||
this.favoriteFilters.forEach(irFilter => {
|
||||
const favorite = this._irFilterToFavorite(irFilter);
|
||||
this._createGroupOfFilters([favorite]);
|
||||
if (this.activateDefaultFavorite && favorite.isDefault) {
|
||||
if (activateFavorite && favorite.isDefault) {
|
||||
this.defaultFavoriteId = favorite.id;
|
||||
}
|
||||
});
|
||||
@@ -733,10 +705,7 @@ odoo.define('web.ControlPanelModel', function (require) {
|
||||
* @private
|
||||
*/
|
||||
_createGroupOfFiltersFromArch() {
|
||||
const children = this.parsedArch.children.filter(
|
||||
child => child instanceof Object && child.tag !== 'searchpanel'
|
||||
);
|
||||
const preFilters = children.reduce(
|
||||
const preFilters = this.config.archNodes.reduce(
|
||||
(preFilters, child) => {
|
||||
if (child.tag === 'group') {
|
||||
return [...preFilters, ...child.children.map(c => this._evalArchChild(c))];
|
||||
@@ -778,22 +747,6 @@ odoo.define('web.ControlPanelModel', function (require) {
|
||||
// and others are passive (require input(s) to become determined)
|
||||
// What is the right place to process the attrs?
|
||||
};
|
||||
if (preFilter.attrs && JSON.parse(preFilter.attrs.modifiers || '{}').invisible) {
|
||||
filter.invisible = true;
|
||||
|
||||
var preFilterFieldName = null;
|
||||
if (preFilter.tag == 'filter' && preFilter.attrs.date) {
|
||||
preFilterFieldName = preFilter.attrs.date;
|
||||
} else if (preFilter.tag == 'groupBy') {
|
||||
preFilterFieldName = preFilter.attrs.fieldName;
|
||||
}
|
||||
if (preFilterFieldName && !this.fields[preFilterFieldName]) {
|
||||
// In some case when a field is limited to specific groups
|
||||
// on the model, we need to ensure to discard related filter
|
||||
// as it may still be present in the view (in 'invisible' state)
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (filter.type === 'filter' || filter.type === 'groupBy') {
|
||||
filter.groupNumber = groupNumber;
|
||||
}
|
||||
@@ -806,7 +759,7 @@ odoo.define('web.ControlPanelModel', function (require) {
|
||||
this._createGroupOfFilters(pregroupOfGroupBys);
|
||||
}
|
||||
const dateFilters = Object.values(this.state.filters).filter(
|
||||
(filter) => filter.isDateFilter
|
||||
(filter) => filter.isDateFilter
|
||||
);
|
||||
if (dateFilters.length) {
|
||||
this._createGroupOfComparisons(dateFilters);
|
||||
@@ -814,26 +767,14 @@ odoo.define('web.ControlPanelModel', function (require) {
|
||||
}
|
||||
|
||||
/**
|
||||
* The parent of the control panel (an action or a controller) is not
|
||||
* necessarily a component. So we need to notify it through a
|
||||
* mechanism different from __notifyComponents. Here we use the fact
|
||||
* that the controlPanelModel is an (owl) EventBus to communicate with
|
||||
* the parent.
|
||||
* @private
|
||||
*/
|
||||
_dispatch() {
|
||||
this.trigger('search', this.getQuery());
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns undefined or a copy of the provided filter with additional information
|
||||
* Returns null or a copy of the provided filter with additional information
|
||||
* used only outside of the control panel model, like in search bar or in the
|
||||
* various menus. The value undefined is returned if the filter should not appear
|
||||
* various menus. The value null is returned if the filter should not appear
|
||||
* for some reason.
|
||||
* @private
|
||||
* @param {Object} filter
|
||||
* @param {Object[]} filterQueryElements
|
||||
* @returns {Object|undefined}
|
||||
* @returns {Object | null}
|
||||
*/
|
||||
_enrichFilterCopy(filter, filterQueryElements) {
|
||||
const isActive = Boolean(filterQueryElements.length);
|
||||
@@ -854,7 +795,7 @@ odoo.define('web.ControlPanelModel', function (require) {
|
||||
queryElem => queryElem.filterId === dateFilterId
|
||||
);
|
||||
if (!dateFilterIsActive) {
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -924,6 +865,9 @@ odoo.define('web.ControlPanelModel', function (require) {
|
||||
filter.isDefault = attrs.isDefault;
|
||||
}
|
||||
filter.description = attrs.string || attrs.help || attrs.name || attrs.domain || 'Ω';
|
||||
if (JSON.parse(attrs.modifiers || '{}').invisible) {
|
||||
filter.invisible = true;
|
||||
}
|
||||
switch (filter.type) {
|
||||
case 'filter':
|
||||
if (attrs.context) {
|
||||
@@ -972,7 +916,7 @@ odoo.define('web.ControlPanelModel', function (require) {
|
||||
filter.defaultRank = -10;
|
||||
filter.defaultAutocompleteValue = attrs.defaultAutocompleteValue;
|
||||
}
|
||||
if (attrs.widget) { // FIXME: drop support in master
|
||||
if (attrs.widget) {
|
||||
filter.widget = attrs.widget;
|
||||
}
|
||||
break;
|
||||
@@ -993,8 +937,8 @@ odoo.define('web.ControlPanelModel', function (require) {
|
||||
*/
|
||||
_favoriteToIrFilter(favorite) {
|
||||
const irFilter = {
|
||||
action_id: this.actionId,
|
||||
model_id: this.modelName,
|
||||
action_id: this.config.actionId,
|
||||
model_id: this.config.modelName,
|
||||
};
|
||||
|
||||
// ir.filter fields
|
||||
@@ -1069,7 +1013,7 @@ odoo.define('web.ControlPanelModel', function (require) {
|
||||
* @private
|
||||
* @returns {Object}
|
||||
*/
|
||||
_getContext(groups, withActionContext = true) {
|
||||
_getContext(groups) {
|
||||
const types = ['filter', 'favorite', 'field'];
|
||||
const contexts = groups.reduce(
|
||||
(contexts, group) => {
|
||||
@@ -1080,9 +1024,6 @@ odoo.define('web.ControlPanelModel', function (require) {
|
||||
},
|
||||
[]
|
||||
);
|
||||
if (withActionContext) {
|
||||
contexts.unshift(this.actionContext);
|
||||
}
|
||||
const evaluationContext = this.env.session.user_context;
|
||||
try {
|
||||
return pyUtils.eval('contexts', contexts, evaluationContext);
|
||||
@@ -1118,35 +1059,97 @@ odoo.define('web.ControlPanelModel', function (require) {
|
||||
* of type 'filter', 'favorite', and 'field'.
|
||||
* @private
|
||||
* @param {Object[]} groups
|
||||
* @param {boolean} [evaluation=true]
|
||||
* @returns {string}
|
||||
*/
|
||||
_getDomain(groups, evaluation = true) {
|
||||
_getDomain(groups) {
|
||||
const types = ['filter', 'favorite', 'field'];
|
||||
const domains = groups.reduce(
|
||||
(domains, group) => {
|
||||
if (types.includes(group.type)) {
|
||||
domains.push(this._getGroupDomain(group));
|
||||
}
|
||||
return domains;
|
||||
},
|
||||
[]
|
||||
);
|
||||
let filterDomain = pyUtils.assembleDomains(domains, 'AND');
|
||||
const domains = [];
|
||||
for (const group of groups) {
|
||||
if (types.includes(group.type)) {
|
||||
domains.push(this._getGroupDomain(group));
|
||||
}
|
||||
}
|
||||
return pyUtils.assembleDomains(domains, 'AND');
|
||||
}
|
||||
|
||||
if (evaluation) {
|
||||
const userContext = this.env.session.user_context;
|
||||
try {
|
||||
return pyUtils.eval('domains', [this.actionDomain, filterDomain], userContext);
|
||||
} catch (err) {
|
||||
throw new Error(
|
||||
this.env._t("Failed to evaluate search domain") + ":\n" +
|
||||
JSON.stringify(err)
|
||||
);
|
||||
/**
|
||||
* Get the filter description to use in the search bar as a facet.
|
||||
* @private
|
||||
* @param {Object} activity
|
||||
* @param {Object} activity.filter
|
||||
* @param {Object[]} activity.filterQueryElements
|
||||
* @returns {string}
|
||||
*/
|
||||
_getFacetDescriptions(activities, type) {
|
||||
const facetDescriptions = [];
|
||||
if (type === 'field') {
|
||||
for (const queryElem of activities[0].filterQueryElements) {
|
||||
facetDescriptions.push(queryElem.label);
|
||||
}
|
||||
} else if (type === 'groupBy') {
|
||||
for (const { filter, filterQueryElements } of activities) {
|
||||
if (filter.hasOptions) {
|
||||
for (const queryElem of filterQueryElements) {
|
||||
const option = this.intervalOptions.find(
|
||||
o => o.id === queryElem.optionId
|
||||
);
|
||||
facetDescriptions.push(filter.description + ': ' + option.description);
|
||||
}
|
||||
} else {
|
||||
facetDescriptions.push(filter.description);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
return filterDomain;
|
||||
let facetDescription;
|
||||
for (const { filter, filterQueryElements } of activities) {
|
||||
// filter, favorite and comparison
|
||||
facetDescription = filter.description;
|
||||
if (filter.isDateFilter) {
|
||||
const description = this._getDateFilterDomain(
|
||||
filter, filterQueryElements, 'description'
|
||||
);
|
||||
facetDescription += `: ${description}`;
|
||||
}
|
||||
facetDescriptions.push(facetDescription);
|
||||
}
|
||||
}
|
||||
return facetDescriptions;
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Object[]}
|
||||
*/
|
||||
_getFacets() {
|
||||
const facets = this._getGroups().map(({ activities, type, id }) => {
|
||||
const values = this._getFacetDescriptions(activities, type);
|
||||
const title = activities[0].filter.description;
|
||||
return { groupId: id, title, type, values };
|
||||
});
|
||||
return facets;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return an array containing enriched copies of the filters of the provided type.
|
||||
* @param {Function} predicate
|
||||
* @returns {Object[]}
|
||||
*/
|
||||
_getFilters(predicate) {
|
||||
const filters = [];
|
||||
Object.values(this.state.filters).forEach(filter => {
|
||||
if ((!predicate || predicate(filter)) && !filter.invisible) {
|
||||
const filterQueryElements = this.state.query.filter(
|
||||
queryElem => queryElem.filterId === filter.id
|
||||
);
|
||||
const enrichedFilter = this._enrichFilterCopy(filter, filterQueryElements);
|
||||
if (enrichedFilter) {
|
||||
filters.push(enrichedFilter);
|
||||
}
|
||||
}
|
||||
});
|
||||
if (filters.some(f => f.type === 'favorite')) {
|
||||
filters.sort((f1, f2) => f1.groupNumber - f2.groupNumber);
|
||||
}
|
||||
return filters;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1358,6 +1361,36 @@ odoo.define('web.ControlPanelModel', function (require) {
|
||||
return selectedOptionIds;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the last timeRanges object found in the query.
|
||||
* TimeRanges objects can be associated with filters of type 'favorite'
|
||||
* or 'comparison'.
|
||||
* @private
|
||||
* @param {boolean} [evaluation=false]
|
||||
* @returns {Object | null}
|
||||
*/
|
||||
_getTimeRanges(evaluation) {
|
||||
let timeRanges;
|
||||
for (const queryElem of this.state.query.slice().reverse()) {
|
||||
const filter = this.state.filters[queryElem.filterId];
|
||||
if (filter.type === 'comparison') {
|
||||
timeRanges = this._computeTimeRanges(filter);
|
||||
break;
|
||||
} else if (filter.type === 'favorite' && filter.comparison) {
|
||||
timeRanges = filter.comparison;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (timeRanges) {
|
||||
if (evaluation) {
|
||||
timeRanges.range = Domain.prototype.stringToArray(timeRanges.range);
|
||||
timeRanges.comparisonRange = Domain.prototype.stringToArray(timeRanges.comparisonRange);
|
||||
}
|
||||
return timeRanges;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a filter of type 'favorite' starting from an ir_filter comming from db.
|
||||
* @private
|
||||
@@ -1496,8 +1529,10 @@ odoo.define('web.ControlPanelModel', function (require) {
|
||||
} else if (fieldType === 'many2one') {
|
||||
const updateLabel = label => {
|
||||
const queryElem = this.state.query.find(({ filterId }) => filterId === id);
|
||||
queryElem.label = label;
|
||||
defaultAutocompleteValue.label = label;
|
||||
if (queryElem) {
|
||||
queryElem.label = label;
|
||||
defaultAutocompleteValue.label = label;
|
||||
}
|
||||
};
|
||||
const promise = this.env.services.rpc({
|
||||
args: [defaultAutocompleteValue.value],
|
||||
@@ -1513,14 +1548,6 @@ odoo.define('web.ControlPanelModel', function (require) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
_prepareInitialState() {
|
||||
this._addFilters();
|
||||
this._activateDefaultFilters();
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the search Query and save it as an ir_filter in db.
|
||||
* No evaluation of domains is done in order to keep them dynamic.
|
||||
@@ -1535,17 +1562,13 @@ odoo.define('web.ControlPanelModel', function (require) {
|
||||
|
||||
const userContext = this.env.session.user_context;
|
||||
let controllerQueryParams;
|
||||
this.trigger(
|
||||
'get-controller-query-params',
|
||||
p => {
|
||||
controllerQueryParams = p;
|
||||
}
|
||||
);
|
||||
this.config.trigger("get-controller-query-params", params => {
|
||||
controllerQueryParams = params;
|
||||
});
|
||||
controllerQueryParams = controllerQueryParams || {};
|
||||
controllerQueryParams.context = controllerQueryParams.context || {};
|
||||
|
||||
const withoutActiveContext = false;
|
||||
const queryContext = this._getContext(groups, withoutActiveContext);
|
||||
const queryContext = this._getContext(groups);
|
||||
const context = pyUtils.eval(
|
||||
'contexts',
|
||||
[userContext, controllerQueryParams.context, queryContext]
|
||||
@@ -1555,7 +1578,7 @@ odoo.define('web.ControlPanelModel', function (require) {
|
||||
}
|
||||
|
||||
const requireEvaluation = false;
|
||||
const domain = this._getDomain(groups, requireEvaluation);
|
||||
const domain = this._getDomain(groups);
|
||||
const groupBys = this._getGroupBy(groups);
|
||||
const timeRanges = this._getTimeRanges(requireEvaluation);
|
||||
const orderedBy = controllerQueryParams.orderedBy ?
|
||||
@@ -1585,128 +1608,30 @@ odoo.define('web.ControlPanelModel', function (require) {
|
||||
return preFilter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the filter description to use in the search bar as a facet.
|
||||
* @private
|
||||
* @param {Object} activity
|
||||
* @param {Object} activity.filter
|
||||
* @param {Object[]} activity.filterQueryElements
|
||||
* @returns {string}
|
||||
*/
|
||||
_getFacetDescriptions(activities, type) {
|
||||
const facetDescriptions = [];
|
||||
if (type === 'field') {
|
||||
for (const queryElem of activities[0].filterQueryElements) {
|
||||
facetDescriptions.push(queryElem.label);
|
||||
}
|
||||
} else if (type === 'groupBy') {
|
||||
for (const { filter, filterQueryElements } of activities) {
|
||||
if (filter.hasOptions) {
|
||||
for (const queryElem of filterQueryElements) {
|
||||
const option = this.intervalOptions.find(
|
||||
o => o.id === queryElem.optionId
|
||||
);
|
||||
facetDescriptions.push(filter.description + ': ' + option.description);
|
||||
}
|
||||
} else {
|
||||
facetDescriptions.push(filter.description);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let facetDescription;
|
||||
for (const { filter, filterQueryElements } of activities) {
|
||||
// filter, favorite and comparison
|
||||
facetDescription = filter.description;
|
||||
if (filter.isDateFilter) {
|
||||
const description = this._getDateFilterDomain(
|
||||
filter, filterQueryElements, 'description'
|
||||
);
|
||||
facetDescription += `: ${description}`;
|
||||
}
|
||||
facetDescriptions.push(facetDescription);
|
||||
}
|
||||
}
|
||||
return facetDescriptions;
|
||||
}
|
||||
//---------------------------------------------------------------------
|
||||
// Static
|
||||
//---------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Returns the last timeRanges object found in the query.
|
||||
* TimeRanges objects can be associated with filters of type 'favorite'
|
||||
* or 'comparison'.
|
||||
* @private
|
||||
* @param {boolean} [evaluation=false]
|
||||
* @returns {(Object|undefined)}
|
||||
* @override
|
||||
* @returns {{ attrs: Object, children: Object[] }}
|
||||
*/
|
||||
_getTimeRanges(evaluation) {
|
||||
let timeRanges;
|
||||
for (const queryElem of this.state.query.slice().reverse()) {
|
||||
const filter = this.state.filters[queryElem.filterId];
|
||||
if (filter.type === 'comparison') {
|
||||
timeRanges = this._computeTimeRanges(filter);
|
||||
break;
|
||||
} else if (filter.type === 'favorite' && filter.comparison) {
|
||||
timeRanges = filter.comparison;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (timeRanges) {
|
||||
if (evaluation) {
|
||||
timeRanges.range = Domain.prototype.stringToArray(timeRanges.range);
|
||||
timeRanges.comparisonRange = Domain.prototype.stringToArray(timeRanges.comparisonRange);
|
||||
}
|
||||
return timeRanges;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Using the constructor parameter object config, set most of the properties
|
||||
* of the control panel model.
|
||||
* @private
|
||||
* @param {Object} config
|
||||
*/
|
||||
_setProperties(config) {
|
||||
this.state = {
|
||||
filters: {},
|
||||
query: [],
|
||||
static extractArchInfo(archs) {
|
||||
const { attrs, children } = archs.search;
|
||||
const controlPanelInfo = {
|
||||
attrs,
|
||||
children: [],
|
||||
};
|
||||
this.env = config.env;
|
||||
this.modelName = config.modelName;
|
||||
this.actionDomain = config.actionDomain || [];
|
||||
this.actionContext = config.actionContext || {};
|
||||
this.actionId = config.actionId;
|
||||
this.withSearchBar = 'withSearchBar' in config ? config.withSearchBar : true;
|
||||
this.searchMenuTypes = config.searchMenuTypes || [];
|
||||
|
||||
this.searchDefaults = [];
|
||||
for (const key in this.actionContext) {
|
||||
const match = /^search_default_(.*)$/.exec(key);
|
||||
if (match) {
|
||||
const val = this.actionContext[key];
|
||||
if (val) {
|
||||
this.searchDefaults[match[1]] = val;
|
||||
}
|
||||
delete this.actionContext[key];
|
||||
for (const child of children) {
|
||||
if (child.tag !== "searchpanel") {
|
||||
controlPanelInfo.children.push(child);
|
||||
}
|
||||
}
|
||||
this.labelPromises = [];
|
||||
|
||||
const viewInfo = config.viewInfo || {};
|
||||
|
||||
this.parsedArch = parseArch(viewInfo.arch || '<search/>');
|
||||
this.fields = viewInfo.fields || {};
|
||||
this.favoriteFilters = viewInfo.favoriteFilters || [];
|
||||
this.activateDefaultFavorite = 'search_disable_custom_filters' in this.actionContext ?
|
||||
!this.actionContext.search_disable_custom_filters :
|
||||
true;
|
||||
|
||||
this.dynamicFilters = config.dynamicFilters || [];
|
||||
|
||||
this.referenceMoment = moment();
|
||||
this.optionGenerators = getPeriodOptions(this.referenceMoment);
|
||||
this.intervalOptions = getIntervalOptions();
|
||||
this.comparisonOptions = getComparisonOptions();
|
||||
return controlPanelInfo;
|
||||
}
|
||||
}
|
||||
|
||||
return ControlPanelModel;
|
||||
ActionModel.registry.add("ControlPanel", ControlPanelModelExtension, 10);
|
||||
|
||||
return ControlPanelModelExtension;
|
||||
});
|
||||
@@ -4,7 +4,7 @@ odoo.define('web.CustomFavoriteItem', function (require) {
|
||||
const DropdownMenuItem = require('web.DropdownMenuItem');
|
||||
const FavoriteMenu = require('web.FavoriteMenu');
|
||||
const { useAutofocus } = require('web.custom_hooks');
|
||||
const { useModel } = require('web.model');
|
||||
const { useModel } = require('web/static/src/js/model.js');
|
||||
|
||||
const { useRef } = owl.hooks;
|
||||
|
||||
@@ -39,7 +39,7 @@ odoo.define('web.CustomFavoriteItem', function (require) {
|
||||
this.shareAllUsersId = `o_favorite_share_all_users_${favId}`;
|
||||
|
||||
this.descriptionRef = useRef('description');
|
||||
this.model = useModel('controlPanelModel');
|
||||
this.model = useModel('searchModel');
|
||||
this.interactive = true;
|
||||
Object.assign(this.state, {
|
||||
description: this.env.action.name || "",
|
||||
@@ -65,7 +65,7 @@ odoo.define('web.CustomFavoriteItem', function (require) {
|
||||
});
|
||||
return this.descriptionRef.el.focus();
|
||||
}
|
||||
const favorites = this.model.getFiltersOfType('favorite');
|
||||
const favorites = this.model.get('filters', f => f.type === 'favorite');
|
||||
if (favorites.some(f => f.description === this.state.description)) {
|
||||
this.env.services.notification.notify({
|
||||
message: this.env._t("Filter with same name already exists."),
|
||||
|
||||
@@ -7,7 +7,7 @@ odoo.define('web.CustomFilterItem', function (require) {
|
||||
const { FIELD_OPERATORS, FIELD_TYPES } = require('web.searchUtils');
|
||||
const field_utils = require('web.field_utils');
|
||||
const patchMixin = require('web.patchMixin');
|
||||
const { useModel } = require('web.model');
|
||||
const { useModel } = require('web/static/src/js/model.js');
|
||||
|
||||
/**
|
||||
* Filter generator menu
|
||||
@@ -46,7 +46,7 @@ odoo.define('web.CustomFilterItem', function (require) {
|
||||
constructor() {
|
||||
super(...arguments);
|
||||
|
||||
this.model = useModel('controlPanelModel');
|
||||
this.model = useModel('searchModel');
|
||||
|
||||
this.canBeOpened = true;
|
||||
this.state.conditions = [];
|
||||
|
||||
@@ -2,7 +2,7 @@ odoo.define('web.CustomGroupByItem', function (require) {
|
||||
"use strict";
|
||||
|
||||
const DropdownMenuItem = require('web.DropdownMenuItem');
|
||||
const { useModel } = require('web.model');
|
||||
const { useModel } = require('web/static/src/js/model.js');
|
||||
|
||||
/**
|
||||
* Group by generator menu
|
||||
@@ -20,7 +20,7 @@ odoo.define('web.CustomGroupByItem', function (require) {
|
||||
this.canBeOpened = true;
|
||||
this.state.fieldName = this.props.fields[0].name;
|
||||
|
||||
this.model = useModel('controlPanelModel');
|
||||
this.model = useModel('searchModel');
|
||||
}
|
||||
|
||||
//---------------------------------------------------------------------
|
||||
|
||||
@@ -5,7 +5,7 @@ odoo.define('web.FavoriteMenu', function (require) {
|
||||
const DropdownMenu = require('web.DropdownMenu');
|
||||
const { FACET_ICONS } = require("web.searchUtils");
|
||||
const Registry = require('web.Registry');
|
||||
const { useModel } = require('web.model');
|
||||
const { useModel } = require('web/static/src/js/model.js');
|
||||
|
||||
/**
|
||||
* 'Favorites' menu
|
||||
@@ -22,7 +22,7 @@ odoo.define('web.FavoriteMenu', function (require) {
|
||||
constructor() {
|
||||
super(...arguments);
|
||||
|
||||
this.model = useModel('controlPanelModel');
|
||||
this.model = useModel('searchModel');
|
||||
this.state.deletedFavorite = false;
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@ odoo.define('web.FavoriteMenu', function (require) {
|
||||
* @override
|
||||
*/
|
||||
get items() {
|
||||
const favorites = this.model.getFiltersOfType('favorite');
|
||||
const favorites = this.model.get('filters', f => f.type === 'favorite');
|
||||
const registryMenus = this.constructor.registry.values().reduce(
|
||||
(menus, Component) => {
|
||||
if (Component.shouldBeDisplayed(this.env)) {
|
||||
@@ -91,7 +91,7 @@ odoo.define('web.FavoriteMenu', function (require) {
|
||||
* @private
|
||||
*/
|
||||
async _onRemoveFavorite() {
|
||||
await this.model.dispatch('deleteFavorite', this.state.deletedFavorite.id);
|
||||
this.model.dispatch('deleteFavorite', this.state.deletedFavorite.id);
|
||||
this.state.deletedFavorite = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ odoo.define('web.FilterMenu', function (require) {
|
||||
const CustomFilterItem = require('web.CustomFilterItem');
|
||||
const DropdownMenu = require('web.DropdownMenu');
|
||||
const { FACET_ICONS } = require("web.searchUtils");
|
||||
const { useModel } = require('web.model');
|
||||
const { useModel } = require('web/static/src/js/model.js');
|
||||
|
||||
/**
|
||||
* 'Filters' menu
|
||||
@@ -20,7 +20,7 @@ odoo.define('web.FilterMenu', function (require) {
|
||||
constructor() {
|
||||
super(...arguments);
|
||||
|
||||
this.model = useModel('controlPanelModel');
|
||||
this.model = useModel('searchModel');
|
||||
}
|
||||
|
||||
//---------------------------------------------------------------------
|
||||
@@ -38,7 +38,7 @@ odoo.define('web.FilterMenu', function (require) {
|
||||
* @override
|
||||
*/
|
||||
get items() {
|
||||
return this.model.getFiltersOfType('filter');
|
||||
return this.model.get('filters', f => f.type === 'filter');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -4,7 +4,7 @@ odoo.define('web.GroupByMenu', function (require) {
|
||||
const CustomGroupByItem = require('web.CustomGroupByItem');
|
||||
const DropdownMenu = require('web.DropdownMenu');
|
||||
const { FACET_ICONS, GROUPABLE_TYPES } = require('web.searchUtils');
|
||||
const { useModel } = require('web.model');
|
||||
const { useModel } = require('web/static/src/js/model.js');
|
||||
|
||||
/**
|
||||
* 'Group by' menu
|
||||
@@ -24,7 +24,7 @@ odoo.define('web.GroupByMenu', function (require) {
|
||||
.filter(field => this._validateField(field))
|
||||
.sort(({ string: a }, { string: b }) => a > b ? 1 : a < b ? -1 : 0);
|
||||
|
||||
this.model = useModel('controlPanelModel');
|
||||
this.model = useModel('searchModel');
|
||||
}
|
||||
|
||||
//---------------------------------------------------------------------
|
||||
@@ -42,7 +42,7 @@ odoo.define('web.GroupByMenu', function (require) {
|
||||
* @override
|
||||
*/
|
||||
get items() {
|
||||
return this.model.getFiltersOfType('groupBy');
|
||||
return this.model.get('filters', f => f.type === 'groupBy');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -4,7 +4,7 @@ odoo.define('web.SearchBar', function (require) {
|
||||
const Domain = require('web.Domain');
|
||||
const field_utils = require('web.field_utils');
|
||||
const { useAutofocus } = require('web.custom_hooks');
|
||||
const { useModel } = require('web.model');
|
||||
const { useModel } = require('web/static/src/js/model.js');
|
||||
|
||||
const CHAR_FIELDS = ['char', 'html', 'many2many', 'many2one', 'one2many', 'text'];
|
||||
const { Component, hooks } = owl;
|
||||
@@ -42,14 +42,14 @@ odoo.define('web.SearchBar', function (require) {
|
||||
|
||||
this.focusOnUpdate = useAutofocus();
|
||||
this.inputRef = useRef('search-input');
|
||||
this.model = useModel('controlPanelModel');
|
||||
this.model = useModel('searchModel');
|
||||
this.state = useState({
|
||||
sources: [],
|
||||
focusedItem: 0,
|
||||
inputValue: "",
|
||||
});
|
||||
|
||||
this.autoCompleteSources = this.model.getFiltersOfType('field').map(
|
||||
this.autoCompleteSources = this.model.get('filters', f => f.type === 'field').map(
|
||||
filter => this._createSource(filter)
|
||||
);
|
||||
this.noResultItem = [null, this.env._t("(no result)")];
|
||||
@@ -60,15 +60,15 @@ odoo.define('web.SearchBar', function (require) {
|
||||
|
||||
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.env.searchModel.on('search', this, this.focusOnUpdate);
|
||||
this.env.searchModel.on('focus-control-panel', this, () => {
|
||||
this.inputRef.el.focus();
|
||||
});
|
||||
}
|
||||
|
||||
willUnmount() {
|
||||
this.env.controlPanelModel.off('search', this);
|
||||
this.env.controlPanelModel.off('focus-control-panel', this);
|
||||
this.env.searchModel.off('search', this);
|
||||
this.env.searchModel.off('focus-control-panel', this);
|
||||
}
|
||||
|
||||
//---------------------------------------------------------------------
|
||||
@@ -376,9 +376,8 @@ odoo.define('web.SearchBar', function (require) {
|
||||
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);
|
||||
this._focusFacet(this.model.get("facets").length - 1);
|
||||
}
|
||||
break;
|
||||
case 'ArrowRight':
|
||||
@@ -408,7 +407,7 @@ odoo.define('web.SearchBar', function (require) {
|
||||
break;
|
||||
case 'Backspace':
|
||||
if (!this.state.inputValue.length) {
|
||||
const facets = this.model.getFacets();
|
||||
const facets = this.model.get("facets");
|
||||
if (facets.length) {
|
||||
this._onFacetRemove(facets[facets.length - 1]);
|
||||
}
|
||||
|
||||
@@ -1,147 +1,154 @@
|
||||
odoo.define('web.Registry', function (require) {
|
||||
"use strict";
|
||||
odoo.define("web.Registry", function (require) {
|
||||
"use strict";
|
||||
|
||||
var Class = require('web.Class');
|
||||
|
||||
/**
|
||||
* The registry is really pretty much only a mapping from some keys to some
|
||||
* values. The Registry class only add a few simple methods around that to make
|
||||
* it nicer and slightly safer.
|
||||
*
|
||||
* Note that registries have a fundamental problem: the value that you try to
|
||||
* get in a registry might not have been added yet, so of course, you need to
|
||||
* make sure that your dependencies are solid. For this reason, it is a good
|
||||
* practice to avoid using the registry if you can simply import what you need
|
||||
* with the 'require' statement.
|
||||
*
|
||||
* However, on the flip side, sometimes you cannot just simply import something
|
||||
* because we would have a dependency cycle. In that case, registries might
|
||||
* help.
|
||||
*/
|
||||
var Registry = Class.extend({
|
||||
/**
|
||||
* @constructor
|
||||
* @param {Object} [mapping] the initial data in the registry
|
||||
*/
|
||||
init: function (mapping) {
|
||||
this.map = Object.create(mapping || null);
|
||||
this._scoreMapping = Object.create(null);
|
||||
this._sortedKeys = null;
|
||||
this.listeners = []; // listening callbacks on newly added items.
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Public
|
||||
//--------------------------------------------------------------------------
|
||||
const { sortBy } = require("web.utils");
|
||||
|
||||
/**
|
||||
* Add a key (and a value) to the registry.
|
||||
* The registry is really pretty much only a mapping from some keys to some
|
||||
* values. The Registry class only add a few simple methods around that to make
|
||||
* it nicer and slightly safer.
|
||||
*
|
||||
* Notify the listeners on newly added item in the registry.
|
||||
* Note that registries have a fundamental problem: the value that you try to
|
||||
* get in a registry might not have been added yet, so of course, you need to
|
||||
* make sure that your dependencies are solid. For this reason, it is a good
|
||||
* practice to avoid using the registry if you can simply import what you need
|
||||
* with the 'require' statement.
|
||||
*
|
||||
* @param {string} key
|
||||
* @param {any} value
|
||||
* @param {number} [score] if given, this value will be used to order keys
|
||||
* @returns {Registry} can be used to chain add calls.
|
||||
* However, on the flip side, sometimes you cannot just simply import something
|
||||
* because we would have a dependency cycle. In that case, registries might
|
||||
* help.
|
||||
*/
|
||||
add: function (key, value, score) {
|
||||
this._scoreMapping[key] = score === undefined ? key : score;
|
||||
this._sortedKeys = null;
|
||||
this.map[key] = value;
|
||||
_.each(this.listeners, function (callback) {
|
||||
callback(key, value);
|
||||
});
|
||||
return this;
|
||||
},
|
||||
/**
|
||||
* Check if the registry contains the key
|
||||
*
|
||||
* @param {string} key
|
||||
* @returns {boolean}
|
||||
*/
|
||||
contains: function (key) {
|
||||
return (key in this.map);
|
||||
},
|
||||
/**
|
||||
* Returns the content of the registry (an object mapping keys to values)
|
||||
*
|
||||
* @returns {Object}
|
||||
*/
|
||||
entries: function () {
|
||||
var entries = {};
|
||||
var keys = this.keys();
|
||||
for (var i = 0; i < keys.length; i++) {
|
||||
entries[keys[i]] = this.map[keys[i]];
|
||||
class Registry {
|
||||
/**
|
||||
* @function predicate
|
||||
* @param {any} value
|
||||
* @returns {boolean}
|
||||
*/
|
||||
/**
|
||||
* @param {Object} [mapping] the initial data in the registry
|
||||
* @param {predicate} [predicate=(() => true)] predicate that each
|
||||
* added value must pass to be registered.
|
||||
*/
|
||||
constructor(mapping, predicate = () => true) {
|
||||
this.map = Object.create(mapping || null);
|
||||
this._scoreMapping = Object.create(null);
|
||||
this._sortedKeys = null;
|
||||
this.listeners = []; // listening callbacks on newly added items.
|
||||
this.predicate = predicate;
|
||||
}
|
||||
return entries;
|
||||
},
|
||||
/**
|
||||
* Returns the value associated to the given key.
|
||||
*
|
||||
* @param {string} key
|
||||
* @returns {any}
|
||||
*/
|
||||
get: function (key) {
|
||||
return this.map[key];
|
||||
},
|
||||
/**
|
||||
* Tries a number of keys, and returns the first object matching one of
|
||||
* the keys.
|
||||
*
|
||||
* @param {string[]} keys a sequence of keys to fetch the object for
|
||||
* @returns {any} the first result found matching an object
|
||||
*/
|
||||
getAny: function (keys) {
|
||||
for (var i=0; i<keys.length; i++) {
|
||||
if (keys[i] in this.map) {
|
||||
return this.map[keys[i]];
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Public
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Add a key (and a value) to the registry.
|
||||
* Notify the listeners on newly added item in the registry.
|
||||
* @param {string} key
|
||||
* @param {any} value
|
||||
* @param {number} [score] if given, this value will be used to order keys
|
||||
* @returns {Registry} can be used to chain add calls.
|
||||
*/
|
||||
add(key, value, score) {
|
||||
if (!this.predicate(value)) {
|
||||
throw new Error(`Value of key "${key}" does not pass the addition predicate.`);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
},
|
||||
/**
|
||||
* Return the list of keys in map object.
|
||||
*
|
||||
* The registry guarantees that the keys have a consistent order, defined by
|
||||
* the 'score' value when the item has been added.
|
||||
*
|
||||
* @returns {string[]}
|
||||
*/
|
||||
keys: function () {
|
||||
var self = this;
|
||||
if (!this._sortedKeys) {
|
||||
var keys = [];
|
||||
for (var key in this.map) {
|
||||
keys.push(key);
|
||||
this._scoreMapping[key] = score === undefined ? key : score;
|
||||
this._sortedKeys = null;
|
||||
this.map[key] = value;
|
||||
for (const callback of this.listeners) {
|
||||
callback(key, value);
|
||||
}
|
||||
this._sortedKeys = _.sortBy(keys, function (key) {
|
||||
return self._scoreMapping[key] || 0;
|
||||
});
|
||||
return this;
|
||||
}
|
||||
return this._sortedKeys;
|
||||
},
|
||||
/**
|
||||
* Register a callback to execute when items are added to the registry.
|
||||
*
|
||||
* @param {function} callback function with parameters (key, value).
|
||||
*/
|
||||
onAdd: function (callback) {
|
||||
this.listeners.push(callback);
|
||||
},
|
||||
/**
|
||||
* Return the list of values in map object
|
||||
*
|
||||
* @returns {string[]}
|
||||
*/
|
||||
values: function () {
|
||||
var self = this;
|
||||
return this.keys().map(function (key) {
|
||||
return self.map[key];
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Check if the registry contains the key
|
||||
* @param {string} key
|
||||
* @returns {boolean}
|
||||
*/
|
||||
contains(key) {
|
||||
return key in this.map;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the content of the registry (an object mapping keys to values)
|
||||
* @returns {Object}
|
||||
*/
|
||||
entries() {
|
||||
const entries = {};
|
||||
const keys = this.keys();
|
||||
for (const key of keys) {
|
||||
entries[key] = this.map[key];
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the value associated to the given key.
|
||||
* @param {string} key
|
||||
* @returns {any}
|
||||
*/
|
||||
get(key) {
|
||||
return this.map[key];
|
||||
}
|
||||
|
||||
/**
|
||||
* Tries a number of keys, and returns the first object matching one of
|
||||
* the keys.
|
||||
* @param {string[]} keys a sequence of keys to fetch the object for
|
||||
* @returns {any} the first result found matching an object
|
||||
*/
|
||||
getAny(keys) {
|
||||
for (const key of keys) {
|
||||
if (key in this.map) {
|
||||
return this.map[key];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the list of keys in map object.
|
||||
*
|
||||
* The registry guarantees that the keys have a consistent order, defined by
|
||||
* the 'score' value when the item has been added.
|
||||
* @returns {string[]}
|
||||
*/
|
||||
keys() {
|
||||
if (!this._sortedKeys) {
|
||||
const keys = [];
|
||||
for (const key in this.map) {
|
||||
keys.push(key);
|
||||
}
|
||||
this._sortedKeys = sortBy(keys,
|
||||
key => this._scoreMapping[key] || 0
|
||||
);
|
||||
}
|
||||
return this._sortedKeys;
|
||||
}
|
||||
|
||||
/**
|
||||
* @function onAddCallback
|
||||
* @param {string} key
|
||||
* @param {any} value
|
||||
*/
|
||||
/**
|
||||
* Register a callback to execute when items are added to the registry.
|
||||
* @param {onAddCallback} callback function with parameters (key, value).
|
||||
*/
|
||||
onAdd(callback) {
|
||||
this.listeners.push(callback);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the list of values in map object
|
||||
* @returns {string[]}
|
||||
*/
|
||||
values() {
|
||||
return this.keys().map((key) => this.map[key]);
|
||||
}
|
||||
}
|
||||
|
||||
return Registry;
|
||||
});
|
||||
|
||||
return Registry;
|
||||
|
||||
});
|
||||
|
||||
|
||||
@@ -571,6 +571,31 @@ var utils = {
|
||||
str = "" + str;
|
||||
return new Array(size - str.length + 1).join('0') + str;
|
||||
},
|
||||
/**
|
||||
* @param {any[]} arr
|
||||
* @param {Function} fn
|
||||
* @returns {any[]}
|
||||
*/
|
||||
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;
|
||||
}, []);
|
||||
},
|
||||
/**
|
||||
* Patch a class and return a function that remove the patch
|
||||
* when called.
|
||||
|
||||
@@ -1,18 +1,12 @@
|
||||
odoo.define('web.field_registry', function (require) {
|
||||
"use strict";
|
||||
"use strict";
|
||||
|
||||
var Registry = require('web.Registry');
|
||||
const Registry = require('web.Registry');
|
||||
|
||||
var FieldRegistry = Registry.extend({
|
||||
add: function (key, value, score) {
|
||||
if (value.prototype instanceof owl.Component) {
|
||||
throw new Error("This registry should not contain any Component. Use 'web.field_registry_owl' instead.");
|
||||
}
|
||||
return this._super(...arguments);
|
||||
},
|
||||
});
|
||||
|
||||
return new FieldRegistry();
|
||||
return new Registry(
|
||||
null,
|
||||
(value) => !(value.prototype instanceof owl.Component)
|
||||
);
|
||||
});
|
||||
|
||||
odoo.define('web._field_registry', function (require) {
|
||||
|
||||
@@ -3,16 +3,10 @@ odoo.define('web.field_registry_owl', function (require) {
|
||||
|
||||
const Registry = require('web.Registry');
|
||||
|
||||
const FieldRegistry = Registry.extend({
|
||||
add: function (key, value, score) {
|
||||
if (!(value.prototype instanceof owl.Component)) {
|
||||
throw new Error("This registry should only contain subclasses of Component");
|
||||
}
|
||||
return this._super(...arguments);
|
||||
},
|
||||
});
|
||||
|
||||
return new FieldRegistry();
|
||||
return new Registry(
|
||||
null,
|
||||
(value) => value.prototype instanceof owl.Component
|
||||
);
|
||||
});
|
||||
|
||||
odoo.define('web._field_registry_owl', function (require) {
|
||||
|
||||
@@ -1,53 +1,27 @@
|
||||
odoo.define('web.model', function () {
|
||||
odoo.define("web/static/src/js/model.js", function (require) {
|
||||
"use strict";
|
||||
|
||||
const { groupBy, partitionBy } = require("web.utils");
|
||||
const Registry = require("web.Registry");
|
||||
|
||||
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;
|
||||
}, []);
|
||||
}
|
||||
const isNotNull = (val) => val !== null && val !== undefined;
|
||||
|
||||
/**
|
||||
* 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
|
||||
* Feature extension of the class Model.
|
||||
* @see {Model}
|
||||
*/
|
||||
class Model extends EventBus {
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.rev = 1;
|
||||
this.mapping = {}; // could be a weak map
|
||||
class ModelExtension {
|
||||
/**
|
||||
* @param {Object} config
|
||||
* @param {Object} config.env
|
||||
*/
|
||||
constructor(config) {
|
||||
this.config = config;
|
||||
this.env = this.config.env;
|
||||
this._shouldLoad = true;
|
||||
this.state = {};
|
||||
}
|
||||
|
||||
//---------------------------------------------------------------------
|
||||
@@ -55,23 +29,362 @@ odoo.define('web.model', function () {
|
||||
//---------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 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>}
|
||||
* Used by the parent model to initiate a load action. The actual
|
||||
* loading of the extension is determined by the "_shouldLoad" property.
|
||||
*/
|
||||
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();
|
||||
async callLoad() {
|
||||
if (this._shouldLoad) {
|
||||
this._shouldLoad = false;
|
||||
await this.load();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Exports the current state of the extension.
|
||||
* @returns {Object}
|
||||
*/
|
||||
exportState() {
|
||||
return this.state;
|
||||
}
|
||||
|
||||
/**
|
||||
* Meant to return the result of the appropriate getter or do nothing
|
||||
* if not concerned by the given property.
|
||||
* @abstract
|
||||
* @param {string} property
|
||||
* @param {...any} args
|
||||
* @returns {null}
|
||||
*/
|
||||
get() {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Imports the given state after parsing it. If no state is given the
|
||||
* extension will prepare a new state and will need to be loaded.
|
||||
* @param {Object} [state]
|
||||
*/
|
||||
importState(state) {
|
||||
this._shouldLoad = !state;
|
||||
if (this._shouldLoad) {
|
||||
this.prepareState();
|
||||
} else {
|
||||
Object.assign(this.state, state);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Called and awaited on initial model load.
|
||||
* @abstract
|
||||
* @returns {Promise}
|
||||
*/
|
||||
async load() {
|
||||
/* ... */
|
||||
}
|
||||
|
||||
/**
|
||||
* Called on initialization if no imported state for the extension is
|
||||
* found.
|
||||
* @abstract
|
||||
*/
|
||||
prepareState() {
|
||||
/* ... */
|
||||
}
|
||||
|
||||
/**
|
||||
* Called and awaited after an action dispatch.
|
||||
* @abstract
|
||||
* @returns {Promise}
|
||||
*/
|
||||
async reloadAfterDispatch() {
|
||||
/* ... */
|
||||
}
|
||||
}
|
||||
/**
|
||||
* The layer of an extension indicates with which other extensions this one
|
||||
* will be loaded. This property must be overridden in case the model
|
||||
* depends on other extensions to be loaded first.
|
||||
*/
|
||||
ModelExtension.layer = 0;
|
||||
|
||||
/**
|
||||
* 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 through the 'dispatch' method, a
|
||||
* notification 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.
|
||||
*
|
||||
* The following snippets show a typical use case of the model system: a
|
||||
* search model with a control panel extension feature.
|
||||
*
|
||||
*-------------------------------------------------------------------------
|
||||
* MODEL AND EXTENSIONS DEFINITION
|
||||
*-------------------------------------------------------------------------
|
||||
*
|
||||
* 1. Definition of the main model
|
||||
* @see Model
|
||||
* ```
|
||||
* class ActionModel extends Model {
|
||||
* // ...
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* 2. Definition of the model extension
|
||||
* @see ModelExtension
|
||||
* ```
|
||||
* class ControlPanelModelExtension extends ActionModel.Extension {
|
||||
* // ...
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* 3. Registration of the extension into the main model
|
||||
* @see Registry()
|
||||
* ```
|
||||
* ActionModel.registry.add("SearchPanel", ControlPanelModelExtension, 10);
|
||||
* ```
|
||||
*
|
||||
*-------------------------------------------------------------------------
|
||||
* ON VIEW/ACTION INIT
|
||||
*-------------------------------------------------------------------------
|
||||
*
|
||||
* 4. Creation of the core model and its extensions
|
||||
* @see Model.prototype.constructor()
|
||||
* ```
|
||||
* const extensions = {
|
||||
* SearchPanel: {
|
||||
* // ...
|
||||
* }
|
||||
* }
|
||||
* const searchModelConfig = {
|
||||
* // ...
|
||||
* };
|
||||
* const actionModel = new ActionModel(extensions, searchModelConfig);
|
||||
* ```
|
||||
*
|
||||
* 5. Loading of all extensions' asynchronous data
|
||||
* @see Model.prototype.load()
|
||||
* ```
|
||||
* await actionModel.load();
|
||||
* ```
|
||||
*
|
||||
* 6. Subscribing to the model changes
|
||||
* @see useModel()
|
||||
* ```
|
||||
* class ControlPanel extends Component {
|
||||
* constructor() {
|
||||
* super(...arguments);
|
||||
* // env must contain the actionModel
|
||||
* this.actionModel = useModel('actionModel');
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
*-------------------------------------------------------------------------
|
||||
* MODEL USAGE ON RUNTIME
|
||||
*-------------------------------------------------------------------------
|
||||
*
|
||||
* Case: dispatch an action
|
||||
* @see Model.prototype.dispatch()
|
||||
* ```
|
||||
* actionModel.dispatch("updateProperty", value);
|
||||
* ```
|
||||
*
|
||||
* Case: call a getter
|
||||
* @see Model.prototype.get()
|
||||
* ```
|
||||
* const result = actionModel.get("property");
|
||||
* ```
|
||||
*
|
||||
* @abstract
|
||||
* @extends EventBus
|
||||
*/
|
||||
class Model extends EventBus {
|
||||
/**
|
||||
* Instantiated extensions are determined by the `extensions` argument:
|
||||
* - keys are the extensions names as added in the registry
|
||||
* - values are the local configurations given to each extension
|
||||
* The extensions are grouped by the sequence number they where
|
||||
* registered with in the registry. Extensions being on the same level
|
||||
* will be loaded in parallel; this means that all extensions belonging
|
||||
* to the same group are awaited before loading the next group.
|
||||
* @param {Object<string, any>} [extensions={}]
|
||||
* @param {Object} [globalConfig={}] global configuration: can be
|
||||
* accessed by itself and each of the added extensions.
|
||||
* @param {Object} [globalConfig.env]
|
||||
* @param {string} [globalConfig.importedState]
|
||||
*/
|
||||
constructor(extensions = {}, globalConfig = {}) {
|
||||
super();
|
||||
|
||||
this.config = globalConfig;
|
||||
this.env = this.config.env;
|
||||
|
||||
this.dispatching = false;
|
||||
this.extensions = [];
|
||||
this.externalState = {};
|
||||
this.mapping = {};
|
||||
this.rev = 1;
|
||||
|
||||
const { name, registry } = this.constructor;
|
||||
if (!registry || !(registry instanceof Registry)) {
|
||||
throw new Error(`Unimplemented registry on model "${name}".`);
|
||||
}
|
||||
// Order, group and sequencially instantiate all extensions
|
||||
const registryExtensions = Object.entries(registry.entries());
|
||||
const extensionNameLayers = registryExtensions.map(
|
||||
([name, { layer }]) => ({ name, layer })
|
||||
);
|
||||
const groupedNameLayers = groupBy(extensionNameLayers, "layer");
|
||||
for (const groupNameLayers of Object.values(groupedNameLayers)) {
|
||||
for (const { name } of groupNameLayers) {
|
||||
if (name in extensions) {
|
||||
this.addExtension(name, extensions[name]);
|
||||
}
|
||||
}
|
||||
}
|
||||
this.importState(this.config.importedState);
|
||||
}
|
||||
|
||||
//---------------------------------------------------------------------
|
||||
// Public
|
||||
//---------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Method used internally to instantiate all extensions. Can also be
|
||||
* called externally to add extensions after model instantiation.
|
||||
* @param {string} extensionName
|
||||
* @param {Object} extensionConfig
|
||||
*/
|
||||
addExtension(extensionName, extensionConfig) {
|
||||
const { name, registry } = this.constructor;
|
||||
const Extension = registry.get(extensionName);
|
||||
if (!Extension) {
|
||||
throw new Error(`Unknown model extension "${extensionName}" in model "${name}"`);
|
||||
}
|
||||
// Extension config = this.config ∪ extension.config
|
||||
const get = this.__get.bind(this, Extension.name);
|
||||
const trigger = this.trigger.bind(this);
|
||||
const config = Object.assign({ get, trigger }, this.config, extensionConfig);
|
||||
const extension = new Extension(config);
|
||||
if (!(Extension.layer in this.extensions)) {
|
||||
this.extensions[Extension.layer] = [];
|
||||
}
|
||||
this.extensions[Extension.layer].push(extension);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the result of the first related method on any instantiated
|
||||
* extension. This method must be overridden if multiple extensions
|
||||
* return a value with a common method (and dispatchAll does not
|
||||
* suffice). After the dispatch of the action, all models are partially
|
||||
* reloaded (@see ModelExtension.reloadAfterDispatch()) and components
|
||||
* are notified afterwards.
|
||||
*/
|
||||
dispatch(method, ...args) {
|
||||
const isInitialDispatch = !this.dispatching;
|
||||
this.dispatching = true;
|
||||
for (const extension of this.extensions.flat()) {
|
||||
if (method in extension) {
|
||||
extension[method](...args);
|
||||
}
|
||||
}
|
||||
if (isInitialDispatch) {
|
||||
this.dispatching = false;
|
||||
(async () => {
|
||||
// Calls 'after dispatch' hooks
|
||||
// Purpose: fetch updated data from the server. This is
|
||||
// considered a loading action and is thus performed by groups
|
||||
// instead of loading all extensions at once.
|
||||
for (let layer = 0; layer < this.extensions.length; layer++) {
|
||||
const afterDispatchPromises = [];
|
||||
for (const extension of this.extensions[layer]) {
|
||||
afterDispatchPromises.push(extension.reloadAfterDispatch());
|
||||
}
|
||||
await Promise.all(afterDispatchPromises);
|
||||
}
|
||||
// Notifies subscribed components
|
||||
// Purpose: re-render components bound by 'useModel'
|
||||
let rev = this.rev;
|
||||
await Promise.resolve();
|
||||
if (rev === this.rev) {
|
||||
this._notifyComponents();
|
||||
}
|
||||
})();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stringifies and exports an object holding the exported state of each
|
||||
* active extension.
|
||||
* @returns {string}
|
||||
*/
|
||||
exportState() {
|
||||
const exported = {};
|
||||
for (const extension of this.extensions.flat()) {
|
||||
exported[extension.constructor.name] = extension.exportState();
|
||||
}
|
||||
const fullState = Object.assign({}, this.externalState, exported);
|
||||
return JSON.stringify(fullState);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the result of the first related getter on any instantiated
|
||||
* extension. This method must be overridden if multiple extensions
|
||||
* share a common getter (and getAll does not make the job).
|
||||
* @param {string} property
|
||||
* @param {...any} args
|
||||
* @returns {any}
|
||||
*/
|
||||
get(property, ...args) {
|
||||
for (const extension of this.extensions.flat()) {
|
||||
const result = extension.get(property, ...args);
|
||||
if (isNotNull(result)) {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the given stringified state object and imports each state
|
||||
* part to its related extension.
|
||||
* @param {string} [stringifiedState="null"]
|
||||
*/
|
||||
importState(stringifiedState = "null") {
|
||||
const state = JSON.parse(stringifiedState) || {};
|
||||
Object.assign(this.externalState, state);
|
||||
for (const extension of this.extensions.flat()) {
|
||||
extension.importState(state[extension.constructor.name]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Must be called after construction and state preparation/import.
|
||||
* Waits for all asynchronous work needed by the model extensions to be
|
||||
* ready.
|
||||
* /!\ The current model extensions do not require a smarter system at
|
||||
* the moment (therefore using layers instead of dependencies). It
|
||||
* should be changed if at some point an extension needs another
|
||||
* specific extension to be loaded instead of a whole batch (with the
|
||||
* current system some promises will be waited needlessly).
|
||||
* @returns {Promise}
|
||||
*/
|
||||
async load() {
|
||||
for (let layer = 0; layer < this.extensions.length; layer++) {
|
||||
const layerPromises = [];
|
||||
for (const extension of this.extensions[layer]) {
|
||||
layerPromises.push(extension.callLoad());
|
||||
}
|
||||
await Promise.all(layerPromises);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
//---------------------------------------------------------------------
|
||||
@@ -79,38 +392,66 @@ odoo.define('web.model', function () {
|
||||
//---------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Returns the list of the results of all extensions providing a getter
|
||||
* for the given property returning a non-null value, excluding the
|
||||
* extension whose name is equal to "excluded". This method is given to
|
||||
* each extension in the "config" object bound to the model scope and
|
||||
* having the extension name bound as the first argument.
|
||||
* @private
|
||||
* @param {string} action
|
||||
* @param {...any} args
|
||||
* @param {string} excluded
|
||||
* @param {string} property
|
||||
* @param {...any} args
|
||||
* @returns {any[]}
|
||||
*/
|
||||
_dispatch(action, ...args) { }
|
||||
__get(excluded, property, ...args) {
|
||||
const results = [];
|
||||
for (const extension of this.extensions.flat()) {
|
||||
if (extension.constructor.name !== excluded) {
|
||||
const result = extension.get(property, ...args);
|
||||
if (isNotNull(result)) {
|
||||
results.push(result);
|
||||
}
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* see Context method in owl.js for explanation
|
||||
* @see Context.__notifyComponents() 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));
|
||||
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));
|
||||
const proms = group.map((sub) =>
|
||||
sub.callback.call(sub.owner, rev)
|
||||
);
|
||||
Component.scheduler.flush();
|
||||
await Promise.all(proms);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Model.Extension = ModelExtension;
|
||||
|
||||
/**
|
||||
* This is more or less the hook 'useContextWithCB' from owl only slightly simplified.
|
||||
* This is more or less the hook 'useContextWithCB' from owl only slightly
|
||||
* simplified.
|
||||
*
|
||||
* @param {string} modelName
|
||||
* @returns {model}
|
||||
*/
|
||||
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}'`);
|
||||
throw new Error(`No Model found when connecting '${
|
||||
component.name
|
||||
}'`);
|
||||
}
|
||||
|
||||
const mapping = model.mapping;
|
||||
@@ -134,7 +475,7 @@ odoo.define('web.model', function () {
|
||||
return renderFn(comp, params);
|
||||
};
|
||||
|
||||
model.on('update', component, async modelRev => {
|
||||
model.on("update", component, async (modelRev) => {
|
||||
if (mapping[componentId] < modelRev) {
|
||||
mapping[componentId] = modelRev;
|
||||
await component.render();
|
||||
@@ -142,7 +483,7 @@ odoo.define('web.model', function () {
|
||||
});
|
||||
|
||||
const __destroy = component.__destroy;
|
||||
component.__destroy = parent => {
|
||||
component.__destroy = (parent) => {
|
||||
model.off("update", component);
|
||||
__destroy.call(component, parent);
|
||||
};
|
||||
|
||||
@@ -16,7 +16,6 @@ var ActionMixin = require('web.ActionMixin');
|
||||
var ajax = require('web.ajax');
|
||||
var concurrency = require('web.concurrency');
|
||||
const { ComponentWrapper } = require('web.OwlCompatibility');
|
||||
const ControlPanel = require('web.ControlPanel');
|
||||
var mvc = require('web.mvc');
|
||||
var session = require('web.session');
|
||||
|
||||
@@ -25,7 +24,6 @@ var AbstractController = mvc.Controller.extend(ActionMixin, {
|
||||
custom_events: _.extend({}, ActionMixin.custom_events, {
|
||||
navigation_move: '_onNavigationMove',
|
||||
open_record: '_onOpenRecord',
|
||||
search_panel_domain_updated: '_onSearchPanelDomainUpdated',
|
||||
switch_view: '_onSwitchView',
|
||||
}),
|
||||
events: {
|
||||
@@ -37,14 +35,13 @@ var AbstractController = mvc.Controller.extend(ActionMixin, {
|
||||
* @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 {Object} [params.controlPanel]
|
||||
* @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 {ActionModel} [params.searchModel]
|
||||
* @param {string} [params.searchPanel]
|
||||
* @param {string} params.viewType
|
||||
* @param {boolean} [params.withControlPanel]
|
||||
@@ -64,18 +61,20 @@ var AbstractController = mvc.Controller.extend(ActionMixin, {
|
||||
this.dp = new concurrency.DropPrevious();
|
||||
|
||||
this.withControlPanel = params.withControlPanel;
|
||||
this.withSearchPanel = params.withSearchPanel;
|
||||
if (params.searchModel) {
|
||||
this.searchModel = params.searchModel;
|
||||
}
|
||||
if (this.withControlPanel) {
|
||||
this.controlPanelProps = params.controlPanelProps;
|
||||
this._controlPanelModel = params.controlPanelModel;
|
||||
const { Component, props } = params.controlPanel;
|
||||
this.ControlPanel = Component;
|
||||
this.controlPanelProps = props;
|
||||
}
|
||||
|
||||
this.withSearchPanel = params.withSearchPanel && params.searchPanel;
|
||||
// the following attributes are used when there is a searchPanel
|
||||
if (this.withSearchPanel) {
|
||||
this._searchPanel = params.searchPanel;
|
||||
const { Component, props } = params.searchPanel;
|
||||
this.SearchPanel = Component;
|
||||
this.searchPanelProps = props;
|
||||
}
|
||||
this.controlPanelDomain = params.controlPanelDomain || [];
|
||||
this.searchPanelDomain = this._searchPanel ? this._searchPanel.getDomain() : [];
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -84,23 +83,23 @@ var AbstractController = mvc.Controller.extend(ActionMixin, {
|
||||
* @returns {Promise}
|
||||
*/
|
||||
start: async function () {
|
||||
if (this.withSearchPanel) {
|
||||
this.$('.o_content')
|
||||
.addClass('o_controller_with_searchpanel')
|
||||
.prepend(this._searchPanel.$el);
|
||||
}
|
||||
this.$el.addClass('o_view_controller');
|
||||
|
||||
this.renderButtons();
|
||||
const promises = [this._super(...arguments)];
|
||||
if (this.withControlPanel) {
|
||||
this._updateControlPanelProps(this.initialState);
|
||||
this._controlPanelWrapper = new ComponentWrapper(this, ControlPanel, this.controlPanelProps);
|
||||
this._controlPanelWrapper = new ComponentWrapper(this, this.ControlPanel, this.controlPanelProps);
|
||||
this._controlPanelWrapper.env.bus.on('focus-view', this, () => this._giveFocus());
|
||||
promises.push(this._controlPanelWrapper.mount(this.el, { position: 'first-child' }));
|
||||
}
|
||||
if (this.withSearchPanel) {
|
||||
this._searchPanelWrapper = new ComponentWrapper(this, this.SearchPanel, this.searchPanelProps);
|
||||
const content = this.el.querySelector(':scope .o_content');
|
||||
content.classList.add('o_controller_with_searchpanel');
|
||||
promises.push(this._searchPanelWrapper.mount(content, { position: 'first-child' }));
|
||||
}
|
||||
await Promise.all(promises);
|
||||
await this._update(this.initialState, { shouldUpdateControlPanel: false });
|
||||
await this._update(this.initialState, { shouldUpdateSearchComponents: false });
|
||||
this.updateButtons();
|
||||
},
|
||||
/**
|
||||
@@ -118,12 +117,9 @@ var AbstractController = mvc.Controller.extend(ActionMixin, {
|
||||
*/
|
||||
on_attach_callback: function () {
|
||||
ActionMixin.on_attach_callback.call(this);
|
||||
if (this.withSearchPanel) {
|
||||
this._searchPanel.on_attach_callback();
|
||||
}
|
||||
this.searchModel.on('search', this, this._onSearch);
|
||||
if (this.withControlPanel) {
|
||||
this._controlPanelModel.on('search', this, this._onSearch);
|
||||
this._controlPanelModel.on('get-controller-query-params', this, this._onGetOwnedQueryParams);
|
||||
this.searchModel.on('get-controller-query-params', this, this._onGetOwnedQueryParams);
|
||||
}
|
||||
if (!(this.renderer instanceof owl.Component)) {
|
||||
this.renderer.on_attach_callback();
|
||||
@@ -134,9 +130,9 @@ var AbstractController = mvc.Controller.extend(ActionMixin, {
|
||||
*/
|
||||
on_detach_callback: function () {
|
||||
ActionMixin.on_detach_callback.call(this);
|
||||
this.searchModel.off('search', this);
|
||||
if (this.withControlPanel) {
|
||||
this._controlPanelModel.off('search', this);
|
||||
this._controlPanelModel.off('get-controller-query-params', this);
|
||||
this.searchModel.off('get-controller-query-params', this);
|
||||
}
|
||||
if (!(this.renderer instanceof owl.Component)) {
|
||||
this.renderer.on_detach_callback();
|
||||
@@ -177,20 +173,32 @@ var AbstractController = mvc.Controller.extend(ActionMixin, {
|
||||
},
|
||||
/**
|
||||
* Export the state of the controller containing information that is shared
|
||||
* between different controllers of a same action (like the current
|
||||
* searchQuery of the controlPanel).
|
||||
* between different controllers of a same action (like the current search
|
||||
* model state or the states of some components).
|
||||
*
|
||||
* @returns {Object}
|
||||
*/
|
||||
exportState: function () {
|
||||
var state = {};
|
||||
if (this.withControlPanel) {
|
||||
state.cpState = this._controlPanelModel.exportState();
|
||||
}
|
||||
exportState() {
|
||||
const exported = {
|
||||
searchModel: this.searchModel.exportState(),
|
||||
};
|
||||
if (this.withSearchPanel) {
|
||||
state.spState = this._searchPanel.exportState();
|
||||
const searchPanel = this._searchPanelWrapper.componentRef.comp;
|
||||
exported.searchPanel = searchPanel.exportState();
|
||||
}
|
||||
return exported;
|
||||
},
|
||||
/**
|
||||
* Parses and imports a previously exported state.
|
||||
*
|
||||
* @param {Object} state
|
||||
*/
|
||||
importState(state) {
|
||||
this.searchModel.importState(state.searchModel);
|
||||
if (this.withSearchPanel) {
|
||||
const searchPanel = this._searchPanelWrapper.componentRef.comp;
|
||||
searchPanel.importState(state.searchPanel);
|
||||
}
|
||||
return state;
|
||||
},
|
||||
/**
|
||||
* The use of this method is discouraged. It is still snakecased, because
|
||||
@@ -207,40 +215,17 @@ var AbstractController = mvc.Controller.extend(ActionMixin, {
|
||||
/**
|
||||
* Short helper method to reload the view
|
||||
*
|
||||
* @param {Object} [params={}] This object will simply be given to the update
|
||||
* @param {Object} [params={}]
|
||||
* @param {Object} [params.controllerState={}]
|
||||
* @returns {Promise}
|
||||
*/
|
||||
reload: async function (params = {}) {
|
||||
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);
|
||||
}
|
||||
let postponeRendering = false;
|
||||
if (this.withSearchPanel) {
|
||||
if (params.domain) {
|
||||
this.controlPanelDomain = params.domain;
|
||||
}
|
||||
if (controllerState.spState) {
|
||||
this._searchPanel.importState(controllerState.spState);
|
||||
} else {
|
||||
const viewDomain = await this._getViewDomain();
|
||||
await this._searchPanel.update({
|
||||
searchDomain: this.controlPanelDomain,
|
||||
viewDomain,
|
||||
});
|
||||
postponeRendering = !params.noRender;
|
||||
params.noRender = true; // wait for searchpanel to be ready to render
|
||||
}
|
||||
this.searchPanelDomain = this._searchPanel.getDomain();
|
||||
params.domain = this.controlPanelDomain.concat(this.searchPanelDomain);
|
||||
}
|
||||
await this.update(params, {});
|
||||
if (postponeRendering) {
|
||||
return this._updateRendererState(false);
|
||||
if (params.controllerState) {
|
||||
this.importState(params.controllerState);
|
||||
await this.searchModel.load();
|
||||
Object.assign(params, this.searchModel.get('query'));
|
||||
}
|
||||
return this.update(params, {});
|
||||
},
|
||||
/**
|
||||
* This is the main entry point for the controller. Changes from the search
|
||||
@@ -287,15 +272,6 @@ var AbstractController = mvc.Controller.extend(ActionMixin, {
|
||||
_getPagingInfo: function (state) {
|
||||
return null;
|
||||
},
|
||||
/**
|
||||
* Get the domain defined by the view. It is meant to be overridden.
|
||||
*
|
||||
* @private
|
||||
* @returns {Promise<Array[]>}
|
||||
*/
|
||||
_getViewDomain: async function () {
|
||||
return [];
|
||||
},
|
||||
/**
|
||||
* Meant to be overriden to return a proper object.
|
||||
* @private
|
||||
@@ -409,7 +385,8 @@ var AbstractController = mvc.Controller.extend(ActionMixin, {
|
||||
* @private
|
||||
* @param {Object} state the state given by the model
|
||||
* @param {Object} [params={}]
|
||||
* @param {Object} [params.shouldUpdateControlPanel]
|
||||
* @param {Array} [params.breadcrumbs]
|
||||
* @param {Object} [params.shouldUpdateSearchComponents]
|
||||
* @returns {Promise}
|
||||
*/
|
||||
async _update(state, params) {
|
||||
@@ -418,12 +395,17 @@ var AbstractController = mvc.Controller.extend(ActionMixin, {
|
||||
this.renderButtons();
|
||||
}
|
||||
const promises = [this._renderBanner()];
|
||||
if (this.withControlPanel && params.shouldUpdateControlPanel !== false) {
|
||||
this._updateControlPanelProps(state);
|
||||
if (params.breadcrumbs) {
|
||||
this.controlPanelProps.breadcrumbs = params.breadcrumbs;
|
||||
if (params.shouldUpdateSearchComponents !== false) {
|
||||
if (this.withControlPanel) {
|
||||
this._updateControlPanelProps(state);
|
||||
if (params.breadcrumbs) {
|
||||
this.controlPanelProps.breadcrumbs = params.breadcrumbs;
|
||||
}
|
||||
promises.push(this.updateControlPanel());
|
||||
}
|
||||
if (this.withSearchPanel) {
|
||||
this._updateSearchPanel();
|
||||
}
|
||||
promises.push(this.updateControlPanel());
|
||||
}
|
||||
this._pushState();
|
||||
await Promise.all(promises);
|
||||
@@ -476,6 +458,15 @@ var AbstractController = mvc.Controller.extend(ActionMixin, {
|
||||
}
|
||||
return this.renderer.updateState(state, params);
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @param {Object} [newProps={}]
|
||||
* @return {Promise}
|
||||
*/
|
||||
async _updateSearchPanel(newProps) {
|
||||
Object.assign(this.searchPanelProps, newProps);
|
||||
await this._searchPanelWrapper.update(this.searchPanelProps);
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Handlers
|
||||
@@ -551,7 +542,7 @@ var AbstractController = mvc.Controller.extend(ActionMixin, {
|
||||
switch (ev.data.direction) {
|
||||
case 'up':
|
||||
ev.stopPropagation();
|
||||
this._controlPanelModel.trigger('focus-control-panel');
|
||||
this.searchModel.trigger('focus-control-panel');
|
||||
break;
|
||||
case 'down':
|
||||
ev.stopPropagation();
|
||||
@@ -593,15 +584,6 @@ var AbstractController = mvc.Controller.extend(ActionMixin, {
|
||||
_onSearch: function (searchQuery) {
|
||||
this.reload(_.extend({ offset: 0, groupsOffset: 0 }, searchQuery));
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @param {OdooEvent} ev
|
||||
* @param {Array[]} ev.data.domain the current domain of the searchPanel
|
||||
*/
|
||||
_onSearchPanelDomainUpdated: function (ev) {
|
||||
this.searchPanelDomain = ev.data.domain;
|
||||
this.reload({offset: 0});
|
||||
},
|
||||
/**
|
||||
* Intercepts the 'switch_view' event to add the controllerID into the data,
|
||||
* and lets the event bubble up.
|
||||
|
||||
@@ -23,12 +23,13 @@ odoo.define('web.AbstractView', function (require) {
|
||||
* in most case discarded.
|
||||
*/
|
||||
|
||||
const ActionModel = require("web/static/src/js/views/action_model.js");
|
||||
var AbstractModel = require('web.AbstractModel');
|
||||
var AbstractRenderer = require('web.AbstractRenderer');
|
||||
var AbstractController = require('web.AbstractController');
|
||||
const ControlPanelModel = require('web.ControlPanelModel');
|
||||
const ControlPanel = require('web.ControlPanel');
|
||||
const SearchPanel = require("web/static/src/js/views/search_panel.js");
|
||||
var mvc = require('web.mvc');
|
||||
var SearchPanel = require('web.SearchPanel');
|
||||
var viewUtils = require('web.viewUtils');
|
||||
|
||||
const { Component } = owl;
|
||||
@@ -60,7 +61,8 @@ var AbstractView = Factory.extend({
|
||||
Model: AbstractModel,
|
||||
Renderer: AbstractRenderer,
|
||||
Controller: AbstractController,
|
||||
SearchPanel: SearchPanel,
|
||||
ControlPanel,
|
||||
SearchPanel,
|
||||
}),
|
||||
|
||||
/**
|
||||
@@ -116,10 +118,6 @@ var AbstractView = Factory.extend({
|
||||
this.arch = this.fieldsView.arch;
|
||||
this.fields = this.fieldsView.viewFields;
|
||||
this.userContext = params.userContext || {};
|
||||
this.withControlPanel = this.withControlPanel && params.withControlPanel;
|
||||
const searchPanelDisabled = 'search_panel' in params.context && !params.search_panel;
|
||||
this.withSearchPanel = this.withSearchPanel && this.multi_record &&
|
||||
params.withSearchPanel && !searchPanelDisabled;
|
||||
|
||||
// the boolean parameter 'isEmbedded' determines if the view should be
|
||||
// considered as a subview. For now this is only used by the graph
|
||||
@@ -147,8 +145,6 @@ var AbstractView = Factory.extend({
|
||||
isEmbedded: isEmbedded,
|
||||
modelName: params.modelName,
|
||||
viewType: this.viewType,
|
||||
withControlPanel: this.withControlPanel,
|
||||
withSearchPanel: this.withSearchPanel,
|
||||
};
|
||||
|
||||
var controllerState = params.controllerState || {};
|
||||
@@ -183,49 +179,32 @@ var AbstractView = Factory.extend({
|
||||
this._updateMVCParams(params.searchQuery);
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
this.withControlPanel = this.withControlPanel && params.withControlPanel;
|
||||
this.withSearchPanel = this.withSearchPanel &&
|
||||
this.multi_record && params.withSearchPanel &&
|
||||
!('search_panel' in params.context && !params.search_panel);
|
||||
|
||||
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;
|
||||
const searchModelParams = Object.assign({}, params, { action });
|
||||
if (this.withControlPanel || this.withSearchPanel) {
|
||||
const { arch, fields, favoriteFilters } = params.controlPanelFieldsView || {};
|
||||
const archInfo = ActionModel.extractArchInfo({ search: arch }, this.viewType);
|
||||
const controlPanelInfo = archInfo[this.config.ControlPanel.modelExtension];
|
||||
const searchPanelInfo = archInfo[this.config.SearchPanel.modelExtension];
|
||||
this.withSearchPanel = this.withSearchPanel && Boolean(searchPanelInfo);
|
||||
Object.assign(searchModelParams, {
|
||||
fields,
|
||||
favoriteFilters,
|
||||
controlPanelInfo,
|
||||
searchPanelInfo,
|
||||
});
|
||||
}
|
||||
|
||||
if (this.withSearchPanel) {
|
||||
this.searchPanelParams = {
|
||||
arch: (params.controlPanelFieldsView || {}).arch,
|
||||
defaultNoFilter: params.searchPanelDefaultNoFilter,
|
||||
fields: this.fields,
|
||||
model: this.loadParams.modelName,
|
||||
state: controllerState.spState,
|
||||
};
|
||||
const searchModel = this._createSearchModel(searchModelParams);
|
||||
this.controllerParams.searchModel = searchModel;
|
||||
if (this.controllerParams.controlPanel) {
|
||||
this.controllerParams.controlPanel.props.searchModel = searchModel;
|
||||
}
|
||||
if (this.controllerParams.searchPanel) {
|
||||
this.controllerParams.searchPanel.props.searchModel = searchModel;
|
||||
}
|
||||
},
|
||||
|
||||
@@ -233,34 +212,107 @@ var AbstractView = Factory.extend({
|
||||
// Public
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @param {Object} params
|
||||
* @param {Object} extraExtensions
|
||||
* @returns {ActionModel}
|
||||
*/
|
||||
_createSearchModel: function (params, extraExtensions) {
|
||||
// Search model + common config
|
||||
const { fields, favoriteFilters, controlPanelInfo, searchPanelInfo } = params;
|
||||
const extensions = Object.assign({}, extraExtensions);
|
||||
const importedState = params.controllerState || {};
|
||||
|
||||
// Control panel params
|
||||
if (this.withControlPanel) {
|
||||
// Control panel (Model)
|
||||
const ControlPanelComponent = this.config.ControlPanel;
|
||||
extensions[ControlPanelComponent.modelExtension] = {
|
||||
actionId: params.action.id,
|
||||
// control initialization
|
||||
activateDefaultFavorite: params.activateDefaultFavorite,
|
||||
archNodes: controlPanelInfo.children,
|
||||
dynamicFilters: params.dynamicFilters,
|
||||
favoriteFilters,
|
||||
withSearchBar: params.withSearchBar,
|
||||
};
|
||||
this.controllerParams.withControlPanel = true;
|
||||
// Control panel (Component)
|
||||
const controlPanelProps = {
|
||||
action: params.action,
|
||||
breadcrumbs: params.breadcrumbs,
|
||||
fields: this.fields,
|
||||
searchMenuTypes: params.searchMenuTypes,
|
||||
view: this.fieldsView,
|
||||
views: params.action.views && params.action.views.filter(
|
||||
v => v.multiRecord === this.multi_record
|
||||
),
|
||||
withBreadcrumbs: params.withBreadcrumbs,
|
||||
withSearchBar: params.withSearchBar,
|
||||
};
|
||||
this.controllerParams.controlPanel = {
|
||||
Component: ControlPanelComponent,
|
||||
props: controlPanelProps,
|
||||
};
|
||||
}
|
||||
|
||||
// Search panel params
|
||||
if (this.withSearchPanel) {
|
||||
// Search panel (Model)
|
||||
const SearchPanelComponent = this.config.SearchPanel;
|
||||
const defaultValues = {};
|
||||
Object.keys(this.loadParams.context).forEach((key) => {
|
||||
let match = /^searchpanel_default_(.*)$/.exec(key);
|
||||
if (match) {
|
||||
defaultValues[match[1]] = this.loadParams.context[key];
|
||||
}
|
||||
});
|
||||
extensions[SearchPanelComponent.modelExtension] = {
|
||||
archNodes: searchPanelInfo.children,
|
||||
defaultNoFilter: params.searchPanelDefaultNoFilter,
|
||||
defaultValues,
|
||||
};
|
||||
this.controllerParams.withSearchPanel = true;
|
||||
this.rendererParams.withSearchPanel = true;
|
||||
// Search panel (Component)
|
||||
const searchPanelProps = {
|
||||
importedState: importedState.searchPanel,
|
||||
};
|
||||
if (searchPanelInfo.attrs.class) {
|
||||
searchPanelProps.className = searchPanelInfo.attrs.class;
|
||||
}
|
||||
this.controllerParams.searchPanel = {
|
||||
Component: SearchPanelComponent,
|
||||
props: searchPanelProps,
|
||||
};
|
||||
}
|
||||
|
||||
const searchModel = new ActionModel(extensions, {
|
||||
env: Component.env,
|
||||
modelName: params.modelName,
|
||||
context: Object.assign({}, this.loadParams.context),
|
||||
domain: this.loadParams.domain || [],
|
||||
importedState: importedState.searchModel,
|
||||
searchMenuTypes: params.searchMenuTypes,
|
||||
searchQuery: params.searchQuery,
|
||||
fields,
|
||||
});
|
||||
|
||||
return searchModel;
|
||||
},
|
||||
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
getController: async function (parent) {
|
||||
getController: async function () {
|
||||
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) {
|
||||
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;
|
||||
searchPanel = await this._createSearchPanel(parent, searchPanelParams);
|
||||
}
|
||||
}
|
||||
await this.controllerParams.searchModel.load();
|
||||
const query = this.controllerParams.searchModel.get('query');
|
||||
this._updateMVCParams(query);
|
||||
// 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);
|
||||
@@ -292,36 +344,6 @@ var AbstractView = Factory.extend({
|
||||
// Private
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {Widget} parent
|
||||
* @returns {Promise<SearchPanel>} resolved when the searchPanel is ready
|
||||
*/
|
||||
_createSearchPanel: async function (parent, params) {
|
||||
var defaultValues = {};
|
||||
Object.keys(this.loadParams.context).forEach((key) => {
|
||||
let match = /^searchpanel_default_(.*)$/.exec(key);
|
||||
if (match) {
|
||||
defaultValues[match[1]] = this.loadParams.context[key];
|
||||
}
|
||||
});
|
||||
var controlPanelDomain = this.loadParams.domain;
|
||||
const viewDomain = await this._getViewDomain(parent);
|
||||
var spParams = _.extend({}, this.searchPanelParams, {
|
||||
defaultValues: defaultValues,
|
||||
searchDomain: controlPanelDomain,
|
||||
viewDomain,
|
||||
classes: params.classes || [],
|
||||
});
|
||||
var searchPanel = new this.config.SearchPanel(parent, spParams);
|
||||
this.controllerParams.searchPanel = searchPanel;
|
||||
this.controllerParams.controlPanelDomain = controlPanelDomain;
|
||||
await searchPanel.appendTo(document.createDocumentFragment());
|
||||
|
||||
var searchPanelDomain = searchPanel.getDomain();
|
||||
this.loadParams.domain = controlPanelDomain.concat(searchPanelDomain);
|
||||
return searchPanel;
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @param {Object} [action]
|
||||
@@ -363,17 +385,6 @@ var AbstractView = Factory.extend({
|
||||
withSearchPanel: this.withSearchPanel,
|
||||
};
|
||||
},
|
||||
/**
|
||||
* Get the domain defined by the view. It is meant to be overridden. The parent
|
||||
* is provided in case some subcomponents need to be retrieved.
|
||||
*
|
||||
* @private
|
||||
* @param {Object} parent
|
||||
* @returns {Promise<Array[]>}
|
||||
*/
|
||||
_getViewDomain: async function (parent) {
|
||||
return [];
|
||||
},
|
||||
/**
|
||||
* Processes a fieldsView. In particular, parses its arch.
|
||||
*
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
odoo.define("web/static/src/js/views/action_model.js", function (require) {
|
||||
"use strict";
|
||||
|
||||
const Domain = require("web.Domain");
|
||||
const { FACET_ICONS } = require("web.searchUtils");
|
||||
const { Model } = require("web/static/src/js/model.js");
|
||||
const { parseArch } = require("web.viewUtils");
|
||||
const pyUtils = require("web.py_utils");
|
||||
const Registry = require("web.Registry");
|
||||
|
||||
const isNotNull = (value) => value !== null && value !== undefined;
|
||||
const isObject = (obj) => typeof obj === "object" && obj !== null;
|
||||
|
||||
/**
|
||||
* @extends Model.Extension
|
||||
*/
|
||||
class ActionModelExtension extends Model.Extension {
|
||||
|
||||
//---------------------------------------------------------------------
|
||||
// Static
|
||||
//---------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @abstract
|
||||
* @param {Object} archs
|
||||
* @param {string | null} [viewType=null]
|
||||
* @returns {null}
|
||||
*/
|
||||
static extractArchInfo() {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @extends Model
|
||||
*/
|
||||
class ActionModel extends Model {
|
||||
|
||||
//---------------------------------------------------------------------
|
||||
// Public
|
||||
//---------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
get(property) {
|
||||
switch (property) {
|
||||
case "query": return this.config.searchQuery || this._getQuery();
|
||||
case "facets": return this._getFacets();
|
||||
}
|
||||
return super.get(...arguments);
|
||||
}
|
||||
|
||||
//---------------------------------------------------------------------
|
||||
// Private
|
||||
//---------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
__get(excluded, property) {
|
||||
const results = super.__get(...arguments);
|
||||
switch (property) {
|
||||
case "domain": return [this.config.domain, ...results];
|
||||
case "context": return [this.config.context, ...results];
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates and formats all facets given by the extensions. This is
|
||||
* done here rather than in the search bar because the searchMenuTypes
|
||||
* are available only to the model.
|
||||
* @private
|
||||
* @returns {Object[]}
|
||||
*/
|
||||
_getFacets() {
|
||||
const types = this.config.searchMenuTypes || [];
|
||||
const isValidType = (type) => (
|
||||
!['groupBy', 'comparison'].includes(type) || types.includes(type)
|
||||
);
|
||||
const facets = [];
|
||||
for (const extension of this.extensions.flat()) {
|
||||
for (const facet of extension.get("facets") || []) {
|
||||
if (!isValidType(facet.type)) {
|
||||
continue;
|
||||
}
|
||||
facet.separator = facet.type === 'groupBy' ? ">" : this.env._t("or");
|
||||
if (facet.type in FACET_ICONS) {
|
||||
facet.icon = FACET_ICONS[facet.type];
|
||||
}
|
||||
facets.push(facet);
|
||||
}
|
||||
}
|
||||
return facets;
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef TimeRanges
|
||||
* @property {string} fieldName
|
||||
* @property {string} comparisonRangeId
|
||||
* @property {Array[]} range
|
||||
* @property {string} rangeDescription
|
||||
* @property {Array[]} comparisonRange
|
||||
* @property {string} comparisonRangeDescription
|
||||
*/
|
||||
/**
|
||||
* @typedef Query
|
||||
* @property {Object} context
|
||||
* @property {Array[]} domain
|
||||
* @property {string[]} groupBy
|
||||
* @property {string[]} orderedBy
|
||||
* @property {TimeRanges?} timeRanges
|
||||
*/
|
||||
/**
|
||||
* @private
|
||||
* @returns {Query}
|
||||
*/
|
||||
_getQuery() {
|
||||
const evalContext = this.env.session.user_context;
|
||||
const contexts = this.__get(null, "context");
|
||||
const domains = this.__get(null, "domain");
|
||||
const query = {
|
||||
context: pyUtils.eval("contexts", contexts, evalContext),
|
||||
domain: Domain.prototype.normalizeArray(
|
||||
pyUtils.eval("domains", domains, evalContext)
|
||||
),
|
||||
orderedBy: this.get("orderedBy") || [],
|
||||
};
|
||||
const searchMenuTypes = this.config.searchMenuTypes || [];
|
||||
if (searchMenuTypes.includes("groupBy")) {
|
||||
query.groupBy = this.get("groupBy") || [];
|
||||
} else {
|
||||
query.groupBy = [];
|
||||
}
|
||||
if (searchMenuTypes.includes("comparison")) {
|
||||
query.timeRanges = this.get("timeRanges") || {};
|
||||
}
|
||||
return query;
|
||||
}
|
||||
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
_notifyComponents() {
|
||||
this.trigger("search", this.get("query"));
|
||||
super._notifyComponents(...arguments);
|
||||
}
|
||||
|
||||
//---------------------------------------------------------------------
|
||||
// Static
|
||||
//---------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @param {Object} archs
|
||||
* @param {string | null} [viewType=null]
|
||||
* @returns {Object}
|
||||
*/
|
||||
static extractArchInfo(archs, viewType = null) {
|
||||
const parsedArchs = {};
|
||||
if (!archs.search) {
|
||||
archs.search = "<search/>";
|
||||
}
|
||||
for (const key in archs) {
|
||||
const { attrs, children } = parseArch(archs[key]);
|
||||
const objectChildren = children.filter(isObject);
|
||||
parsedArchs[key] = {
|
||||
attrs,
|
||||
children: objectChildren,
|
||||
};
|
||||
}
|
||||
const archInfo = {};
|
||||
for (const key of this.registry.keys()) {
|
||||
const extension = this.registry.get(key);
|
||||
const result = extension.extractArchInfo(parsedArchs, viewType);
|
||||
if (isNotNull(result)) {
|
||||
archInfo[key] = result;
|
||||
}
|
||||
}
|
||||
return archInfo;
|
||||
}
|
||||
}
|
||||
|
||||
ActionModel.Extension = ActionModelExtension;
|
||||
ActionModel.registry = new Registry(null,
|
||||
(value) => value.prototype instanceof ActionModel.Extension
|
||||
);
|
||||
|
||||
return ActionModel;
|
||||
});
|
||||
@@ -101,7 +101,7 @@ var KanbanController = BasicController.extend({
|
||||
*/
|
||||
_addQuickCreate(groupId) {
|
||||
this._removeSampleData(async () => {
|
||||
await this.update({ shouldUpdateControlPanel: false }, { reload: false });
|
||||
await this.update({ shouldUpdateSearchComponents: false }, { reload: false });
|
||||
return this.renderer.addQuickCreate(groupId);
|
||||
});
|
||||
},
|
||||
|
||||
@@ -1,789 +1,219 @@
|
||||
odoo.define('web.SearchPanel', function (require) {
|
||||
"use strict";
|
||||
odoo.define("web/static/src/js/views/search_panel.js", function (require) {
|
||||
"use strict";
|
||||
|
||||
/**
|
||||
* This file defines the SearchPanel widget for Kanban. It allows to
|
||||
* filter/manage data easily.
|
||||
*/
|
||||
const { Model, useModel } = require("web/static/src/js/model.js");
|
||||
const patchMixin = require("web.patchMixin");
|
||||
|
||||
const core = require('web.core');
|
||||
const Domain = require('web.Domain');
|
||||
const pyUtils = require('web.py_utils');
|
||||
const { sortBy } = require('web.utils');
|
||||
const viewUtils = require('web.viewUtils');
|
||||
const Widget = require('web.Widget');
|
||||
|
||||
const qweb = core.qweb;
|
||||
|
||||
// defaultViewTypes is the list of view types for which the searchpanel is
|
||||
// present by default (if not explicitly stated in the 'view_types' attribute
|
||||
// in the arch)
|
||||
const defaultViewTypes = ['kanban', 'tree'];
|
||||
let nextSectionId = 1;
|
||||
|
||||
const SEARCH_PANEL_DEFAULT_LIMIT = 200;
|
||||
|
||||
/**
|
||||
* Given a <searchpanel> arch node, iterate over its children to generate the
|
||||
* description of each section (being either a category or a filter).
|
||||
*
|
||||
* @param {Object} node a <searchpanel> arch node
|
||||
* @param {Object} fields the fields of the model
|
||||
* @returns {Object}
|
||||
*/
|
||||
function _processSearchPanelNode(node, fields) {
|
||||
const sections = {};
|
||||
node.children.forEach(({ attrs, tag }, index) => {
|
||||
if (tag !== 'field' || attrs.invisible === "1") {
|
||||
return;
|
||||
}
|
||||
const fieldName = attrs.name;
|
||||
const type = attrs.select === 'multi' ? 'filter' : 'category';
|
||||
|
||||
const sectionId = `section_${nextSectionId++}`;
|
||||
const section = {
|
||||
color: attrs.color,
|
||||
description: attrs.string || fields[fieldName].string,
|
||||
enableCounters: !!pyUtils.py_eval(attrs.enable_counters || '0'),
|
||||
expand: !!pyUtils.py_eval(attrs.expand || '0'),
|
||||
fieldName,
|
||||
icon: attrs.icon,
|
||||
id: sectionId,
|
||||
index,
|
||||
limit: attrs.limit ?
|
||||
pyUtils.py_eval(attrs.limit) :
|
||||
SEARCH_PANEL_DEFAULT_LIMIT,
|
||||
type,
|
||||
};
|
||||
if (section.type === 'category') {
|
||||
section.icon = section.icon || 'fa-folder';
|
||||
section.hierarchize = !!pyUtils.py_eval(attrs.hierarchize || '1');
|
||||
} else if (section.type === 'filter') {
|
||||
section.domain = attrs.domain || '[]';
|
||||
section.groupBy = attrs.groupby;
|
||||
section.icon = section.icon || 'fa-filter';
|
||||
}
|
||||
sections[sectionId] = section;
|
||||
});
|
||||
return sections;
|
||||
}
|
||||
|
||||
const SearchPanel = Widget.extend({
|
||||
className: 'o_search_panel',
|
||||
events: {
|
||||
'click .o_search_panel_category_value header': '_onCategoryValueClicked',
|
||||
'change .o_search_panel_filter_value > header > div > input': '_onFilterValueChanged',
|
||||
'change .o_search_panel_filter_group > header > div > input': '_onFilterGroupChanged',
|
||||
},
|
||||
const { Component, hooks } = owl;
|
||||
const { useState, useSubEnv } = hooks;
|
||||
|
||||
/**
|
||||
* @constructor
|
||||
* @param {Object} params
|
||||
* @param {Object} [params.defaultValues={}] the value(s) to activate by
|
||||
* default, for each filter and category
|
||||
* @param {boolean} [params.defaultNoFilter=false] if true, select 'All' as
|
||||
* value for each category that has no value specified in defaultValues
|
||||
* (instead of looking in the localStorage for the last selected value)
|
||||
* @param {Object} params.fields
|
||||
* @param {string} params.model
|
||||
* @param {Array[]} params.searchDomain domain coming from controlPanel
|
||||
* @param {Array[]} params.viewDomain domain coming from the view (ill advised)
|
||||
* @param {Object} params.sections
|
||||
* @param {Object} [params.state] state exported by another searchpanel
|
||||
* instance
|
||||
* Search panel
|
||||
*
|
||||
* Represent an extension of the search interface located on the left side of
|
||||
* the view. It is divided in sections defined in a "<searchpanel>" node located
|
||||
* inside of a "<search>" arch. Each section is represented by a list of different
|
||||
* values (categories or ungrouped filters) or groups of values (grouped filters).
|
||||
* Its state is directly affected by its model (@see SearchPanelModelExtension).
|
||||
* @extends Component
|
||||
*/
|
||||
init(parent, params) {
|
||||
this._super(...arguments);
|
||||
class SearchPanel extends Component {
|
||||
constructor() {
|
||||
super(...arguments);
|
||||
|
||||
this.categories = {};
|
||||
this.filters = {};
|
||||
for (const section of Object.values(params.sections)) {
|
||||
const key = section.type === 'category' ? 'categories' : 'filters';
|
||||
this[key][section.id] = section;
|
||||
}
|
||||
useSubEnv({ searchModel: this.props.searchModel });
|
||||
|
||||
this.initialState = params.state;
|
||||
this.scrollTop = this.initialState && this.initialState.scrollTop || null;
|
||||
this.defaultValues = params.defaultValues || {};
|
||||
if (params.defaultNoFilter) {
|
||||
Object.values(this.categories).forEach(({ fieldName }) => {
|
||||
this.defaultValues[fieldName] = this.defaultValues[fieldName] || false;
|
||||
this.state = useState({
|
||||
active: {},
|
||||
expanded: {},
|
||||
});
|
||||
}
|
||||
this.fields = params.fields;
|
||||
this.model = params.model;
|
||||
this.className = params.classes.concat(['o_search_panel']).join(' ');
|
||||
this.internalChangeOccurred = false;
|
||||
this.searchDomain = params.searchDomain;
|
||||
this.viewDomain = params.viewDomain;
|
||||
},
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
async willStart() {
|
||||
const _super = this._super;
|
||||
if (this.initialState) {
|
||||
this.filters = this.initialState.filters;
|
||||
this.categories = this.initialState.categories;
|
||||
} else {
|
||||
await this._fetchCategories({ initialLoad: true });
|
||||
await this._fetchFilters();
|
||||
await this._applyDefaultFilterValues();
|
||||
}
|
||||
return _super.call(this, ...arguments);
|
||||
},
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
start() {
|
||||
this._render();
|
||||
return this._super(...arguments);
|
||||
},
|
||||
/**
|
||||
* Called each time the searchPanel is attached into the DOM.
|
||||
*/
|
||||
on_attach_callback() {
|
||||
if (this.scrollTop !== null) {
|
||||
this.el.scrollTop = this.scrollTop;
|
||||
}
|
||||
},
|
||||
this.model = useModel("searchModel");
|
||||
this.scrollTop = 0;
|
||||
this.hasImportedState = false;
|
||||
|
||||
//-------------------------------------------------------------------------
|
||||
// Public
|
||||
//-------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Parse a given search view arch to extract the searchpanel information
|
||||
* (i.e. a description of each filter/category). Note that according to the
|
||||
* 'view_types' attribute on the <searchpanel> node, and the given viewType,
|
||||
* it may return undefined, meaning that no searchpanel should be rendered
|
||||
* for the current view.
|
||||
*
|
||||
* Note that this is static method, called by AbstractView, *before*
|
||||
* instantiating the SearchPanel, as depending on what it returns, we may
|
||||
* or may not instantiate a SearchPanel.
|
||||
*
|
||||
* @static
|
||||
* @param {Object} viewInfo the viewInfo of a search view
|
||||
* @param {string} viewInfo.arch
|
||||
* @param {Object} viewInfo.fields
|
||||
* @param {string} viewType the type of the current view (e.g. 'kanban')
|
||||
* @returns {Object}
|
||||
*/
|
||||
computeSearchPanelParams(arch, fields, viewType) {
|
||||
const searchPanelParams = {};
|
||||
if (arch && fields) {
|
||||
const parsedArch = viewUtils.parseArch(arch);
|
||||
const searchPanelNode = parsedArch.children.find(child => child.tag === 'searchpanel');
|
||||
if (searchPanelNode) {
|
||||
const { attrs } = searchPanelNode;
|
||||
const type = viewType === 'list' ? 'tree' : viewType;
|
||||
let viewTypes = defaultViewTypes;
|
||||
if (attrs.view_types) {
|
||||
viewTypes = attrs.view_types.split(',');
|
||||
}
|
||||
if (attrs.class) {
|
||||
searchPanelParams.classes = attrs.class.split(' ');
|
||||
}
|
||||
if (viewTypes.includes(type)) {
|
||||
searchPanelParams.sections = _processSearchPanelNode(searchPanelNode, fields);
|
||||
}
|
||||
}
|
||||
}
|
||||
return searchPanelParams;
|
||||
},
|
||||
/**
|
||||
* Export the current state (categories and filters) of the searchpanel.
|
||||
*
|
||||
* @returns {Object}
|
||||
*/
|
||||
exportState() {
|
||||
return {
|
||||
categories: this.categories,
|
||||
filters: this.filters,
|
||||
scrollTop: this.el ? this.el.scrollTop : null,
|
||||
};
|
||||
},
|
||||
/**
|
||||
* @returns {Array[]} the current searchPanel domain based on active
|
||||
* categories and checked filters
|
||||
*/
|
||||
getDomain() {
|
||||
return this._getCategoryDomain().concat(this._getFilterDomain());
|
||||
},
|
||||
/**
|
||||
* Import a previously exported state (see exportState).
|
||||
*
|
||||
* @param {Object} state
|
||||
* @param {Object} state.filters.
|
||||
* @param {Object} state.categories
|
||||
*/
|
||||
importState(state) {
|
||||
this.categories = state.categories || this.categories;
|
||||
this.filters = state.filters || this.filters;
|
||||
this.scrollTop = state.scrollTop;
|
||||
this._render();
|
||||
},
|
||||
/**
|
||||
* Reload the filters and categories and re-render. Note that we only reload them
|
||||
* if the controlPanel domain or searchPanel domain has changed.
|
||||
*
|
||||
* @param {Object} params
|
||||
* @param {Array[]} params.searchDomain domain coming from controlPanel
|
||||
* @param {Array[]} params.viewDomain domain coming from view
|
||||
* @returns {Promise}
|
||||
*/
|
||||
async update(params) {
|
||||
const currentDomain = JSON.stringify([...this.searchDomain, ...this.viewDomain]);
|
||||
const newDomain = JSON.stringify([...params.searchDomain, ...params.viewDomain]);
|
||||
const externalDomainUpdated = currentDomain !== newDomain;
|
||||
if (this.internalChangeOccurred || externalDomainUpdated) {
|
||||
this.searchDomain = params.searchDomain;
|
||||
this.viewDomain = params.viewDomain;
|
||||
await this._fetchCategories({ externalDomainUpdated });
|
||||
await this._fetchFilters();
|
||||
this.internalChangeOccurred = false;
|
||||
}
|
||||
return this._render();
|
||||
},
|
||||
|
||||
//-------------------------------------------------------------------------
|
||||
// Private
|
||||
//-------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Set active values for each filter (coming from context). This needs to be
|
||||
* done only once, at widget startup.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_applyDefaultFilterValues() {
|
||||
Object.values(this.filters).forEach(filter => {
|
||||
const defaultValues = this.defaultValues[filter.fieldName] || [];
|
||||
defaultValues.forEach(value => {
|
||||
if (filter.values[value]) {
|
||||
filter.values[value].checked = true;
|
||||
}
|
||||
});
|
||||
Object.values(filter.groups || []).forEach(this._updateFilterGroupState);
|
||||
});
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @param {string} categoryId
|
||||
* @param {Object} result
|
||||
*/
|
||||
_createCategoryTree(categoryId, result) {
|
||||
const category = this.categories[categoryId];
|
||||
let { error_msg, parent_field: parentField, values } = result;
|
||||
if (error_msg) {
|
||||
category.errorMsg = error_msg;
|
||||
values = [];
|
||||
}
|
||||
if (category.hierarchize) {
|
||||
category.parentField = parentField;
|
||||
this.importState(this.props.importedState);
|
||||
}
|
||||
|
||||
const unfoldedIds = Object.values(category.values || {})
|
||||
.filter(c => c.folded === false)
|
||||
.map(c => c.id);
|
||||
async willStart() {
|
||||
this._expandDefaultValue();
|
||||
this._updateActiveValues();
|
||||
}
|
||||
|
||||
category.values = {};
|
||||
values.forEach(value => {
|
||||
category.values[value.id] = Object.assign({}, value, {
|
||||
childrenIds: [],
|
||||
folded: !unfoldedIds.includes(value.id),
|
||||
parentId: value[parentField] || false,
|
||||
});
|
||||
});
|
||||
values.forEach(value => {
|
||||
const { parentId: parentCategoryId } = category.values[value.id];
|
||||
if (parentCategoryId && parentCategoryId in category.values) {
|
||||
category.values[parentCategoryId].childrenIds.push(value.id);
|
||||
}
|
||||
});
|
||||
category.rootIds = [];
|
||||
for (const value of values) {
|
||||
const { parentId } = category.values[value.id];
|
||||
if (!parentId) {
|
||||
category.rootIds.push(value.id);
|
||||
mounted() {
|
||||
this._updateGroupHeadersChecked();
|
||||
if (this.hasImportedState) {
|
||||
this.el.scroll({ top: this.scrollTop });
|
||||
}
|
||||
}
|
||||
|
||||
// set active value
|
||||
const validValues = [...Object.values(category.values).map(v => v.id), false];
|
||||
const value = this._getCategoryDefaultValue(category, validValues);
|
||||
category.activeValueId = validValues.includes(value) ? value : false;
|
||||
|
||||
// unfold ancestor values of active value to make it is visible
|
||||
if (category.activeValueId) {
|
||||
const parentValueIds = this._getAncestorValueIds(category, category.activeValueId);
|
||||
parentValueIds.forEach(parentValue => {
|
||||
category.values[parentValue].folded = false;
|
||||
});
|
||||
}
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @param {string} filterId
|
||||
* @param {Object} result
|
||||
*/
|
||||
_createFilterTree(filterId, result) {
|
||||
const filter = this.filters[filterId];
|
||||
let { error_msg, values } = result;
|
||||
if (error_msg) {
|
||||
filter.errorMsg = error_msg;
|
||||
values = [];
|
||||
async willUpdateProps() {
|
||||
this._updateActiveValues();
|
||||
}
|
||||
|
||||
// restore checked property
|
||||
values.forEach(value => {
|
||||
const oldValue = filter.values && filter.values[value.id];
|
||||
value.checked = oldValue && oldValue.checked || false;
|
||||
});
|
||||
//---------------------------------------------------------------------
|
||||
// Public
|
||||
//---------------------------------------------------------------------
|
||||
|
||||
filter.values = {};
|
||||
const groupIds = [];
|
||||
if (filter.groupBy) {
|
||||
const groups = {};
|
||||
values.forEach(value => {
|
||||
const groupId = JSON.stringify(value.group_id);
|
||||
if (!groups[groupId]) {
|
||||
if (groupId !== 'false') {
|
||||
groupIds.push(groupId);
|
||||
}
|
||||
groups[groupId] = {
|
||||
folded: false,
|
||||
id: groupId,
|
||||
name: value.group_name,
|
||||
values: {},
|
||||
tooltip: value.group_tooltip,
|
||||
sequence: value.group_sequence,
|
||||
hex_color: value.group_hex_color,
|
||||
sortedValueIds: [],
|
||||
};
|
||||
// restore former checked and folded state
|
||||
const oldGroup = filter.groups && filter.groups[groupId];
|
||||
groups[groupId].state = oldGroup && oldGroup.state || false;
|
||||
groups[groupId].folded = oldGroup && oldGroup.folded || false;
|
||||
}
|
||||
groups[groupId].values[value.id] = value;
|
||||
groups[groupId].sortedValueIds.push(value.id);
|
||||
});
|
||||
filter.groups = groups;
|
||||
filter.sortedGroupIds = sortBy(groupIds,
|
||||
id => groups[id].sequence || groups[id].name
|
||||
);
|
||||
Object.values(filter.groups).forEach(group => {
|
||||
Object.assign(filter.values, group.values);
|
||||
});
|
||||
} else {
|
||||
values.forEach(value => {
|
||||
filter.values[value.id] = value;
|
||||
});
|
||||
filter.sortedValueIds = values.map(value => value.id);
|
||||
exportState() {
|
||||
const exported = {
|
||||
expanded: this.state.expanded,
|
||||
scrollTop: this.el.scrollTop,
|
||||
};
|
||||
return JSON.stringify(exported);
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Fetch values for each category at startup. At reload a category is only
|
||||
* fetched if the situation requires it.
|
||||
*
|
||||
* @private
|
||||
* @param {Object} [options={}]
|
||||
* @param {boolean} [options.initialLoad]
|
||||
* @param {boolean} [options.externalDomainUpdated]
|
||||
* @returns {Promise} resolved when all categories have been fetched
|
||||
*/
|
||||
_fetchCategories(options = {}) {
|
||||
const proms = [];
|
||||
for (const category of Object.values(this.categories)) {
|
||||
const { enableCounters, expand, hierarchize, limit } = category;
|
||||
if (options.initialLoad || enableCounters || (options.externalDomainUpdated && !expand)) {
|
||||
const prom = this._rpc({
|
||||
method: 'search_panel_select_range',
|
||||
model: this.model,
|
||||
args: [category.fieldName],
|
||||
kwargs: {
|
||||
category_domain: this._getCategoryDomain(category.id),
|
||||
enable_counters: enableCounters,
|
||||
expand,
|
||||
filter_domain: this._getFilterDomain(),
|
||||
hierarchize,
|
||||
limit,
|
||||
search_domain: this.searchDomain,
|
||||
},
|
||||
}).then(
|
||||
(result) => this._createCategoryTree(category.id, result)
|
||||
);
|
||||
proms.push(prom);
|
||||
}
|
||||
}
|
||||
return Promise.all(proms);
|
||||
},
|
||||
/**
|
||||
* Fetch values for each filter. This is done at startup, and at each reload
|
||||
* (when the controlPanel or searchPanel domain changes).
|
||||
*
|
||||
* @private
|
||||
* @returns {Promise} resolved when all filters have been fetched
|
||||
*/
|
||||
_fetchFilters() {
|
||||
const evalContext = {};
|
||||
for (const category of Object.values(this.categories)) {
|
||||
evalContext[category.fieldName] = category.activeValueId;
|
||||
}
|
||||
const categoryDomain = this._getCategoryDomain();
|
||||
const proms = [];
|
||||
for (const filter of Object.values(this.filters)) {
|
||||
const { enableCounters, expand, groupBy, limit } = filter;
|
||||
const prom = this._rpc({
|
||||
method: 'search_panel_select_multi_range',
|
||||
model: this.model,
|
||||
args: [filter.fieldName],
|
||||
kwargs: {
|
||||
category_domain: categoryDomain,
|
||||
comodel_domain: Domain.prototype.stringToArray(filter.domain, evalContext),
|
||||
enable_counters: enableCounters,
|
||||
filter_domain: this._getFilterDomain(filter.id),
|
||||
expand,
|
||||
group_by: groupBy || false,
|
||||
group_domain: this._getGroupDomain(filter),
|
||||
limit,
|
||||
search_domain: [...this.searchDomain, ...this.viewDomain],
|
||||
},
|
||||
}).then(
|
||||
(result) => this._createFilterTree(filter.id, result)
|
||||
);
|
||||
proms.push(prom);
|
||||
}
|
||||
return Promise.all(proms);
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @param {Object} category
|
||||
* @param {Array} validValues
|
||||
* @returns {(number|boolean)} id of the default item of the category or false
|
||||
*/
|
||||
_getCategoryDefaultValue(category, validValues) {
|
||||
// set active value from context
|
||||
const value = this.defaultValues[category.fieldName];
|
||||
// if not set in context, or set to an unknown value, set active value
|
||||
// from localStorage
|
||||
if (!validValues.includes(value)) {
|
||||
const storageKey = this._getLocalStorageKey(category);
|
||||
return this.call('local_storage', 'getItem', storageKey);
|
||||
}
|
||||
return value;
|
||||
},
|
||||
/**
|
||||
* Compute and return the domain based on the current active categories.
|
||||
*
|
||||
* @private
|
||||
* @param {string} [excludedCategoryId]
|
||||
* @returns {Array[]}
|
||||
*/
|
||||
_getCategoryDomain(excludedCategoryId) {
|
||||
const domain = [];
|
||||
for (const category of Object.values(this.categories)) {
|
||||
if (category.id === excludedCategoryId) {
|
||||
continue;
|
||||
}
|
||||
if (category.activeValueId) {
|
||||
const field = this.fields[category.fieldName];
|
||||
const op = (field.type === 'many2one' && category.parentField) ? 'child_of' : '=';
|
||||
domain.push([category.fieldName, op, category.activeValueId]);
|
||||
}
|
||||
}
|
||||
return domain;
|
||||
},
|
||||
/**
|
||||
* Compute and return the domain based on the current checked filters.
|
||||
* The values of a single filter are combined using a simple rule: checked values within
|
||||
* a same group are combined with an 'OR' (this is expressed as single condition using a list)
|
||||
* and groups are combined with an 'AND' (expressed by concatenation of conditions).
|
||||
* If a filter has no groups, its checked values are implicitely considered as forming
|
||||
* a group (and grouped using an 'OR').
|
||||
*
|
||||
* @private
|
||||
* @param {string} [excludedFilterId]
|
||||
* @returns {Array[]}
|
||||
*/
|
||||
_getFilterDomain(excludedFilterId) {
|
||||
const domain = [];
|
||||
|
||||
function addCondition(fieldName, checkedValues) {
|
||||
if (checkedValues.length) {
|
||||
const ids = checkedValues.map(v => v.id);
|
||||
domain.push([fieldName, 'in', ids]);
|
||||
importState(stringifiedState) {
|
||||
this.hasImportedState = Boolean(stringifiedState);
|
||||
if (this.hasImportedState) {
|
||||
const state = JSON.parse(stringifiedState);
|
||||
this.state.expanded = state.expanded;
|
||||
this.scrollTop = state.scrollTop;
|
||||
}
|
||||
}
|
||||
|
||||
for (const filter of Object.values(this.filters)) {
|
||||
if (filter.id === excludedFilterId) {
|
||||
continue;
|
||||
}
|
||||
const { fieldName } = filter;
|
||||
let checkedValues;
|
||||
if (filter.groups) {
|
||||
for (const group of Object.values(filter.groups)) {
|
||||
checkedValues = Object.values(group.values).filter(v => v.checked);
|
||||
addCondition(fieldName, checkedValues);
|
||||
}
|
||||
} else if (filter.values) {
|
||||
checkedValues = Object.values(filter.values).filter(v => v.checked);
|
||||
addCondition(fieldName, checkedValues);
|
||||
}
|
||||
}
|
||||
return domain;
|
||||
},
|
||||
/**
|
||||
* Returns a domain or an object of domains used to complement
|
||||
* the filter domains to accurately describe the constraints on
|
||||
* records when computing record counts associated with the filter
|
||||
* values (if a groupBy is provided). The idea is that the checked values
|
||||
* within a group should not impact the counts for the other values
|
||||
* in the same group.
|
||||
*
|
||||
* @private
|
||||
* @param {string} filter
|
||||
* @returns {(Array{}|Array[]|undefined)}
|
||||
*/
|
||||
_getGroupDomain(filter) {
|
||||
const { fieldName, groups, enableCounters } = filter;
|
||||
const { type: fieldType } = this.fields[fieldName];
|
||||
//---------------------------------------------------------------------
|
||||
// Private
|
||||
//---------------------------------------------------------------------
|
||||
|
||||
if (!enableCounters || !groups) {
|
||||
switch (fieldType) {
|
||||
case 'many2one': return [];
|
||||
case 'many2many': return {};
|
||||
default: return;
|
||||
/**
|
||||
* Expands category values holding the default value of a category.
|
||||
* @private
|
||||
*/
|
||||
_expandDefaultValue() {
|
||||
if (this.hasImportedState) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
let groupDomain;
|
||||
if (fieldType === 'many2one') {
|
||||
for (const group of Object.values(groups)) {
|
||||
const valueIds = [];
|
||||
let active = false;
|
||||
for (const value of Object.values(group.values || {})) {
|
||||
const { id, checked } = value;
|
||||
valueIds.push(id);
|
||||
if (checked) {
|
||||
active = true;
|
||||
}
|
||||
}
|
||||
if (active) {
|
||||
if (groupDomain) {
|
||||
groupDomain = Domain.FALSE_DOMAIN;
|
||||
break;
|
||||
} else {
|
||||
groupDomain = [[fieldName, 'in', valueIds]];
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (fieldType === 'many2many') {
|
||||
const checkedValueIds = {};
|
||||
for (const group of Object.values(groups)) {
|
||||
for (const value of Object.values(group.values || {})) {
|
||||
const { id, checked } = value;
|
||||
if (checked) {
|
||||
if (!checkedValueIds[group.id]) {
|
||||
checkedValueIds[group.id] = [];
|
||||
}
|
||||
checkedValueIds[group.id].push(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
groupDomain = {};
|
||||
for (const gId in checkedValueIds) {
|
||||
const ids = checkedValueIds[gId];
|
||||
for (const group of Object.values(groups)) {
|
||||
if (gId !== group.id) {
|
||||
if (!groupDomain[group.id]) {
|
||||
groupDomain[group.id] = [];
|
||||
}
|
||||
groupDomain[group.id].push(
|
||||
[fieldName, 'in', ids]
|
||||
);
|
||||
const categories = this.model.get("sections", s => s.type === "category");
|
||||
for (const category of categories) {
|
||||
this.state.expanded[category.id] = {};
|
||||
if (category.activeValueId) {
|
||||
const ancestorIds = this._getAncestorValueIds(category, category.activeValueId);
|
||||
for (const ancestorId of ancestorIds) {
|
||||
this.state.expanded[category.id][ancestorId] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return groupDomain;
|
||||
},
|
||||
/**
|
||||
* The active id of each category is stored in the localStorage, s.t. it
|
||||
* can be restored afterwards (when the action is reloaded, for instance).
|
||||
* This function returns the key in the sessionStorage for a given category.
|
||||
*
|
||||
* @param {Object} category
|
||||
* @returns {string}
|
||||
*/
|
||||
_getLocalStorageKey(category) {
|
||||
return 'searchpanel_' + this.model + '_' + category.fieldName;
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @param {Object} category
|
||||
* @param {number} categoryValueId
|
||||
* @returns {number[]} list of ids of the ancestors of the given value in
|
||||
* the given category
|
||||
*/
|
||||
_getAncestorValueIds(category, categoryValueId) {
|
||||
const { parentId } = category.values[categoryValueId];
|
||||
if (parentId) {
|
||||
return [parentId].concat(this._getAncestorValueIds(category, parentId));
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {Object} category
|
||||
* @param {number} categoryValueId
|
||||
* @returns {number[]} list of ids of the ancestors of the given value in
|
||||
* the given category.
|
||||
*/
|
||||
_getAncestorValueIds(category, categoryValueId) {
|
||||
const { parentId } = category.values.get(categoryValueId);
|
||||
return parentId ? [...this._getAncestorValueIds(category, parentId), parentId] : [];
|
||||
}
|
||||
return [];
|
||||
},
|
||||
/**
|
||||
* Compute the current searchPanel domain based on categories and filters,
|
||||
* and notify environment of the domain change.
|
||||
*
|
||||
* Note that this assumes that the environment will update the searchPanel.
|
||||
* This is done as such to ensure the coordination between the reloading of
|
||||
* the searchPanel and the reloading of the data.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_notifyDomainUpdated() {
|
||||
this.internalChangeOccurred = true;
|
||||
this.trigger_up('search_panel_domain_updated', {
|
||||
domain: this.getDomain(),
|
||||
});
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
_render() {
|
||||
this.$el.empty();
|
||||
|
||||
// sort categories and filters according to their index
|
||||
const categories = Object.values(this.categories);
|
||||
const filters = Object.values(this.filters);
|
||||
const sections = categories.concat(filters).sort((s1, s2) => s1.index - s2.index);
|
||||
|
||||
sections.forEach(section => {
|
||||
if (Object.keys(section.values).length || section.errorMsg) {
|
||||
if (section.type === 'category') {
|
||||
this.$el.append(this._renderCategory(section));
|
||||
/**
|
||||
* Prevent unnecessary calls to the model by ensuring a different category
|
||||
* is clicked.
|
||||
* @private
|
||||
* @param {Object} category
|
||||
* @param {Object} value
|
||||
*/
|
||||
async _toggleCategory(category, value) {
|
||||
if (value.childrenIds.length) {
|
||||
const categoryState = this.state.expanded[category.id];
|
||||
if (categoryState[value.id] && category.activeValueId === value.id) {
|
||||
delete categoryState[value.id];
|
||||
} else {
|
||||
this.$el.append(this._renderFilter(section));
|
||||
categoryState[value.id] = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @param {Object} category
|
||||
* @returns {string}
|
||||
*/
|
||||
_renderCategory(category) {
|
||||
return qweb.render('SearchPanel.Category', { category });
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @param {Object} filter
|
||||
* @returns {HTMLElement}
|
||||
*/
|
||||
_renderFilter(filter) {
|
||||
const filterElement = $(qweb.render('SearchPanel.Filter', { filter }))[0];
|
||||
|
||||
// set group inputs in indeterminate state when necessary
|
||||
Object.keys(filter.groups || {}).forEach(groupId => {
|
||||
const group = filter.groups[groupId];
|
||||
const { state } = group;
|
||||
// group 'false' is not displayed
|
||||
if (groupId !== 'false' && state === 'indeterminate') {
|
||||
const sel = `.o_search_panel_filter_group[data-group-id="${groupId}"] input`;
|
||||
filterElement.querySelector(sel).indeterminate = true;
|
||||
if (category.activeValueId !== value.id) {
|
||||
this.state.active[category.id] = value.id;
|
||||
this.model.dispatch("toggleCategoryValue", category.id, value.id);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return filterElement;
|
||||
},
|
||||
/**
|
||||
* Updates the state property of a given filter's group according to the
|
||||
* checked property of its values.
|
||||
*
|
||||
* @private
|
||||
* @param {Object} group
|
||||
*/
|
||||
_updateFilterGroupState(group) {
|
||||
if (Object.values(group.values).some(v => v.checked)) {
|
||||
if (Object.values(group.values).some(v => !v.checked)) {
|
||||
group.state = 'indeterminate';
|
||||
} else {
|
||||
group.state = 'checked';
|
||||
/**
|
||||
* @private
|
||||
* @param {number} filterId
|
||||
* @param {{ values: Map<Object> }} group
|
||||
*/
|
||||
_toggleFilterGroup(filterId, { values }) {
|
||||
const valueIds = [];
|
||||
const checked = [...values.values()].every(
|
||||
(value) => this.state.active[filterId][value.id]
|
||||
);
|
||||
values.forEach(({ id }) => {
|
||||
valueIds.push(id);
|
||||
this.state.active[filterId][id] = !checked;
|
||||
});
|
||||
this.model.dispatch("toggleFilterValues", filterId, valueIds, !checked);
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {number} filterId
|
||||
* @param {Object} [group]
|
||||
* @param {number} valueId
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
_toggleFilterValue(filterId, valueId, { currentTarget }) {
|
||||
this.state.active[filterId][valueId] = currentTarget.checked;
|
||||
this._updateGroupHeadersChecked();
|
||||
this.model.dispatch("toggleFilterValues", filterId, [valueId]);
|
||||
}
|
||||
|
||||
_updateActiveValues() {
|
||||
for (const section of this.model.get("sections")) {
|
||||
if (section.type === "category") {
|
||||
this.state.active[section.id] = section.activeValueId;
|
||||
} else {
|
||||
this.state.active[section.id] = {};
|
||||
if (section.groups) {
|
||||
for (const group of section.groups.values()) {
|
||||
for (const value of group.values.values()) {
|
||||
this.state.active[section.id][value.id] = value.checked;
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const value of section.values.values()) {
|
||||
this.state.active[section.id][value.id] = value.checked;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
group.state = 'unchecked';
|
||||
}
|
||||
},
|
||||
|
||||
//-------------------------------------------------------------------------
|
||||
// Handlers
|
||||
//-------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
_onCategoryValueClicked(ev) {
|
||||
ev.stopPropagation();
|
||||
const item = ev.currentTarget.closest('.o_search_panel_category_value');
|
||||
const category = this.categories[item.dataset.categoryId];
|
||||
const valueId = !isNaN(item.dataset.id) ?
|
||||
Number(item.dataset.id) :
|
||||
item.dataset.id || false;
|
||||
const hasChanged = category.activeValueId !== valueId;
|
||||
category.activeValueId = valueId;
|
||||
const value = category.values[category.activeValueId];
|
||||
if (value) {
|
||||
value.folded = value.folded ? false : !hasChanged;
|
||||
}
|
||||
if (hasChanged) {
|
||||
const storageKey = this._getLocalStorageKey(category);
|
||||
this.call('local_storage', 'setItem', storageKey, valueId);
|
||||
this._notifyDomainUpdated();
|
||||
} else {
|
||||
this._render();
|
||||
}
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
_onFilterGroupChanged(ev) {
|
||||
ev.stopPropagation();
|
||||
const item = ev.target.closest('.o_search_panel_filter_group');
|
||||
const filter = this.filters[item.dataset.filterId];
|
||||
const group = filter.groups[item.dataset.groupId];
|
||||
group.state = group.state === 'checked' ? 'unchecked' : 'checked';
|
||||
Object.values(group.values).forEach(value => {
|
||||
value.checked = group.state === 'checked';
|
||||
});
|
||||
this._notifyDomainUpdated();
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
_onFilterValueChanged(ev) {
|
||||
ev.stopPropagation();
|
||||
const item = ev.target.closest('.o_search_panel_filter_value');
|
||||
const filter = this.filters[item.dataset.filterId];
|
||||
const value = filter.values[item.dataset.valueId];
|
||||
value.checked = !value.checked;
|
||||
const groupId = JSON.stringify(value.group_id);
|
||||
const group = filter.groups && filter.groups[groupId];
|
||||
if (group) {
|
||||
this._updateFilterGroupState(group);
|
||||
}
|
||||
this._notifyDomainUpdated();
|
||||
},
|
||||
});
|
||||
|
||||
return SearchPanel;
|
||||
/**
|
||||
* Updates the "checked" or "indeterminate" state of each of the group
|
||||
* headers according to the state of their values.
|
||||
* @private
|
||||
*/
|
||||
_updateGroupHeadersChecked() {
|
||||
const groups = this.el.querySelectorAll(":scope .o_search_panel_filter_group");
|
||||
for (const group of groups) {
|
||||
const header = group.querySelector(":scope .o_search_panel_group_header input");
|
||||
const vals = [...group.querySelectorAll(":scope .o_search_panel_filter_value input")];
|
||||
header.checked = false;
|
||||
header.indeterminate = false;
|
||||
if (vals.every((v) => v.checked)) {
|
||||
header.checked = true;
|
||||
} else if (vals.some((v) => v.checked)) {
|
||||
header.indeterminate = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
SearchPanel.modelExtension = "SearchPanel";
|
||||
|
||||
SearchPanel.props = {
|
||||
className: { type: String, optional: 1 },
|
||||
importedState: {
|
||||
type: Object,
|
||||
optional: true,
|
||||
shape: {
|
||||
scrollTop: { type: Number, optional: 1 },
|
||||
expanded: { type: Object, optional: 1 },
|
||||
},
|
||||
},
|
||||
searchModel: Model,
|
||||
};
|
||||
SearchPanel.template = "web.SearchPanel";
|
||||
|
||||
return patchMixin(SearchPanel);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,730 @@
|
||||
odoo.define("web/static/src/js/views/search_panel_model_extension.js", function (require) {
|
||||
"use strict";
|
||||
|
||||
const ActionModel = require("web/static/src/js/views/action_model.js");
|
||||
const { sortBy } = require("web.utils");
|
||||
const Domain = require("web.Domain");
|
||||
const pyUtils = require("web.py_utils");
|
||||
|
||||
// DefaultViewTypes is the list of view types for which the searchpanel is
|
||||
// present by default (if not explicitly stated in the "view_types" attribute
|
||||
// in the arch).
|
||||
const DEFAULT_VIEW_TYPES = ["kanban", "tree"];
|
||||
const DEFAULT_LIMIT = 200;
|
||||
let nextSectionId = 1;
|
||||
|
||||
/**
|
||||
* @param {Section} section
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function hasValues({ errorMsg, groups, type, values }) {
|
||||
if (errorMsg) {
|
||||
return true;
|
||||
} else if (groups) {
|
||||
return [...groups.values()].some((g) => g.values.size);
|
||||
} else if (type === "category") {
|
||||
return values && values.size > 1; // false item ignored
|
||||
} else {
|
||||
return values && values.size > 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef Section
|
||||
* @prop {string} color
|
||||
* @prop {string} description
|
||||
* @prop {boolean} enableCounters
|
||||
* @prop {boolean} expand
|
||||
* @prop {string} fieldName
|
||||
* @prop {string} icon
|
||||
* @prop {number} id
|
||||
* @prop {number} index
|
||||
* @prop {number} limit
|
||||
* @prop {string} type
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Section} Category
|
||||
* @prop {boolean} hierarchize
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Section} Filter
|
||||
* @prop {string} domain
|
||||
* @prop {string} groupBy
|
||||
*/
|
||||
|
||||
/**
|
||||
* @function sectionPredicate
|
||||
* @param {Section} section
|
||||
* @returns {boolean}
|
||||
*/
|
||||
|
||||
/**
|
||||
* @property {{ sections: Map<number, Section> }} state
|
||||
* @extends ActionModel.Extension
|
||||
*/
|
||||
class SearchPanelModelExtension extends ActionModel.Extension {
|
||||
constructor() {
|
||||
super(...arguments);
|
||||
|
||||
this.searchDomain = null;
|
||||
this.DEFAULT_VALUE_INDEX = 0;
|
||||
}
|
||||
|
||||
//---------------------------------------------------------------------
|
||||
// Public
|
||||
//---------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
exportState() {
|
||||
const state = super.exportState();
|
||||
state.sections = [...state.sections];
|
||||
for (const [id, section] of state.sections) {
|
||||
section.values = [...section.values];
|
||||
if (section.groups) {
|
||||
section.groups = [...section.groups];
|
||||
for (const [id, group] of section.groups) {
|
||||
group.values = [...group.values];
|
||||
}
|
||||
}
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
/**
|
||||
* @override
|
||||
* @returns {any}
|
||||
*/
|
||||
get(property, ...args) {
|
||||
switch (property) {
|
||||
case "domain": return this.getDomain();
|
||||
case "sections": return this.getSections(...args);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
importState(importedState) {
|
||||
super.importState(...arguments);
|
||||
if (importedState) {
|
||||
this.state.sections = new Map(this.state.sections);
|
||||
for (const section of this.state.sections.values()) {
|
||||
section.values = new Map(section.values);
|
||||
if (section.groups) {
|
||||
section.groups = new Map(section.groups);
|
||||
for (const group of section.groups.values()) {
|
||||
group.values = new Map(group.values);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
async load() {
|
||||
await this._fetchSections({ initialLoad: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
prepareState() {
|
||||
Object.assign(this.state, { sections: new Map() });
|
||||
this._createSectionsFromArch();
|
||||
}
|
||||
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
async reloadAfterDispatch() {
|
||||
await this._fetchSections();
|
||||
}
|
||||
|
||||
//---------------------------------------------------------------------
|
||||
// Actions / Getters
|
||||
//---------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Returns the concatenation of the category domain ad the filter
|
||||
* domain.
|
||||
* @returns {Array[]}
|
||||
*/
|
||||
getDomain() {
|
||||
return Domain.prototype.normalizeArray([
|
||||
...this._getCategoryDomain(),
|
||||
...this._getFilterDomain(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a sorted list of a copy of all sections. This list can be
|
||||
* filtered by a given predicate.
|
||||
* @param {sectionPredicate} [predicate] used to determine
|
||||
* which subsets of sections is wanted
|
||||
* @returns {Section[]}
|
||||
*/
|
||||
getSections(predicate) {
|
||||
let sections = [...this.state.sections.values()].map((section) =>
|
||||
Object.assign({}, section, { empty: !hasValues(section) })
|
||||
);
|
||||
if (predicate) {
|
||||
sections = sections.filter(predicate);
|
||||
}
|
||||
return sections.sort((s1, s2) => s1.index - s2.index);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the active value id of a given category. This will also update
|
||||
* the local storage value.
|
||||
* @param {number} sectionId
|
||||
* @param {number} valueId
|
||||
*/
|
||||
toggleCategoryValue(sectionId, valueId) {
|
||||
const category = this.state.sections.get(sectionId);
|
||||
category.activeValueId = valueId;
|
||||
const storageKey = `searchpanel_${this.config.modelName}_${category.fieldName}`;
|
||||
this.env.services.local_storage.setItem(storageKey, valueId);
|
||||
// FIXME this should be removed (in another commit): should no longer (re)store
|
||||
// active category in localStorage
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggles a the filter value of a given section. The value will be set
|
||||
* to "forceTo" if provided, else it will be its own opposed value.
|
||||
* @param {number} sectionId
|
||||
* @param {number[]} valueIds
|
||||
* @param {boolean} [forceTo=null]
|
||||
*/
|
||||
toggleFilterValues(sectionId, valueIds, forceTo = null) {
|
||||
const section = this.state.sections.get(sectionId);
|
||||
for (const valueId of valueIds) {
|
||||
const value = section.values.get(valueId);
|
||||
value.checked = forceTo === null ? !value.checked : forceTo;
|
||||
}
|
||||
}
|
||||
|
||||
//---------------------------------------------------------------------
|
||||
// Internal getters
|
||||
//---------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Shorthand access to sections of type "category".
|
||||
* @returns {Category[]}
|
||||
*/
|
||||
get categories() {
|
||||
return [...this.state.sections.values()].filter(s => s.type === "category");
|
||||
}
|
||||
|
||||
/**
|
||||
* Shorthand access to sections of type "filter".
|
||||
* @returns {Filter[]}
|
||||
*/
|
||||
get filters() {
|
||||
return [...this.state.sections.values()].filter(s => s.type === "filter");
|
||||
}
|
||||
|
||||
//---------------------------------------------------------------------
|
||||
// Private
|
||||
//---------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Sets active values for each filter (coming from context). This needs
|
||||
* to be done only once at initialization.
|
||||
* @private
|
||||
*/
|
||||
_applyDefaultFilterValues() {
|
||||
for (const { fieldName, values } of this.filters) {
|
||||
const defaultValues = this.config.defaultValues[fieldName] || [];
|
||||
for (const valueId of defaultValues) {
|
||||
const value = values.get(valueId);
|
||||
if (value) {
|
||||
value.checked = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {string} sectionId
|
||||
* @param {Object} result
|
||||
*/
|
||||
_createCategoryTree(sectionId, result) {
|
||||
const category = this.state.sections.get(sectionId);
|
||||
|
||||
let { error_msg, parent_field: parentField, values, } = result;
|
||||
if (error_msg) {
|
||||
category.errorMsg = error_msg;
|
||||
values = [];
|
||||
}
|
||||
if (category.hierarchize) {
|
||||
category.parentField = parentField;
|
||||
}
|
||||
|
||||
const allItem = {
|
||||
childrenIds: [],
|
||||
display_name: this.env._t("All"),
|
||||
id: false,
|
||||
bold: true,
|
||||
parentId: false,
|
||||
};
|
||||
category.values = new Map([[false, allItem]]);
|
||||
for (const value of values) {
|
||||
category.values.set(
|
||||
value.id,
|
||||
Object.assign({}, value, {
|
||||
childrenIds: [],
|
||||
parentId: value[parentField] || false,
|
||||
})
|
||||
);
|
||||
}
|
||||
for (const value of values) {
|
||||
const { parentId } = category.values.get(value.id);
|
||||
if (parentId && category.values.has(parentId)) {
|
||||
category.values.get(parentId).childrenIds.push(value.id);
|
||||
}
|
||||
}
|
||||
// collect rootIds
|
||||
category.rootIds = [false];
|
||||
for (const value of values) {
|
||||
const { parentId } = category.values.get(value.id);
|
||||
if (!parentId) {
|
||||
category.rootIds.push(value.id);
|
||||
}
|
||||
}
|
||||
// Set active value from context
|
||||
category.activeValueId = this._getCategoryDefaultValue(values, category);
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {string} sectionId
|
||||
* @param {Object} result
|
||||
*/
|
||||
_createFilterTree(sectionId, result) {
|
||||
const filter = this.state.sections.get(sectionId);
|
||||
|
||||
let { error_msg, values, } = result;
|
||||
if (error_msg) {
|
||||
filter.errorMsg = error_msg;
|
||||
values = [];
|
||||
}
|
||||
|
||||
// restore checked property
|
||||
values.forEach((value) => {
|
||||
const oldValue = filter.values && filter.values.get(value.id);
|
||||
value.checked = (oldValue && oldValue.checked) || false;
|
||||
});
|
||||
|
||||
filter.values = new Map();
|
||||
const groupIds = [];
|
||||
if (filter.groupBy) {
|
||||
const groups = new Map();
|
||||
for (const value of values) {
|
||||
const groupId = value.group_id;
|
||||
if (!groups.has(groupId)) {
|
||||
if (groupId) {
|
||||
groupIds.push(groupId);
|
||||
}
|
||||
groups.set(groupId, {
|
||||
id: groupId,
|
||||
name: value.group_name,
|
||||
values: new Map(),
|
||||
tooltip: value.group_tooltip,
|
||||
sequence: value.group_sequence,
|
||||
hex_color: value.group_hex_color,
|
||||
});
|
||||
// restore former checked state
|
||||
const oldGroup =
|
||||
filter.groups && filter.groups.get(groupId);
|
||||
groups.get(groupId).state =
|
||||
(oldGroup && oldGroup.state) || false;
|
||||
}
|
||||
groups.get(groupId).values.set(value.id, value);
|
||||
}
|
||||
filter.groups = groups;
|
||||
filter.sortedGroupIds = sortBy(
|
||||
groupIds,
|
||||
(id) => groups.get(id).sequence || groups.get(id).name
|
||||
);
|
||||
for (const group of filter.groups.values()) {
|
||||
for (const [valueId, value] of group.values) {
|
||||
filter.values.set(valueId, value);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (const value of values) {
|
||||
filter.values.set(value.id, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a section in this.state.sections for each visible field found
|
||||
* in the search panel arch.
|
||||
* @private
|
||||
*/
|
||||
_createSectionsFromArch() {
|
||||
this.config.archNodes.forEach(({ attrs, tag }, index) => {
|
||||
if (tag !== "field" || attrs.invisible === "1") {
|
||||
return;
|
||||
}
|
||||
const type = attrs.select === "multi" ? "filter" : "category";
|
||||
const section = {
|
||||
color: attrs.color,
|
||||
description:
|
||||
attrs.string || this.config.fields[attrs.name].string,
|
||||
enableCounters: !!pyUtils.py_eval(
|
||||
attrs.enable_counters || "0"
|
||||
),
|
||||
expand: !!pyUtils.py_eval(attrs.expand || "0"),
|
||||
fieldName: attrs.name,
|
||||
icon: attrs.icon,
|
||||
id: nextSectionId++,
|
||||
index,
|
||||
limit: pyUtils.py_eval(attrs.limit || String(DEFAULT_LIMIT)),
|
||||
type,
|
||||
};
|
||||
if (type === "category") {
|
||||
section.icon = section.icon || "fa-folder";
|
||||
section.hierarchize = !!pyUtils.py_eval(
|
||||
attrs.hierarchize || "1"
|
||||
);
|
||||
} else {
|
||||
section.domain = attrs.domain || "[]";
|
||||
section.groupBy = attrs.groupby;
|
||||
section.icon = section.icon || "fa-filter";
|
||||
}
|
||||
this.state.sections.set(section.id, section);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches values for each category at startup. At reload a category is
|
||||
* only fetched if needed.
|
||||
* @private
|
||||
* @param {{
|
||||
* initialLoad: boolean,
|
||||
* searchDomainHasChanged: boolean
|
||||
* }} options
|
||||
* @returns {Promise} resolved when all categories have been fetched
|
||||
*/
|
||||
async _fetchCategories({ initialLoad, searchDomainHasChanged }) {
|
||||
const proms = [];
|
||||
for (const category of this.categories) {
|
||||
const { enableCounters, expand, hierarchize, limit } = category;
|
||||
if (initialLoad || enableCounters || (searchDomainHasChanged && !expand)) {
|
||||
const prom = this.env.services.rpc({
|
||||
method: "search_panel_select_range",
|
||||
model: this.config.modelName,
|
||||
args: [category.fieldName],
|
||||
kwargs: {
|
||||
category_domain: this._getCategoryDomain(
|
||||
category.id
|
||||
),
|
||||
enable_counters: enableCounters,
|
||||
expand,
|
||||
filter_domain: this._getFilterDomain(),
|
||||
hierarchize,
|
||||
limit,
|
||||
search_domain: this.searchDomain,
|
||||
},
|
||||
}).then(result => this._createCategoryTree(category.id, result));
|
||||
proms.push(prom);
|
||||
}
|
||||
}
|
||||
await Promise.all(proms);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches values for each filter. This is done at startup and at each
|
||||
* reload if needed.
|
||||
* @private
|
||||
* @param {{
|
||||
* initialLoad: boolean,
|
||||
* searchDomainHasChanged: boolean
|
||||
* }} options
|
||||
* @returns {Promise} resolved when all filters have been fetched
|
||||
*/
|
||||
async _fetchFilters({ initialLoad, searchDomainHasChanged }) {
|
||||
const evalContext = {};
|
||||
for (const category of this.categories) {
|
||||
evalContext[category.fieldName] = category.activeValueId;
|
||||
}
|
||||
const categoryDomain = this._getCategoryDomain();
|
||||
const proms = [];
|
||||
for (const filter of this.filters) {
|
||||
const { enableCounters, expand, groupBy, limit } = filter;
|
||||
if (initialLoad || enableCounters || (searchDomainHasChanged && !expand)) {
|
||||
const prom = this.env.services.rpc({
|
||||
method: "search_panel_select_multi_range",
|
||||
model: this.config.modelName,
|
||||
args: [filter.fieldName],
|
||||
kwargs: {
|
||||
category_domain: categoryDomain,
|
||||
comodel_domain: Domain.prototype.stringToArray(
|
||||
filter.domain,
|
||||
evalContext
|
||||
),
|
||||
enable_counters: enableCounters,
|
||||
filter_domain: this._getFilterDomain(filter.id),
|
||||
expand,
|
||||
group_by: groupBy || false,
|
||||
group_domain: this._getGroupDomain(filter),
|
||||
limit,
|
||||
search_domain: this.searchDomain,
|
||||
},
|
||||
}).then(result => this._createFilterTree(filter.id, result));
|
||||
proms.push(prom);
|
||||
}
|
||||
}
|
||||
await Promise.all(proms);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches categories and filters at startup or if needed.
|
||||
* @private
|
||||
* @param {Object} [options={}]
|
||||
* @param {boolean} [options.initialLoad=false]
|
||||
*/
|
||||
async _fetchSections(options = {}) {
|
||||
const searchDomain = this._getExternalDomain();
|
||||
if (!options.initialLoad) {
|
||||
options.searchDomainHasChanged =
|
||||
JSON.stringify(this.searchDomain) !== JSON.stringify(searchDomain);
|
||||
}
|
||||
this.searchDomain = searchDomain;
|
||||
await this._fetchCategories(options);
|
||||
await this._fetchFilters(options);
|
||||
if (options.initialLoad) {
|
||||
this._applyDefaultFilterValues();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes and returns the domain based on the current active
|
||||
* categories. If "excludedCategoryId" is provided, the category with
|
||||
* that id is not taken into account in the domain computation.
|
||||
* @private
|
||||
* @param {string} [excludedCategoryId]
|
||||
* @returns {Array[]}
|
||||
*/
|
||||
_getCategoryDomain(excludedCategoryId) {
|
||||
const domain = [];
|
||||
for (const category of this.categories) {
|
||||
if (
|
||||
category.id === excludedCategoryId ||
|
||||
category.activeValueId === false
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
if (category.activeValueId) {
|
||||
const field = this.config.fields[category.fieldName];
|
||||
const operator =
|
||||
field.type === "many2one" && category.parentField ? "child_of" : "=";
|
||||
domain.push([
|
||||
category.fieldName,
|
||||
operator,
|
||||
category.activeValueId,
|
||||
]);
|
||||
}
|
||||
}
|
||||
return domain;
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {Object[]} values
|
||||
* @param {Category} category
|
||||
* @returns {number | false}
|
||||
*/
|
||||
_getCategoryDefaultValue(values, { fieldName }) {
|
||||
const validValues = [false, ...values.map((val) => val.id)];
|
||||
const defaultValue = this.config.defaultValues[fieldName];
|
||||
if (validValues.includes(defaultValue)) {
|
||||
return defaultValue;
|
||||
}
|
||||
if (this.config.defaultNoFilter) {
|
||||
return false;
|
||||
}
|
||||
// If not set in context, or set to an unknown value, set active value
|
||||
// from localStorage
|
||||
const storageKey = `searchpanel_${this.config.modelName}_${fieldName}`;
|
||||
const storageValue = this.env.services.local_storage.getItem(storageKey);
|
||||
if (validValues.includes(storageValue)) {
|
||||
return storageValue;
|
||||
}
|
||||
// If still not a valid value, get the search panel default value
|
||||
// from the given valid values.
|
||||
return validValues[
|
||||
Math.min(validValues.length - 1, this.DEFAULT_VALUE_INDEX)
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the domain retrieved from the other model extensions.
|
||||
* @private
|
||||
* @returns {Array[]}
|
||||
*/
|
||||
_getExternalDomain() {
|
||||
const domains = this.config.get("domain");
|
||||
const domain = domains.reduce((acc, dom) => [...acc, ...dom], []);
|
||||
return Domain.prototype.normalizeArray(domain);
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes and returns the domain based on the current checked
|
||||
* filters. The values of a single filter are combined using a simple
|
||||
* rule: checked values within a same group are combined with an "OR"
|
||||
* operator (this is expressed as single condition using a list) and
|
||||
* groups are combined with an "AND" operator (expressed by
|
||||
* concatenation of conditions).
|
||||
* If a filter has no group, its checked values are implicitely
|
||||
* considered as forming a group (and grouped using an "OR").
|
||||
* If excludedFilterId is provided, the filter with that id is not
|
||||
* taken into account in the domain computation.
|
||||
* @private
|
||||
* @param {string} [excludedFilterId]
|
||||
* @returns {Array[]}
|
||||
*/
|
||||
_getFilterDomain(excludedFilterId) {
|
||||
const domain = [];
|
||||
|
||||
function addCondition(fieldName, valueMap) {
|
||||
const ids = [];
|
||||
for (const [valueId, value] of valueMap) {
|
||||
if (value.checked) {
|
||||
ids.push(valueId);
|
||||
}
|
||||
}
|
||||
if (ids.length) {
|
||||
domain.push([fieldName, "in", ids]);
|
||||
}
|
||||
}
|
||||
|
||||
for (const filter of this.filters) {
|
||||
if (filter.id === excludedFilterId) {
|
||||
continue;
|
||||
}
|
||||
const { fieldName, groups, values } = filter;
|
||||
if (groups) {
|
||||
for (const group of groups.values()) {
|
||||
addCondition(fieldName, group.values);
|
||||
}
|
||||
} else if (values) {
|
||||
addCondition(fieldName, values);
|
||||
}
|
||||
}
|
||||
return domain;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a domain or an object of domains used to complement
|
||||
* the filter domains to accurately describe the constrains on
|
||||
* records when computing record counts associated to the filter
|
||||
* values (if a groupBy is provided). The idea is that the checked
|
||||
* values within a group should not impact the counts for the other
|
||||
* values in the same group.
|
||||
* @private
|
||||
* @param {string} filter
|
||||
* @returns {Object<string, Array[]> | Array[] | null}
|
||||
*/
|
||||
_getGroupDomain(filter) {
|
||||
const { fieldName, groups, enableCounters } = filter;
|
||||
const { type: fieldType } = this.config.fields[fieldName];
|
||||
|
||||
if (!enableCounters || !groups) {
|
||||
return {
|
||||
many2one: [],
|
||||
many2many: {},
|
||||
}[fieldType];
|
||||
}
|
||||
let groupDomain = null;
|
||||
if (fieldType === "many2one") {
|
||||
for (const group of groups.values()) {
|
||||
const valueIds = [];
|
||||
let active = false;
|
||||
for (const [valueId, value] of group.values) {
|
||||
const { checked } = value;
|
||||
valueIds.push(valueId);
|
||||
if (checked) {
|
||||
active = true;
|
||||
}
|
||||
}
|
||||
if (active) {
|
||||
if (groupDomain) {
|
||||
groupDomain = [[0, "=", 1]];
|
||||
break;
|
||||
} else {
|
||||
groupDomain = [[fieldName, "in", valueIds]];
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (fieldType === "many2many") {
|
||||
const checkedValueIds = new Map();
|
||||
groups.forEach(({ values }, groupId) => {
|
||||
values.forEach(({ checked }, valueId) => {
|
||||
if (checked) {
|
||||
if (!checkedValueIds.has(groupId)) {
|
||||
checkedValueIds.set(groupId, []);
|
||||
}
|
||||
checkedValueIds.get(groupId).push(valueId);
|
||||
}
|
||||
});
|
||||
});
|
||||
groupDomain = {};
|
||||
for (const [gId, ids] of checkedValueIds.entries()) {
|
||||
for (const groupId of groups.keys()) {
|
||||
if (gId !== groupId) {
|
||||
const key = JSON.stringify(groupId);
|
||||
if (!groupDomain[key]) {
|
||||
groupDomain[key] = [];
|
||||
}
|
||||
groupDomain[key].push([fieldName, "in", ids]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return groupDomain;
|
||||
}
|
||||
|
||||
//---------------------------------------------------------------------
|
||||
// Static
|
||||
//---------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @override
|
||||
* @returns {{ attrs: Object, children: Object[] } | null}
|
||||
*/
|
||||
static extractArchInfo(archs, viewType) {
|
||||
const { children } = archs.search;
|
||||
const spNode = children.find(c => c.tag === "searchpanel");
|
||||
const isObject = (obj) => typeof obj === "object" && obj !== null;
|
||||
if (spNode) {
|
||||
const actualType = viewType === "list" ? "tree" : viewType;
|
||||
const { view_types } = spNode.attrs;
|
||||
const viewTypes = view_types ?
|
||||
view_types.split(",") :
|
||||
DEFAULT_VIEW_TYPES;
|
||||
if (viewTypes.includes(actualType)) {
|
||||
return {
|
||||
attrs: spNode.attrs,
|
||||
children: spNode.children.filter(isObject),
|
||||
};
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
SearchPanelModelExtension.layer = 1;
|
||||
|
||||
ActionModel.registry.add("SearchPanel", SearchPanelModelExtension, 30);
|
||||
|
||||
return SearchPanelModelExtension;
|
||||
});
|
||||
@@ -16,116 +16,128 @@ $o-searchpanel-filter-default-color: #D59244;
|
||||
overflow: auto; // make the renderer and search panel scroll individually
|
||||
max-height: 100%;
|
||||
}
|
||||
.o_search_panel {
|
||||
flex: 0 0 220px;
|
||||
overflow: auto;
|
||||
height: 100%;
|
||||
padding: $o-searchpanel-p-small $o-searchpanel-p-small $o-searchpanel-p*2 $o-searchpanel-p;
|
||||
border-right: 1px solid $gray-300;
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
.o_search_panel_category .o_search_panel_section_icon {
|
||||
color: $o-brand-odoo;
|
||||
}
|
||||
.o_search_panel {
|
||||
flex: 0 0 220px;
|
||||
overflow: auto;
|
||||
height: 100%;
|
||||
padding: $o-searchpanel-p-small $o-searchpanel-p-small $o-searchpanel-p*2 $o-searchpanel-p;
|
||||
border-right: 1px solid $gray-300;
|
||||
background-color: white;
|
||||
|
||||
.o_search_panel_filter .o_search_panel_section_icon {
|
||||
color: $o-searchpanel-filter-default-color;
|
||||
}
|
||||
.o_search_panel_category .o_search_panel_section_icon {
|
||||
color: $o-brand-odoo;
|
||||
}
|
||||
|
||||
.o_toggle_fold {
|
||||
text-align: center;
|
||||
width: 1.5rem;
|
||||
}
|
||||
.o_search_panel_filter .o_search_panel_section_icon {
|
||||
color: $o-searchpanel-filter-default-color;
|
||||
}
|
||||
|
||||
.o_search_panel_label {
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
user-select: none;
|
||||
}
|
||||
.o_toggle_fold {
|
||||
text-align: center;
|
||||
width: 1.5rem;
|
||||
}
|
||||
|
||||
.o_search_panel_section_header {
|
||||
padding: $o-searchpanel-p-small 0;
|
||||
cursor: default;
|
||||
}
|
||||
.o_search_panel_label {
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
user-select: none;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.list-group {
|
||||
padding-bottom: $o-searchpanel-p-tiny;
|
||||
}
|
||||
.o_search_panel_section_header {
|
||||
cursor: default;
|
||||
padding: $o-searchpanel-p-small 0;
|
||||
}
|
||||
|
||||
.list-group {
|
||||
padding-bottom: $o-searchpanel-p-tiny;
|
||||
}
|
||||
|
||||
.list-group-item {
|
||||
padding: 0;
|
||||
|
||||
.list-group-item {
|
||||
padding: 0;
|
||||
padding: 0 0 0 $custom-control-gutter;
|
||||
margin-bottom: $o-searchpanel-p-tiny*0.5;
|
||||
}
|
||||
|
||||
.list-group-item {
|
||||
padding: 0 0 0 $custom-control-gutter;
|
||||
margin-bottom: $o-searchpanel-p-tiny*0.5;
|
||||
}
|
||||
.o_search_panel_label_title {
|
||||
color: $headings-color;
|
||||
width: 100%;
|
||||
@include o-text-overflow;
|
||||
}
|
||||
|
||||
span.o_search_panel_label_title {
|
||||
color: $headings-color;
|
||||
@include o-text-overflow;
|
||||
}
|
||||
header.active {
|
||||
background-color: $o-searchpanel-active-bg;
|
||||
}
|
||||
}
|
||||
|
||||
header.active {
|
||||
background-color: $o-searchpanel-active-bg;
|
||||
}
|
||||
.o_search_panel_category_value {
|
||||
cursor: pointer;
|
||||
|
||||
header {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 4px 6px 4px 0px;
|
||||
}
|
||||
|
||||
.o_search_panel_label.o_with_counters {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.o_search_panel_category_value {
|
||||
cursor: pointer;
|
||||
margin-bottom: 0;
|
||||
padding-left: $o-searchpanel-p;
|
||||
position: relative;
|
||||
|
||||
header {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 4px 6px 4px 0px;
|
||||
}
|
||||
.o_search_panel_label.o_with_counters {
|
||||
overflow: hidden;
|
||||
}
|
||||
.o_search_panel_category_value {
|
||||
position: relative;
|
||||
padding-left: $o-searchpanel-p;
|
||||
margin-bottom: 0;
|
||||
|
||||
&:before, &:after {
|
||||
@include o-position-absolute(0, $left: $o-searchpanel-p-tiny);
|
||||
@include size(1px, 100%);
|
||||
background: $gray-500;
|
||||
content: '';
|
||||
margin-left: 4px;
|
||||
}
|
||||
&:after {
|
||||
top: 12px;
|
||||
@include size(8px, 1px);
|
||||
}
|
||||
&:last-child:before {
|
||||
height: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.o_search_panel_filter_value,
|
||||
.o_search_panel_filter_group {
|
||||
cursor: pointer;
|
||||
padding-bottom: $o-searchpanel-p-small;
|
||||
|
||||
.o_search_panel_label,
|
||||
.o_search_panel_label_title {
|
||||
padding-right: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
.o_search_panel_filter_group {
|
||||
|
||||
header {
|
||||
display: flex;
|
||||
&:before,
|
||||
&:after {
|
||||
content: '';
|
||||
background: $gray-500;
|
||||
margin-left: 4px;
|
||||
@include o-position-absolute(0, $left: $o-searchpanel-p-tiny);
|
||||
@include size(1px, 100%);
|
||||
}
|
||||
|
||||
.o_search_panel_label.o_with_counters {
|
||||
justify-content: flex-start;
|
||||
&:after {
|
||||
top: 12px;
|
||||
@include size(8px, 1px);
|
||||
}
|
||||
|
||||
&:last-child:before {
|
||||
height: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.o_search_panel_group_header .custom-control {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.o_search_panel_filter_value,
|
||||
.o_search_panel_filter_group {
|
||||
cursor: pointer;
|
||||
padding-bottom: $o-searchpanel-p-small;
|
||||
|
||||
.o_search_panel_label,
|
||||
.o_search_panel_label_title {
|
||||
padding-right: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
.o_search_panel_filter_group {
|
||||
|
||||
header {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.o_search_panel_label.o_with_counters {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,7 +62,7 @@
|
||||
class="o_group_by_menu"
|
||||
fields="fields"
|
||||
/>
|
||||
<ComparisonMenu t-if="props.searchMenuTypes.includes('comparison') and model.getFiltersOfType('comparison').length"
|
||||
<ComparisonMenu t-if="props.searchMenuTypes.includes('comparison') and model.get('filters', f => f.type === 'comparison').length"
|
||||
class="o_comparison_menu"
|
||||
/>
|
||||
<FavoriteMenu t-if="props.searchMenuTypes.includes('favorite')"
|
||||
@@ -555,15 +555,15 @@
|
||||
|
||||
<t t-name="web.SearchBar" owl="1">
|
||||
<div class="o_searchview_input_container">
|
||||
<div t-foreach="model.getFacets()" t-as="facet" t-key="facet_index"
|
||||
<div t-foreach="model.get('facets')" t-as="facet" t-key="facet_index"
|
||||
tabindex="0"
|
||||
class="o_searchview_facet"
|
||||
role="img"
|
||||
aria-label="search"
|
||||
t-on-keydown="_onFacetKeydown(facet, facet_index)"
|
||||
>
|
||||
<span t-if="facet.title" class="o_searchview_facet_label" t-esc="facet.title"/>
|
||||
<span t-else="" t-attf-class="o_searchview_facet_label {{ facet.icon }}"/>
|
||||
<span t-if="facet.icon" t-attf-class="o_searchview_facet_label {{ facet.icon }}"/>
|
||||
<span t-else="" class="o_searchview_facet_label" t-esc="facet.title"/>
|
||||
<div class="o_facet_values">
|
||||
<t t-foreach="facet.values" t-as="facetValue" t-key="facetValue_index">
|
||||
<span t-if="!facetValue_first" class="o_facet_values_sep" t-esc="facet.separator"/>
|
||||
|
||||
@@ -209,132 +209,6 @@
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<!-- Kanban SearchPanel -->
|
||||
<t t-name="SearchPanel.SectionHeader">
|
||||
<header class="o_search_panel_section_header text-uppercase">
|
||||
<i t-attf-class="mr-1 fa #{section.icon} o_search_panel_section_icon" t-attf-style="#{section.color ? ('color: ' + section.color) : ''}"/>
|
||||
<b><t t-esc="section.description"/></b>
|
||||
</header>
|
||||
</t>
|
||||
|
||||
<t t-name="SearchPanel.Category">
|
||||
<section class="o_search_panel_section o_search_panel_category">
|
||||
<t t-call="SearchPanel.SectionHeader">
|
||||
<t t-set="section" t-value="category"/>
|
||||
</t>
|
||||
<div t-if="category.errorMsg" class="alert alert-warning">
|
||||
<span><t t-esc="category.errorMsg"/></span>
|
||||
</div>
|
||||
<ul t-else="" class="list-group d-block o_search_panel_field" t-att-data-field-name="category.fieldName">
|
||||
<li class="o_search_panel_category_value border-0 list-group-item" t-att-data-category-id="category.id">
|
||||
<header t-att-class="'list-group-item-action' + (!category.activeValueId ? ' active' : '')">
|
||||
<label class="o_search_panel_label mb0">
|
||||
<div class="o_toggle_fold"/>
|
||||
<span class="o_search_panel_label_title"><b>All</b></span>
|
||||
</label>
|
||||
</header>
|
||||
</li>
|
||||
<t t-call="SearchPanel.CategoryValues">
|
||||
<t t-set="values" t-value="category.rootIds"/>
|
||||
</t>
|
||||
</ul>
|
||||
</section>
|
||||
</t>
|
||||
<t t-name="SearchPanel.CategoryValues">
|
||||
<t t-foreach="values" t-as="valueId">
|
||||
<t t-set="value" t-value="category.values[valueId]"/>
|
||||
<li class="o_search_panel_category_value border-0 list-group-item" t-att-data-id="value.id" t-att-data-category-id="category.id">
|
||||
<header t-att-class="'list-group-item-action' + (value.id === category.activeValueId ? ' active' : '')">
|
||||
<label t-attf-class="o_search_panel_label mb0{{ category.enableCounters ? ' o_with_counters' : '' }}">
|
||||
<div class="o_toggle_fold">
|
||||
<i t-if="value.childrenIds.length" t-attf-class="fa fa-caret-{{ value.folded ? 'right' : 'down' }}"/>
|
||||
</div>
|
||||
<span class="o_search_panel_label_title"><t t-esc="value.display_name"/></span>
|
||||
</label>
|
||||
<span t-if="category.enableCounters and value.__count > 0"
|
||||
class="o_search_panel_counter text-muted ml-2 small"
|
||||
t-esc="value.__count"
|
||||
/>
|
||||
</header>
|
||||
<ul t-if="!value.folded and value.childrenIds.length" class="list-group d-block">
|
||||
<t t-call="SearchPanel.CategoryValues">
|
||||
<t t-set="values" t-value="value.childrenIds"/>
|
||||
</t>
|
||||
</ul>
|
||||
</li>
|
||||
</t>
|
||||
</t>
|
||||
|
||||
<t t-name="SearchPanel.Filter">
|
||||
<section class="o_search_panel_section o_search_panel_filter">
|
||||
<t t-call="SearchPanel.SectionHeader">
|
||||
<t t-set="section" t-value="filter"/>
|
||||
</t>
|
||||
<div t-if="filter.errorMsg" class="alert alert-warning">
|
||||
<span><t t-esc="filter.errorMsg"/></span>
|
||||
</div>
|
||||
<ul t-else="" class="list-group d-block o_search_panel_field" t-att-data-field-name="filter.fieldName">
|
||||
<t t-if="filter.groups" t-call="SearchPanel.FilterGroups">
|
||||
<t t-set="groups" t-value="filter.groups"/>
|
||||
</t>
|
||||
<t t-else="" t-call="SearchPanel.FilterValues">
|
||||
<t t-set="sortedValueIds" t-value="filter.sortedValueIds"/>
|
||||
<t t-set="values" t-value="filter.values"/>
|
||||
</t>
|
||||
</ul>
|
||||
</section>
|
||||
</t>
|
||||
<t t-name="SearchPanel.FilterGroups">
|
||||
<li t-foreach="filter.sortedGroupIds" t-as="groupId" t-att-data-group-id="groupId" t-att-data-filter-id="filter.id"
|
||||
class="o_search_panel_filter_group list-group-item border-0">
|
||||
<t t-set="group" t-value="groups[groupId]"/>
|
||||
<t t-set="inputId" t-value="_.uniqueId('input_')"/>
|
||||
<header class="o_search_panel_group_header">
|
||||
<div class="custom-control custom-checkbox">
|
||||
<input type="checkbox" t-att-id="inputId" t-att-checked="group.state === 'checked' ? 'checked' : undefined" class="custom-control-input"/>
|
||||
<label t-att-for="inputId" t-attf-class="o_search_panel_label custom-control-label{{ group.enableCounters ? ' o_with_counters' : '' }}" t-att-title="group.tooltip">
|
||||
<span class="o_search_panel_label_title">
|
||||
<span t-if="group.hex_color" t-attf-style="color: {{ group.hex_color }};">●</span>
|
||||
<t t-esc="group.name"/>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</header>
|
||||
<ul t-if="!group.folded" class="list-group d-block">
|
||||
<t t-call="SearchPanel.FilterValues">
|
||||
<t t-set="sortedValueIds" t-value="group.sortedValueIds"/>
|
||||
<t t-set="values" t-value="group.values"/>
|
||||
</t>
|
||||
</ul>
|
||||
</li>
|
||||
<ul t-if="groups['false']" class="list-group d-block">
|
||||
<t t-call="SearchPanel.FilterValues">
|
||||
<t t-set="group" t-value="groups['false']"/>
|
||||
<t t-set="sortedValueIds" t-value="group.sortedValueIds"/>
|
||||
<t t-set="values" t-value="group.values"/>
|
||||
</t>
|
||||
</ul>
|
||||
</t>
|
||||
<t t-name="SearchPanel.FilterValues">
|
||||
<li t-foreach="sortedValueIds" t-as="valueId" t-att-data-value-id="valueId" t-att-data-filter-id="filter.id"
|
||||
class="o_search_panel_filter_value list-group-item border-0">
|
||||
<t t-set="value" t-value="values[valueId]"/>
|
||||
<t t-set="inputId" t-value="_.uniqueId('input_')"/>
|
||||
<header class="o_search_panel_group_header">
|
||||
<div class="custom-control custom-checkbox w-100">
|
||||
<input type="checkbox" t-att-id="inputId" t-att-checked="value.checked ? 'checked' : undefined" class="custom-control-input"/>
|
||||
<label t-att-for="inputId" class="o_search_panel_label custom-control-label" t-att-title="group && group.tooltip">
|
||||
<span class="o_search_panel_label_title" t-esc="value.display_name"/>
|
||||
<span t-if="filter.enableCounters and value.__count > 0"
|
||||
class="o_search_panel_counter text-muted ml-2 small"
|
||||
t-esc="value.__count"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</header>
|
||||
</li>
|
||||
</t>
|
||||
|
||||
<!-- Mobile templates -->
|
||||
<t t-name="KanbanView.MobileTabs">
|
||||
<div class="o_kanban_mobile_tabs_container">
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates id="template" xml:space="preserve">
|
||||
|
||||
<t t-name="web.SearchPanel" owl="1">
|
||||
<div class="o_search_panel" t-att-class="props.className">
|
||||
<section t-foreach="model.get('sections', s => !s.empty)" t-as="section" t-key="section.id"
|
||||
t-attf-class="o_search_panel_section o_search_panel_{{ section.type }}"
|
||||
>
|
||||
<header class="o_search_panel_section_header text-uppercase">
|
||||
<i t-attf-class="fa {{ section.icon }} o_search_panel_section_icon mr-2"
|
||||
t-att-style="section.color and ('color: ' + section.color)"
|
||||
/>
|
||||
<b t-esc="section.description"/>
|
||||
</header>
|
||||
<div t-if="section.errorMsg" class="alert alert-warning">
|
||||
<span><t t-esc="section.errorMsg"/></span>
|
||||
</div>
|
||||
<ul t-else="" class="list-group d-block o_search_panel_field">
|
||||
<t t-if="section.type === 'category'" t-call="web.SearchPanel.Category">
|
||||
<t t-set="values" t-value="section.rootIds"/>
|
||||
</t>
|
||||
<t t-elif="section.groups">
|
||||
<li t-foreach="section.sortedGroupIds" t-as="groupId" t-key="groupId"
|
||||
class="o_search_panel_filter_group list-group-item border-0"
|
||||
>
|
||||
<!-- TODO: this is a workaround for issue https://github.com/odoo/owl/issues/695 (remove when solved) -->
|
||||
<t t-set="_section" t-value="section"/>
|
||||
<t t-set="group" t-value="section.groups.get(groupId)"/>
|
||||
<header class="o_search_panel_group_header">
|
||||
<div class="custom-control custom-checkbox">
|
||||
<!-- TODO: "indeterminate" could not be set in the template and had to be set in
|
||||
JS manually. See https://github.com/odoo/owl/issues/713 (adapt when solved)
|
||||
-->
|
||||
<input type="checkbox"
|
||||
class="custom-control-input"
|
||||
t-attf-id="{{ section.id }}_input_{{ groupId }})"
|
||||
t-on-click="_toggleFilterGroup(section.id, group)"
|
||||
/>
|
||||
<label t-attf-for="{{ section.id }}_input_{{ groupId }})"
|
||||
class="o_search_panel_label custom-control-label"
|
||||
t-att-class="{ o_with_counters: group.enableCounters }"
|
||||
t-att-title="group.tooltip or false"
|
||||
>
|
||||
<span class="o_search_panel_label_title">
|
||||
<span t-if="group.hex_color" class="mr-1" t-attf-style="color: {{ group.hex_color }};">●</span>
|
||||
<t t-esc="group.name"/>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</header>
|
||||
<ul class="list-group d-block">
|
||||
<t t-call="web.SearchPanel.FiltersGroup">
|
||||
<t t-set="values" t-value="group.values"/>
|
||||
<!-- TODO: this is a workaround for issue https://github.com/odoo/owl/issues/695 (remove when solved) -->
|
||||
<t t-set="section" t-value="_section"/>
|
||||
</t>
|
||||
</ul>
|
||||
</li>
|
||||
<ul t-if="section.groups.get(false)" class="list-group d-block">
|
||||
<t t-call="web.SearchPanel.FiltersGroup">
|
||||
<t t-set="group" t-value="section.groups.get(false)"/>
|
||||
<t t-set="values" t-value="group.values"/>
|
||||
<!-- TODO: this is a workaround for issue https://github.com/odoo/owl/issues/695 (remove when solved) -->
|
||||
<t t-set="section" t-value="section"/>
|
||||
</t>
|
||||
</ul>
|
||||
</t>
|
||||
<t t-else="" t-call="web.SearchPanel.FiltersGroup">
|
||||
<t t-set="values" t-value="section.values"/>
|
||||
</t>
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<t t-name="web.SearchPanel.Category" owl="1">
|
||||
<t t-foreach="values" t-as="valueId" t-key="valueId">
|
||||
<t t-set="value" t-value="section.values.get(valueId)"/>
|
||||
<li class="o_search_panel_category_value list-group-item border-0">
|
||||
<header class="list-group-item-action"
|
||||
t-att-class="{ active: state.active[section.id] === valueId }"
|
||||
t-on-click="_toggleCategory(section, value)"
|
||||
>
|
||||
<label class="o_search_panel_label mb0" t-att-class="{ o_with_counters: section.enableCounters }">
|
||||
<div class="o_toggle_fold">
|
||||
<i t-if="value.childrenIds.length"
|
||||
t-attf-class="fa fa-caret-{{ state.expanded[section.id][valueId] ? 'down' : 'right' }}"
|
||||
/>
|
||||
</div>
|
||||
<b t-if="value.bold" class="o_search_panel_label_title" t-esc="value.display_name"/>
|
||||
<span t-else="" class="o_search_panel_label_title" t-esc="value.display_name"/>
|
||||
</label>
|
||||
<span t-if="section.enableCounters and value.__count gt 0"
|
||||
class="o_search_panel_counter text-muted ml-2 small"
|
||||
t-esc="value.__count"
|
||||
/>
|
||||
</header>
|
||||
<ul t-if="value.childrenIds.length and state.expanded[section.id][valueId]" class="list-group d-block">
|
||||
<t t-call="web.SearchPanel.Category">
|
||||
<t t-set="values" t-value="value.childrenIds"/>
|
||||
</t>
|
||||
</ul>
|
||||
</li>
|
||||
</t>
|
||||
</t>
|
||||
|
||||
<t t-name="web.SearchPanel.FiltersGroup" owl="1">
|
||||
<li t-foreach="[...values.keys()]" t-as="valueId" t-key="valueId"
|
||||
class="o_search_panel_filter_value list-group-item border-0"
|
||||
>
|
||||
<t t-set="value" t-value="values.get(valueId)"/>
|
||||
<div class="custom-control custom-checkbox w-100">
|
||||
<input type="checkbox"
|
||||
t-attf-id="{{ section.id }}_input_{{ valueId }}"
|
||||
t-att-checked="state.active[section.id][valueId]"
|
||||
class="custom-control-input"
|
||||
t-on-click="_toggleFilterValue(section.id, valueId)"
|
||||
/>
|
||||
<label class="o_search_panel_label custom-control-label"
|
||||
t-attf-for="{{ section.id }}_input_{{ valueId }}"
|
||||
t-att-title="(group and group.tooltip) or false">
|
||||
<span class="o_search_panel_label_title" t-esc="value.display_name"/>
|
||||
<span t-if="section.enableCounters and value.__count gt 0"
|
||||
class="o_search_panel_counter text-muted ml-2 small"
|
||||
t-esc="value.__count"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</li>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
@@ -18,13 +18,14 @@ odoo.define('web.comparison_menu_tests', function (require) {
|
||||
float_field: { string: "Float", type: "float", group_operator: 'sum' },
|
||||
foo: { string: "Foo", type: "char", store: true, sortable: true },
|
||||
};
|
||||
this.viewInfo = {
|
||||
this.cpModelConfig = {
|
||||
arch: `
|
||||
<search>
|
||||
<filter name="birthday" date="birthday"/>
|
||||
<filter name="date_field" date="date_field"/>
|
||||
</search>`,
|
||||
fields: this.fields,
|
||||
searchMenuTypes,
|
||||
};
|
||||
},
|
||||
}, function () {
|
||||
@@ -36,7 +37,7 @@ odoo.define('web.comparison_menu_tests', function (require) {
|
||||
|
||||
const unpatchDate = patchDate(1997, 0, 9, 12, 0, 0);
|
||||
const params = {
|
||||
cpStoreConfig: { viewInfo: this.viewInfo, searchMenuTypes },
|
||||
cpModelConfig: this.cpModelConfig,
|
||||
cpProps: { fields: this.fields, searchMenuTypes },
|
||||
};
|
||||
const controlPanel = await createControlPanel(params);
|
||||
@@ -71,7 +72,7 @@ odoo.define('web.comparison_menu_tests', function (require) {
|
||||
|
||||
const unpatchDate = patchDate(1997, 0, 9, 12, 0, 0);
|
||||
const params = {
|
||||
cpStoreConfig: { viewInfo: this.viewInfo, searchMenuTypes },
|
||||
cpModelConfig: this.cpModelConfig,
|
||||
cpProps: { fields: this.fields, searchMenuTypes },
|
||||
};
|
||||
const controlPanel = await createControlPanel(params);
|
||||
@@ -125,10 +126,9 @@ odoo.define('web.comparison_menu_tests', function (require) {
|
||||
QUnit.test('no timeRanges key in search query if "comparison" not in searchMenuTypes', async function (assert) {
|
||||
assert.expect(1);
|
||||
|
||||
this.cpModelConfig.searchMenuTypes = ['filter'];
|
||||
const params = {
|
||||
cpStoreConfig: {
|
||||
viewInfo: this.viewInfo, searchMenuTypes: ['filter'],
|
||||
},
|
||||
cpModelConfig: this.cpModelConfig,
|
||||
cpProps: { fields: this.fields, searchMenuTypes: ['filter'] },
|
||||
};
|
||||
const controlPanel = await createControlPanel(params);
|
||||
|
||||
+53
-107
@@ -1,17 +1,31 @@
|
||||
odoo.define('web.control_panel_model_tests', function (require) {
|
||||
odoo.define("web/static/tests/control_panel/control_panel_model_extension_tests.js", function (require) {
|
||||
"use strict";
|
||||
|
||||
const ControlPanelModel = require('web.ControlPanelModel');
|
||||
const ActionModel = require("web/static/src/js/views/action_model.js");
|
||||
const makeTestEnvironment = require('web.test_env');
|
||||
|
||||
function createControlPanelModel(config = {}, env, rpc) {
|
||||
return new ControlPanelModel(Object.assign(
|
||||
{ env: makeTestEnvironment(env, rpc) },
|
||||
config
|
||||
));
|
||||
function createModel(params = {}) {
|
||||
const archs = (params.arch && { search: params.arch, }) || {};
|
||||
const { ControlPanel: controlPanelInfo, } = ActionModel.extractArchInfo(archs);
|
||||
const extensions = {
|
||||
ControlPanel: {
|
||||
context: params.context,
|
||||
archNodes: controlPanelInfo.children,
|
||||
dynamicFilters: params.dynamicFilters,
|
||||
favoriteFilters: params.favoriteFilters,
|
||||
env: makeTestEnvironment(),
|
||||
fields: params.fields,
|
||||
},
|
||||
};
|
||||
const model = new ActionModel(extensions);
|
||||
return model;
|
||||
}
|
||||
function sanitizeFilters(model) {
|
||||
return Object.values(model.state.filters).map(filter => {
|
||||
const cpme = model.extensions[0].find(
|
||||
(ext) => ext.constructor.name === "ControlPanelModelExtension"
|
||||
);
|
||||
const filters = Object.values(cpme.state.filters);
|
||||
return filters.map(filter => {
|
||||
const copy = Object.assign({}, filter);
|
||||
delete copy.groupId;
|
||||
delete copy.groupNumber;
|
||||
@@ -20,7 +34,7 @@ odoo.define('web.control_panel_model_tests', function (require) {
|
||||
});
|
||||
}
|
||||
|
||||
QUnit.module('ControlPanelModel', {
|
||||
QUnit.module('ControlPanelModelExtension', {
|
||||
beforeEach() {
|
||||
this.fields = {
|
||||
display_name: { string: "Displayed name", type: 'char' },
|
||||
@@ -33,21 +47,20 @@ odoo.define('web.control_panel_model_tests', function (require) {
|
||||
}, function () {
|
||||
QUnit.module('Arch parsing');
|
||||
|
||||
QUnit.test('empty arch', function (assert) {
|
||||
QUnit.test('empty arch', async function (assert) {
|
||||
assert.expect(1);
|
||||
|
||||
const model = createControlPanelModel();
|
||||
const model = createModel();
|
||||
assert.deepEqual(sanitizeFilters(model), []);
|
||||
});
|
||||
|
||||
QUnit.test('one field tag', function (assert) {
|
||||
QUnit.test('one field tag', async function (assert) {
|
||||
assert.expect(1);
|
||||
const arch = `
|
||||
<search>
|
||||
<field name="bar"/>
|
||||
</search>`;
|
||||
const fields = this.fields;
|
||||
const model = createControlPanelModel({ viewInfo: { arch, fields } });
|
||||
const model = createModel({ arch, fields, });
|
||||
assert.deepEqual(sanitizeFilters(model), [
|
||||
{
|
||||
description: "Bar",
|
||||
@@ -58,18 +71,18 @@ odoo.define('web.control_panel_model_tests', function (require) {
|
||||
]);
|
||||
});
|
||||
|
||||
QUnit.test('one separator tag', function (assert) {
|
||||
QUnit.test('one separator tag', async function (assert) {
|
||||
assert.expect(1);
|
||||
const arch = `
|
||||
<search>
|
||||
<separator/>
|
||||
</search>`;
|
||||
<search>
|
||||
<separator/>
|
||||
</search>`;
|
||||
const fields = this.fields;
|
||||
const model = createControlPanelModel({ viewInfo: { arch, fields } });
|
||||
const model = createModel({ arch, fields, });
|
||||
assert.deepEqual(sanitizeFilters(model), []);
|
||||
});
|
||||
|
||||
QUnit.test('one separator tag and one field tag', function (assert) {
|
||||
QUnit.test('one separator tag and one field tag', async function (assert) {
|
||||
assert.expect(1);
|
||||
const arch = `
|
||||
<search>
|
||||
@@ -77,7 +90,7 @@ odoo.define('web.control_panel_model_tests', function (require) {
|
||||
<field name="bar"/>
|
||||
</search>`;
|
||||
const fields = this.fields;
|
||||
const model = createControlPanelModel({ viewInfo: { arch, fields } });
|
||||
const model = createModel({ arch, fields, });
|
||||
assert.deepEqual(sanitizeFilters(model), [
|
||||
{
|
||||
description: "Bar",
|
||||
@@ -88,14 +101,14 @@ odoo.define('web.control_panel_model_tests', function (require) {
|
||||
]);
|
||||
});
|
||||
|
||||
QUnit.test('one filter tag', function (assert) {
|
||||
QUnit.test('one filter tag', async function (assert) {
|
||||
assert.expect(1);
|
||||
const arch = `
|
||||
<search>
|
||||
<filter name="filter" string="Hello" domain="[]"/>
|
||||
</search>`;
|
||||
const fields = this.fields;
|
||||
const model = createControlPanelModel({ viewInfo: { arch, fields } });
|
||||
const model = createModel({ arch, fields, });
|
||||
assert.deepEqual(sanitizeFilters(model), [
|
||||
{
|
||||
description: "Hello",
|
||||
@@ -105,15 +118,15 @@ odoo.define('web.control_panel_model_tests', function (require) {
|
||||
]);
|
||||
});
|
||||
|
||||
QUnit.test('one filter tag with date attribute', function (assert) {
|
||||
QUnit.test('one filter tag with date attribute', async function (assert) {
|
||||
assert.expect(1);
|
||||
const arch = `
|
||||
<search>
|
||||
<filter name="date_filter" string="Date" date="date_field"/>
|
||||
</search>`;
|
||||
const fields = this.fields;
|
||||
const model = createControlPanelModel({ viewInfo: { arch, fields } });
|
||||
const dateFilterId = Object.values(model.state.filters)[0].id;
|
||||
const model = createModel({ arch, fields, });
|
||||
const dateFilterId = Object.values(model.get('filters'))[0].id;
|
||||
assert.deepEqual(sanitizeFilters(model), [
|
||||
{
|
||||
defaultOptionId: "this_month",
|
||||
@@ -139,14 +152,14 @@ odoo.define('web.control_panel_model_tests', function (require) {
|
||||
]);
|
||||
});
|
||||
|
||||
QUnit.test('one groupBy tag', function (assert) {
|
||||
QUnit.test('one groupBy tag', async function (assert) {
|
||||
assert.expect(1);
|
||||
const arch = `
|
||||
<search>
|
||||
<filter name="groupby" string="Hi" context="{ 'group_by': 'date_field:day'}"/>
|
||||
</search>`;
|
||||
const fields = this.fields;
|
||||
const model = createControlPanelModel({ viewInfo: { arch, fields } });
|
||||
const model = createModel({ arch, fields, });
|
||||
assert.deepEqual(sanitizeFilters(model), [
|
||||
{
|
||||
defaultOptionId: "day",
|
||||
@@ -159,7 +172,7 @@ odoo.define('web.control_panel_model_tests', function (require) {
|
||||
]);
|
||||
});
|
||||
|
||||
QUnit.test('two filter tags', function (assert) {
|
||||
QUnit.test('two filter tags', async function (assert) {
|
||||
assert.expect(1);
|
||||
const arch = `
|
||||
<search>
|
||||
@@ -167,7 +180,7 @@ odoo.define('web.control_panel_model_tests', function (require) {
|
||||
<filter name="filter_2" string="Hello Two" domain="[('bar', '=', 3)]"/>
|
||||
</search>`;
|
||||
const fields = this.fields;
|
||||
const model = createControlPanelModel({ viewInfo: { arch, fields } });
|
||||
const model = createModel({ arch, fields, });
|
||||
assert.deepEqual(sanitizeFilters(model), [
|
||||
{
|
||||
description: "Hello One",
|
||||
@@ -182,7 +195,7 @@ odoo.define('web.control_panel_model_tests', function (require) {
|
||||
]);
|
||||
});
|
||||
|
||||
QUnit.test('two filter tags separated by a separator', function (assert) {
|
||||
QUnit.test('two filter tags separated by a separator', async function (assert) {
|
||||
assert.expect(1);
|
||||
const arch = `
|
||||
<search>
|
||||
@@ -191,7 +204,7 @@ odoo.define('web.control_panel_model_tests', function (require) {
|
||||
<filter name="filter_2" string="Hello Two" domain="[('bar', '=', 3)]"/>
|
||||
</search>`;
|
||||
const fields = this.fields;
|
||||
const model = createControlPanelModel({ viewInfo: { arch, fields } });
|
||||
const model = createModel({ arch, fields, });
|
||||
assert.deepEqual(sanitizeFilters(model), [
|
||||
{
|
||||
description: "Hello One",
|
||||
@@ -206,7 +219,7 @@ odoo.define('web.control_panel_model_tests', function (require) {
|
||||
]);
|
||||
});
|
||||
|
||||
QUnit.test('one filter tag and one field', function (assert) {
|
||||
QUnit.test('one filter tag and one field', async function (assert) {
|
||||
assert.expect(1);
|
||||
const arch = `
|
||||
<search>
|
||||
@@ -214,7 +227,7 @@ odoo.define('web.control_panel_model_tests', function (require) {
|
||||
<field name="bar"/>
|
||||
</search>`;
|
||||
const fields = this.fields;
|
||||
const model = createControlPanelModel({ viewInfo: { arch, fields } });
|
||||
const model = createModel({ arch, fields, });
|
||||
assert.deepEqual(sanitizeFilters(model), [
|
||||
{
|
||||
description: "Hello",
|
||||
@@ -230,7 +243,7 @@ odoo.define('web.control_panel_model_tests', function (require) {
|
||||
]);
|
||||
});
|
||||
|
||||
QUnit.test('two field tags', function (assert) {
|
||||
QUnit.test('two field tags', async function (assert) {
|
||||
assert.expect(1);
|
||||
const arch = `
|
||||
<search>
|
||||
@@ -238,7 +251,7 @@ odoo.define('web.control_panel_model_tests', function (require) {
|
||||
<field name="bar"/>
|
||||
</search>`;
|
||||
const fields = this.fields;
|
||||
const model = createControlPanelModel({ viewInfo: { arch, fields } });
|
||||
const model = createModel({ arch, fields, });
|
||||
assert.deepEqual(sanitizeFilters(model), [
|
||||
{
|
||||
description: "Foo",
|
||||
@@ -270,7 +283,7 @@ odoo.define('web.control_panel_model_tests', function (require) {
|
||||
domain: "[('user_id', '=', uid)]",
|
||||
}];
|
||||
|
||||
const model = createControlPanelModel({ viewInfo: { favoriteFilters } });
|
||||
const model = createModel({ favoriteFilters });
|
||||
assert.deepEqual(sanitizeFilters(model), [
|
||||
{
|
||||
context: {},
|
||||
@@ -303,7 +316,7 @@ odoo.define('web.control_panel_model_tests', function (require) {
|
||||
domain: [['id', 'in', [1, 3, 4]]]
|
||||
}];
|
||||
|
||||
const model = createControlPanelModel({ dynamicFilters });
|
||||
const model = createModel({ dynamicFilters });
|
||||
assert.deepEqual(sanitizeFilters(model), [
|
||||
{
|
||||
description: 'Quick search',
|
||||
@@ -318,7 +331,7 @@ odoo.define('web.control_panel_model_tests', function (require) {
|
||||
QUnit.test('falsy search defaults are not activated', async function (assert) {
|
||||
assert.expect(1);
|
||||
|
||||
const actionContext = {
|
||||
const context = {
|
||||
search_default_filter: false,
|
||||
search_default_bar: 0,
|
||||
search_default_groupby: 2,
|
||||
@@ -330,7 +343,7 @@ odoo.define('web.control_panel_model_tests', function (require) {
|
||||
<field name="bar"/>
|
||||
</search>`;
|
||||
const fields = this.fields;
|
||||
const model = createControlPanelModel({ viewInfo: { arch, fields }, actionContext });
|
||||
const model = createModel({ arch, fields, context });
|
||||
// only the truthy filter 'groupby' has isDefault true
|
||||
assert.deepEqual(sanitizeFilters(model), [
|
||||
{
|
||||
@@ -356,72 +369,5 @@ odoo.define('web.control_panel_model_tests', function (require) {
|
||||
|
||||
});
|
||||
|
||||
QUnit.test('search default field many2one with filter domain', async function (assert) {
|
||||
assert.expect(3);
|
||||
|
||||
const actionContext = {
|
||||
search_default_bar: 20,
|
||||
};
|
||||
const arch = `
|
||||
<search>
|
||||
<field name="bar" string="Partner" filter_domain="[('bar', 'ilike', self)]"/>
|
||||
</search>`;
|
||||
const fields = this.fields;
|
||||
const model = createControlPanelModel(
|
||||
{ viewInfo: { arch, fields }, actionContext }, {},
|
||||
async function mockRPC(route, params) {
|
||||
assert.deepEqual(params, {
|
||||
args: [20],
|
||||
kwargs: {},
|
||||
method: "name_get",
|
||||
model: "partner",
|
||||
});
|
||||
return [[20, "Gandalf"]];
|
||||
}
|
||||
);
|
||||
|
||||
// Labels not fetched: self = undefined
|
||||
assert.throws(() => model.getQuery());
|
||||
|
||||
await model.isReady;
|
||||
|
||||
// Labels fetched: domain should be ready
|
||||
assert.deepEqual(model.getQuery().domain, [["bar", "ilike", "Gandalf"]]);
|
||||
});
|
||||
|
||||
QUnit.test('search default field many2one with filter domain and default favorite', async function (assert) {
|
||||
assert.expect(2);
|
||||
|
||||
const actionContext = {
|
||||
search_default_bar: 20,
|
||||
};
|
||||
const arch = `
|
||||
<search>
|
||||
<field name="bar" string="Partner" filter_domain="[('bar', 'ilike', self)]"/>
|
||||
</search>`;
|
||||
const favoriteFilters = [{
|
||||
domain: "[('user_id', '=', uid)]",
|
||||
id: 5,
|
||||
is_default: true,
|
||||
name: 'Sorted filter',
|
||||
user_id: [2, "Mitchell Admin"],
|
||||
}];
|
||||
const fields = this.fields;
|
||||
const model = createControlPanelModel(
|
||||
{ viewInfo: { arch, favoriteFilters, fields }, actionContext },
|
||||
{ session: { user_context: { uid: 2 } } },
|
||||
async function mockRPC() {
|
||||
throw new Error("There should be no name_get");
|
||||
}
|
||||
);
|
||||
|
||||
// Labels not fetched, but favorite overrides search_default
|
||||
assert.deepEqual(model.getQuery().domain, [["user_id", "=", 2]]);
|
||||
|
||||
await model.isReady;
|
||||
|
||||
// domain should be the same
|
||||
assert.deepEqual(model.getQuery().domain, [["user_id", "=", 2]]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -24,12 +24,11 @@ odoo.define('web.control_panel_tests', function (require) {
|
||||
assert.expect(2);
|
||||
|
||||
const params = {
|
||||
cpStoreConfig: {
|
||||
viewInfo: {
|
||||
arch: `<search> <field name="foo"/></search>`,
|
||||
fields: this.fields,
|
||||
},
|
||||
actionContext: { search_default_foo: "a" },
|
||||
cpModelConfig: {
|
||||
arch: `<search> <field name="foo"/></search>`,
|
||||
fields: this.fields,
|
||||
context: { search_default_foo: "a" },
|
||||
searchMenuTypes: ['filter'],
|
||||
},
|
||||
cpProps: { fields: this.fields },
|
||||
};
|
||||
@@ -67,9 +66,10 @@ odoo.define('web.control_panel_tests', function (require) {
|
||||
</search>`;
|
||||
const searchMenuTypes = ['filter', 'groupBy'];
|
||||
const params = {
|
||||
cpStoreConfig: {
|
||||
viewInfo: { arch, fields: this.fields },
|
||||
actionContext: {
|
||||
cpModelConfig: {
|
||||
arch,
|
||||
fields: this.fields,
|
||||
context: {
|
||||
show_filterC: true,
|
||||
search_default_display_name: 'value',
|
||||
search_default_filterB: true,
|
||||
@@ -179,9 +179,10 @@ odoo.define('web.control_panel_tests', function (require) {
|
||||
const arch = `<search/>`;
|
||||
const searchMenuTypes = ['filter'];
|
||||
const params = {
|
||||
cpStoreConfig: {
|
||||
viewInfo: { arch, fields: this.fields },
|
||||
searchMenuTypes
|
||||
cpModelConfig: {
|
||||
arch,
|
||||
fields: this.fields,
|
||||
searchMenuTypes,
|
||||
},
|
||||
cpProps: { fields: this.fields, searchMenuTypes },
|
||||
};
|
||||
|
||||
@@ -3,7 +3,7 @@ odoo.define('web.filter_menu_generator_tests', function (require) {
|
||||
|
||||
const Domain = require('web.Domain');
|
||||
const CustomFilterItem = require('web.CustomFilterItem');
|
||||
const { Model } = require('web.model');
|
||||
const ActionModel = require('web/static/src/js/views/action_model.js');
|
||||
const pyUtils = require('web.py_utils');
|
||||
const testUtils = require('web.test_utils');
|
||||
|
||||
@@ -33,7 +33,7 @@ odoo.define('web.filter_menu_generator_tests', function (require) {
|
||||
fields: this.fields,
|
||||
},
|
||||
env: {
|
||||
controlPanelModel: new Model(),
|
||||
searchModel: new ActionModel(),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -71,20 +71,22 @@ odoo.define('web.filter_menu_generator_tests', function (require) {
|
||||
});
|
||||
|
||||
QUnit.test('selection field: default and updated value', async function (assert) {
|
||||
assert.expect(2);
|
||||
assert.expect(4);
|
||||
|
||||
let expectedFilters;
|
||||
class MockedControlPanelModel extends Model {
|
||||
createNewFilters(preFilters) {
|
||||
class MockedSearchModel extends ActionModel {
|
||||
dispatch(method, ...args) {
|
||||
assert.strictEqual(method, 'createNewFilters');
|
||||
const preFilters = args[0];
|
||||
assert.deepEqual(preFilters, expectedFilters);
|
||||
}
|
||||
}
|
||||
const controlPanelModel = new MockedControlPanelModel();
|
||||
const searchModel = new MockedSearchModel();
|
||||
const cfi = await createComponent(CustomFilterItem, {
|
||||
props: {
|
||||
fields: this.fields,
|
||||
},
|
||||
env: { controlPanelModel },
|
||||
env: { searchModel },
|
||||
});
|
||||
|
||||
// Default value
|
||||
@@ -112,23 +114,26 @@ odoo.define('web.filter_menu_generator_tests', function (require) {
|
||||
});
|
||||
|
||||
QUnit.test('adding a simple filter works', async function (assert) {
|
||||
assert.expect(5);
|
||||
assert.expect(6);
|
||||
|
||||
delete this.fields.date_field;
|
||||
class MockedControlPanelModel extends Model {
|
||||
createNewFilters(preFilters) {
|
||||
|
||||
class MockedSearchModel extends ActionModel {
|
||||
dispatch(method, ...args) {
|
||||
assert.strictEqual(method, 'createNewFilters');
|
||||
const preFilters = args[0];
|
||||
const preFilter = preFilters[0];
|
||||
assert.strictEqual(preFilter.type, 'filter');
|
||||
assert.strictEqual(preFilter.description, 'Boolean Field is true');
|
||||
assert.strictEqual(preFilter.domain, '[["boolean_field","=",True]]');
|
||||
}
|
||||
}
|
||||
const controlPanelModel = new MockedControlPanelModel();
|
||||
const searchModel = new MockedSearchModel();
|
||||
const cfi = await createComponent(CustomFilterItem, {
|
||||
props: {
|
||||
fields: this.fields,
|
||||
},
|
||||
env: { controlPanelModel },
|
||||
env: { searchModel },
|
||||
});
|
||||
|
||||
await cpHelpers.toggleAddCustomFilter(cfi);
|
||||
@@ -142,7 +147,7 @@ odoo.define('web.filter_menu_generator_tests', function (require) {
|
||||
});
|
||||
|
||||
QUnit.test('commit search with an extended proposition with field char does not cause a crash', async function (assert) {
|
||||
assert.expect(6);
|
||||
assert.expect(12);
|
||||
|
||||
this.fields.many2one_field = { name: 'many2one_field', string: "Trululu", type: "many2one", searchable: true };
|
||||
const expectedDomains = [
|
||||
@@ -154,8 +159,11 @@ odoo.define('web.filter_menu_generator_tests', function (require) {
|
||||
[['many2one_field', 'ilike', `\\`]],
|
||||
];
|
||||
const testedValues = [`a`, `"a"`, `'a'`, `'`, `"`, `\\`];
|
||||
class MockedControlPanelModel extends Model {
|
||||
createNewFilters(preFilters) {
|
||||
|
||||
class MockedSearchModel extends ActionModel {
|
||||
dispatch(method, ...args) {
|
||||
assert.strictEqual(method, 'createNewFilters');
|
||||
const preFilters = args[0];
|
||||
const preFilter = preFilters[0];
|
||||
// this step combine a tokenization/parsing followed by a string formatting
|
||||
let domain = pyUtils.assembleDomains([preFilter.domain]);
|
||||
@@ -163,12 +171,12 @@ odoo.define('web.filter_menu_generator_tests', function (require) {
|
||||
assert.deepEqual(domain, expectedDomains.shift());
|
||||
}
|
||||
}
|
||||
const controlPanelModel = new MockedControlPanelModel();
|
||||
const searchModel = new MockedSearchModel();
|
||||
const cfi = await createComponent(CustomFilterItem, {
|
||||
props: {
|
||||
fields: this.fields,
|
||||
},
|
||||
env: { controlPanelModel },
|
||||
env: { searchModel },
|
||||
});
|
||||
|
||||
async function testValue(value) {
|
||||
@@ -186,14 +194,17 @@ odoo.define('web.filter_menu_generator_tests', function (require) {
|
||||
await testValue(value);
|
||||
}
|
||||
|
||||
delete ActionModel.registry.map.testExtension;
|
||||
cfi.destroy();
|
||||
});
|
||||
|
||||
QUnit.test('custom filter datetime with equal operator', async function (assert) {
|
||||
assert.expect(4);
|
||||
assert.expect(5);
|
||||
|
||||
class MockedControlPanelModel extends Model {
|
||||
createNewFilters(preFilters) {
|
||||
class MockedSearchModel extends ActionModel {
|
||||
dispatch(method, ...args) {
|
||||
assert.strictEqual(method, 'createNewFilters');
|
||||
const preFilters = args[0];
|
||||
const preFilter = preFilters[0];
|
||||
assert.strictEqual(preFilter.description,
|
||||
'DateTime is equal to "02/22/2017 11:00:00"',
|
||||
@@ -201,9 +212,9 @@ odoo.define('web.filter_menu_generator_tests', function (require) {
|
||||
assert.deepEqual(preFilter.domain,
|
||||
'[["date_time_field","=","2017-02-22 15:00:00"]]',
|
||||
"domain should be in UTC format");
|
||||
}
|
||||
}
|
||||
}
|
||||
const controlPanelModel = new MockedControlPanelModel();
|
||||
const searchModel = new MockedSearchModel();
|
||||
const cfi = await createComponent(CustomFilterItem, {
|
||||
props: {
|
||||
fields: this.fields,
|
||||
@@ -213,7 +224,7 @@ odoo.define('web.filter_menu_generator_tests', function (require) {
|
||||
return -240;
|
||||
},
|
||||
},
|
||||
env: { controlPanelModel },
|
||||
env: { searchModel },
|
||||
});
|
||||
|
||||
await cpHelpers.toggleAddCustomFilter(cfi);
|
||||
@@ -230,10 +241,12 @@ odoo.define('web.filter_menu_generator_tests', function (require) {
|
||||
});
|
||||
|
||||
QUnit.test('custom filter datetime between operator', async function (assert) {
|
||||
assert.expect(4);
|
||||
assert.expect(5);
|
||||
|
||||
class MockedControlPanelModel extends Model {
|
||||
createNewFilters(preFilters) {
|
||||
class MockedSearchModel extends ActionModel {
|
||||
dispatch(method, ...args) {
|
||||
assert.strictEqual(method, 'createNewFilters');
|
||||
const preFilters = args[0];
|
||||
const preFilter = preFilters[0];
|
||||
assert.strictEqual(preFilter.description,
|
||||
'DateTime is between "02/22/2017 11:00:00 and 02/22/2017 17:00:00"',
|
||||
@@ -244,7 +257,7 @@ odoo.define('web.filter_menu_generator_tests', function (require) {
|
||||
"domain should be in UTC format");
|
||||
}
|
||||
}
|
||||
const controlPanelModel = new MockedControlPanelModel();
|
||||
const searchModel = new MockedSearchModel();
|
||||
const cfi = await createComponent(CustomFilterItem, {
|
||||
props: {
|
||||
fields: this.fields,
|
||||
@@ -254,7 +267,7 @@ odoo.define('web.filter_menu_generator_tests', function (require) {
|
||||
return -240;
|
||||
},
|
||||
},
|
||||
env: { controlPanelModel },
|
||||
env: { searchModel },
|
||||
});
|
||||
|
||||
await cpHelpers.toggleAddCustomFilter(cfi);
|
||||
@@ -279,7 +292,7 @@ odoo.define('web.filter_menu_generator_tests', function (require) {
|
||||
fields: this.fields,
|
||||
},
|
||||
env: {
|
||||
controlPanelModel: new Model(),
|
||||
searchModel: new ActionModel(),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -312,10 +325,12 @@ odoo.define('web.filter_menu_generator_tests', function (require) {
|
||||
});
|
||||
|
||||
QUnit.test('add custom filter with multiple values', async function (assert) {
|
||||
assert.expect(1);
|
||||
assert.expect(2);
|
||||
|
||||
class MockedControlPanelModel extends Model {
|
||||
createNewFilters(preFilters) {
|
||||
class MockedSearchModel extends ActionModel {
|
||||
dispatch(method, ...args) {
|
||||
assert.strictEqual(method, 'createNewFilters');
|
||||
const preFilters = args[0];
|
||||
const expected = [
|
||||
{
|
||||
description: 'A date is equal to "01/09/1997"',
|
||||
@@ -342,12 +357,12 @@ odoo.define('web.filter_menu_generator_tests', function (require) {
|
||||
"Conditions should be in the correct order witht the right values.");
|
||||
}
|
||||
}
|
||||
const controlPanelModel = new MockedControlPanelModel();
|
||||
const searchModel = new MockedSearchModel();
|
||||
const cfi = await createComponent(CustomFilterItem, {
|
||||
props: {
|
||||
fields: this.fields,
|
||||
},
|
||||
env: { controlPanelModel },
|
||||
env: { searchModel },
|
||||
});
|
||||
|
||||
await cpHelpers.toggleAddCustomFilter(cfi);
|
||||
|
||||
@@ -2,7 +2,7 @@ odoo.define('web.groupby_menu_generator_tests', function (require) {
|
||||
"use strict";
|
||||
|
||||
const CustomGroupByItem = require('web.CustomGroupByItem');
|
||||
const { Model } = require('web.model');
|
||||
const ActionModel = require('web/static/src/js/views/action_model.js');
|
||||
const testUtils = require('web.test_utils');
|
||||
|
||||
const { createComponent } = testUtils;
|
||||
@@ -21,7 +21,7 @@ odoo.define('web.groupby_menu_generator_tests', function (require) {
|
||||
],
|
||||
},
|
||||
env: {
|
||||
controlPanelModel: new Model(),
|
||||
searchModel: new ActionModel(),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -43,20 +43,22 @@ odoo.define('web.groupby_menu_generator_tests', function (require) {
|
||||
});
|
||||
|
||||
QUnit.test('select a field name in Add Custom Group menu properly trigger the corresponding field', async function (assert) {
|
||||
assert.expect(3);
|
||||
assert.expect(4);
|
||||
|
||||
const fields = [
|
||||
{ sortable: true, name: 'candlelight', string: 'Candlelight', type: 'boolean' },
|
||||
];
|
||||
class MockedControlPanelModel extends Model {
|
||||
createNewGroupBy(field) {
|
||||
class MockedSearchModel extends ActionModel {
|
||||
dispatch(method, ...args) {
|
||||
assert.strictEqual(method, 'createNewGroupBy');
|
||||
const field = args[0];
|
||||
assert.deepEqual(field, fields[0]);
|
||||
}
|
||||
}
|
||||
const controlPanelModel = new MockedControlPanelModel();
|
||||
const searchModel = new MockedSearchModel();
|
||||
const cgi = await createComponent(CustomGroupByItem, {
|
||||
props: { fields },
|
||||
env: { controlPanelModel },
|
||||
env: { searchModel },
|
||||
});
|
||||
|
||||
await testUtils.dom.click(cgi.el.querySelector('.o_generator_menu button.o_add_custom_group_by'));
|
||||
|
||||
@@ -28,7 +28,7 @@ odoo.define('web.favorite_menu_tests', function (require) {
|
||||
assert.expect(8);
|
||||
|
||||
const params = {
|
||||
cpStoreConfig: { searchMenuTypes },
|
||||
cpModelConfig: { searchMenuTypes },
|
||||
cpProps: { fields: this.fields, searchMenuTypes, action: { name: "Action Name" } },
|
||||
};
|
||||
const controlPanel = await createControlPanel(params);
|
||||
@@ -61,7 +61,7 @@ odoo.define('web.favorite_menu_tests', function (require) {
|
||||
assert.expect(11);
|
||||
|
||||
const params = {
|
||||
cpStoreConfig: {
|
||||
cpModelConfig: {
|
||||
viewInfo: { fields: this.fields },
|
||||
searchMenuTypes
|
||||
},
|
||||
@@ -109,8 +109,8 @@ odoo.define('web.favorite_menu_tests', function (require) {
|
||||
assert.expect(1);
|
||||
|
||||
const params = {
|
||||
cpStoreConfig: {
|
||||
viewInfo: { fields: this.fields },
|
||||
cpModelConfig: {
|
||||
fields: this.fields,
|
||||
searchMenuTypes
|
||||
},
|
||||
cpProps: {
|
||||
@@ -156,10 +156,11 @@ odoo.define('web.favorite_menu_tests', function (require) {
|
||||
</search>
|
||||
`;
|
||||
const params = {
|
||||
cpStoreConfig: {
|
||||
viewInfo: { fields: {}, arch },
|
||||
cpModelConfig: {
|
||||
fields: {},
|
||||
arch ,
|
||||
searchMenuTypes,
|
||||
actionContext: {
|
||||
context: {
|
||||
search_default_positive: true,
|
||||
}
|
||||
},
|
||||
@@ -202,8 +203,9 @@ odoo.define('web.favorite_menu_tests', function (require) {
|
||||
|
||||
const arch = `<search><field name="foo"/></search>`;
|
||||
const params = {
|
||||
cpStoreConfig: {
|
||||
viewInfo: { fields: this.fields, arch },
|
||||
cpModelConfig: {
|
||||
fields: this.fields,
|
||||
arch ,
|
||||
searchMenuTypes,
|
||||
},
|
||||
cpProps: {
|
||||
@@ -258,7 +260,7 @@ odoo.define('web.favorite_menu_tests', function (require) {
|
||||
user_id: [2, "Mitchell Admin"],
|
||||
}];
|
||||
const params = {
|
||||
cpStoreConfig: { viewInfo: { favoriteFilters }, searchMenuTypes },
|
||||
cpModelConfig: { favoriteFilters, searchMenuTypes },
|
||||
cpProps: { searchMenuTypes, action: {} },
|
||||
search: function (searchQuery) {
|
||||
const { domain } = searchQuery;
|
||||
@@ -305,10 +307,10 @@ odoo.define('web.favorite_menu_tests', function (require) {
|
||||
user_id: [2, "Mitchell Admin"],
|
||||
}];
|
||||
const params = {
|
||||
cpStoreConfig: {
|
||||
viewInfo: { favoriteFilters },
|
||||
cpModelConfig: {
|
||||
favoriteFilters,
|
||||
searchMenuTypes,
|
||||
actionContext: { search_disable_custom_filters: true }
|
||||
context: { search_disable_custom_filters: true }
|
||||
},
|
||||
cpProps: { searchMenuTypes, action: {} },
|
||||
};
|
||||
@@ -354,10 +356,12 @@ odoo.define('web.favorite_menu_tests', function (require) {
|
||||
`;
|
||||
const searchMenuTypes = ['filter', 'groupBy', 'comparison', 'favorite'];
|
||||
const params = {
|
||||
cpStoreConfig: {
|
||||
viewInfo: { favoriteFilters, arch, fields: this.fields },
|
||||
cpModelConfig: {
|
||||
favoriteFilters,
|
||||
arch,
|
||||
fields: this.fields,
|
||||
searchMenuTypes,
|
||||
actionContext: {
|
||||
context: {
|
||||
search_default_positive: true,
|
||||
search_default_coolName: true,
|
||||
search_default_foo: "a",
|
||||
@@ -442,7 +446,7 @@ odoo.define('web.favorite_menu_tests', function (require) {
|
||||
user_id: [2, "Mitchell Admin"],
|
||||
}];
|
||||
const params = {
|
||||
cpStoreConfig: { viewInfo: { favoriteFilters }, searchMenuTypes },
|
||||
cpModelConfig: { favoriteFilters, searchMenuTypes },
|
||||
cpProps: { searchMenuTypes, action: {} },
|
||||
'get-controller-query-params': function (callback) {
|
||||
callback();
|
||||
|
||||
@@ -23,7 +23,7 @@ odoo.define('web.filter_menu_tests', function (require) {
|
||||
assert.expect(2);
|
||||
|
||||
const params = {
|
||||
cpStoreConfig: { searchMenuTypes },
|
||||
cpModelConfig: { searchMenuTypes },
|
||||
cpProps: { fields: this.fields, searchMenuTypes },
|
||||
};
|
||||
const controlPanel = await createControlPanel(params);
|
||||
@@ -43,7 +43,7 @@ odoo.define('web.filter_menu_tests', function (require) {
|
||||
<filter string="Foo" name="foo" domain="[]"/>
|
||||
</search>`;
|
||||
const params = {
|
||||
cpStoreConfig: { viewInfo: { arch, fields: this.fields }, searchMenuTypes },
|
||||
cpModelConfig: { arch, fields: this.fields, searchMenuTypes },
|
||||
cpProps: { fields: this.fields, searchMenuTypes },
|
||||
};
|
||||
const controlPanel = await createControlPanel(params);
|
||||
@@ -60,7 +60,7 @@ odoo.define('web.filter_menu_tests', function (require) {
|
||||
assert.expect(2);
|
||||
|
||||
const params = {
|
||||
cpStoreConfig: { viewInfo: { fields: this.fields }, searchMenuTypes },
|
||||
cpModelConfig: { fields: this.fields, searchMenuTypes },
|
||||
cpProps: { fields: this.fields, searchMenuTypes },
|
||||
};
|
||||
const controlPanel = await createControlPanel(params);
|
||||
@@ -86,7 +86,7 @@ odoo.define('web.filter_menu_tests', function (require) {
|
||||
<filter string="Foo" name="foo" domain="[['foo', '=', 'qsdf']]"/>
|
||||
</search>`;
|
||||
const params = {
|
||||
cpStoreConfig: { viewInfo: { arch }, searchMenuTypes },
|
||||
cpModelConfig: { arch, searchMenuTypes },
|
||||
cpProps: { fields: {}, searchMenuTypes },
|
||||
search: function (searchQuery) {
|
||||
const { domain } = searchQuery;
|
||||
@@ -118,7 +118,7 @@ odoo.define('web.filter_menu_tests', function (require) {
|
||||
assert.expect(1);
|
||||
|
||||
const params = {
|
||||
cpStoreConfig: { viewInfo: { fields: this.fields }, searchMenuTypes },
|
||||
cpModelConfig: { fields: this.fields, searchMenuTypes },
|
||||
cpProps: { fields: this.fields, searchMenuTypes },
|
||||
};
|
||||
const controlPanel = await createControlPanel(params);
|
||||
@@ -144,7 +144,7 @@ odoo.define('web.filter_menu_tests', function (require) {
|
||||
const unpatchDate = patchDate(2020, 1, 5, 12, 20, 0);
|
||||
|
||||
const params = {
|
||||
cpStoreConfig: { viewInfo: { fields: this.fields }, searchMenuTypes },
|
||||
cpModelConfig: { fields: this.fields, searchMenuTypes },
|
||||
cpProps: { fields: this.fields, searchMenuTypes },
|
||||
};
|
||||
const controlPanel = await createControlPanel(params);
|
||||
@@ -207,10 +207,11 @@ odoo.define('web.filter_menu_tests', function (require) {
|
||||
<filter string="Date" name="date_field" date="date_field"/>
|
||||
</search>`;
|
||||
const params = {
|
||||
cpStoreConfig: {
|
||||
viewInfo: { arch, fields: this.fields },
|
||||
cpModelConfig: {
|
||||
arch,
|
||||
fields: this.fields,
|
||||
searchMenuTypes,
|
||||
actionContext: { search_default_date_field: 1 },
|
||||
context: { search_default_date_field: 1 },
|
||||
},
|
||||
cpProps: { fields: this.fields, searchMenuTypes },
|
||||
search: function (searchQuery) {
|
||||
@@ -291,10 +292,11 @@ odoo.define('web.filter_menu_tests', function (require) {
|
||||
<filter string="Date" name="some_filter" date="date_field" default_period="last_month"/>
|
||||
</search>`;
|
||||
const params = {
|
||||
cpStoreConfig: {
|
||||
viewInfo: { arch, fields: this.fields },
|
||||
cpModelConfig: {
|
||||
arch,
|
||||
fields: this.fields,
|
||||
searchMenuTypes,
|
||||
actionContext: { search_default_some_filter: 1 },
|
||||
context: { search_default_some_filter: 1 },
|
||||
},
|
||||
cpProps: { fields: this.fields, searchMenuTypes },
|
||||
};
|
||||
@@ -328,8 +330,9 @@ odoo.define('web.filter_menu_tests', function (require) {
|
||||
<filter string="Filter" name="some_filter" domain="[]" context="{'coucou_1': 1}"/>
|
||||
</search>`;
|
||||
const params = {
|
||||
cpStoreConfig: {
|
||||
viewInfo: { arch, fields: this.fields },
|
||||
cpModelConfig: {
|
||||
arch,
|
||||
fields: this.fields,
|
||||
searchMenuTypes
|
||||
},
|
||||
cpProps: { fields: this.fields, searchMenuTypes },
|
||||
@@ -358,8 +361,9 @@ odoo.define('web.filter_menu_tests', function (require) {
|
||||
<filter string="Foo" name="gently_weeps" domain="${_.escape(xml_domain)}"/>
|
||||
</search>`;
|
||||
const params = {
|
||||
cpStoreConfig: {
|
||||
viewInfo: { arch, fields: this.fields },
|
||||
cpModelConfig: {
|
||||
arch,
|
||||
fields: this.fields,
|
||||
searchMenuTypes,
|
||||
},
|
||||
cpProps: { fields: this.fields, searchMenuTypes },
|
||||
@@ -388,10 +392,11 @@ odoo.define('web.filter_menu_tests', function (require) {
|
||||
<filter string="Date" name="date_field" date="date_field" default_period="last_month"/>
|
||||
</search>`,
|
||||
params = {
|
||||
cpStoreConfig: {
|
||||
viewInfo: { arch, fields: this.fields },
|
||||
cpModelConfig: {
|
||||
arch,
|
||||
fields: this.fields,
|
||||
searchMenuTypes,
|
||||
actionContext: {
|
||||
context: {
|
||||
search_default_date_field: true
|
||||
}
|
||||
},
|
||||
@@ -416,10 +421,11 @@ odoo.define('web.filter_menu_tests', function (require) {
|
||||
<filter string="Filter 2 GROUP 2" name="f2_g2" domain="[['foo', '=', 'f2_g2']]"/>
|
||||
</search>`,
|
||||
params = {
|
||||
cpStoreConfig: {
|
||||
viewInfo: { arch, fields: this.fields },
|
||||
cpModelConfig: {
|
||||
arch,
|
||||
fields: this.fields,
|
||||
searchMenuTypes,
|
||||
actionContext: {
|
||||
context: {
|
||||
search_default_f_1_g1: true,
|
||||
search_default_f1_g2: true,
|
||||
search_default_f2_g2: true,
|
||||
@@ -474,8 +480,9 @@ odoo.define('web.filter_menu_tests', function (require) {
|
||||
<filter string="11" name="coolName11" domain="[]"/>
|
||||
</search>`,
|
||||
params = {
|
||||
cpStoreConfig: {
|
||||
viewInfo: { arch, fields: this.fields },
|
||||
cpModelConfig: {
|
||||
arch,
|
||||
fields: this.fields,
|
||||
searchMenuTypes,
|
||||
},
|
||||
cpProps: { fields: this.fields, searchMenuTypes },
|
||||
@@ -493,4 +500,4 @@ odoo.define('web.filter_menu_tests', function (require) {
|
||||
controlPanel.destroy();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -26,7 +26,7 @@ odoo.define('web.groupby_menu_tests', function (require) {
|
||||
|
||||
assert.expect(1);
|
||||
const params = {
|
||||
cpStoreConfig: { searchMenuTypes },
|
||||
cpModelConfig: { searchMenuTypes },
|
||||
cpProps: { fields: {}, searchMenuTypes },
|
||||
};
|
||||
const controlPanel = await createControlPanel(params);
|
||||
@@ -40,7 +40,7 @@ odoo.define('web.groupby_menu_tests', function (require) {
|
||||
assert.expect(5);
|
||||
|
||||
const params = {
|
||||
cpStoreConfig: { searchMenuTypes },
|
||||
cpModelConfig: { searchMenuTypes },
|
||||
cpProps: { fields: this.fields, searchMenuTypes },
|
||||
};
|
||||
const controlPanel = await createControlPanel(params);
|
||||
@@ -67,7 +67,7 @@ odoo.define('web.groupby_menu_tests', function (require) {
|
||||
<filter string="Groupby Foo" name="gb_foo" context="{'group_by': 'foo'}"/>
|
||||
</search>`;
|
||||
const params = {
|
||||
cpStoreConfig: { viewInfo: { arch, fields: this.fields }, searchMenuTypes },
|
||||
cpModelConfig: { arch, fields: this.fields, searchMenuTypes },
|
||||
cpProps: { fields: this.fields, searchMenuTypes },
|
||||
};
|
||||
const controlPanel = await createControlPanel(params);
|
||||
@@ -90,7 +90,7 @@ odoo.define('web.groupby_menu_tests', function (require) {
|
||||
<filter string="Groupby Foo" name="gb_foo" context="{'group_by': 'foo'}"/>
|
||||
</search>`;
|
||||
const params = {
|
||||
cpStoreConfig: { viewInfo: { arch, fields: this.fields }, searchMenuTypes },
|
||||
cpModelConfig: {arch, fields: this.fields, searchMenuTypes },
|
||||
cpProps: { fields: this.fields, searchMenuTypes },
|
||||
search: function (searchQuery) {
|
||||
const { groupBy } = searchQuery;
|
||||
@@ -125,7 +125,7 @@ odoo.define('web.groupby_menu_tests', function (require) {
|
||||
<filter string="Groupby Foo" name="gb_foo" context="{'group_by': 'foo'}"/>
|
||||
</search>`;
|
||||
const params = {
|
||||
cpStoreConfig: { viewInfo: { arch, fields: this.fields }, searchMenuTypes },
|
||||
cpModelConfig: { arch, fields: this.fields, searchMenuTypes },
|
||||
cpProps: { fields: this.fields, searchMenuTypes },
|
||||
};
|
||||
const controlPanel = await createControlPanel(params);
|
||||
@@ -147,10 +147,11 @@ odoo.define('web.groupby_menu_tests', function (require) {
|
||||
<filter string="Groupby Foo" name="gb_foo" context="{'group_by': 'foo'}"/>
|
||||
</search>`;
|
||||
const params = {
|
||||
cpStoreConfig: {
|
||||
viewInfo: { arch, fields: this.fields },
|
||||
cpModelConfig: {
|
||||
arch,
|
||||
fields: this.fields,
|
||||
searchMenuTypes,
|
||||
actionContext: { search_default_gb_foo: 1 }
|
||||
context: { search_default_gb_foo: 1 }
|
||||
},
|
||||
cpProps: { fields: this.fields, searchMenuTypes },
|
||||
search: function (searchQuery) {
|
||||
@@ -189,10 +190,11 @@ odoo.define('web.groupby_menu_tests', function (require) {
|
||||
<filter string="Date" name="date" context="{'group_by': 'date_field:week'}"/>
|
||||
</search>`;
|
||||
const params = {
|
||||
cpStoreConfig: {
|
||||
viewInfo: { arch, fields: this.fields },
|
||||
cpModelConfig: {
|
||||
arch,
|
||||
fields: this.fields,
|
||||
searchMenuTypes,
|
||||
actionContext: { search_default_date: 1 }
|
||||
context: { search_default_date: 1 }
|
||||
},
|
||||
cpProps: { fields: this.fields, searchMenuTypes },
|
||||
search: function (searchQuery) {
|
||||
@@ -251,10 +253,11 @@ odoo.define('web.groupby_menu_tests', function (require) {
|
||||
<filter string="Foo" name="foo" context="{'group_by': 'foo'}"/>
|
||||
</search>`;
|
||||
const params = {
|
||||
cpStoreConfig: {
|
||||
viewInfo: { arch, fields: this.fields },
|
||||
cpModelConfig: {
|
||||
arch,
|
||||
fields: this.fields,
|
||||
searchMenuTypes,
|
||||
actionContext: { search_default_bar: 1 }
|
||||
context: { search_default_bar: 1 }
|
||||
},
|
||||
cpProps: { fields: this.fields, searchMenuTypes },
|
||||
};
|
||||
@@ -309,7 +312,7 @@ odoo.define('web.groupby_menu_tests', function (require) {
|
||||
id: { sortable: true, string: 'ID', type: 'integer' }
|
||||
};
|
||||
const params = {
|
||||
cpStoreConfig: { searchMenuTypes },
|
||||
cpModelConfig: { searchMenuTypes },
|
||||
cpProps: { fields, searchMenuTypes },
|
||||
};
|
||||
const controlPanel = await createControlPanel(params);
|
||||
@@ -332,7 +335,7 @@ odoo.define('web.groupby_menu_tests', function (require) {
|
||||
id: { sortable: true, string: 'ID', type: 'integer' }
|
||||
};
|
||||
const params = {
|
||||
cpStoreConfig: { viewInfo: { fields }, searchMenuTypes },
|
||||
cpModelConfig: { fields, searchMenuTypes },
|
||||
cpProps: { fields, searchMenuTypes },
|
||||
search: function (searchQuery) {
|
||||
const { groupBy } = searchQuery;
|
||||
@@ -363,10 +366,11 @@ odoo.define('web.groupby_menu_tests', function (require) {
|
||||
<filter string="Date" name="date" context="{'group_by': 'date_field:week'}"/>
|
||||
</search>`;
|
||||
const params = {
|
||||
cpStoreConfig: {
|
||||
viewInfo: { arch, fields: this.fields },
|
||||
cpModelConfig: {
|
||||
arch,
|
||||
fields: this.fields,
|
||||
searchMenuTypes,
|
||||
actionContext: { search_default_birthday: 2, search_default_date: 1 }
|
||||
context: { search_default_birthday: 2, search_default_date: 1 }
|
||||
},
|
||||
cpProps: { fields: this.fields, searchMenuTypes },
|
||||
};
|
||||
@@ -391,8 +395,9 @@ odoo.define('web.groupby_menu_tests', function (require) {
|
||||
<filter string="Bar" name="superName" context="{'group_by': 'bar'}"/>
|
||||
</search>`;
|
||||
const params = {
|
||||
cpStoreConfig: {
|
||||
viewInfo: { arch, fields: this.fields },
|
||||
cpModelConfig: {
|
||||
arch,
|
||||
fields: this.fields,
|
||||
searchMenuTypes,
|
||||
},
|
||||
cpProps: { fields: this.fields, searchMenuTypes },
|
||||
@@ -453,10 +458,11 @@ odoo.define('web.groupby_menu_tests', function (require) {
|
||||
<filter string="Date" name="date" context="{'group_by': 'foo'}"/>
|
||||
</search>`;
|
||||
const params = {
|
||||
cpStoreConfig: {
|
||||
viewInfo: { arch, fields: this.fields },
|
||||
cpModelConfig: {
|
||||
arch,
|
||||
fields: this.fields,
|
||||
searchMenuTypes,
|
||||
actionContext: { search_default_birthday: false, search_default_foo: 0 }
|
||||
context: { search_default_birthday: false, search_default_foo: 0 }
|
||||
},
|
||||
cpProps: { fields: this.fields, searchMenuTypes },
|
||||
};
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
odoo.define('web.search_bar_tests', function (require) {
|
||||
"use strict";
|
||||
|
||||
const { Model } = require('web.model');
|
||||
const { Model } = require('web/static/src/js/model.js');
|
||||
const Registry = require("web.Registry");
|
||||
const SearchBar = require('web.SearchBar');
|
||||
const testUtils = require('web.test_utils');
|
||||
|
||||
@@ -173,22 +174,30 @@ odoo.define('web.search_bar_tests', function (require) {
|
||||
assert.expect(9);
|
||||
|
||||
const fields = this.data.partner.fields;
|
||||
class MockedControlPanelModel extends Model {
|
||||
getFacets() {
|
||||
return [];
|
||||
}
|
||||
getFiltersOfType() {
|
||||
return Object.keys(fields).map((fname, index) => Object.assign({
|
||||
description: fields[fname].string,
|
||||
fieldName: fname,
|
||||
fieldType: fields[fname].type,
|
||||
id: index,
|
||||
}, fields[fname]));
|
||||
|
||||
class TestModelExtension extends Model.Extension {
|
||||
get(property) {
|
||||
switch (property) {
|
||||
case 'facets':
|
||||
return [];
|
||||
case 'filters':
|
||||
return Object.keys(fields).map((fname, index) => Object.assign({
|
||||
description: fields[fname].string,
|
||||
fieldName: fname,
|
||||
fieldType: fields[fname].type,
|
||||
id: index,
|
||||
}, fields[fname]));
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
class MockedModel extends Model { }
|
||||
MockedModel.registry = new Registry({ Test: TestModelExtension, });
|
||||
const searchModel = new MockedModel({ Test: {} });
|
||||
const searchBar = await createComponent(SearchBar, {
|
||||
data: this.data,
|
||||
env: { controlPanelModel: new MockedControlPanelModel() },
|
||||
env: { searchModel },
|
||||
props: { fields },
|
||||
});
|
||||
const input = searchBar.el.querySelector('.o_searchview_input');
|
||||
@@ -477,7 +486,7 @@ odoo.define('web.search_bar_tests', function (require) {
|
||||
const searchInput = actionManager.el.querySelector('.o_searchview_input');
|
||||
searchInput.value = "ABC";
|
||||
await testUtils.dom.triggerEvent(searchInput, 'input',
|
||||
{ inputType: 'insertFromPaste' });
|
||||
{ inputType: 'insertFromPaste' });
|
||||
await testUtils.nextTick();
|
||||
assert.containsOnce(actionManager, '.o_searchview_autocomplete',
|
||||
"should display autocomplete dropdown menu on paste in search view");
|
||||
|
||||
@@ -70,7 +70,21 @@ QUnit.module('core', {}, function () {
|
||||
assert.deepEqual(registry.values(), ['bar', 'foo', 'qux']);
|
||||
});
|
||||
|
||||
QUnit.test("predicate prevents invalid values", function (assert) {
|
||||
assert.expect(5);
|
||||
|
||||
const predicate = value => typeof value === "number";
|
||||
const registry = new Registry(null, predicate);
|
||||
registry.onAdd((key) => assert.step(key));
|
||||
|
||||
assert.ok(registry.add("age", 23));
|
||||
assert.throws(
|
||||
() => registry.add("name", "Fred"),
|
||||
new Error(`Value of key "name" does not pass the addition predicate.`)
|
||||
);
|
||||
assert.deepEqual(registry.entries(), { age: 23 });
|
||||
assert.verifySteps(["age"]);
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
|
||||
@@ -177,6 +177,7 @@ odoo.define('web.test_utils', async function (require) {
|
||||
triggerMouseEvent: testUtilsDom.triggerMouseEvent,
|
||||
triggerPositionalMouseEvent: testUtilsDom.triggerPositionalMouseEvent,
|
||||
dragAndDrop: testUtilsDom.dragAndDrop,
|
||||
find: testUtilsDom.findItem,
|
||||
getNode: testUtilsDom.getNode,
|
||||
openDatepicker: testUtilsDom.openDatepicker,
|
||||
click: testUtilsDom.click,
|
||||
|
||||
@@ -1,47 +1,9 @@
|
||||
odoo.define('web.test_utils_control_panel', function (require) {
|
||||
"use strict";
|
||||
|
||||
const { click, triggerEvent, getNode } = require('web.test_utils_dom');
|
||||
const { click, findItem, getNode, triggerEvent } = require('web.test_utils_dom');
|
||||
const { editInput, editSelect, editAndTrigger } = require('web.test_utils_fields');
|
||||
|
||||
//-------------------------------------------------------------------------
|
||||
// Private functions
|
||||
//-------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Helper method to retrieve a distinct item from a collection of elements defined
|
||||
* by the given 'selector' string. It can either be the index of the item or its
|
||||
* inner text.
|
||||
* @private
|
||||
* @param {HTMLElement} el
|
||||
* @param {string} selector
|
||||
* @param {(number|string)} elFinder
|
||||
*/
|
||||
function findItem(el, selector, elFinder) {
|
||||
const elements = [...el.querySelectorAll(selector)];
|
||||
if (!elements.length) {
|
||||
throw new Error(`No element found with selector "${selector}".`);
|
||||
}
|
||||
switch (typeof elFinder) {
|
||||
case 'number':
|
||||
const element = elements[elFinder];
|
||||
if (!element) {
|
||||
throw new Error(`No element with selector "${selector}" at index ${elFinder}.`);
|
||||
}
|
||||
return element;
|
||||
case 'string':
|
||||
const stringMatch = elements.find(
|
||||
e => e.innerText.trim().toLowerCase() === elFinder.toLowerCase()
|
||||
);
|
||||
if (!stringMatch) {
|
||||
throw new Error(`No element with selector "${selector}" containing "${elFinder}".`);
|
||||
}
|
||||
return stringMatch;
|
||||
default:
|
||||
throw new Error(`Invalid provided element finder: must be a number|string|function.`);
|
||||
}
|
||||
}
|
||||
|
||||
//-------------------------------------------------------------------------
|
||||
// Exported functions
|
||||
//-------------------------------------------------------------------------
|
||||
@@ -52,7 +14,7 @@ odoo.define('web.test_utils_control_panel', function (require) {
|
||||
* @returns {Promise}
|
||||
*/
|
||||
async function toggleMenu(el, menuFinder) {
|
||||
const menu = findItem(getNode(el), `.o_dropdown > button`, menuFinder);
|
||||
const menu = findItem(el, `.o_dropdown > button`, menuFinder);
|
||||
await click(menu);
|
||||
}
|
||||
|
||||
@@ -62,7 +24,7 @@ odoo.define('web.test_utils_control_panel', function (require) {
|
||||
* @returns {Promise}
|
||||
*/
|
||||
async function toggleMenuItem(el, itemFinder) {
|
||||
const item = findItem(getNode(el), `.o_menu_item > a`, itemFinder);
|
||||
const item = findItem(el, `.o_menu_item > a`, itemFinder);
|
||||
await click(item);
|
||||
}
|
||||
|
||||
@@ -73,7 +35,7 @@ odoo.define('web.test_utils_control_panel', function (require) {
|
||||
* @returns {Promise}
|
||||
*/
|
||||
async function toggleMenuItemOption(el, itemFinder, optionFinder) {
|
||||
const item = findItem(getNode(el), `.o_menu_item > a`, itemFinder);
|
||||
const item = findItem(el, `.o_menu_item > a`, itemFinder);
|
||||
const option = findItem(item.parentNode, '.o_item_option > a', optionFinder);
|
||||
await click(option);
|
||||
}
|
||||
@@ -84,7 +46,7 @@ odoo.define('web.test_utils_control_panel', function (require) {
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isItemSelected(el, itemFinder) {
|
||||
const item = findItem(getNode(el), `.o_menu_item > a`, itemFinder);
|
||||
const item = findItem(el, `.o_menu_item > a`, itemFinder);
|
||||
return item.classList.contains('selected');
|
||||
}
|
||||
|
||||
@@ -95,7 +57,7 @@ odoo.define('web.test_utils_control_panel', function (require) {
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isOptionSelected(el, itemFinder, optionFinder) {
|
||||
const item = findItem(getNode(el), `.o_menu_item > a`, itemFinder);
|
||||
const item = findItem(el, `.o_menu_item > a`, itemFinder);
|
||||
const option = findItem(item.parentNode, '.o_item_option > a', optionFinder);
|
||||
return option.classList.contains('selected');
|
||||
}
|
||||
@@ -217,7 +179,7 @@ odoo.define('web.test_utils_control_panel', function (require) {
|
||||
* @returns {Promise}
|
||||
*/
|
||||
async function deleteFavorite(el, favoriteFinder) {
|
||||
const favorite = findItem(getNode(el), `.o_favorite_menu .o_menu_item`, favoriteFinder);
|
||||
const favorite = findItem(el, `.o_favorite_menu .o_menu_item`, favoriteFinder);
|
||||
await click(favorite.querySelector('i.fa-trash-o'));
|
||||
}
|
||||
|
||||
@@ -245,7 +207,7 @@ odoo.define('web.test_utils_control_panel', function (require) {
|
||||
* @returns {Promise}
|
||||
*/
|
||||
async function removeFacet(el, facetFinder = 0) {
|
||||
const facet = findItem(getNode(el), `.o_searchview .o_searchview_facet`, facetFinder);
|
||||
const facet = findItem(el, `.o_searchview .o_searchview_facet`, facetFinder);
|
||||
await click(facet.querySelector('.o_facet_remove'));
|
||||
}
|
||||
|
||||
@@ -275,7 +237,7 @@ odoo.define('web.test_utils_control_panel', function (require) {
|
||||
* @returns {Promise}
|
||||
*/
|
||||
async function toggleActionMenu(el, menuFinder = "Action") {
|
||||
const dropdown = findItem(getNode(el), `.o_cp_action_menus button`, menuFinder);
|
||||
const dropdown = findItem(el, `.o_cp_action_menus button`, menuFinder);
|
||||
await click(dropdown);
|
||||
}
|
||||
|
||||
|
||||
@@ -15,11 +15,11 @@ odoo.define('web.test_utils_create', function (require) {
|
||||
const concurrency = require('web.concurrency');
|
||||
const config = require('web.config');
|
||||
const ControlPanel = require('web.ControlPanel');
|
||||
const ControlPanelModel = require('web.ControlPanelModel');
|
||||
const customHooks = require('web.custom_hooks');
|
||||
const DebugManager = require('web.DebugManager.Backend');
|
||||
const dom = require('web.dom');
|
||||
const makeTestEnvironment = require('web.test_env');
|
||||
const ActionModel = require('web/static/src/js/views/action_model.js');
|
||||
const Registry = require('web.Registry');
|
||||
const testUtilsMock = require('web.test_utils_mock');
|
||||
const Widget = require('web.Widget');
|
||||
@@ -173,7 +173,7 @@ odoo.define('web.test_utils_create', function (require) {
|
||||
* available event handlers respectively.
|
||||
* @param {Object} [params={}]
|
||||
* @param {Object} [params.cpProps]
|
||||
* @param {Object} [params.cpStoreConfig]
|
||||
* @param {Object} [params.cpModelConfig]
|
||||
* @param {boolean} [params.debug]
|
||||
* @param {Object} [params.env]
|
||||
* @returns {Object} useful control panel testing elements:
|
||||
@@ -183,61 +183,70 @@ odoo.define('web.test_utils_create', function (require) {
|
||||
* available helpers)
|
||||
*/
|
||||
async function createControlPanel(params = {}) {
|
||||
const config = params.cpStoreConfig || {};
|
||||
const debug = params.debug || false;
|
||||
const env = params.env || {};
|
||||
const env = makeTestEnvironment(params.env || {});
|
||||
const props = Object.assign({
|
||||
action: {},
|
||||
fields: {},
|
||||
}, params.cpProps);
|
||||
const globalConfig = Object.assign({
|
||||
context: {},
|
||||
domain: [],
|
||||
}, params.cpModelConfig);
|
||||
|
||||
if (config.viewInfo && config.viewInfo.arch && config.viewInfo.fields) {
|
||||
const { arch, fields } = config.viewInfo;
|
||||
if (globalConfig.arch && globalConfig.fields) {
|
||||
const model = "__mockmodel__";
|
||||
const serverParams = {
|
||||
model,
|
||||
data: { [model]: { fields, records: [] } },
|
||||
data: { [model]: { fields: globalConfig.fields, records: [] } },
|
||||
};
|
||||
const mockServer = await testUtilsMock.addMockEnvironment(
|
||||
new Widget(),
|
||||
serverParams,
|
||||
);
|
||||
Object.assign(config.viewInfo, testUtilsMock.fieldsViewGet(mockServer, {
|
||||
arch,
|
||||
fields,
|
||||
const { arch, fields } = testUtilsMock.fieldsViewGet(mockServer, {
|
||||
arch: globalConfig.arch,
|
||||
fields: globalConfig.fields,
|
||||
model,
|
||||
viewOptions: { context: config.actionContext },
|
||||
}));
|
||||
viewOptions: { context: globalConfig.context },
|
||||
});
|
||||
Object.assign(globalConfig, { arch, fields });
|
||||
}
|
||||
|
||||
globalConfig.env = env;
|
||||
const archs = (globalConfig.arch && { search: globalConfig.arch, }) || {};
|
||||
const { ControlPanel: controlPanelInfo, } = ActionModel.extractArchInfo(archs);
|
||||
const extensions = {
|
||||
ControlPanel: { archNodes: controlPanelInfo.children, },
|
||||
};
|
||||
|
||||
class Parent extends Component {
|
||||
constructor() {
|
||||
super();
|
||||
config.env = this.env;
|
||||
this._controlPanelModel = new ControlPanelModel(config);
|
||||
this.searchModel = new ActionModel(extensions, globalConfig);
|
||||
this.state = useState(props);
|
||||
this.controlPanel = useRef("controlPanel");
|
||||
}
|
||||
async willStart() {
|
||||
await this._controlPanelModel.isReady;
|
||||
await this.searchModel.load();
|
||||
}
|
||||
mounted() {
|
||||
if (params['get-controller-query-params']) {
|
||||
this._controlPanelModel.on('get-controller-query-params', this,
|
||||
this.searchModel.on('get-controller-query-params', this,
|
||||
params['get-controller-query-params']);
|
||||
}
|
||||
if (params.search) {
|
||||
this._controlPanelModel.on('search', this, params.search);
|
||||
this.searchModel.on('search', this, params.search);
|
||||
}
|
||||
}
|
||||
}
|
||||
Parent.components = { ControlPanel };
|
||||
Parent.env = makeTestEnvironment(env);
|
||||
Parent.env = env;
|
||||
Parent.template = xml`
|
||||
<ControlPanel
|
||||
t-ref="controlPanel"
|
||||
t-props="state"
|
||||
controlPanelModel="_controlPanelModel"
|
||||
searchModel="searchModel"
|
||||
/>`;
|
||||
|
||||
const parent = new Parent();
|
||||
@@ -249,7 +258,7 @@ odoo.define('web.test_utils_create', function (require) {
|
||||
controlPanel.destroy = destroy;
|
||||
parent.destroy();
|
||||
};
|
||||
controlPanel.getQuery = () => parent._controlPanelModel.getQuery();
|
||||
controlPanel.getQuery = () => parent.searchModel.get('query');
|
||||
|
||||
return controlPanel;
|
||||
}
|
||||
|
||||
@@ -290,6 +290,47 @@ odoo.define('web.test_utils_dom', function (require) {
|
||||
return returnAfterNextAnimationFrame();
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper method to retrieve a distinct item from a collection of elements defined
|
||||
* by the given "selector" string. It can either be the index of the item or its
|
||||
* inner text.
|
||||
* @param {Element} el
|
||||
* @param {string} selector
|
||||
* @param {number | string} [elFinder=0]
|
||||
* @returns {Element | null}
|
||||
*/
|
||||
function findItem(el, selector, elFinder = 0) {
|
||||
const elements = [...getNode(el).querySelectorAll(selector)];
|
||||
if (!elements.length) {
|
||||
throw new Error(`No element found with selector "${selector}".`);
|
||||
}
|
||||
switch (typeof elFinder) {
|
||||
case "number": {
|
||||
const match = elements[elFinder];
|
||||
if (!match) {
|
||||
throw new Error(
|
||||
`No element with selector "${selector}" at index ${elFinder}.`
|
||||
);
|
||||
}
|
||||
return match;
|
||||
}
|
||||
case "string": {
|
||||
const match = elements.find(
|
||||
(el) => el.innerText.trim().toLowerCase() === elFinder.toLowerCase()
|
||||
);
|
||||
if (!match) {
|
||||
throw new Error(
|
||||
`No element with selector "${selector}" containing "${elFinder}".
|
||||
`);
|
||||
}
|
||||
return match;
|
||||
}
|
||||
default: throw new Error(
|
||||
`Invalid provided element finder: must be a number|string|function.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function used to extract an HTML EventTarget element from a given
|
||||
* target. The extracted element will depend on the target type:
|
||||
@@ -497,6 +538,7 @@ odoo.define('web.test_utils_dom', function (require) {
|
||||
clickFirst,
|
||||
clickLast,
|
||||
dragAndDrop,
|
||||
findItem,
|
||||
getNode,
|
||||
openDatepicker,
|
||||
returnAfterNextAnimationFrame,
|
||||
|
||||
@@ -2369,7 +2369,7 @@ QUnit.module('Views', {
|
||||
if (route === '/web/dataset/search_read') {
|
||||
assert.step('search_read');
|
||||
const domain = args.domain;
|
||||
assert.deepEqual(domain, [['customer', '=', 1], ['foo', '=', 12]],
|
||||
assert.deepEqual(domain, ['&', ['customer', '=', 1], ['foo', '=', 12]],
|
||||
'list domain is correct');
|
||||
}
|
||||
return this._super.apply(this, arguments);
|
||||
@@ -2444,8 +2444,8 @@ QUnit.module('Views', {
|
||||
"2", "0", "-100%", "2", "0", "-100%",
|
||||
"0", "12", "100%", "0", "12", "100%",
|
||||
"0", "1", "100%", "0", "1", "100%",
|
||||
|
||||
|
||||
|
||||
|
||||
];
|
||||
assert.strictEqual(getCurrentValues(pivot), values.join());
|
||||
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
odoo.define('web.search_panel_tests', function (require) {
|
||||
odoo.define("web/static/tests/views/search_panel_tests.js", function (require) {
|
||||
"use strict";
|
||||
|
||||
var AbstractStorageService = require('web.AbstractStorageService');
|
||||
var FormView = require('web.FormView');
|
||||
var KanbanView = require('web.KanbanView');
|
||||
var RamStorage = require('web.RamStorage');
|
||||
var testUtils = require('web.test_utils');
|
||||
const AbstractStorageService = require('web.AbstractStorageService');
|
||||
const FormView = require('web.FormView');
|
||||
const KanbanView = require('web.KanbanView');
|
||||
const RamStorage = require('web.RamStorage');
|
||||
const testUtils = require('web.test_utils');
|
||||
const SearchPanel = require("web/static/src/js/views/search_panel.js");
|
||||
|
||||
const cpHelpers = testUtils.controlPanel;
|
||||
var createActionManager = testUtils.createActionManager;
|
||||
var createView = testUtils.createView;
|
||||
const createActionManager = testUtils.createActionManager;
|
||||
const createView = testUtils.createView;
|
||||
|
||||
/**
|
||||
* Return the list of counters displayed in the search panel (if any).
|
||||
@@ -412,8 +413,8 @@ QUnit.module('Views', {
|
||||
|
||||
assert.verifySteps([
|
||||
'[["bar","=",true]]',
|
||||
'[["bar","=",true],["company_id","child_of",3]]',
|
||||
'[["bar","=",true],["company_id","child_of",5]]',
|
||||
'["&",["bar","=",true],["company_id","child_of",3]]',
|
||||
'["&",["bar","=",true],["company_id","child_of",5]]',
|
||||
'[["bar","=",true]]',
|
||||
]);
|
||||
|
||||
@@ -490,11 +491,12 @@ QUnit.module('Views', {
|
||||
|
||||
var Storage = RamStorage.extend({
|
||||
getItem: function (key) {
|
||||
assert.step('getItem ' + key);
|
||||
return this._super(...arguments) || 3; // default: 'asustek'
|
||||
assert.step(`getItem ${key}`);
|
||||
const item = this._super(...arguments);
|
||||
return item || 3; // default: 'asustek'
|
||||
},
|
||||
setItem: function (key, value) {
|
||||
assert.step('setItem ' + key + ' to ' + value);
|
||||
assert.step(`setItem ${key} to ${value}`);
|
||||
this._super(...arguments);
|
||||
},
|
||||
});
|
||||
@@ -522,11 +524,11 @@ QUnit.module('Views', {
|
||||
</kanban>`,
|
||||
archs: {
|
||||
'partner,false,search': `
|
||||
<seasrch>
|
||||
<search>
|
||||
<searchpanel>
|
||||
<field name="company_id" enable_counters="1"/>
|
||||
</searchpanel>
|
||||
</seasrch>`,
|
||||
</search>`,
|
||||
},
|
||||
mockRPC: function (route, args) {
|
||||
if (route === '/web/dataset/search_read') {
|
||||
@@ -713,15 +715,30 @@ QUnit.module('Views', {
|
||||
assert.containsN(kanban, '.o_kanban_record:not(.o_kanban_ghost)', 3);
|
||||
|
||||
// select 'asustek'
|
||||
await testUtils.dom.click(kanban.$('.o_search_panel_category_value[data-id=3] header'));
|
||||
await testUtils.dom.click(
|
||||
[
|
||||
...kanban.el.querySelectorAll('.o_search_panel_category_value header .o_search_panel_label_title')
|
||||
]
|
||||
.filter(el => el.innerText === 'asustek')
|
||||
);
|
||||
assert.containsN(kanban, '.o_kanban_record:not(.o_kanban_ghost)', 2);
|
||||
|
||||
// select 'abc'
|
||||
await testUtils.dom.click(kanban.$('.o_search_panel_category_value[data-id=abc] header'));
|
||||
await testUtils.dom.click(
|
||||
[
|
||||
...kanban.el.querySelectorAll('.o_search_panel_category_value header .o_search_panel_label_title')
|
||||
]
|
||||
.filter(el => el.innerText === 'ABC')
|
||||
);
|
||||
assert.containsN(kanban, '.o_kanban_record:not(.o_kanban_ghost)', 1);
|
||||
|
||||
// select 'ghi'
|
||||
await testUtils.dom.click(kanban.$('.o_search_panel_category_value[data-id=ghi] header'));
|
||||
await testUtils.dom.click(
|
||||
[
|
||||
...kanban.el.querySelectorAll('.o_search_panel_category_value header .o_search_panel_label_title')
|
||||
]
|
||||
.filter(el => el.innerText === 'GHI')
|
||||
);
|
||||
assert.containsN(kanban, '.o_kanban_record:not(.o_kanban_ghost)', 1);
|
||||
|
||||
// select 'All' in first category (company_id)
|
||||
@@ -734,10 +751,10 @@ QUnit.module('Views', {
|
||||
|
||||
assert.verifySteps([
|
||||
'[["bar","=",true]]',
|
||||
'[["bar","=",true],["company_id","child_of",3]]',
|
||||
'[["bar","=",true],["company_id","child_of",3],["state","=","abc"]]',
|
||||
'[["bar","=",true],["company_id","child_of",3],["state","=","ghi"]]',
|
||||
'[["bar","=",true],["state","=","ghi"]]',
|
||||
'["&",["bar","=",true],["company_id","child_of",3]]',
|
||||
'["&",["bar","=",true],"&",["company_id","child_of",3],["state","=","abc"]]',
|
||||
'["&",["bar","=",true],"&",["company_id","child_of",3],["state","=","ghi"]]',
|
||||
'["&",["bar","=",true],["state","=","ghi"]]',
|
||||
'[["bar","=",true]]',
|
||||
]);
|
||||
|
||||
@@ -1080,8 +1097,8 @@ QUnit.module('Views', {
|
||||
|
||||
assert.verifySteps([
|
||||
'[["bar","=",true]]',
|
||||
'[["bar","=",true],["company_id","child_of",3]]',
|
||||
'[["bar","=",true],["company_id","child_of",5]]',
|
||||
'["&",["bar","=",true],["company_id","child_of",3]]',
|
||||
'["&",["bar","=",true],["company_id","child_of",5]]',
|
||||
]);
|
||||
|
||||
kanban.destroy();
|
||||
@@ -1136,7 +1153,7 @@ QUnit.module('Views', {
|
||||
|
||||
// 'All' should still be selected, and there should still be 4 records
|
||||
assert.containsOnce(kanban, '.o_search_panel_category_value .active');
|
||||
assert.containsOnce(kanban, '.o_search_panel_category_value:first .active');
|
||||
assert.containsOnce(kanban, '.o_search_panel_category_value:nth(1) .active');
|
||||
assert.containsN(kanban, '.o_kanban_record:not(.o_kanban_ghost)', 4);
|
||||
|
||||
// select 'ghi' (delay the reload)
|
||||
@@ -1146,7 +1163,7 @@ QUnit.module('Views', {
|
||||
|
||||
// 'All' should still be selected, and there should still be 4 records
|
||||
assert.containsOnce(kanban, '.o_search_panel_category_value .active');
|
||||
assert.containsOnce(kanban, '.o_search_panel_category_value:first .active');
|
||||
assert.containsOnce(kanban, '.o_search_panel_category_value:nth(3) .active');
|
||||
assert.containsN(kanban, '.o_kanban_record:not(.o_kanban_ghost)', 4);
|
||||
|
||||
// unlock ghi search
|
||||
@@ -1195,6 +1212,7 @@ QUnit.module('Views', {
|
||||
archs: {
|
||||
'partner,false,search': `
|
||||
<search>
|
||||
<filter name="Filter" domain="[('id', '=', 1)]"/>
|
||||
<searchpanel>
|
||||
<field name="company_id" select="multi" enable_counters="1"/>
|
||||
</searchpanel>
|
||||
@@ -1206,7 +1224,8 @@ QUnit.module('Views', {
|
||||
|
||||
// trigger a reload and delay the get_filter
|
||||
def = testUtils.makeTestPromise();
|
||||
kanban.reload({domain: [['id', '=', 1]]});
|
||||
await cpHelpers.toggleFilterMenu(kanban);
|
||||
await cpHelpers.toggleMenuItem(kanban, 0);
|
||||
await testUtils.nextTick();
|
||||
|
||||
assert.containsN(kanban, '.o_kanban_view .o_kanban_record:not(.o_kanban_ghost)', 4);
|
||||
@@ -1311,13 +1330,13 @@ QUnit.module('Views', {
|
||||
'[["bar","=",true]]',
|
||||
// 'asustek' checked
|
||||
'[]',
|
||||
'[["bar","=",true],["company_id","in",[3]]]',
|
||||
'["&",["bar","=",true],["company_id","in",[3]]]',
|
||||
// 'asustek' and 'agrolait' checked
|
||||
'[]',
|
||||
'[["bar","=",true],["company_id","in",[3,5]]]',
|
||||
'["&",["bar","=",true],["company_id","in",[3,5]]]',
|
||||
// 'agrolait' checked
|
||||
'[]',
|
||||
'[["bar","=",true],["company_id","in",[5]]]',
|
||||
'["&",["bar","=",true],["company_id","in",[5]]]',
|
||||
// nothing checked
|
||||
'[]',
|
||||
'[["bar","=",true]]',
|
||||
@@ -1418,13 +1437,13 @@ QUnit.module('Views', {
|
||||
'[["bar","=",true]]',
|
||||
// 'asustek' checked
|
||||
'[]',
|
||||
'[["bar","=",true],["state","in",["abc"]]]',
|
||||
'["&",["bar","=",true],["state","in",["abc"]]]',
|
||||
// 'asustek' and 'agrolait' checked
|
||||
'[]',
|
||||
'[["bar","=",true],["state","in",["abc","def"]]]',
|
||||
'["&",["bar","=",true],["state","in",["abc","def"]]]',
|
||||
// 'agrolait' checked
|
||||
'[]',
|
||||
'[["bar","=",true],["state","in",["def"]]]',
|
||||
'["&",["bar","=",true],["state","in",["def"]]]',
|
||||
// nothing checked
|
||||
'[]',
|
||||
'[["bar","=",true]]',
|
||||
@@ -1458,6 +1477,7 @@ QUnit.module('Views', {
|
||||
archs: {
|
||||
'partner,false,search': `
|
||||
<search>
|
||||
<filter name="Filter" domain="[('id', '<', 5)]"/>
|
||||
<searchpanel>
|
||||
<field name="state" expand="1"/>
|
||||
<field name="company_id" select="multi" enable_counters="1" expand="1"/>
|
||||
@@ -1483,7 +1503,8 @@ QUnit.module('Views', {
|
||||
]);
|
||||
|
||||
// reload with another domain, so the filters should be reloaded
|
||||
await kanban.reload({domain: [['id', '<', 5]]});
|
||||
await cpHelpers.toggleFilterMenu(kanban);
|
||||
await cpHelpers.toggleMenuItem(kanban, 0);
|
||||
|
||||
assert.verifySteps([
|
||||
'search_panel_select_multi_range',
|
||||
@@ -1526,6 +1547,7 @@ QUnit.module('Views', {
|
||||
archs: {
|
||||
'partner,false,search': `
|
||||
<search>
|
||||
<filter name="domain" domain="[('id', '<', 5)]"/>
|
||||
<searchpanel>
|
||||
<field name="category_id" expand="1"/>
|
||||
<field name="company_id" select="multi" enable_counters="1" expand="1"/>
|
||||
@@ -1551,7 +1573,8 @@ QUnit.module('Views', {
|
||||
]);
|
||||
|
||||
// reload with another domain, so the filters should be reloaded
|
||||
await kanban.reload({domain: [['id', '<', 5]]});
|
||||
await cpHelpers.toggleFilterMenu(kanban);
|
||||
await cpHelpers.toggleMenuItem(kanban, 0);
|
||||
|
||||
assert.verifySteps([
|
||||
'search_panel_select_multi_range',
|
||||
@@ -1597,6 +1620,7 @@ QUnit.module('Views', {
|
||||
archs: {
|
||||
'partner,false,search': `
|
||||
<search>
|
||||
<filter name="Filter" domain="[('id', '<', 3)]"/>
|
||||
<searchpanel>
|
||||
<field name="state" enable_counters="1" expand="1"/>
|
||||
<field name="company_id" expand="1"/>
|
||||
@@ -1637,8 +1661,9 @@ QUnit.module('Views', {
|
||||
[ "All", "ABC1", "DEF1", "GHI2", "All", "asustek", "agrolait"]
|
||||
);
|
||||
|
||||
// reload with another domain, so the category 'state' should be reloaded
|
||||
await kanban.reload({domain: [['id', '<', 3]]});
|
||||
// reload with another domain, so the categories 'state' and 'company_id' should be reloaded
|
||||
await cpHelpers.toggleFilterMenu(kanban);
|
||||
await cpHelpers.toggleMenuItem(kanban, 0);
|
||||
|
||||
assert.verifySteps([
|
||||
'search_panel_select_range',
|
||||
@@ -1700,6 +1725,7 @@ QUnit.module('Views', {
|
||||
archs: {
|
||||
'partner,false,search': `
|
||||
<search>
|
||||
<filter name="Filter" domain="[('id', '<', 3)]"/>
|
||||
<searchpanel>
|
||||
<field name="state" expand="1"/>
|
||||
</searchpanel>
|
||||
@@ -1738,7 +1764,8 @@ QUnit.module('Views', {
|
||||
);
|
||||
|
||||
// reload with another domain, so the category 'state' should be reloaded
|
||||
await kanban.reload({domain: [['id', '<', 3]]});
|
||||
await cpHelpers.toggleFilterMenu(kanban);
|
||||
await cpHelpers.toggleMenuItem(kanban, 0);
|
||||
|
||||
assert.verifySteps([
|
||||
'/web/dataset/search_read',
|
||||
@@ -1884,16 +1911,16 @@ QUnit.module('Views', {
|
||||
'[["bar","=",true]]',
|
||||
// 'asustek' checked
|
||||
'[]',
|
||||
'[["bar","=",true],["company_id","in",[3]]]',
|
||||
'["&",["bar","=",true],["company_id","in",[3]]]',
|
||||
// 'asustek' and 'agrolait' checked
|
||||
'[]',
|
||||
'[["bar","=",true],["company_id","in",[3]],["company_id","in",[5]]]',
|
||||
'["&",["bar","=",true],"&",["company_id","in",[3]],["company_id","in",[5]]]',
|
||||
// 'asustek', 'agrolait' and 'camptocamp' checked
|
||||
'[]',
|
||||
'[["bar","=",true],["company_id","in",[3]],["company_id","in",[5,11]]]',
|
||||
'["&",["bar","=",true],"&",["company_id","in",[3]],["company_id","in",[5,11]]]',
|
||||
// 'asustek' checked
|
||||
'[]',
|
||||
'[["bar","=",true],["company_id","in",[3]]]',
|
||||
'["&",["bar","=",true],["company_id","in",[3]]]',
|
||||
]);
|
||||
|
||||
kanban.destroy();
|
||||
@@ -2038,6 +2065,7 @@ QUnit.module('Views', {
|
||||
assert.expect(4);
|
||||
|
||||
var expectedDomain = [
|
||||
"&",
|
||||
['company_id', 'in', [5]],
|
||||
['state', 'in', ['abc', 'ghi']],
|
||||
];
|
||||
@@ -2548,6 +2576,9 @@ QUnit.module('Views', {
|
||||
QUnit.test('scroll position is kept when switching between controllers', async function (assert) {
|
||||
assert.expect(6);
|
||||
|
||||
const originalDebounce = SearchPanel.scrollDebounce;
|
||||
SearchPanel.scrollDebounce = 0;
|
||||
|
||||
for (var i = 10; i < 20; i++) {
|
||||
this.data.category.records.push({id: i, name: "Cat " + i});
|
||||
}
|
||||
@@ -2560,24 +2591,30 @@ QUnit.module('Views', {
|
||||
});
|
||||
actionManager.$el.css('max-height', 300);
|
||||
|
||||
async function scroll(top) {
|
||||
actionManager.el.querySelector(".o_search_panel").scrollTop = top;
|
||||
await testUtils.nextTick();
|
||||
}
|
||||
|
||||
await actionManager.doAction(1);
|
||||
|
||||
assert.containsOnce(actionManager, '.o_content .o_kanban_view');
|
||||
assert.strictEqual(actionManager.$('.o_search_panel').scrollTop(), 0);
|
||||
|
||||
// simulate a scroll in the search panel and switch into list
|
||||
actionManager.$('.o_search_panel').scrollTop(50);
|
||||
await scroll(50);
|
||||
await cpHelpers.switchView(actionManager, 'list');
|
||||
assert.containsOnce(actionManager, '.o_content .o_list_view');
|
||||
assert.strictEqual(actionManager.$('.o_search_panel').scrollTop(), 50);
|
||||
|
||||
// simulate another scroll and switch back to kanban
|
||||
actionManager.$('.o_search_panel').scrollTop(30);
|
||||
await scroll(30);
|
||||
await cpHelpers.switchView(actionManager, 'kanban');
|
||||
assert.containsOnce(actionManager, '.o_content .o_kanban_view');
|
||||
assert.strictEqual(actionManager.$('.o_search_panel').scrollTop(), 30);
|
||||
|
||||
actionManager.destroy();
|
||||
SearchPanel.scrollDebounce = originalDebounce;
|
||||
});
|
||||
|
||||
QUnit.test('search panel is not instantiated in dialogs', async function (assert) {
|
||||
@@ -3949,7 +3986,7 @@ QUnit.module('Views', {
|
||||
|
||||
assert.containsOnce(kanban, '.o_search_panel_section');
|
||||
assert.containsOnce(kanban, '.o_search_panel_section_header');
|
||||
assert.strictEqual(kanban.el.querySelector('.o_search_panel_section_header').innerText, " COMPANY");
|
||||
assert.strictEqual(kanban.el.querySelector('.o_search_panel_section_header').innerText, "COMPANY");
|
||||
assert.containsOnce(kanban, 'section div.alert.alert-warning');
|
||||
assert.strictEqual(kanban.el.querySelector('section div.alert.alert-warning').innerText, "Too many items to display.");
|
||||
assert.containsNone(kanban, '.o_search_panel_category_value');
|
||||
@@ -3987,7 +4024,7 @@ QUnit.module('Views', {
|
||||
|
||||
assert.containsOnce(kanban, '.o_search_panel_section');
|
||||
assert.containsOnce(kanban, '.o_search_panel_section_header');
|
||||
assert.strictEqual(kanban.el.querySelector('.o_search_panel_section_header').innerText, " COMPANY");
|
||||
assert.strictEqual(kanban.el.querySelector('.o_search_panel_section_header').innerText, "COMPANY");
|
||||
assert.containsOnce(kanban, 'section div.alert.alert-warning');
|
||||
assert.strictEqual(kanban.el.querySelector('section div.alert.alert-warning').innerText, "Too many items to display.");
|
||||
assert.containsNone(kanban, '.o_search_panel_filter_value');
|
||||
|
||||
@@ -148,8 +148,11 @@ QUnit.module('Views', {
|
||||
mockRPC: function (route, args) {
|
||||
if (args.method === 'web_read_group') {
|
||||
assert.deepEqual(args.kwargs, {
|
||||
context: {},
|
||||
domain: [["display_name", "like", "a"], "&", ["display_name", "ilike", "piou"], ["foo", "ilike", "piou"]],
|
||||
context: {
|
||||
search_default_foo: "piou",
|
||||
search_default_groupby_bar: true,
|
||||
},
|
||||
domain: ["&", ["display_name", "like", "a"], "&", ["display_name", "ilike", "piou"], ["foo", "ilike", "piou"]],
|
||||
fields: ["display_name", "foo", "bar"],
|
||||
groupby: ["bar"],
|
||||
orderby: '',
|
||||
@@ -160,8 +163,12 @@ QUnit.module('Views', {
|
||||
if (search === 0 && route === '/web/dataset/search_read') {
|
||||
search++;
|
||||
assert.deepEqual(args, {
|
||||
context: {'bin_size': true}, // not part of the test, may change
|
||||
domain: [["display_name", "like", "a"], "&", ["display_name", "ilike", "piou"], ["foo", "ilike", "piou"]],
|
||||
context: {
|
||||
search_default_foo: "piou",
|
||||
search_default_groupby_bar: true,
|
||||
bin_size: true
|
||||
}, // not part of the test, may change
|
||||
domain: ["&", ["display_name", "like", "a"], "&", ["display_name", "ilike", "piou"], ["foo", "ilike", "piou"]],
|
||||
fields: ["display_name", "foo"],
|
||||
model: "partner",
|
||||
limit: 80,
|
||||
@@ -169,7 +176,11 @@ QUnit.module('Views', {
|
||||
}, "should search with the complete domain (domain + search)");
|
||||
} else if (search === 1 && route === '/web/dataset/search_read') {
|
||||
assert.deepEqual(args, {
|
||||
context: {'bin_size': true}, // not part of the test, may change
|
||||
context: {
|
||||
search_default_foo: "piou",
|
||||
search_default_groupby_bar: true,
|
||||
bin_size: true
|
||||
}, // not part of the test, may change
|
||||
domain: [["display_name", "like", "a"]],
|
||||
fields: ["display_name", "foo"],
|
||||
model: "partner",
|
||||
|
||||
@@ -323,7 +323,7 @@
|
||||
<script type="text/javascript" src="/web/static/src/js/views/basic/basic_renderer.js"></script>
|
||||
<script type="text/javascript" src="/web/static/src/js/control_panel/comparison_menu.js"></script>
|
||||
<script type="text/javascript" src="/web/static/src/js/control_panel/control_panel.js"></script>
|
||||
<script type="text/javascript" src="/web/static/src/js/control_panel/control_panel_model.js"></script>
|
||||
<script type="text/javascript" src="/web/static/src/js/control_panel/control_panel_model_extension.js"></script>
|
||||
<script type="text/javascript" src="/web/static/src/js/control_panel/control_panel_x2many.js"></script>
|
||||
<script type="text/javascript" src="/web/static/src/js/control_panel/custom_favorite_item.js"></script>
|
||||
<script type="text/javascript" src="/web/static/src/js/control_panel/favorite_menu.js"></script>
|
||||
@@ -333,7 +333,9 @@
|
||||
<script type="text/javascript" src="/web/static/src/js/control_panel/custom_group_by_item.js"></script>
|
||||
<script type="text/javascript" src="/web/static/src/js/control_panel/search_bar.js"></script>
|
||||
<script type="text/javascript" src="/web/static/src/js/control_panel/search_utils.js"></script>
|
||||
<script type="text/javascript" src="/web/static/src/js/views/search_panel_model_extension.js"></script>
|
||||
<script type="text/javascript" src="/web/static/src/js/views/search_panel.js"></script>
|
||||
<script type="text/javascript" src="/web/static/src/js/views/action_model.js"></script>
|
||||
<script type="text/javascript" src="/web/static/src/js/views/field_manager_mixin.js"></script>
|
||||
<script type="text/javascript" src="/web/static/src/js/views/file_upload_mixin.js"></script>
|
||||
<script type="text/javascript" src="/web/static/src/js/views/file_upload_progress_bar.js"></script>
|
||||
@@ -749,7 +751,7 @@
|
||||
<script type="text/javascript" src="/web/static/tests/components/action_menus_tests.js"></script>
|
||||
<script type="text/javascript" src="/web/static/tests/components/dropdown_menu_tests.js"></script>
|
||||
|
||||
<script type="text/javascript" src="/web/static/tests/control_panel/control_panel_model_tests.js"></script>
|
||||
<script type="text/javascript" src="/web/static/tests/control_panel/control_panel_model_extension_tests.js"></script>
|
||||
<script type="text/javascript" src="/web/static/tests/control_panel/control_panel_tests.js"></script>
|
||||
<script type="text/javascript" src="/web/static/tests/control_panel/comparison_menu_tests.js"></script>
|
||||
<script type="text/javascript" src="/web/static/tests/control_panel/favorite_menu_tests.js"></script>
|
||||
|
||||
Reference in New Issue
Block a user