diff --git a/addons/crm/__manifest__.py b/addons/crm/__manifest__.py
index a864bc74c8d..8841e81f3b7 100644
--- a/addons/crm/__manifest__.py
+++ b/addons/crm/__manifest__.py
@@ -91,6 +91,7 @@
'web.qunit_suite_tests': [
'crm/static/tests/mock_server.js',
'crm/static/tests/forecast_kanban_tests.js',
+ 'crm/static/tests/forecast_view_tests.js',
'crm/static/tests/crm_rainbowman_tests.js',
],
},
diff --git a/addons/crm/static/src/js/forecast/forecast_model_extension.js b/addons/crm/static/src/js/forecast/forecast_model_extension.js
index 0c6401317c8..1d6fc53751c 100644
--- a/addons/crm/static/src/js/forecast/forecast_model_extension.js
+++ b/addons/crm/static/src/js/forecast/forecast_model_extension.js
@@ -41,7 +41,6 @@ class ForecastModelExtension extends ActionModel.Extension {
super.prepareState(...arguments);
Object.assign(this.state, {
forecastField: null, // {string} forecast_field from the context (name)
- forecastFieldType: null, // {string} forecast_field type (date or datetime)
forecastFilter: null, // {boolean} if the "Forecast" filter is active
forecastStart: null, // {string} limiting bound of the filter (if active)
// -> starting date before which records are filtered out
@@ -55,6 +54,8 @@ class ForecastModelExtension extends ActionModel.Extension {
* @returns {Promise}
*/
callLoad() {
+ // this is bad to do this always: if a state has been received and the view is loaded the
+ // first time, the state won't be used! to fix!
this._computeState();
return super.callLoad(...arguments);
}
@@ -111,6 +112,6 @@ class ForecastModelExtension extends ActionModel.Extension {
}
}
-ActionModel.registry.add("Forecast", ForecastModelExtension, 20);
+ActionModel.registry.add("forecast", ForecastModelExtension, 20);
export default ForecastModelExtension;
diff --git a/addons/crm/static/src/js/forecast/forecast_search_model.js b/addons/crm/static/src/js/forecast/forecast_search_model.js
new file mode 100644
index 00000000000..5cb0a2a4abc
--- /dev/null
+++ b/addons/crm/static/src/js/forecast/forecast_search_model.js
@@ -0,0 +1,105 @@
+/** @odoo-module */
+
+import { Domain } from "@web/core/domain";
+import { makeContext } from "@web/core/context";
+import { SearchModel } from "@web/search/search_model";
+
+/**
+ * This is the conversion of ForecastModelExtension. See there for more
+ * explanations of what is done here.
+ */
+const DATE_FORMAT = {
+ datetime: "YYYY-MM-DD HH:mm:ss",
+ date: "YYYY-MM-DD",
+};
+
+export class ForecastSearchModel extends SearchModel {
+ /**
+ * @override
+ */
+ exportState() {
+ const state = super.exportState();
+ state.forecast = {
+ forecastField: this.forecastField,
+ forecastFilter: this.forecastFilter,
+ forecastStart: this.forecastStart,
+ };
+ return state;
+ }
+
+ /**
+ * @protected
+ * @returns {string}
+ */
+ _getForecastStart() {
+ /** @todo stop using moment */
+ const type = this.searchViewFields[this.forecastField].type;
+ let startMoment;
+ const groupBy = this.groupBy;
+ const firstForecastGroupBy = groupBy.find((gb) => gb.includes(this.forecastField));
+ let granularity = "month";
+ if (firstForecastGroupBy) {
+ granularity = firstForecastGroupBy.split(":")[1] || "month";
+ } else if (groupBy.length) {
+ granularity = "day";
+ }
+ startMoment = moment().startOf(granularity);
+ if (type === "datetime") {
+ startMoment = moment.utc(startMoment);
+ }
+ const format = DATE_FORMAT[type];
+ return startMoment.format(format);
+ }
+
+ _getSearchItemDomain(activeItem) {
+ const domain = super._getSearchItemDomain(...arguments);
+
+ const { forecast_field: forecastField } = this.globalContext;
+ const searchItem = this.searchItems[activeItem.searchItemId];
+
+ if (forecastField && searchItem.type === "filter") {
+ const context = makeContext(searchItem.context || {});
+ if (!context.forecast_filter) {
+ return domain;
+ }
+
+ this.forecastField = forecastField;
+ this.forecastFilter = true;
+ if (!this.forecastStart) {
+ this.forecastStart = this._getForecastStart();
+ }
+
+ const forecastDomain = [
+ "|",
+ [this.forecastField, "=", false],
+ [this.forecastField, ">=", this.forecastStart],
+ ];
+ return Domain.and([domain, forecastDomain]);
+ }
+
+ return domain;
+ }
+
+ /**
+ * @override
+ */
+ _importState(state) {
+ super._importState(...arguments);
+ if (state.Forecast) {
+ const { forecastField, forecastFilter, forecastStart } = state.forecast;
+ this.forecastField = forecastField;
+ this.forecastFilter = forecastFilter;
+ this.forecastStart = forecastStart;
+ }
+ }
+
+ /**
+ * @override
+ */
+ _reset() {
+ super._reset();
+ this.forecastField = null;
+ this.forecastFilter = false;
+ this.forecastStart = null;
+ }
+}
diff --git a/addons/crm/static/src/js/forecast/forecast_views.js b/addons/crm/static/src/js/forecast/forecast_views.js
index 6b19eeec85e..f1eac289a37 100644
--- a/addons/crm/static/src/js/forecast/forecast_views.js
+++ b/addons/crm/static/src/js/forecast/forecast_views.js
@@ -2,29 +2,24 @@
import { ForecastKanbanController } from './forecast_controllers';
import { ForecastKanbanModel } from './forecast_models';
import { ForecastKanbanRenderer } from './forecast_renderers';
-import GraphView from 'web.GraphView';
+import { ForecastSearchModel } from "./forecast_search_model";
+import { GraphView } from "@web/views/graph/graph_view";
import KanbanView from 'web.KanbanView';
import ListView from 'web.ListView';
-import PivotView from 'web.PivotView';
+import { PivotView } from "@web/views/pivot/pivot_view";
+import { registry } from "@web/core/registry";
import viewRegistry from 'web.view_registry';
/**
- * Graph view to be used for a Forecast @see ForecastModelExtension
+ * Graph view to be used for a Forecast @see ForecastSearchModel
* requires:
* - context key `forecast_field` on a date/datetime field
* - special filter "Forecast" (which must set the `forecast_filter:1` context key)
*/
-const ForecastGraphView = GraphView.extend({
- /**
- * @private
- * @override
- */
- _createSearchModel(params, extraExtensions = {}) {
- Object.assign(extraExtensions, { Forecast: {} });
- return this._super(params, extraExtensions);
- },
-});
-viewRegistry.add('forecast_graph', ForecastGraphView);
+class ForecastGraphView extends GraphView {}
+ForecastGraphView.SearchModel = ForecastSearchModel;
+
+registry.category("views").add("forecast_graph", ForecastGraphView);
/**
* Kanban view to be used for a Forecast
@@ -46,7 +41,7 @@ const ForecastKanbanView = KanbanView.extend({
* @override
*/
_createSearchModel(params, extraExtensions={}) {
- Object.assign(extraExtensions, { Forecast: {} });
+ Object.assign(extraExtensions, { forecast: {} });
return this._super(params, extraExtensions);
},
});
@@ -64,29 +59,22 @@ const ForecastListView = ListView.extend({
* @override
*/
_createSearchModel(params, extraExtensions = {}) {
- Object.assign(extraExtensions, { Forecast: {} });
+ Object.assign(extraExtensions, { forecast: {} });
return this._super(params, extraExtensions);
},
});
viewRegistry.add('forecast_list', ForecastListView);
/**
- * Pivot view to be used for a Forecast @see ForecastModelExtension
+ * Pivot view to be used for a Forecast @see ForecastSearchModel
* requires:
* - context key `forecast_field` on a date/datetime field
* - special filter "Forecast" (which must set the `forecast_filter:1` context key)
*/
-const ForecastPivotView = PivotView.extend({
- /**
- * @private
- * @override
- */
- _createSearchModel(params, extraExtensions = {}) {
- Object.assign(extraExtensions, { Forecast: {} });
- return this._super(params, extraExtensions);
- },
-});
-viewRegistry.add('forecast_pivot', ForecastPivotView);
+class ForecastPivotView extends PivotView {}
+ForecastPivotView.SearchModel = ForecastSearchModel;
+
+registry.category("views").add("forecast_pivot", ForecastPivotView);
export {
ForecastGraphView,
diff --git a/addons/crm/static/tests/forecast_view_tests.js b/addons/crm/static/tests/forecast_view_tests.js
new file mode 100644
index 00000000000..476b8c2ae36
--- /dev/null
+++ b/addons/crm/static/tests/forecast_view_tests.js
@@ -0,0 +1,255 @@
+/** @odoo-module **/
+
+import { _lt } from "@web/core/l10n/translation";
+import AbstractView from "web.AbstractView";
+import AbstractModel from "web.AbstractModel";
+import { controlPanel as cpHelpers } from "web.test_utils";
+import { createWebClient, doAction } from "@web/../tests/webclient/helpers";
+import { dialogService } from "@web/core/dialog/dialog_service";
+import { legacyExtraNextTick, patchWithCleanup } from "@web/../tests/helpers/utils";
+import legacyViewRegistry from "web.view_registry";
+import { makeView } from "@web/../tests/views/helpers";
+import { mock } from "web.test_utils";
+import { registry } from "@web/core/registry";
+import {
+ setupControlPanelServiceRegistry,
+ switchView,
+ toggleFilterMenu,
+ toggleGroupByMenu,
+ toggleMenuItem,
+ toggleMenuItemOption,
+} from "@web/../tests/search/helpers";
+
+const patchDate = mock.patchDate;
+
+const serviceRegistry = registry.category("services");
+
+const forecastDomain = (forecastStart) => {
+ return ["|", ["date_field", "=", false], ["date_field", ">=", forecastStart]];
+};
+
+let serverData;
+QUnit.module("Views", (hooks) => {
+ hooks.beforeEach(async () => {
+ serverData = {
+ models: {
+ foo: {
+ fields: {
+ date_field: {
+ string: "Date Field",
+ type: "date",
+ store: true,
+ sortable: true,
+ },
+ bar: {
+ string: "Bar",
+ type: "many2one",
+ relation: "partner",
+ store: true,
+ sortable: true,
+ },
+ },
+ records: [],
+ },
+ },
+ views: {
+ "foo,false,legacy_toy": ``,
+ "foo,false,graph": ``,
+ "foo,false,search": `
+
+
+
+
+
+ `,
+ },
+ };
+ setupControlPanelServiceRegistry();
+ serviceRegistry.add("dialog", dialogService);
+ });
+
+ QUnit.module("Forecast views");
+
+ QUnit.test("Forecast graph view", async function (assert) {
+ assert.expect(5);
+
+ const unpatchDate = patchDate(2021, 8, 16, 16, 54, 0);
+
+ const expectedDomains = [
+ forecastDomain("2021-09-01"), // month granularity due to no groupby
+ forecastDomain("2021-09-16"), // day granularity due to simple bar groupby
+ // quarter granularity due to date field groupby activated with quarter interval option
+ forecastDomain("2021-07-01"),
+ // quarter granularity due to date field groupby activated with quarter and year interval option
+ forecastDomain("2021-01-01"),
+ // forecast filter no more active
+ [],
+ ];
+
+ const forecastGraph = await makeView({
+ resModel: "foo",
+ type: "forecast_graph",
+ serverData,
+ searchViewId: false,
+ context: {
+ search_default_forecast_filter: 1,
+ forecast_field: "date_field",
+ },
+ mockRPC(_, args) {
+ if (args.method === "web_read_group") {
+ const { domain } = args.kwargs;
+ assert.deepEqual(domain, expectedDomains.shift());
+ }
+ },
+ });
+
+ await toggleGroupByMenu(forecastGraph);
+ await toggleMenuItem(forecastGraph, "Bar");
+
+ await toggleMenuItem(forecastGraph, "Date Field");
+ await toggleMenuItemOption(forecastGraph, "Date Field", "Quarter");
+
+ await toggleMenuItemOption(forecastGraph, "Date Field", "Year");
+
+ await toggleFilterMenu(forecastGraph);
+ await toggleMenuItem(forecastGraph, "Forecast Filter");
+
+ unpatchDate();
+ });
+
+ /** @todo remove this legacy test when conversion of all forecast views is done */
+ QUnit.test(
+ "legacy and new forecast views can share search model state",
+ async function (assert) {
+ assert.expect(16);
+
+ const unpatchDate = patchDate(2021, 8, 16, 16, 54, 0);
+
+ const expectedDomains = [
+ // first doAction
+ forecastDomain("2021-09-01"), // initial load of forecast_graph
+ forecastDomain("2021-07-01"), // toggle date field groupby with quarter option
+ forecastDomain("2021-07-01"), // initial load of legacy_toy
+ forecastDomain("2021-01-01"), // toggle date field groupby with year option
+ forecastDomain("2021-01-01"), // switch back to forecast_graph
+
+ // second doAction
+
+ forecastDomain("2021-09-01"), // initial load of legacy_toy
+ forecastDomain("2021-07-01"), // toggle date field groupby with quarter option
+ forecastDomain("2021-07-01"), // switch to forecast_graph
+ forecastDomain("2021-01-01"), // toggle date field groupby with year option
+ forecastDomain("2021-01-01"), // switch back to legacy_toy
+ ];
+
+ patchWithCleanup(AbstractModel.prototype, {
+ async load(params) {
+ this._super(...arguments);
+ assert.deepEqual(params.domain, expectedDomains.shift());
+ },
+ async reload(_, params) {
+ this._super(...arguments);
+ assert.deepEqual(params.domain, expectedDomains.shift());
+ },
+ });
+
+ const LegacyForecastView = AbstractView.extend({
+ display_name: _lt("Legacy toy view"),
+ icon: "fa fa-bars",
+ multiRecord: true,
+ viewType: "legacy_toy",
+ searchMenuTypes: ["filter", "groupBy"],
+
+ _createSearchModel(params, extraExtensions = {}) {
+ Object.assign(extraExtensions, { forecast: {} });
+ return this._super(params, extraExtensions);
+ },
+ });
+
+ legacyViewRegistry.add("legacy_toy", LegacyForecastView);
+
+ const webClient = await createWebClient({
+ serverData,
+ mockRPC(_, args) {
+ if (args.method === "web_read_group") {
+ const { domain } = args.kwargs;
+ assert.deepEqual(domain, expectedDomains.shift());
+ }
+ },
+ });
+
+ // first doAction forecast_graph -> legacy_toy -> forecast_graph
+
+ await doAction(webClient, {
+ name: "Action name",
+ res_model: "foo",
+ type: "ir.actions.act_window",
+ views: [
+ [false, "graph"],
+ [false, "legacy_toy"],
+ ],
+ context: {
+ search_default_forecast_filter: 1,
+ forecast_field: "date_field",
+ },
+ });
+
+ assert.containsOnce(webClient, ".o_switch_view.o_graph.active");
+
+ await toggleGroupByMenu(webClient);
+ await toggleMenuItem(webClient, "Date Field");
+ await toggleMenuItemOption(webClient, "Date Field", "Quarter");
+
+ await switchView(webClient, "legacy_toy");
+ await legacyExtraNextTick();
+
+ assert.containsOnce(webClient, ".o_switch_view.o_legacy_toy.active");
+
+ await cpHelpers.toggleGroupByMenu(webClient);
+ await cpHelpers.toggleMenuItem(webClient, "Date Field");
+ await cpHelpers.toggleMenuItemOption(webClient, "Date Field", "Year");
+
+ await switchView(webClient, "graph");
+
+ assert.containsOnce(webClient, ".o_switch_view.o_graph.active");
+
+ // second doAction legacy_toy -> forecast_graph -> legacy_toy
+
+ await doAction(webClient, {
+ name: "Action name",
+ res_model: "foo",
+ type: "ir.actions.act_window",
+ views: [
+ [false, "legacy_toy"],
+ [false, "graph"],
+ ],
+ context: {
+ search_default_forecast_filter: 1,
+ forecast_field: "date_field",
+ },
+ });
+ await legacyExtraNextTick();
+
+ assert.containsOnce(webClient, ".o_switch_view.o_legacy_toy.active");
+
+ await cpHelpers.toggleGroupByMenu(webClient);
+ await cpHelpers.toggleMenuItem(webClient, "Date Field");
+ await cpHelpers.toggleMenuItemOption(webClient, "Date Field", "Quarter");
+
+ await switchView(webClient, "graph");
+
+ assert.containsOnce(webClient, ".o_switch_view.o_graph.active");
+
+ await toggleGroupByMenu(webClient);
+ await toggleMenuItem(webClient, "Date Field");
+ await toggleMenuItemOption(webClient, "Date Field", "Year");
+
+ await switchView(webClient, "legacy_toy");
+ await legacyExtraNextTick();
+
+ assert.containsOnce(webClient, ".o_switch_view.o_legacy_toy.active");
+
+ unpatchDate();
+ }
+ );
+});
diff --git a/addons/web/static/src/legacy/backend_utils.js b/addons/web/static/src/legacy/backend_utils.js
index b4607667c7d..e1b06e44e0e 100644
--- a/addons/web/static/src/legacy/backend_utils.js
+++ b/addons/web/static/src/legacy/backend_utils.js
@@ -1,6 +1,7 @@
/** @odoo-module **/
import ActionModel from "@web/legacy/js/views/action_model";
+import { makeContext } from "@web/core/context";
/**
* @param {string} state
@@ -165,6 +166,12 @@ export function searchModelStateToLegacy(state) {
filter.orderedBy = item.orderBy;
delete filter.orderBy;
break;
+ case "filter":
+ let context = item.context;
+ try {
+ context = makeContext(context);
+ } catch (e) {}
+ filter.context = context;
}
filters[item.id] = filter;
}
diff --git a/addons/web/static/src/search/search_model.js b/addons/web/static/src/search/search_model.js
index 0b577cf6997..9576b143ab2 100644
--- a/addons/web/static/src/search/search_model.js
+++ b/addons/web/static/src/search/search_model.js
@@ -219,11 +219,7 @@ export class SearchModel extends EventBus {
this.view = view || { id: false };
// used to avoid useless recomputations
- // this._comparison is left undefined (we have to distinguish it from null)
- this._context = null;
- this._domain = null;
- this._groupBy = null;
- this._orderBy = null;
+ this._reset();
const { comparison, context, domain, groupBy, orderBy } = config;
@@ -391,12 +387,7 @@ export class SearchModel extends EventBus {
* @param {string[]} [config.orderBy=[]]
*/
async reload(config = {}) {
- // used to avoid useless recomputations
- delete this._comparison;
- this._context = null;
- this._domain = null;
- this._groupBy = null;
- this._orderBy = null;
+ this._reset();
const { comparison, context, domain, groupBy, orderBy } = config;
@@ -2015,11 +2006,8 @@ export class SearchModel extends EventBus {
if (this.blockNotification) {
return;
}
- delete this._comparison;
- this._context = null;
- this._domain = null;
- this._groupBy = null;
- this._orderBy = null;
+
+ this._reset();
await this._reloadSections();
@@ -2066,6 +2054,14 @@ export class SearchModel extends EventBus {
this.blockNotification = false;
}
+ _reset() {
+ delete this._comparison;
+ this._context = null;
+ this._domain = null;
+ this._groupBy = null;
+ this._orderBy = null;
+ }
+
/**
* Returns whether the query informations should be considered as ready
* before or after having (re-)fetched the sections data.