From 75f8814afdb7f4e53199f46cdbeba651a67afc33 Mon Sep 17 00:00:00 2001 From: Mathieu Duckerts-Antoine Date: Wed, 15 Sep 2021 10:04:53 +0000 Subject: [PATCH] [FIX] crm, web: forecast views After the conversion of the graph and pivot views done in https://github.com/odoo/odoo/pull/73311, it was no more possible to open the forecast_graph and forecast_pivot views. This is due to the fact that the new View component cannot manage a legacy view: every extension of a converted view must be converted. We thus convert forecast_graph and forecast_pivot. closes odoo/odoo#76793 X-original-commit: ff28db335fa77dadc3198e754f1e0649811fba7b Signed-off-by: Aaron Bohy (aab) --- addons/crm/__manifest__.py | 1 + .../js/forecast/forecast_model_extension.js | 5 +- .../src/js/forecast/forecast_search_model.js | 105 ++++++++ .../static/src/js/forecast/forecast_views.js | 44 ++- .../crm/static/tests/forecast_view_tests.js | 255 ++++++++++++++++++ addons/web/static/src/legacy/backend_utils.js | 7 + addons/web/static/src/search/search_model.js | 28 +- 7 files changed, 399 insertions(+), 46 deletions(-) create mode 100644 addons/crm/static/src/js/forecast/forecast_search_model.js create mode 100644 addons/crm/static/tests/forecast_view_tests.js 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.