[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) <aab@odoo.com>
This commit is contained in:
Mathieu Duckerts-Antoine
2021-09-20 10:54:23 +00:00
parent 3dadf5214b
commit 75f8814afd
7 changed files with 399 additions and 46 deletions
+1
View File
@@ -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',
],
},
@@ -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;
@@ -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;
}
}
@@ -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,
@@ -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": `<legacy_toy/>`,
"foo,false,graph": `<graph js_class="forecast_graph"/>`,
"foo,false,search": `
<search>
<filter name="forecast_filter" string="Forecast Filter" context="{ 'forecast_filter': 1 }"/>
<filter name="group_by_bar" string="Bar" context="{ 'group_by': 'bar' }"/>
<filter name="group_by_date_field" string="Date Field" context="{ 'group_by': 'date_field' }"/>
</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();
}
);
});
@@ -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;
}
+12 -16
View File
@@ -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.