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(); + }); +});