[IMP] web: Model: extensions registry

Refactoring of the Model class of `model.js` to support multiple
extensions.

The purpose is to build a model having the appropriate modules for a
given situation. For instance, we don't need the control panel model if
we don't have a control panel to start with.

Some logic of the control panel model (which was until now the "main"
search model) has been given to another model extension named
"Search Model Extension" which will synchronize the informations of all
models providing search data (domain, context, etc.).

The model instantiated in the actions/views is now an instance of the
"Action Model" (which directly extends the Model) and 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.).

Part of task 2228968

Co-authored-by: Julien Mougenot <jum@odoo.com>
Co-authored-by: Mathieu Duckerts-Antoine <dam@odoo.com>
This commit is contained in:
Julien Mougenot
2020-07-23 08:21:10 +00:00
committed by Mathieu Duckerts-Antoine
co-authored by Mathieu Duckerts-Antoine
parent 5698d1fab9
commit d6ffbfd479
5 changed files with 904 additions and 422 deletions
@@ -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;
});
+25
View File
@@ -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.
+409 -68
View File
@@ -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);
};
@@ -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;
});
+2 -1
View File
@@ -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>
@@ -334,6 +334,7 @@
<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.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>