diff --git a/addons/crm/__manifest__.py b/addons/crm/__manifest__.py
index 395bf2ee09b..9282c7991f0 100644
--- a/addons/crm/__manifest__.py
+++ b/addons/crm/__manifest__.py
@@ -71,9 +71,13 @@
'application': True,
'auto_install': False,
'assets': {
+ 'web.assets_qweb': [
+ 'crm/static/src/xml/forecast_kanban.xml',
+ ],
'web.assets_backend': [
'crm/static/src/js/crm_form.js',
'crm/static/src/js/crm_kanban.js',
+ 'crm/static/src/js/forecast/*',
'crm/static/src/js/systray_activity_menu.js',
'crm/static/src/js/tours/crm.js',
'crm/static/src/scss/crm.scss',
@@ -84,6 +88,7 @@
],
'web.qunit_suite_tests': [
'crm/static/tests/mock_server.js',
+ 'crm/static/tests/forecast_kanban_tests.js',
'crm/static/tests/crm_rainbowman_tests.js',
],
}
diff --git a/addons/crm/static/src/js/forecast/fill_temporal_service.js b/addons/crm/static/src/js/forecast/fill_temporal_service.js
new file mode 100644
index 00000000000..f5fac01bd2f
--- /dev/null
+++ b/addons/crm/static/src/js/forecast/fill_temporal_service.js
@@ -0,0 +1,318 @@
+/** @odoo-module */
+import AbstractService from 'web.AbstractService';
+import core from 'web.core';
+
+/**
+ * Configuration depending on the granularity:
+ * @param {function} startOf function to get the start moment of the period from a moment
+ * @param {int} cycle amount of 'granularity' periods constituting a cycle. The cycle duration
+ * is arbitrary for each granularity:
+ * cycle --- granularity
+ * ___________________________
+ * 1 day hour
+ * 1 week day
+ * 1 week week # there is no greater time period that takes an integer amount of weeks
+ * 1 year month
+ * 1 year quarter
+ * 1 year year # we are not using a greater time period in Odoo (yet)
+ * @param {int} cyclePos function to get the position (index) in the cycle from a moment.
+ * {1} is the first index. {+1} is used for functions which have an index
+ * starting from 0, to standardize between granularities.
+ */
+const GRANULARITY_TABLE = {
+ hour: {
+ startOf: x => x.startOf('hour'),
+ cycle: 24,
+ cyclePos: x => x.hour() + 1,
+ },
+ day: {
+ startOf: x => x.startOf('day'),
+ cycle: 7,
+ cyclePos: x => x.isoWeekday(),
+ },
+ week: {
+ startOf: x => x.startOf('isoWeek'),
+ cycle: 1,
+ cyclePos: x => 1,
+ },
+ month: {
+ startOf: x => x.startOf('month'),
+ cycle: 12,
+ cyclePos: x => x.month() + 1,
+ },
+ quarter: {
+ startOf: x => x.startOf('quarter'),
+ cycle: 4,
+ cyclePos: x => x.quarter(),
+ },
+ year: {
+ startOf: x => x.startOf('year'),
+ cycle: 1,
+ cyclePos: x => 1,
+ },
+};
+
+/**
+ * configuration depending on the time type:
+ * @param {string} format moment format to display this type as a string
+ * @param {string} minGranularity granularity of the smallest time interval used in Odoo for this
+ * type
+ */
+const FIELD_TYPE_TABLE = {
+ date: {
+ format: "YYYY-MM-DD",
+ minGranularity: "day",
+ },
+ datetime: {
+ format: "YYYY-MM-DD HH:mm:ss",
+ minGranularity: "second",
+ }
+};
+
+/**
+ * fill_temporal period:
+ * Represents a specific date/time range for a specific model, field and granularity.
+ *
+ * It is used to add new domain and context constraints related to a specific date/time
+ * field, in order to configure the _read_group_fill_temporal (see core models.py)
+ * method. It will be used when we want to get continuous groups in chronological
+ * order in a specific date/time range.
+ */
+class FillTemporalPeriod {
+ /**
+ * This constructor is meant to be used only by the FillTemporalService (see below)
+ *
+ * @param {string} modelName directly taken from model.loadParams.modelName.
+ * this is the `res_model` from the action (i.e. `crm.lead`)
+ * @param {Object} field a dictionary with keys "name" and "type".
+ * name: Name of the field on which the fill_temporal should apply
+ * (i.e. 'date_deadline')
+ * type: 'date' or 'datetime'
+ * @param {string} granularity can either be : hour, day, week, month, quarter, year
+ * @param {integer} minGroups minimum amount of groups to display, regardless of other
+ * constraints
+ */
+ constructor(modelName, field, granularity, minGroups) {
+ this.modelName = modelName;
+ this.field = field;
+ this.granularity = granularity || "month";
+ this.setMinGroups(minGroups);
+
+ this._computeStart();
+ this._computeEnd();
+ }
+ /**
+ * Compute the moment for the start of the period containing "now"
+ *
+ * @private
+ */
+ _computeStart() {
+ this.start = GRANULARITY_TABLE[this.granularity].startOf(moment());
+ }
+ /**
+ * Compute the moment for the end of the fill_temporal period. This bound is exclusive.
+ * The fill_temporal period is the number of [granularity] from [start] to the end of the
+ * [cycle] reached after adding [minGroups]
+ * i.e. we are in october 2020 :
+ * [start] = 2020-10-01
+ * [granularity] = 'month',
+ * [cycle] = 12
+ * [minGroups] = 4,
+ * => fillTemporalPeriod = 15 months (until end of december 2021)
+ *
+ * @private
+ */
+ _computeEnd() {
+ const cycle = GRANULARITY_TABLE[this.granularity].cycle;
+ const cyclePos = GRANULARITY_TABLE[this.granularity].cyclePos(this.start);
+ /**
+ * fillTemporalPeriod formula explanation :
+ * We want to know how many steps need to be taken from the current position until the end
+ * of the cycle reached after guaranteeing minGroups positions. Let's call this cycle (C).
+ *
+ * (1) compute the steps needed to reach the last position of the current cycle, from the
+ * current position:
+ * {cycle - cyclePos}
+ *
+ * (2) ignore {minGroups - 1} steps from the position reached in (1). Now, the current
+ * position is somewhere in (C). One step from minGroups is reserved to reach the first
+ * position after (C), hence {-1}
+ *
+ * (3) compute the additional steps needed to reach the last position of (C), from the
+ * position reached in (2):
+ * {cycle - (minGroups - 1) % cycle}
+ *
+ * (4) combine (1) and (3), the sum should not be greater than a full cycle (-> truncate):
+ * {(2 * cycle - (minGroups - 1) % cycle - cyclePos) % cycle}
+ *
+ * (5) add minGroups!
+ */
+ const fillTemporalPeriod = (2 * cycle - (this.minGroups - 1) % cycle - cyclePos) % cycle + this.minGroups;
+ this.end = moment(this.start).add(fillTemporalPeriod, `${this.granularity}s`);
+ this.computedEnd = true;
+ }
+ /**
+ * The server needs a date/time in UTC, but we don't want a day shift in case
+ * of dates, even if the date is not in UTC
+ *
+ * @param {moment} bound the moment to be formatted (this.start or this.end)
+ */
+ _getFormattedServerDate(bound) {
+ if (bound.isUTC() || this.field.type === "date") {
+ return bound.format(FIELD_TYPE_TABLE[this.field.type].format);
+ } else {
+ return moment.utc(bound).format(FIELD_TYPE_TABLE[this.field.type].format);
+ }
+ }
+ /**
+ * @param {Object} configuration
+ * @param {Array[]} [domain]
+ * @param {boolean} [forceStartBound=true] whether this.start moment must be used as a domain
+ * constraint to limit read_group results or not
+ * @param {boolean} [forceEndBound=true] whether this.end moment must be used as a domain
+ * constraint to limit read_group results or not
+ * @returns {Array[]} new domain
+ */
+ getDomain({domain, forceStartBound=true, forceEndBound=true}) {
+ if (!forceEndBound && !forceStartBound) {
+ return domain;
+ }
+ const originalDomain = domain.length ? ['&', ...domain] : [];
+ const defaultDomain = ['|', [this.field.name, '=', false]];
+ const linkDomain = forceStartBound && forceEndBound ? ['&'] : [];
+ const startDomain = !forceStartBound ? [] : [
+ [this.field.name, '>=', this._getFormattedServerDate(this.start)]];
+ const endDomain = !forceEndBound ? [] : [
+ [this.field.name, '<', this._getFormattedServerDate(this.end)]];
+ return [...originalDomain, ...defaultDomain, ...linkDomain, ...startDomain, ...endDomain];
+ }
+ /**
+ * The default value of forceFillingTo is false when this.end is the
+ * computed one, and true when it is manually set. This is because the default value of
+ * this.end is computed without any knowledge of the existing data, and as such, we only
+ * want to get continuous groups until the last group with data (no need to force until
+ * this.end). On the contrary, when we set this.end, this means that we want groups until
+ * that date.
+ *
+ * @param {Object} configuration
+ * @param {Object} [context]
+ * @param {boolean} [forceFillingFrom=true] fill_temporal must apply from:
+ * true: this.start
+ * false: the first group with at least one record
+ * @param {boolean} [forceFillingTo=!this.computedEnd] fill_temporal must apply until:
+ * true: this.end
+ * false: the last group with at least one record
+ * @returns {Object} new context
+ */
+ getContext({context, forceFillingFrom=true, forceFillingTo=!this.computedEnd}) {
+ const fillTemporal = {
+ min_groups: this.minGroups,
+ };
+ if (forceFillingFrom) {
+ fillTemporal.fill_from = this._getFormattedServerDate(this.start);
+ }
+ if (forceFillingTo) {
+ fillTemporal.fill_to = this._getFormattedServerDate(
+ moment(this.end).subtract(1, FIELD_TYPE_TABLE[this.field.type].minGranularity)
+ );
+ }
+ context = {...context, fill_temporal: fillTemporal};
+ return context;
+ }
+ /**
+ * @param {integer} minGroups minimum amount of groups to display, regardless of other
+ * constraints
+ */
+ setMinGroups(minGroups) {
+ this.minGroups = minGroups || 1;
+ }
+ /**
+ * sets the end of the period to the desired moment. It must be greater
+ * than start. Changes the default behavior of getContext forceFillingTo
+ * (becomes true instead of false)
+ *
+ * @param {moment} end
+ */
+ setEnd(end) {
+ this.end = moment.max(this.start, end);
+ this.computedEnd = false;
+ }
+ /**
+ * sets the start of the period to the desired moment. It must be smaller than end
+ *
+ * @param {moment} start
+ */
+ setStart(start) {
+ this.start = moment.min(this.end, start);
+ }
+ /**
+ * Adds one "granularity" period to [this.end], to expand the current fill_temporal period
+ */
+ expand() {
+ this.setEnd(this.end.add(1, `${this.granularity}s`));
+ }
+}
+
+/**
+ * fill_temporal Service
+ *
+ * This service will be used to generate or retrieve fill_temporal periods
+ *
+ * A specific fill_temporal period configuration will always refer to the same instance
+ * unless forceRecompute is true
+ */
+ const FillTemporalService = AbstractService.extend({
+ /**
+ * @override
+ */
+ start() {
+ this._super.apply(...arguments);
+ this._fillTemporalPeriods = {};
+ },
+ /**
+ * Get a fill_temporal period according to the configuration.
+ * The default initial fill_temporal period is the number of [granularity] from [start]
+ * to the end of the [cycle] reached after adding [minGroups]
+ * i.e. we are in october 2020 :
+ * [start] = 2020-10-01
+ * [granularity] = 'month',
+ * [cycle] = 12 (one year)
+ * [minGroups] = 4,
+ * => fillTemporalPeriod = 15 months (until the end of december 2021)
+ * Once created, a fill_temporal period for a specific configuration will be stored
+ * until requested again. This allows to manipulate the period and store the changes
+ * to it. This also allows to keep the configuration when switching to another view
+ *
+ * @param {Object} configuration
+ * @param {string} [modelName] directly taken from model.loadParams.modelName.
+ * this is the `res_model` from the action (i.e. `crm.lead`)
+ * @param {Object} [field] a dictionary with keys "name" and "type".
+ * @param {string} [field.name] name of the field on which the fill_temporal should apply
+ * (i.e. 'date_deadline')
+ * @param {string} [field.type] date field type: 'date' or 'datetime'
+ * @param {string} [granularity] can either be : hour, day, week, month, quarter, year
+ * @param {integer} [minGroups=4] optional minimal amount of desired groups
+ * @param {boolean} [forceRecompute=false] optional whether the fill_temporal period should be
+ * reinstancied
+ * @returns {FillTemporalPeriod}
+ */
+ getFillTemporalPeriod({modelName, field, granularity, minGroups=4, forceRecompute=false}) {
+ if (!(modelName in this._fillTemporalPeriods)) {
+ this._fillTemporalPeriods[modelName] = {};
+ }
+ if (!(field.name in this._fillTemporalPeriods[modelName])) {
+ this._fillTemporalPeriods[modelName][field.name] = {};
+ }
+ if (!(granularity in this._fillTemporalPeriods[modelName][field.name]) || forceRecompute) {
+ this._fillTemporalPeriods[modelName][field.name][granularity] =
+ new FillTemporalPeriod(modelName, field, granularity, minGroups);
+ } else if (this._fillTemporalPeriods[modelName][field.name][granularity].minGroups != minGroups) {
+ this._fillTemporalPeriods[modelName][field.name][granularity].setMinGroups(minGroups);
+ }
+ return this._fillTemporalPeriods[modelName][field.name][granularity];
+ }
+});
+
+core.serviceRegistry.add('fillTemporalService', FillTemporalService);
+
+export default FillTemporalService;
diff --git a/addons/crm/static/src/js/forecast/forecast_controllers.js b/addons/crm/static/src/js/forecast/forecast_controllers.js
new file mode 100644
index 00000000000..bdac0ccad2e
--- /dev/null
+++ b/addons/crm/static/src/js/forecast/forecast_controllers.js
@@ -0,0 +1,35 @@
+/** @odoo-module */
+import KanbanController from 'web.KanbanController';
+
+const ForecastKanbanController = KanbanController.extend({
+ custom_events: _.extend({}, KanbanController.prototype.custom_events, {
+ forecast_kanban_add_column: '_onAddColumnForecast',
+ }),
+
+ /**
+ * Expand the fill_temporal period after the ForecastColumnQuickCreate has been used, then
+ * reload the view to refetch updated data
+ *
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onAddColumnForecast(ev) {
+ ev.stopPropagation();
+ this.call('fillTemporalService', 'getFillTemporalPeriod', {
+ modelName: this.model.loadParams.modelName,
+ field: {
+ name: this.model.forecast_field,
+ type: this.model.loadParams.fields[this.model.forecast_field].type,
+ },
+ granularity: this.model.granularity,
+ }).expand();
+ this.mutex.exec(() => this.update(
+ { groupBy: [`${this.model.forecast_field}:${this.model.granularity}`] },
+ { reload: true }
+ ));
+ },
+});
+
+export {
+ ForecastKanbanController,
+};
diff --git a/addons/crm/static/src/js/forecast/forecast_kanban_column_quick_create.js b/addons/crm/static/src/js/forecast/forecast_kanban_column_quick_create.js
new file mode 100644
index 00000000000..cc207962e7a
--- /dev/null
+++ b/addons/crm/static/src/js/forecast/forecast_kanban_column_quick_create.js
@@ -0,0 +1,48 @@
+/** @odoo-module */
+import Widget from 'web.Widget';
+import { _t } from 'web.core';
+
+/**
+ * Widget to handle events related to the Add button in
+ * ForecastKanban views. The view is supposed to automatically add
+ * the next column (chronological order), and use the
+ * FillTemporalService which will handle the covered fill_temporal period
+ * to know which column to add.
+ */
+const ForecastColumnQuickCreate = Widget.extend({
+ template: 'KanbanView.ForecastColumnQuickCreate',
+ events: {
+ 'click .o_quick_create_folded': '_onAddColumnClicked',
+ },
+
+ /**
+ * @override
+ * @param {Object} options
+ * @param {string} [options.addColumnLabel] displayed label
+ * (should be the granularity of a date/datetime groupBy)
+ */
+ init: function (parent, options) {
+ this._super.apply(this, arguments);
+ this.addColumnLabel = _.str.sprintf(_t('Add next %s'), options.addColumnLabel);
+ },
+
+ /**
+ * Notify the environment to add a column
+ *
+ * @private
+ */
+ _addColumn: function () {
+ this.trigger_up('forecast_kanban_add_column', {});
+ },
+
+ /**
+ * @private
+ * @param {MouseEvent} event
+ */
+ _onAddColumnClicked: function (event) {
+ event.stopPropagation();
+ this._addColumn();
+ },
+});
+
+export default ForecastColumnQuickCreate;
diff --git a/addons/crm/static/src/js/forecast/forecast_model_extension.js b/addons/crm/static/src/js/forecast/forecast_model_extension.js
new file mode 100644
index 00000000000..0c6401317c8
--- /dev/null
+++ b/addons/crm/static/src/js/forecast/forecast_model_extension.js
@@ -0,0 +1,116 @@
+/** @odoo-module */
+import ActionModel from 'web.ActionModel';
+
+/**
+ * This file contains the logic behind a special "Forecast" filter.
+ * Any such filter should set the context key {forecast_filter: 1}
+ * Another context key must also be set for the view using this model extension:
+ * {forecast_field: "date_field_name"}, which represents the date/time field on which
+ * the forecast should be applied
+ *
+ * The main purpose is to be able to modify the domain depending on the groupby granularity
+ * when the field is a `date/time` field and is the `forecast_field`. The domain should filter
+ * records from the period (granularity) containing "now", or those where the forecast_field
+ * is not set.
+ * example:
+ * today: 2021-04-21
+ * granularity: month
+ * field name: date_field
+ * -> the domain would be: ['&', ['date_field', '=', false], ['date_field', '>=', '2021-04-01']]
+ */
+const DATE_FORMAT = {
+ datetime: "YYYY-MM-DD HH:mm:ss",
+ date: "YYYY-MM-DD",
+};
+class ForecastModelExtension extends ActionModel.Extension {
+ /**
+ * @override
+ * @returns {any}
+ */
+ get(property) {
+ switch (property) {
+ case "domain": return this.getDomain();
+ default: return super.get(...arguments);
+ }
+ }
+
+ /**
+ * @override
+ */
+ prepareState() {
+ 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
+ });
+ }
+
+ /**
+ * The state needs to be recomputed each time the parent model initiates a load action.
+ *
+ * @override
+ * @returns {Promise}
+ */
+ callLoad() {
+ this._computeState();
+ return super.callLoad(...arguments);
+ }
+
+ /**
+ * Adds a domain constraint to only get records from the start of the period containing "now",
+ * only if the "Forecast" filter is active
+ *
+ * @returns {Array[]}
+ */
+ getDomain() {
+ return !this.state.forecastFilter ? null :
+ ['|', [this.state.forecastField, '=', false],
+ [this.state.forecastField, '>=', this.state.forecastStart]
+ ];
+ }
+
+ /**
+ * The state is mostly dependent on 2 context keys :
+ * forecast_field -> is a prerequisite for this extension to work
+ * forecast_filter -> a filter which applies this context key should be present and active in
+ * order to get the modified domain
+ * If the forecast_field is present in the groupby, the related granularity is used
+ * If no granularity is specified, or if there is no groupby, the default is "month"
+ * If there is a groupby, but not the forecast_field, "day" is used (the filter will get data
+ * from 00:00:00 today, browser time)
+ *
+ * @private
+ */
+ _computeState() {
+ this.prepareState();
+ if (!this.config.context.forecast_field) { return; }
+ this.state.forecastField = this.config.context.forecast_field;
+ const format = DATE_FORMAT[this.config.fields[this.state.forecastField].type];
+ const filters = this.config.get('filters').flat();
+ this.state.forecastFilter = !!filters.filter(f => f.isActive && f.context &&
+ f.context.forecast_filter).length;
+ const groupBy = this.config.get('groupBy').flat();
+ const forecastGroupBys = groupBy.filter(gb => gb.includes(this.state.forecastField));
+ let granularity = "month";
+ if (forecastGroupBys.length > 0) {
+ [this.state.forecastField, granularity] = [...forecastGroupBys[0].split(":"), granularity];
+ } else if (groupBy.length) {
+ // there is a groupBy, but it is not the forecast_field
+ granularity = "day";
+ }
+ let startMoment = moment().startOf(granularity);
+ // The server needs a date/time in UTC, but to avoid a day shift in case
+ // of date, we only need to consider it for datetime fields
+ if (this.config.fields[this.state.forecastField].type === "datetime") {
+ startMoment = moment.utc(startMoment);
+ }
+ this.state.forecastStart = startMoment.format(format);
+ }
+}
+
+ActionModel.registry.add("Forecast", ForecastModelExtension, 20);
+
+export default ForecastModelExtension;
diff --git a/addons/crm/static/src/js/forecast/forecast_models.js b/addons/crm/static/src/js/forecast/forecast_models.js
new file mode 100644
index 00000000000..2ce27cca5e5
--- /dev/null
+++ b/addons/crm/static/src/js/forecast/forecast_models.js
@@ -0,0 +1,170 @@
+/** @odoo-module */
+import KanbanModel from 'web.KanbanModel';
+
+const ForecastKanbanModel = KanbanModel.extend({
+ /**
+ * Checks whether to apply the forecast logic to a load or a reload, depending on the groupby
+ * and the forecast_field. Sets the current granularity if the forecast applies
+ *
+ * @private
+ * @param {Object} element what to load (params) / reload (localData from id)
+ * @param {Object} params optional reload params
+ * @returns {boolean}
+ */
+ _isForecast(element, params={}) {
+ let groupby, granularity;
+ if (!element) {
+ return false;
+ }
+ if ("groupBy" in params) {
+ [groupby, granularity] = params.groupBy[0].split(':');
+ } else if (element.groupedBy.length != 0) {
+ [groupby, granularity] = element.groupedBy[0].split(':');
+ } else if (element.groupBy.length != 0) {
+ [groupby, granularity] = element.groupBy[0].split(':');
+ }
+ granularity = granularity || "month";
+ if (!this.forecast_field || groupby != this.forecast_field) {
+ return false;
+ }
+ this.granularity = granularity;
+ return true;
+ },
+
+ /**
+ * At every __load/__reload, we have to check the range of the last group received from the
+ * read_group, and update the fillTemporalPeriod from the FillTemporalService accordingly
+ *
+ * @private
+ */
+ _updateFillTemporalPeriodEnd(fillTemporalPeriod) {
+ let lastGroup = this.get(this.handle).data.filter(group => group.value).slice(-1)[0];
+ if (lastGroup) {
+ lastGroup = this.localData[lastGroup.id];
+ fillTemporalPeriod.setEnd(moment.utc(lastGroup.range[this.forecast_field].to));
+ }
+ },
+
+ /**
+ * Applies the forecast logic to the domain and context if needed before the read_group.
+ * After the read_group, checks the end date of the last displayed group in order to be able to
+ * add the next group with the ForecastColumnQuickCreate widget
+ *
+ * @private
+ * @override
+ */
+ async __load(params) {
+ this.loadDomain = params.domain;
+ this.loadContext = params.context;
+ this.forecast_field = params.context.forecast_field;
+ if (!this._isForecast(params)) {
+ return this._super(...arguments);
+ }
+ this.minGroups =
+ params.context.fill_temporal && params.context.fill_temporal.min_groups || undefined;
+ const fillTemporalPeriod = this.call('fillTemporalService', 'getFillTemporalPeriod', {
+ modelName: params.modelName,
+ field: {
+ name: this.forecast_field,
+ type: params.fields[this.forecast_field].type,
+ },
+ granularity: this.granularity,
+ minGroups: this.minGroups,
+ forceRecompute: true,
+ });
+ params.domain = fillTemporalPeriod.getDomain({
+ domain: this.loadDomain,
+ forceStartBound: false
+ });
+ params.context = fillTemporalPeriod.getContext({ context: this.loadContext });
+ this.handle = await this._super(...arguments);
+ this._updateFillTemporalPeriodEnd(fillTemporalPeriod);
+ return this.handle;
+ },
+
+ /**
+ * In order to display the sample data, the context and domain need to be the same as before a
+ * reload. If the user switches to another view (list, pivot, ...) and comes back without
+ * adding any record or modifying the search bar, we still want to display the sample data, but
+ * the actual domain and context won't be the same because load and reload are modifying them.
+ *
+ * As such, we will only compare the original domain and context (without the use of the
+ * FillTemporalService) to determine whether the sample data should be enabled or not.
+ *
+ * The behavior for timeRanges and groupBy is unchanged from the original
+ *
+ * @private
+ * @override
+ * @param {Object} [params={}]
+ * @param {Object} [params.context]
+ * @param {Array[]} [params.domain]
+ * @param {Object} [params.timeRanges]
+ * @param {string[]} [params.groupBy]
+ * @returns {boolean}
+ */
+ _haveParamsChanged(params = {}) {
+ const originalParams = [this.loadContext, this.loadDomain];
+ const currentParams = [this.reloadContext, this.reloadDomain];
+ if (JSON.stringify(originalParams) !== JSON.stringify(currentParams)) {
+ return true;
+ }
+ if ('timeRanges' in params) {
+ const diff = JSON.stringify(params.timeRanges) !== JSON.stringify(this.loadParams.timeRanges);
+ if (diff) {
+ return true;
+ }
+ }
+ if (this.useSampleModel && 'groupBy' in params) {
+ return JSON.stringify(params.groupBy) !== JSON.stringify(this.loadParams.groupedBy);
+ }
+ },
+
+ /**
+ * Applies the forecast logic to the domain and context if needed before the read_group.
+ * After the read_group, checks the end date of the last displayed group in order to be able to
+ * add the next group with the ForecastColumnQuickCreate widget
+ *
+ * @private
+ * @override
+ */
+ async __reload(id, params) {
+ if (this.handle !== id || this.isSampleModel) {
+ // the forecast logic should only apply when the whole view is reloading on real data
+ return this._super(...arguments);
+ }
+ if ("domain" in params) {
+ this.reloadDomain = params.domain;
+ }
+ if ("context" in params) {
+ this.reloadContext = params.context;
+ this.forecast_field = params.context.forecast_field;
+ this.minGroups =
+ params.context.fill_temporal && params.context.fill_temporal.min_groups || this.minGroups;
+ }
+ const element = this.localData[id];
+ if (!this._isForecast(element, params)) {
+ return this._super(...arguments);
+ }
+ const fillTemporalPeriod = this.call('fillTemporalService', 'getFillTemporalPeriod', {
+ modelName: this.loadParams.modelName,
+ field: {
+ name: this.forecast_field,
+ type: this.loadParams.fields[this.forecast_field].type,
+ },
+ granularity: this.granularity,
+ minGroups: this.minGroups,
+ });
+ params.domain = fillTemporalPeriod.getDomain({
+ domain: this.reloadDomain || this.loadDomain,
+ forceStartBound: false
+ });
+ params.context = fillTemporalPeriod.getContext({ context: this.reloadContext || this.loadContext });
+ const reload = await this._super(...arguments);
+ this._updateFillTemporalPeriodEnd(fillTemporalPeriod);
+ return reload;
+ },
+});
+
+export {
+ ForecastKanbanModel,
+};
diff --git a/addons/crm/static/src/js/forecast/forecast_renderers.js b/addons/crm/static/src/js/forecast/forecast_renderers.js
new file mode 100644
index 00000000000..27067b0a656
--- /dev/null
+++ b/addons/crm/static/src/js/forecast/forecast_renderers.js
@@ -0,0 +1,31 @@
+/** @odoo-module */
+import ForecastColumnQuickCreate from './forecast_kanban_column_quick_create';
+import KanbanRenderer from 'web.KanbanRenderer';
+
+const ForecastKanbanRenderer = KanbanRenderer.extend({
+ /**
+ * Adds the widget ForecastColumnQuickCreate if there is a forecast_field and the current
+ * groupby targets it. It will be used to automatically add the next group for a date/datetime
+ * field
+ *
+ * @private
+ * @override
+ * @param {DocumentFragment} fragment
+ */
+ _renderGrouped(fragment) {
+ this._super(...arguments);
+ let [groupby, granularity] = this.state.groupedBy[0].split(":");
+ const forecast_field = this.state.context.forecast_field;
+ if (forecast_field && groupby === forecast_field) {
+ granularity = granularity || "month";
+ this.forecastColumnQuickCreate = new ForecastColumnQuickCreate(this, {
+ addColumnLabel: granularity,
+ });
+ this.defs.push(this.forecastColumnQuickCreate.appendTo(fragment));
+ }
+ },
+});
+
+export {
+ ForecastKanbanRenderer,
+};
diff --git a/addons/crm/static/src/js/forecast/forecast_views.js b/addons/crm/static/src/js/forecast/forecast_views.js
new file mode 100644
index 00000000000..6b19eeec85e
--- /dev/null
+++ b/addons/crm/static/src/js/forecast/forecast_views.js
@@ -0,0 +1,96 @@
+/** @odoo-module */
+import { ForecastKanbanController } from './forecast_controllers';
+import { ForecastKanbanModel } from './forecast_models';
+import { ForecastKanbanRenderer } from './forecast_renderers';
+import GraphView from 'web.GraphView';
+import KanbanView from 'web.KanbanView';
+import ListView from 'web.ListView';
+import PivotView from 'web.PivotView';
+import viewRegistry from 'web.view_registry';
+
+/**
+ * Graph view to be used for a Forecast @see ForecastModelExtension
+ * 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);
+
+/**
+ * Kanban view to be used for a Forecast
+ * @see ForecastModelExtension
+ * @see FillTemporalService
+ * @see ForecastKanbanColumnQuickCreate
+ * requires:
+ * - context key `forecast_field` on a date/datetime field
+ * - special filter "Forecast" (which must set the `forecast_filter:1` context key)
+ */
+const ForecastKanbanView = KanbanView.extend({
+ config: _.extend({}, KanbanView.prototype.config, {
+ Renderer: ForecastKanbanRenderer,
+ Model: ForecastKanbanModel,
+ Controller: ForecastKanbanController,
+ }),
+ /**
+ * @private
+ * @override
+ */
+ _createSearchModel(params, extraExtensions={}) {
+ Object.assign(extraExtensions, { Forecast: {} });
+ return this._super(params, extraExtensions);
+ },
+});
+viewRegistry.add('forecast_kanban', ForecastKanbanView);
+
+/**
+ * List view to be used for a Forecast @see ForecastModelExtension
+ * requires:
+ * - context key `forecast_field` on a date/datetime field
+ * - special filter "Forecast" (which must set the `forecast_filter:1` context key)
+ */
+const ForecastListView = ListView.extend({
+ /**
+ * @private
+ * @override
+ */
+ _createSearchModel(params, extraExtensions = {}) {
+ Object.assign(extraExtensions, { Forecast: {} });
+ return this._super(params, extraExtensions);
+ },
+});
+viewRegistry.add('forecast_list', ForecastListView);
+
+/**
+ * Pivot view to be used for a Forecast @see ForecastModelExtension
+ * 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);
+
+export {
+ ForecastGraphView,
+ ForecastKanbanView,
+ ForecastListView,
+ ForecastPivotView,
+};
diff --git a/addons/crm/static/src/xml/forecast_kanban.xml b/addons/crm/static/src/xml/forecast_kanban.xml
new file mode 100644
index 00000000000..7cbbee8f0f9
--- /dev/null
+++ b/addons/crm/static/src/xml/forecast_kanban.xml
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
diff --git a/addons/crm/static/tests/forecast_kanban_tests.js b/addons/crm/static/tests/forecast_kanban_tests.js
new file mode 100644
index 00000000000..5c4a7b25f2c
--- /dev/null
+++ b/addons/crm/static/tests/forecast_kanban_tests.js
@@ -0,0 +1,258 @@
+/** @odoo-module */
+import FillTemporalService from '../src/js/forecast/fill_temporal_service';
+import { ForecastKanbanView } from '../src/js/forecast/forecast_views';
+import testUtils from 'web.test_utils';
+
+QUnit.module('Crm Forecast Model Extension', {
+ beforeEach: async function () {
+ this.testKanbanView = {
+ arch: `
+
+
+
+
+
+
+
+
+ `,
+ archs: {
+ "crm.lead,false,search": `
+
+
+
+
+ `
+ },
+ data: {
+ 'crm.lead': {
+ fields: {
+ name: {string: 'Name', type: 'char'},
+ date_deadline: {string: "Expected closing", type: 'date'},
+ date_closed: {string: "Closed Date", type: 'datetime'},
+ },
+ records: [
+ {id: 1, name: 'Lead 1', date_deadline: '2021-01-01', date_closed: '2021-01-01 00:00:00'},
+ {id: 2, name: 'Lead 2', date_deadline: '2021-01-20', date_closed: '2021-01-20 00:00:00'},
+ {id: 3, name: 'Lead 3', date_deadline: '2021-02-01', date_closed: '2021-02-01 00:00:00'},
+ {id: 4, name: 'Lead 4', date_deadline: '2021-02-20', date_closed: '2021-02-20 00:00:00'},
+ {id: 5, name: 'Lead 5', date_deadline: '2021-03-01', date_closed: '2021-03-01 00:00:00'},
+ {id: 6, name: 'Lead 6', date_deadline: '2021-03-20', date_closed: '2021-03-20 00:00:00'},
+ ],
+ },
+ },
+ services: { 'fillTemporalService': FillTemporalService },
+ model: 'crm.lead',
+ View: ForecastKanbanView,
+ viewOptions: {
+ context: {
+ search_default_forecast: true,
+ search_default_groupby_date_deadline: true,
+ forecast_field: 'date_deadline',
+ },
+ },
+ groupBy: ['date_deadline'],
+
+ };
+ this.unPatchDate = testUtils.mock.patchDate(2021, 1, 10, 0, 0, 0);
+ },
+ afterEach: async function () {
+ this.unPatchDate();
+ },
+
+}, function () {
+ QUnit.test("filter out every records before the start of the current month with forecast_filter for a date field", async function (assert) {
+ // the filter is used by the forecast model extension, and applies the forecast_filter context key,
+ // which adds a domain constraint on the field marked in the other context key forecast_field
+ assert.expect(7);
+
+ const kanban = await testUtils.createView(this.testKanbanView);
+
+ // the filter is active
+ assert.containsN(kanban, '.o_kanban_group', 2, "There should be 2 columns");
+ assert.containsN(kanban, '.o_kanban_group:nth-child(1) .o_kanban_record', 2,
+ "1st column February should contain 2 record");
+ assert.containsN(kanban, '.o_kanban_group:nth-child(2) .o_kanban_record', 2,
+ "2nd column March should contain 2 records");
+
+ // remove the filter
+ await testUtils.dom.click($('.o_searchview_facet:contains("Forecast") .o_facet_remove'));
+
+ assert.containsN(kanban, '.o_kanban_group', 3, "There should be 3 columns");
+ assert.containsN(kanban, '.o_kanban_group:nth-child(1) .o_kanban_record', 2,
+ "1st column January should contain 2 record");
+ assert.containsN(kanban, '.o_kanban_group:nth-child(2) .o_kanban_record', 2,
+ "2nd column February should contain 2 records");
+ assert.containsN(kanban, '.o_kanban_group:nth-child(3) .o_kanban_record', 2,
+ "3nd column March should contain 2 records");
+ kanban.destroy();
+ });
+
+ QUnit.test("filter out every records before the start of the current month with forecast_filter for a datetime field", async function (assert) {
+ // same tests as for the date field
+ assert.expect(7);
+
+ this.testKanbanView.viewOptions.context = {
+ search_default_forecast: true,
+ search_default_groupby_date_closed: true,
+ forecast_field: 'date_closed',
+ };
+ this.testKanbanView.groupBy = ['date_closed'];
+ const kanban = await testUtils.createView(this.testKanbanView);
+
+ // with the filter
+ assert.containsN(kanban, '.o_kanban_group', 2, "There should be 2 columns");
+ assert.containsN(kanban, '.o_kanban_group:nth-child(1) .o_kanban_record', 2,
+ "1st column February should contain 2 record");
+ assert.containsN(kanban, '.o_kanban_group:nth-child(2) .o_kanban_record', 2,
+ "2nd column March should contain 2 records");
+
+ // remove the filter
+ await testUtils.dom.click($('.o_searchview_facet:contains("Forecast") .o_facet_remove'));
+
+ assert.containsN(kanban, '.o_kanban_group', 3, "There should be 3 columns");
+ assert.containsN(kanban, '.o_kanban_group:nth-child(1) .o_kanban_record', 2,
+ "1st column January should contain 2 record");
+ assert.containsN(kanban, '.o_kanban_group:nth-child(2) .o_kanban_record', 2,
+ "2nd column February should contain 2 records");
+ assert.containsN(kanban, '.o_kanban_group:nth-child(3) .o_kanban_record', 2,
+ "3nd column March should contain 2 records");
+ kanban.destroy();
+ });
+});
+
+QUnit.module('Crm Fill Temporal Service', {
+ /**
+ * Remark: -> the filter with the groupBy is needed for the model_extension to access the groupby
+ * when created with testUtils.createView. Not needed in production.
+ * -> testKanbanView.groupBy is still needed to apply the groupby on the view
+ */
+ beforeEach: async function () {
+ this.testKanbanView = {
+ arch: `
+
+
+
+
+
+
+
+ `,
+ archs: {
+ "crm.lead,false,search": `
+
+
+
+ `
+ },
+ data: {
+ 'crm.lead': {
+ fields: {
+ name: {string: 'Name', type: 'char'},
+ date_deadline: {string: "Expected Closing", type: 'date'},
+ },
+ },
+ },
+ services: { 'fillTemporalService': FillTemporalService },
+ model: 'crm.lead',
+ View: ForecastKanbanView,
+ viewOptions: {
+ context: {
+ search_default_forecast: true,
+ search_default_groupby_date_deadline: true,
+ forecast_field: 'date_deadline',
+ },
+ },
+ groupBy: ['date_deadline'],
+ };
+ this.unPatchDate = testUtils.mock.patchDate(2021, 9, 10, 0, 0, 0);
+ },
+ afterEach: async function () {
+ this.unPatchDate();
+ },
+
+}, function () {
+ /**
+ * Since mock_server does not support fill_temporal,
+ * we only check the domain and the context sent to the read_group, as well
+ * as the end value of the FillTemporal Service after the read_group (which should have been updated in the model)
+ */
+ QUnit.test("Forecast on months, until the end of the year of the latest data", async function (assert) {
+ assert.expect(3);
+
+ this.testKanbanView.data['crm.lead'].records = [
+ {id: 1, name: 'Lead 1', date_deadline: '2021-01-01'},
+ {id: 2, name: 'Lead 2', date_deadline: '2021-02-01'},
+ {id: 3, name: 'Lead 3', date_deadline: '2021-11-01'},
+ {id: 4, name: 'Lead 4', date_deadline: '2022-01-01'},
+ ];
+ this.testKanbanView.mockRPC = function (route, args) {
+ if (route === '/web/dataset/call_kw/crm.lead/web_read_group') {
+ assert.deepEqual(args.kwargs.context.fill_temporal, {
+ fill_from: "2021-10-01",
+ min_groups: 4,
+ });
+ assert.deepEqual(args.kwargs.domain, [
+ "&", "|",
+ ["date_deadline", "=", false], ["date_deadline", ">=", "2021-10-01"],
+ "|",
+ ["date_deadline", "=", false], ["date_deadline", "<", "2023-01-01"],
+ ]);
+ }
+ return this._super.apply(this, arguments);
+ };
+ const kanban = await testUtils.createView(this.testKanbanView);
+ assert.strictEqual(kanban.call('fillTemporalService', 'getFillTemporalPeriod', {
+ modelName: 'crm.lead',
+ field: {
+ name: 'date_deadline',
+ type: 'date',
+ },
+ granularity: 'month',
+ }).end.format('YYYY-MM-DD'), '2022-02-01');
+ kanban.destroy();
+ });
+
+ /**
+ * Since mock_server does not support fill_temporal,
+ * we only check the domain and the context sent to the read_group, as well
+ * as the end value of the FillTemporal Service after the read_group (which should have been updated in the model)
+ */
+ QUnit.test("Forecast on years, until the end of the year of the latest data", async function (assert) {
+ assert.expect(3);
+
+ this.testKanbanView.data['crm.lead'].records = [
+ {id: 1, name: 'Lead 1', date_deadline: '2021-01-01'},
+ {id: 2, name: 'Lead 2', date_deadline: '2022-02-01'},
+ {id: 3, name: 'Lead 3', date_deadline: '2027-11-01'},
+ ];
+ this.testKanbanView.groupBy = ['date_deadline:year'];
+ this.testKanbanView.archs["crm.lead,false,search"] =
+ this.testKanbanView.archs["crm.lead,false,search"].replace("'date_deadline'", "'date_deadline:year'");
+ this.testKanbanView.mockRPC = function (route, args) {
+ if (route === '/web/dataset/call_kw/crm.lead/web_read_group') {
+ assert.deepEqual(args.kwargs.context.fill_temporal, {
+ fill_from: "2021-01-01",
+ min_groups: 4,
+ });
+ assert.deepEqual(args.kwargs.domain, [
+ "&", "|",
+ ["date_deadline", "=", false], ["date_deadline", ">=", "2021-01-01"],
+ "|",
+ ["date_deadline", "=", false], ["date_deadline", "<", "2025-01-01"],
+ ]);
+ }
+ return this._super.apply(this, arguments);
+ };
+ const kanban = await testUtils.createView(this.testKanbanView);
+ assert.strictEqual(kanban.call('fillTemporalService', 'getFillTemporalPeriod', {
+ modelName: 'crm.lead',
+ field: {
+ name: 'date_deadline',
+ type: 'date',
+ },
+ granularity: 'year',
+ }).end.format('YYYY-MM-DD'), '2023-01-01');
+ kanban.destroy();
+ });
+});