[ADD] base modules
This commit is contained in:
207
web_cohort/static/src/js/cohort_controller.js
Executable file
207
web_cohort/static/src/js/cohort_controller.js
Executable file
@@ -0,0 +1,207 @@
|
||||
odoo.define('web_cohort.CohortController', function (require) {
|
||||
'use strict';
|
||||
|
||||
const AbstractController = require('web.AbstractController');
|
||||
const config = require('web.config');
|
||||
const core = require('web.core');
|
||||
const framework = require('web.framework');
|
||||
const session = require('web.session');
|
||||
|
||||
const qweb = core.qweb;
|
||||
const _t = core._t;
|
||||
|
||||
var CohortController = AbstractController.extend({
|
||||
custom_events: Object.assign({}, AbstractController.prototype.custom_events, {
|
||||
row_clicked: '_onRowClicked',
|
||||
}),
|
||||
/**
|
||||
* @override
|
||||
* @param {Widget} parent
|
||||
* @param {CohortModel} model
|
||||
* @param {CohortRenderer} renderer
|
||||
* @param {Object} params
|
||||
* @param {string} params.modelName
|
||||
* @param {string} params.title
|
||||
* @param {Object} params.measures
|
||||
* @param {Object} params.intervals
|
||||
* @param {string} params.dateStartString
|
||||
* @param {string} params.dateStopString
|
||||
* @param {string} params.timeline
|
||||
* @param {Array[]} params.views
|
||||
*/
|
||||
init: function (parent, model, renderer, params) {
|
||||
this._super.apply(this, arguments);
|
||||
this.title = params.title;
|
||||
this.measures = params.measures;
|
||||
this.intervals = params.intervals;
|
||||
this.dateStartString = params.dateStartString;
|
||||
this.dateStopString = params.dateStopString;
|
||||
this.timeline = params.timeline;
|
||||
this.views = params.views;
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Public
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Returns the current mode, measure and groupbys, so we can restore the
|
||||
* view when we save the current state in the search view, or when we add it
|
||||
* to the dashboard.
|
||||
*
|
||||
* @override
|
||||
* @returns {Object}
|
||||
*/
|
||||
getOwnedQueryParams: function () {
|
||||
var state = this.model.get();
|
||||
return {
|
||||
context: {
|
||||
cohort_measure: state.measure,
|
||||
cohort_interval: state.interval,
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* @override
|
||||
* @param {jQuery} [$node]
|
||||
*/
|
||||
renderButtons: function ($node) {
|
||||
this.$buttons = $(qweb.render('CohortView.buttons', {
|
||||
measures: _.sortBy(_.pairs(this.measures), function(x){ return x[1].toLowerCase(); }),
|
||||
intervals: this.intervals,
|
||||
isMobile: config.device.isMobile
|
||||
}));
|
||||
this.$measureList = this.$buttons.find('.o_cohort_measures_list');
|
||||
this.$buttons.on('click', 'button', this._onButtonClick.bind(this));
|
||||
if ($node) {
|
||||
this.$buttons.appendTo($node);
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Makes sure that the buttons in the control panel matches the current
|
||||
* state (so, correct active buttons and stuff like that);
|
||||
*
|
||||
* @override
|
||||
*/
|
||||
updateButtons: function () {
|
||||
if (!this.$buttons) {
|
||||
return;
|
||||
}
|
||||
var data = this.model.get();
|
||||
// Hide download button if no cohort data
|
||||
var noData = !data.report.rows.length &&
|
||||
(!data.comparisonReport ||
|
||||
!data.comparisonReport.rows.length);
|
||||
this.$buttons.find('.o_cohort_download_button').toggleClass(
|
||||
'd-none',
|
||||
noData
|
||||
);
|
||||
if (config.device.isMobile) {
|
||||
var $activeInterval = this.$buttons
|
||||
.find('.o_cohort_interval_button[data-interval="' + data.interval + '"]');
|
||||
this.$buttons.find('.dropdown_cohort_content').text($activeInterval.text());
|
||||
}
|
||||
this.$buttons.find('.o_cohort_interval_button').removeClass('active');
|
||||
this.$buttons
|
||||
.find('.o_cohort_interval_button[data-interval="' + data.interval + '"]')
|
||||
.addClass('active');
|
||||
_.each(this.$measureList.find('.dropdown-item'), function (el) {
|
||||
var $el = $(el);
|
||||
$el.toggleClass('selected', $el.data('field') === data.measure);
|
||||
});
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Private
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Export cohort data in Excel file
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_downloadExcel: function () {
|
||||
var data = this.model.get();
|
||||
data = _.extend(data, {
|
||||
title: this.title,
|
||||
interval_string: this.intervals[data.interval].toString(), // intervals are lazy-translated
|
||||
measure_string: this.measures[data.measure] || _t('Count'),
|
||||
date_start_string: this.dateStartString,
|
||||
date_stop_string: this.dateStopString,
|
||||
timeline: this.timeline,
|
||||
});
|
||||
framework.blockUI();
|
||||
session.get_file({
|
||||
url: '/web/cohort/export',
|
||||
data: {data: JSON.stringify(data)},
|
||||
complete: framework.unblockUI,
|
||||
error: (error) => this.call('crash_manager', 'rpc_error', error),
|
||||
});
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @param {string} interval
|
||||
*/
|
||||
_setInterval: function (interval) {
|
||||
this.update({interval: interval});
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @param {string} measure should be a valid (and aggregatable) field name
|
||||
*/
|
||||
_setMeasure: function (measure) {
|
||||
this.update({measure: measure});
|
||||
},
|
||||
/**
|
||||
* @override
|
||||
* @private
|
||||
* @returns {Promise}
|
||||
*/
|
||||
_update: function () {
|
||||
this.updateButtons();
|
||||
return this._super.apply(this, arguments);
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Handlers
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Do what need to be done when a button from the control panel is clicked.
|
||||
*
|
||||
* @private
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
_onButtonClick: function (ev) {
|
||||
var $btn = $(ev.currentTarget);
|
||||
if ($btn.hasClass('o_cohort_interval_button')) {
|
||||
this._setInterval($btn.data('interval'));
|
||||
} else if ($btn.hasClass('o_cohort_download_button')) {
|
||||
this._downloadExcel();
|
||||
} else if ($btn.closest('.o_cohort_measures_list').length) {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
this._setMeasure($btn.data('field'));
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Open view when clicked on row
|
||||
*
|
||||
* @private
|
||||
* @param {OdooEvent} event
|
||||
*/
|
||||
_onRowClicked: function (event) {
|
||||
this.do_action({
|
||||
type: 'ir.actions.act_window',
|
||||
name: this.title,
|
||||
res_model: this.modelName,
|
||||
views: this.views,
|
||||
domain: event.data.domain,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return CohortController;
|
||||
|
||||
});
|
||||
143
web_cohort/static/src/js/cohort_model.js
Executable file
143
web_cohort/static/src/js/cohort_model.js
Executable file
@@ -0,0 +1,143 @@
|
||||
odoo.define('web_cohort.CohortModel', function (require) {
|
||||
'use strict';
|
||||
|
||||
var AbstractModel = require('web.AbstractModel');
|
||||
|
||||
var CohortModel = AbstractModel.extend({
|
||||
//--------------------------------------------------------------------------
|
||||
// Public
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @override
|
||||
* @returns {Object}
|
||||
*/
|
||||
__get: function () {
|
||||
const { rangeDescription, comparisonRangeDescription } = this.timeRanges;
|
||||
return Object.assign({}, this.data, {
|
||||
hasContent: !this._isEmpty(),
|
||||
isSample: this.isSampleModel,
|
||||
rangeDescription,
|
||||
comparisonRangeDescription
|
||||
});
|
||||
},
|
||||
/**
|
||||
* @override
|
||||
* @param {Object} params
|
||||
* @param {string} params.modelName
|
||||
* @param {string} params.dateStart
|
||||
* @param {string} params.dateStop
|
||||
* @param {string} params.measure
|
||||
* @param {string} params.interval
|
||||
* @param {Array[]} params.domain
|
||||
* @param {string} params.mode
|
||||
* @param {string} params.timeline
|
||||
* @param {Object} params.timeRanges
|
||||
* @returns {Promise}
|
||||
*/
|
||||
__load: function (params) {
|
||||
this.modelName = params.modelName;
|
||||
this.dateStart = params.dateStart;
|
||||
this.dateStop = params.dateStop;
|
||||
this.measure = params.measure;
|
||||
this.interval = params.interval;
|
||||
this.domain = params.domain;
|
||||
this.mode = params.mode;
|
||||
this.timeline = params.timeline;
|
||||
this.data = {
|
||||
measure: this.measure,
|
||||
interval: this.interval,
|
||||
};
|
||||
this.context = params.context;
|
||||
this.timeRanges = params.timeRanges;
|
||||
return this._fetchData();
|
||||
},
|
||||
/**
|
||||
* Reload data.
|
||||
*
|
||||
* @param {any} handle
|
||||
* @param {Object} params
|
||||
* @param {string} [params.measure]
|
||||
* @param {string} [params.interval]
|
||||
* @param {Array[]} [params.domain]
|
||||
* @param {Object} [params.timeRanges]
|
||||
* @returns {Promise}
|
||||
*/
|
||||
__reload: function (handle, params) {
|
||||
if ('measure' in params) {
|
||||
this.data.measure = params.measure;
|
||||
}
|
||||
if ('interval' in params) {
|
||||
this.data.interval = params.interval;
|
||||
}
|
||||
if ('domain' in params) {
|
||||
this.domain = params.domain;
|
||||
}
|
||||
if ('timeRanges' in params) {
|
||||
this.timeRanges = params.timeRanges;
|
||||
}
|
||||
return this._fetchData();
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Private
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Fetch cohort data.
|
||||
*
|
||||
* @private
|
||||
* @returns {Promise}
|
||||
*/
|
||||
_fetchData: function () {
|
||||
const domains = this._getDomains();
|
||||
const proms = domains.map(domain => {
|
||||
return this._rpc({
|
||||
model: this.modelName,
|
||||
method: 'get_cohort_data',
|
||||
kwargs: {
|
||||
date_start: this.dateStart,
|
||||
date_stop: this.dateStop,
|
||||
measure: this.data.measure,
|
||||
interval: this.data.interval,
|
||||
domain: domain,
|
||||
mode: this.mode,
|
||||
timeline: this.timeline,
|
||||
context: this.context
|
||||
}
|
||||
});
|
||||
});
|
||||
return Promise.all(proms).then(([report, comparisonReport]) => {
|
||||
this.data.report = report;
|
||||
this.data.comparisonReport = comparisonReport;
|
||||
});
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @returns {Array[]}
|
||||
*/
|
||||
_getDomains: function () {
|
||||
const { range, comparisonRange } = this.timeRanges;
|
||||
if (!range) {
|
||||
return [this.domain];
|
||||
}
|
||||
return [
|
||||
this.domain.concat(range),
|
||||
this.domain.concat(comparisonRange),
|
||||
];
|
||||
},
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
_isEmpty() {
|
||||
let rowCount = this.data.report.rows.length;
|
||||
if (this.data.comparisonReport) {
|
||||
rowCount += this.data.comparisonReport.rows.length;
|
||||
}
|
||||
return rowCount === 0;
|
||||
},
|
||||
});
|
||||
|
||||
return CohortModel;
|
||||
|
||||
});
|
||||
83
web_cohort/static/src/js/cohort_renderer.js
Executable file
83
web_cohort/static/src/js/cohort_renderer.js
Executable file
@@ -0,0 +1,83 @@
|
||||
odoo.define('web_cohort.CohortRenderer', function (require) {
|
||||
'use strict';
|
||||
|
||||
const OwlAbstractRenderer = require('web.AbstractRendererOwl');
|
||||
const field_utils = require('web.field_utils');
|
||||
const patchMixin = require('web.patchMixin');
|
||||
|
||||
class CohortRenderer extends OwlAbstractRenderer {
|
||||
|
||||
constructor() {
|
||||
super(...arguments);
|
||||
this.sampleDataTargets = ['table'];
|
||||
}
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Private
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @param {integer} value
|
||||
* @returns {Array} first integers from 0 to value-1
|
||||
*/
|
||||
_range(value) {
|
||||
return _.range(value);
|
||||
}
|
||||
/**
|
||||
* @param {float} value
|
||||
* @returns {string} formatted value with 1 digit
|
||||
*/
|
||||
_formatFloat(value) {
|
||||
return field_utils.format.float(value, null, {
|
||||
digits: [42, 1],
|
||||
});
|
||||
}
|
||||
/**
|
||||
* @param {float} value
|
||||
* @returns {string} formatted value with 1 digit
|
||||
*/
|
||||
_formatPercentage(value) {
|
||||
return field_utils.format.percentage(value, null, {
|
||||
digits: [42, 1],
|
||||
});
|
||||
}
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Handlers
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
_onClickRow(ev) {
|
||||
if (!ev.target.classList.contains('o_cohort_value')) {
|
||||
return;
|
||||
}
|
||||
const rowData = ev.currentTarget.dataset;
|
||||
const rowIndex = rowData.rowIndex;
|
||||
const colIndex = ev.target.dataset.colIndex;
|
||||
const row = (rowData.type === 'data') ?
|
||||
this.props.report.rows[rowIndex] :
|
||||
this.props.comparisonReport.rows[rowIndex];
|
||||
const rowDomain = row ? row.domain : [];
|
||||
const cellContent = row ? row.columns[colIndex] : false;
|
||||
const cellDomain = cellContent ? cellContent.domain : [];
|
||||
|
||||
const fullDomain = rowDomain.concat(cellDomain);
|
||||
if (cellDomain.length) {
|
||||
fullDomain.unshift('&');
|
||||
}
|
||||
if (fullDomain.length) {
|
||||
this.trigger('row_clicked', {
|
||||
domain: fullDomain
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
CohortRenderer.template = 'web_cohort.CohortRenderer';
|
||||
|
||||
return patchMixin(CohortRenderer);
|
||||
|
||||
});
|
||||
136
web_cohort/static/src/js/cohort_view.js
Executable file
136
web_cohort/static/src/js/cohort_view.js
Executable file
@@ -0,0 +1,136 @@
|
||||
odoo.define('web_cohort.CohortView', function (require) {
|
||||
'use strict';
|
||||
|
||||
var AbstractView = require('web.AbstractView');
|
||||
var core = require('web.core');
|
||||
var view_registry = require('web.view_registry');
|
||||
var CohortController = require('web_cohort.CohortController');
|
||||
var CohortModel = require('web_cohort.CohortModel');
|
||||
var CohortRenderer = require('web_cohort.CohortRenderer');
|
||||
const RendererWrapper = require('web.RendererWrapper');
|
||||
|
||||
var _t = core._t;
|
||||
var _lt = core._lt;
|
||||
|
||||
var intervals = {
|
||||
day: _lt('Day'),
|
||||
week: _lt('Week'),
|
||||
month: _lt('Month'),
|
||||
year: _lt('Year'),
|
||||
};
|
||||
|
||||
var CohortView = AbstractView.extend({
|
||||
display_name: _lt('Cohort'),
|
||||
icon: 'fa-signal',
|
||||
config: _.extend({}, AbstractView.prototype.config, {
|
||||
Model: CohortModel,
|
||||
Controller: CohortController,
|
||||
Renderer: CohortRenderer,
|
||||
}),
|
||||
viewType: 'cohort',
|
||||
searchMenuTypes: ['filter', 'comparison', 'favorite'],
|
||||
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
init: function (viewInfo, params) {
|
||||
this._super.apply(this, arguments);
|
||||
|
||||
var fields = this.fields;
|
||||
var attrs = this.arch.attrs;
|
||||
const additionalMeasures = params.additionalMeasures || [];
|
||||
|
||||
if (!attrs.date_start) {
|
||||
throw new Error(_lt('Cohort view has not defined "date_start" attribute.'));
|
||||
}
|
||||
if (!attrs.date_stop) {
|
||||
throw new Error(_lt('Cohort view has not defined "date_stop" attribute.'));
|
||||
}
|
||||
|
||||
|
||||
// Renderer Parameters
|
||||
var measures = {};
|
||||
_.each(fields, function (field, name) {
|
||||
if (name !== 'id' && field.store === true && _.contains(['integer', 'float', 'monetary'], field.type)) {
|
||||
measures[name] = field.string;
|
||||
}
|
||||
});
|
||||
|
||||
this.arch.children.forEach(field => {
|
||||
let fieldName = field.attrs.name;
|
||||
// Remove invisible fields from the measures
|
||||
if (
|
||||
!additionalMeasures.includes(fieldName) &&
|
||||
field.attrs.invisible && py.eval(field.attrs.invisible)
|
||||
) {
|
||||
delete measures[fieldName];
|
||||
return;
|
||||
}
|
||||
if (fieldName in measures && field.attrs.string) {
|
||||
measures[fieldName] = field.attrs.string;
|
||||
}
|
||||
});
|
||||
|
||||
measures.__count__ = _t('Count');
|
||||
this.rendererParams.measures = measures;
|
||||
this.rendererParams.intervals = intervals;
|
||||
|
||||
// Controller Parameters
|
||||
this.controllerParams.measures = _.omit(measures, '__count__');
|
||||
this.controllerParams.intervals = intervals;
|
||||
this.controllerParams.title = params.title || attrs.string || _t('Untitled');
|
||||
// Used in export
|
||||
// Retrieve form and list view ids from the action to open those views
|
||||
// when a row of the cohort view is clicked
|
||||
this.controllerParams.views = [
|
||||
_findViewID('list'),
|
||||
_findViewID('form'),
|
||||
];
|
||||
function _findViewID(viewType) {
|
||||
var action = params.action;
|
||||
|
||||
if (action === undefined) {
|
||||
return [false, viewType];
|
||||
}
|
||||
var contextID = viewType === 'list' ? action.context.list_view_id : action.context.form_view_id;
|
||||
var result = _.findWhere(action.views, {type: viewType});
|
||||
return [contextID || (result ? result.viewID : false), viewType];
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
_updateMVCParams: function () {
|
||||
this._super.apply(this, arguments);
|
||||
// Model Parameters
|
||||
var context = this.loadParams.context;
|
||||
var attrs = this.arch.attrs;
|
||||
this.loadParams.dateStart = context.cohort_date_start || attrs.date_start;
|
||||
this.loadParams.dateStop = context.cohort_date_stop || attrs.date_stop;
|
||||
this.loadParams.mode = context.cohort_mode || attrs.mode || 'retention';
|
||||
this.loadParams.timeline = context.cohort_timeline || attrs.timeline || 'forward';
|
||||
this.loadParams.measure = context.cohort_measure || attrs.measure || '__count__';
|
||||
this.loadParams.interval = context.cohort_interval || attrs.interval || 'day';
|
||||
|
||||
this.rendererParams.mode = this.loadParams.mode;
|
||||
this.rendererParams.timeline = this.loadParams.timeline;
|
||||
this.rendererParams.dateStartString = this.fields[this.loadParams.dateStart].string;
|
||||
this.rendererParams.dateStopString = this.fields[this.loadParams.dateStop].string;
|
||||
|
||||
this.controllerParams.dateStartString = this.rendererParams.dateStartString;
|
||||
this.controllerParams.dateStopString = this.rendererParams.dateStopString;
|
||||
this.controllerParams.timeline = this.rendererParams.timeline;
|
||||
},
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
getRenderer(parent, state) {
|
||||
state = Object.assign({}, state, this.rendererParams);
|
||||
return new RendererWrapper(null, this.config.Renderer, state);
|
||||
},
|
||||
});
|
||||
|
||||
view_registry.add('cohort', CohortView);
|
||||
|
||||
return CohortView;
|
||||
|
||||
});
|
||||
92
web_cohort/static/src/js/sample_server.js
Executable file
92
web_cohort/static/src/js/sample_server.js
Executable file
@@ -0,0 +1,92 @@
|
||||
odoo.define('web_cohort/static/src/js/sample_server.js', function (require) {
|
||||
"use strict";
|
||||
|
||||
const SampleServer = require('web.SampleServer');
|
||||
|
||||
/**
|
||||
* This function mocks calls to the 'get_cohort_data' method. It is
|
||||
* registered to the SampleServer's mockRegistry, so it is called with a
|
||||
* SampleServer instance as "this".
|
||||
* @private
|
||||
* @param {Object} params
|
||||
* @param {string} params.model
|
||||
* @param {Object} params.kwargs
|
||||
* @returns {Object}
|
||||
*/
|
||||
function _mockGetCohortData(params) {
|
||||
const { model } = params;
|
||||
const { date_start, interval, measure, mode, timeline } = params.kwargs;
|
||||
|
||||
const columns_avg = {};
|
||||
const rows = [];
|
||||
let initialChurnValue = 0;
|
||||
|
||||
const groups = this._mockReadGroup({ model, fields: [date_start], groupBy: [date_start + ':' + interval] });
|
||||
const totalCount = groups.length;
|
||||
let totalValue = 0;
|
||||
for (const group of groups) {
|
||||
const format = SampleServer.FORMATS[interval];
|
||||
const displayFormat = SampleServer.DISPLAY_FORMATS[interval];
|
||||
const date = moment(group[date_start + ':' + interval], format);
|
||||
const now = moment();
|
||||
let colStartDate = date.clone();
|
||||
if (timeline === 'backward') {
|
||||
colStartDate = colStartDate.subtract(15, interval);
|
||||
}
|
||||
|
||||
let value = measure === '__count__' ?
|
||||
this._getRandomInt(SampleServer.MAX_INTEGER) :
|
||||
this._generateFieldValue(model, measure);
|
||||
value = value || 25;
|
||||
totalValue += value;
|
||||
let initialValue = value;
|
||||
let max = value;
|
||||
|
||||
const columns = [];
|
||||
for (let column = 0; column <= 15; column++) {
|
||||
if (!columns_avg[column]) {
|
||||
columns_avg[column] = { percentage: 0, count: 0 };
|
||||
}
|
||||
if (colStartDate.clone().add(column, interval) > now) {
|
||||
columns.push({ value: '-', churn_value: '-', percentage: '' });
|
||||
continue;
|
||||
}
|
||||
let colValue = 0;
|
||||
if (max > 0) {
|
||||
colValue = Math.min(Math.round(Math.random() * max), max);
|
||||
max -= colValue;
|
||||
}
|
||||
if (timeline === 'backward' && column === 0) {
|
||||
initialValue = Math.min(Math.round(Math.random() * value), value);
|
||||
initialChurnValue = value - initialValue;
|
||||
}
|
||||
const previousValue = column === 0 ? initialValue : columns[column - 1].value;
|
||||
const remainingValue = previousValue - colValue;
|
||||
const previousChurnValue = column === 0 ? initialChurnValue : columns[column - 1].churn_value;
|
||||
const churn_value = colValue + previousChurnValue;
|
||||
let percentage = value ? parseFloat(remainingValue / value) : 0;
|
||||
if (mode === 'churn') {
|
||||
percentage = 1 - percentage;
|
||||
}
|
||||
percentage = Number((100 * percentage).toFixed(1));
|
||||
columns_avg[column].percentage += percentage;
|
||||
columns_avg[column].count += 1;
|
||||
columns.push({
|
||||
value: remainingValue,
|
||||
churn_value,
|
||||
percentage,
|
||||
period: column, // used as a t-key but we don't care about value itself
|
||||
});
|
||||
}
|
||||
const keepRow = columns.some(c => c.percentage !== '');
|
||||
if (keepRow) {
|
||||
rows.push({ date: date.format(displayFormat), value, columns });
|
||||
}
|
||||
}
|
||||
const avg_value = totalCount ? (totalValue / totalCount) : 0;
|
||||
const avg = { avg_value, columns_avg };
|
||||
return { rows, avg };
|
||||
}
|
||||
|
||||
SampleServer.mockRegistry.add('get_cohort_data', _mockGetCohortData);
|
||||
});
|
||||
67
web_cohort/static/src/scss/web_cohort.scss
Executable file
67
web_cohort/static/src/scss/web_cohort.scss
Executable file
@@ -0,0 +1,67 @@
|
||||
// ------------------------------------------------------------------
|
||||
// Cohort View
|
||||
// ------------------------------------------------------------------
|
||||
$o-cohort-heading-bg-color: darken(theme-color('light'), 4%);
|
||||
$o-cohort-border-color: darken($o-cohort-heading-bg-color, 6%);
|
||||
$o-cohort-hover-color: lighten($o-cohort-heading-bg-color, 2%);
|
||||
|
||||
.o_cohort_view {
|
||||
.table {
|
||||
border-bottom: 1px solid $o-cohort-border-color;
|
||||
thead {
|
||||
background-color: $o-cohort-heading-bg-color;
|
||||
> tr > th {
|
||||
border-bottom: 1px solid $o-cohort-border-color;
|
||||
}
|
||||
}
|
||||
tbody {
|
||||
> tr {
|
||||
&.o_cohort_row_clickable {
|
||||
&:hover {
|
||||
.o_cohort_value {
|
||||
cursor: pointer;
|
||||
}
|
||||
background-color: $o-cohort-hover-color;
|
||||
@include media-breakpoint-up(lg) {
|
||||
td:first-child {
|
||||
position: relative;
|
||||
&:before {
|
||||
content: '';
|
||||
@include o-position-absolute($top: 0px, $left: 0px);
|
||||
height: 100%;
|
||||
width: 3px;
|
||||
background-color: $o-brand-primary;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
& > td {
|
||||
padding: 0px;
|
||||
> div {
|
||||
padding: 3px;
|
||||
&.o_cohort_highlight {
|
||||
margin: 2px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
tfoot {
|
||||
background-color: $o-cohort-heading-bg-color;
|
||||
font-weight: 500;
|
||||
}
|
||||
tr > th, tr > td {
|
||||
border-left: 1px solid $o-cohort-border-color;
|
||||
text-align: center;
|
||||
vertical-align: middle;
|
||||
}
|
||||
}
|
||||
.o_cohort_no_data {
|
||||
padding: 15px;
|
||||
font-size: 18px;
|
||||
border: 1px solid $o-cohort-border-color;
|
||||
background-color: $o-cohort-heading-bg-color;
|
||||
}
|
||||
}
|
||||
157
web_cohort/static/src/xml/web_cohort.xml
Executable file
157
web_cohort/static/src/xml/web_cohort.xml
Executable file
@@ -0,0 +1,157 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates>
|
||||
|
||||
<t t-name="CohortView.buttons">
|
||||
<div class="btn-group" role="group">
|
||||
<button class="btn btn-primary dropdown-toggle" data-toggle="dropdown">
|
||||
Measures <span class="caret" />
|
||||
</button>
|
||||
<div class="dropdown-menu o_cohort_measures_list">
|
||||
<button t-foreach="measures" t-as="measure"
|
||||
type="button"
|
||||
class="dropdown-item"
|
||||
t-att-data-field="measure[0]">
|
||||
<t t-esc="measure[1]"/>
|
||||
</button>
|
||||
<div t-if="_.keys(measures).length" class="dropdown-divider"/>
|
||||
<button type="button" class="dropdown-item" data-field="__count__">Count</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="btn-group" t-if="isMobile">
|
||||
<a class="btn btn-link dropdown-toggle" href="#" data-toggle="dropdown" aria-expanded="false">
|
||||
<span class="dropdown_cohort_content mr4"></span>
|
||||
</a>
|
||||
<div class="dropdown-menu" role="menu">
|
||||
<button t-foreach="intervals" t-as="interval" class="btn btn-secondary o_cohort_interval_button dropdown-item" t-att-data-interval="interval" style="display:block;">
|
||||
<t t-esc="intervals[interval]" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="btn-group" t-else="">
|
||||
<button t-foreach="intervals" t-as="interval" class="btn btn-secondary o_cohort_interval_button" t-att-data-interval="interval">
|
||||
<t t-esc="intervals[interval]" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="btn-group">
|
||||
<button class="btn btn-secondary fa fa-download o_cohort_download_button" title="Download in Excel file"></button>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<div t-name="web_cohort.CohortRenderer" class="o_cohort_view" owl="1">
|
||||
<div t-if="props.hasContent">
|
||||
<div t-if="props.comparisonReport && (props.comparisonReport.rows.length || props.report.rows.length)" class="table-responsive">
|
||||
<t t-call="CohortView.tableTitle">
|
||||
<t t-set="title" t-value="props.rangeDescription"/>
|
||||
</t>
|
||||
</div>
|
||||
<div t-if="props.report.rows.length" class="table-responsive">
|
||||
<t t-call="CohortView.tableContent">
|
||||
<t t-set="col_length" t-value="props.report.rows[0].columns.length"/>
|
||||
<t t-set="report_rows" t-value="props.report.rows"/>
|
||||
<t t-set="report_avg" t-value="props.report.avg" />
|
||||
</t>
|
||||
</div>
|
||||
<div t-if="!props.report.rows.length && props.comparisonReport && props.comparisonReport.rows.length" class="o_cohort_no_data text-center">
|
||||
No data available.
|
||||
</div>
|
||||
<br/>
|
||||
<div t-if="props.comparisonReport && (props.report.rows.length || props.comparisonReport.rows.length)" class="table-responsive">
|
||||
<t t-call="CohortView.tableTitle">
|
||||
<t t-set="title" t-value="props.comparisonRangeDescription"/>
|
||||
</t>
|
||||
</div>
|
||||
<div t-if="props.comparisonReport && props.comparisonReport.rows.length" class="table-responsive">
|
||||
<t t-call="CohortView.tableContent">
|
||||
<t t-set="col_length" t-value="props.comparisonReport.rows[0].columns.length"/>
|
||||
<t t-set="report_rows" t-value="props.comparisonReport.rows"/>
|
||||
<t t-set="report_avg" t-value="props.comparisonReport.avg" />
|
||||
</t>
|
||||
</div>
|
||||
<div t-if="props.report.rows.length && props.comparisonReport && !props.comparisonReport.rows.length" class="o_cohort_no_data text-center">
|
||||
No data available.
|
||||
</div>
|
||||
</div>
|
||||
<t t-if="!props.hasContent or (props.isSample and !props.isEmbedded)">
|
||||
<t t-if="props.noContentHelp" t-call="web.ActionHelper">
|
||||
<t t-set="noContentHelp" t-value="props.noContentHelp"/>
|
||||
</t>
|
||||
<t t-else="" t-call="web.NoContentHelper"/>
|
||||
</t>
|
||||
</div>
|
||||
|
||||
<t t-name="CohortView.tableTitle" owl="1">
|
||||
<table class="table text-center mb0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th colspan="16">
|
||||
<t t-esc="title" />
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
</table>
|
||||
</t>
|
||||
|
||||
<t t-name="CohortView.tableContent" owl="1">
|
||||
<table class="table text-center mb0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th rowspan="2"><t t-esc="props.dateStartString" /></th>
|
||||
<th rowspan="2">
|
||||
<t t-esc="props.measures[props.measure]"/>
|
||||
</th>
|
||||
<th colspan="16">
|
||||
<t t-esc="props.dateStopString" /> - By <t t-esc="props.intervals[props.interval]" />
|
||||
</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<th t-foreach="_range(col_length)" t-as="intervalNumber">
|
||||
<t t-if="props.timeline === 'backward'">
|
||||
<t t-esc="intervalNumber - (col_length - 1)"/>
|
||||
</t>
|
||||
<t t-else="">
|
||||
+<t t-esc="intervalNumber"/>
|
||||
</t>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr t-foreach="report_rows" t-as="row" t-key="row.date" data-type="data" t-att-data-row-index="row_index" class="o_cohort_row_clickable" t-on-click="_onClickRow">
|
||||
<td class="o_cohort_value">
|
||||
<t t-esc="row.date" />
|
||||
</td>
|
||||
<td class="o_cohort_value">
|
||||
<t t-esc="_formatFloat(row.value)" />
|
||||
</td>
|
||||
<td t-foreach="row.columns" t-as="col" t-key="col.period">
|
||||
<t t-if="col.percentage !== ''">
|
||||
<t t-set="count" t-value="mode === 'churn' ? (col.churn_value === '-' ? '' : col.churn_value) : (col.value === '-' ? '' : col.value)"/>
|
||||
<t t-set="measure" t-value="props.measures[props.measure]"/>
|
||||
<div class="o_cohort_highlight"
|
||||
t-attf-title="Periode: {{col.period}} {{measure}}: {{count}}"
|
||||
t-attf-style="background-color: rgba(0, 160, 157, {{col.percentage/100.0}}); color: {{col.percentage gt 50 and '#FFFFFF' or 'inherit'}}"
|
||||
t-att-class="{o_cohort_value: col.value !== '-'}">
|
||||
<t t-esc="_formatPercentage(col.percentage / 100.0)"/>
|
||||
</div>
|
||||
</t>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr>
|
||||
<td>
|
||||
Average
|
||||
</td>
|
||||
<td>
|
||||
<t t-esc="_formatFloat(report_avg.avg_value)"/>
|
||||
</td>
|
||||
<td t-foreach="report_avg.columns_avg" t-as="col">
|
||||
<t t-if="report_avg.columns_avg[col]['count']">
|
||||
<t t-esc="_formatPercentage(report_avg.columns_avg[col]['percentage'] / (report_avg.columns_avg[col]['count'] * 100.0))" />
|
||||
</t>
|
||||
</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
672
web_cohort/static/tests/cohort_tests.js
Executable file
672
web_cohort/static/tests/cohort_tests.js
Executable file
@@ -0,0 +1,672 @@
|
||||
odoo.define('web_cohort.cohort_tests', function (require) {
|
||||
'use strict';
|
||||
|
||||
var CohortView = require('web_cohort.CohortView');
|
||||
var testUtils = require('web.test_utils');
|
||||
|
||||
const cpHelpers = testUtils.controlPanel;
|
||||
var createView = testUtils.createView;
|
||||
var createActionManager = testUtils.createActionManager;
|
||||
var patchDate = testUtils.mock.patchDate;
|
||||
|
||||
QUnit.module('Views', {
|
||||
beforeEach: function () {
|
||||
this.data = {
|
||||
subscription: {
|
||||
fields: {
|
||||
id: {string: 'ID', type: 'integer'},
|
||||
start: {string: 'Start', type: 'date', sortable: true},
|
||||
stop: {string: 'Stop', type: 'date', sortable: true},
|
||||
recurring: {string: 'Recurring Price', type: 'integer', store: true},
|
||||
},
|
||||
records: [
|
||||
{id: 1, start: '2017-07-12', stop: '2017-08-11', recurring: 10},
|
||||
{id: 2, start: '2017-08-14', stop: '', recurring: 20},
|
||||
{id: 3, start: '2017-08-21', stop: '2017-08-29', recurring: 10},
|
||||
{id: 4, start: '2017-08-21', stop: '', recurring: 20},
|
||||
{id: 5, start: '2017-08-23', stop: '', recurring: 10},
|
||||
{id: 6, start: '2017-08-24', stop: '', recurring: 22},
|
||||
{id: 7, start: '2017-08-24', stop: '2017-08-29', recurring: 10},
|
||||
{id: 8, start: '2017-08-24', stop: '', recurring: 22},
|
||||
]
|
||||
},
|
||||
lead: {
|
||||
fields: {
|
||||
id: {string: 'ID', type: 'integer'},
|
||||
start: {string: 'Start', type: 'date'},
|
||||
stop: {string: 'Stop', type: 'date'},
|
||||
revenue: {string: 'Revenue', type: 'float', store: true},
|
||||
},
|
||||
records: [
|
||||
{id: 1, start: '2017-07-12', stop: '2017-08-11', revenue: 1200.20},
|
||||
{id: 2, start: '2017-08-14', stop: '', revenue: 500},
|
||||
{id: 3, start: '2017-08-21', stop: '2017-08-29', revenue: 5599.99},
|
||||
{id: 4, start: '2017-08-21', stop: '', revenue: 13500},
|
||||
{id: 5, start: '2017-08-23', stop: '', revenue: 6000},
|
||||
{id: 6, start: '2017-08-24', stop: '', revenue: 1499.99},
|
||||
{id: 7, start: '2017-08-24', stop: '2017-08-29', revenue: 16000},
|
||||
{id: 8, start: '2017-08-24', stop: '', revenue: 22000},
|
||||
]
|
||||
},
|
||||
attendee: {
|
||||
fields: {
|
||||
id: {string: 'ID', type: 'integer'},
|
||||
event_begin_date: {string: 'Event Start Date', type: 'date'},
|
||||
registration_date: {string: 'Registration Date', type: 'date'},
|
||||
},
|
||||
records: [
|
||||
{id: 1, event_begin_date: '2018-06-30', registration_date: '2018-06-13'},
|
||||
{id: 2, event_begin_date: '2018-06-30', registration_date: '2018-06-20'},
|
||||
{id: 3, event_begin_date: '2018-06-30', registration_date: '2018-06-22'},
|
||||
{id: 4, event_begin_date: '2018-06-30', registration_date: '2018-06-22'},
|
||||
{id: 5, event_begin_date: '2018-06-30', registration_date: '2018-06-29'},
|
||||
]
|
||||
},
|
||||
};
|
||||
}
|
||||
}, function () {
|
||||
QUnit.module('CohortView');
|
||||
|
||||
QUnit.test('simple cohort rendering', async function (assert) {
|
||||
assert.expect(7);
|
||||
|
||||
var cohort = await createView({
|
||||
View: CohortView,
|
||||
model: 'subscription',
|
||||
data: this.data,
|
||||
arch: '<cohort string="Subscription" date_start="start" date_stop="stop" />',
|
||||
});
|
||||
|
||||
assert.containsOnce(cohort, '.table',
|
||||
'should have a table');
|
||||
assert.ok(cohort.$('.table thead tr:first th:first:contains(Start)').length,
|
||||
'should contain "Start" in header of first column');
|
||||
assert.ok(cohort.$('.table thead tr:first th:nth-child(3):contains(Stop - By Day)').length,
|
||||
'should contain "Stop - By Day" in title');
|
||||
assert.ok(cohort.$('.table thead tr:nth-child(2) th:first:contains(+0)').length,
|
||||
'interval should start with 0');
|
||||
assert.ok(cohort.$('.table thead tr:nth-child(2) th:nth-child(16):contains(+15)').length,
|
||||
'interval should end with 15');
|
||||
|
||||
assert.strictEqual(cohort.$buttons.find('.o_cohort_measures_list').length, 1,
|
||||
'should have list of measures');
|
||||
assert.strictEqual(cohort.$buttons.find('.o_cohort_interval_button').length, 4,
|
||||
'should have buttons of intervals');
|
||||
|
||||
cohort.destroy();
|
||||
});
|
||||
|
||||
QUnit.test('no content helper', async function (assert) {
|
||||
assert.expect(1);
|
||||
this.data.subscription.records = [];
|
||||
|
||||
var cohort = await createView({
|
||||
View: CohortView,
|
||||
model: "subscription",
|
||||
data: this.data,
|
||||
arch: '<cohort string="Subscription" date_start="start" date_stop="stop" />',
|
||||
});
|
||||
|
||||
assert.containsOnce(cohort, 'div.o_view_nocontent');
|
||||
|
||||
cohort.destroy();
|
||||
});
|
||||
|
||||
QUnit.test('no content helper after update', async function (assert) {
|
||||
assert.expect(2);
|
||||
|
||||
var cohort = await createView({
|
||||
View: CohortView,
|
||||
model: "subscription",
|
||||
data: this.data,
|
||||
arch: '<cohort string="Subscription" date_start="start" date_stop="stop" measure="recurring"/>',
|
||||
});
|
||||
|
||||
assert.containsNone(cohort, 'div.o_view_nocontent');
|
||||
|
||||
await cohort.update({domain: [['recurring', '>', 25]]});
|
||||
|
||||
assert.containsOnce(cohort, 'div.o_view_nocontent');
|
||||
|
||||
cohort.destroy();
|
||||
});
|
||||
|
||||
QUnit.test('correctly set by default measure and interval', async function (assert) {
|
||||
assert.expect(4);
|
||||
|
||||
var cohort = await createView({
|
||||
View: CohortView,
|
||||
model: 'subscription',
|
||||
data: this.data,
|
||||
arch: '<cohort string="Subscription" date_start="start" date_stop="stop" />'
|
||||
});
|
||||
|
||||
assert.hasClass(cohort.$buttons.find('.o_cohort_measures_list [data-field=__count__]'),'selected',
|
||||
'count should by default for measure');
|
||||
assert.hasClass(cohort.$buttons.find('.o_cohort_interval_button[data-interval=day]'),'active',
|
||||
'day should by default for interval');
|
||||
|
||||
assert.ok(cohort.$('.table thead tr:first th:nth-child(2):contains(Count)').length,
|
||||
'should contain "Count" in header of second column');
|
||||
assert.ok(cohort.$('.table thead tr:first th:nth-child(3):contains(Stop - By Day)').length,
|
||||
'should contain "Stop - By Day" in title');
|
||||
|
||||
cohort.destroy();
|
||||
});
|
||||
|
||||
QUnit.test('correctly sort measure items', async function (assert) {
|
||||
assert.expect(1);
|
||||
|
||||
var data = this.data;
|
||||
// It's important to compare capitalized and lowercased words
|
||||
// to be sure the sorting is effective with both of them
|
||||
data.subscription.fields.flop = {string: 'Abc', type: 'integer', store: true};
|
||||
data.subscription.fields.add = {string: 'add', type: 'integer', store: true};
|
||||
data.subscription.fields.zoo = {string: 'Zoo', type: 'integer', store: true};
|
||||
|
||||
var cohort = await createView({
|
||||
View: CohortView,
|
||||
model: 'subscription',
|
||||
data: this.data,
|
||||
arch: '<cohort string="Subscription" date_start="start" date_stop="stop"/>',
|
||||
});
|
||||
|
||||
const buttonsEls = cpHelpers.getButtons(cohort);
|
||||
const measureButtonEls = buttonsEls[0].querySelectorAll('.o_cohort_measures_list > button');
|
||||
assert.deepEqual(
|
||||
[...measureButtonEls].map(e => e.innerText.trim()),
|
||||
["Abc", "add", "Recurring Price", "Zoo", "Count"]
|
||||
);
|
||||
|
||||
cohort.destroy();
|
||||
});
|
||||
|
||||
QUnit.test('correctly set measure and interval after changed', async function (assert) {
|
||||
assert.expect(8);
|
||||
|
||||
var cohort = await createView({
|
||||
View: CohortView,
|
||||
model: 'subscription',
|
||||
data: this.data,
|
||||
arch: '<cohort string="Subscription" date_start="start" date_stop="stop" measure="recurring" interval="week" />'
|
||||
});
|
||||
|
||||
assert.hasClass(cohort.$buttons.find('.o_cohort_measures_list [data-field=recurring]'),'selected',
|
||||
'should recurring for measure');
|
||||
assert.hasClass(cohort.$buttons.find('.o_cohort_interval_button[data-interval=week]'),'active',
|
||||
'should week for interval');
|
||||
|
||||
assert.ok(cohort.$('.table thead tr:first th:nth-child(2):contains(Recurring Price)').length,
|
||||
'should contain "Recurring Price" in header of second column');
|
||||
assert.ok(cohort.$('.table thead tr:first th:nth-child(3):contains(Stop - By Week)').length,
|
||||
'should contain "Stop - By Week" in title');
|
||||
|
||||
await testUtils.dom.click(cohort.$buttons.find('.dropdown-toggle:contains(Measures)'));
|
||||
await testUtils.dom.click(cohort.$buttons.find('.o_cohort_measures_list [data-field=__count__]'));
|
||||
assert.hasClass(cohort.$buttons.find('.o_cohort_measures_list [data-field=__count__]'),'selected',
|
||||
'should active count for measure');
|
||||
assert.ok(cohort.$('.table thead tr:first th:nth-child(2):contains(Count)').length,
|
||||
'should contain "Count" in header of second column');
|
||||
|
||||
await testUtils.dom.click(cohort.$buttons.find('.o_cohort_interval_button[data-interval=month]'));
|
||||
assert.hasClass(cohort.$buttons.find('.o_cohort_interval_button[data-interval=month]'),'active',
|
||||
'should active month for interval');
|
||||
assert.ok(cohort.$('.table thead tr:first th:nth-child(3):contains(Stop - By Month)').length,
|
||||
'should contain "Stop - By Month" in title');
|
||||
|
||||
cohort.destroy();
|
||||
});
|
||||
|
||||
QUnit.test('cohort view without attribute invisible on field', async function (assert) {
|
||||
assert.expect(3);
|
||||
|
||||
var cohort = await createView({
|
||||
View: CohortView,
|
||||
model: 'subscription',
|
||||
data: this.data,
|
||||
arch: `<cohort string="Subscription" date_start="start" date_stop="stop"/>`,
|
||||
});
|
||||
|
||||
await testUtils.dom.click(cohort.$('.btn-group:first button'));
|
||||
assert.containsN(cohort, '.o_cohort_measures_list button', 2);
|
||||
assert.containsOnce(cohort, '.o_cohort_measures_list button[data-field="recurring"]');
|
||||
assert.containsOnce(cohort, '.o_cohort_measures_list button[data-field="__count__"]');
|
||||
|
||||
cohort.destroy();
|
||||
});
|
||||
|
||||
QUnit.test('cohort view with attribute invisible on field', async function (assert) {
|
||||
assert.expect(2);
|
||||
|
||||
var cohort = await createView({
|
||||
View: CohortView,
|
||||
model: 'subscription',
|
||||
data: this.data,
|
||||
arch: `
|
||||
<cohort string="Subscription" date_start="start" date_stop="stop">
|
||||
<field name="recurring" invisible="1"/>
|
||||
</cohort>`,
|
||||
});
|
||||
|
||||
await testUtils.dom.click(cohort.$('.btn-group:first button'));
|
||||
assert.containsOnce(cohort, '.o_cohort_measures_list button');
|
||||
assert.containsNone(cohort, '.o_cohort_measures_list button[data-field="recurring"]');
|
||||
|
||||
cohort.destroy();
|
||||
});
|
||||
|
||||
QUnit.test('export cohort', async function (assert) {
|
||||
assert.expect(6);
|
||||
|
||||
var cohort = await createView({
|
||||
View: CohortView,
|
||||
model: 'subscription',
|
||||
data: this.data,
|
||||
arch: '<cohort string="Subscription" date_start="start" date_stop="stop" />',
|
||||
session: {
|
||||
get_file: async function (params) {
|
||||
var data = JSON.parse(params.data.data);
|
||||
assert.strictEqual(params.url, '/web/cohort/export');
|
||||
assert.strictEqual(data.interval_string, 'Day');
|
||||
assert.strictEqual(data.measure_string, 'Count');
|
||||
assert.strictEqual(data.date_start_string, 'Start');
|
||||
assert.strictEqual(data.date_stop_string, 'Stop');
|
||||
assert.strictEqual(data.title, 'Subscription');
|
||||
params.complete();
|
||||
return true;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await testUtils.dom.click(cohort.$buttons.find('.o_cohort_download_button'));
|
||||
|
||||
cohort.destroy();
|
||||
});
|
||||
|
||||
QUnit.test('when clicked on cell redirects to the correct list/form view ', async function(assert) {
|
||||
assert.expect(6);
|
||||
|
||||
var actionManager = await createActionManager({
|
||||
data: this.data,
|
||||
archs: {
|
||||
'subscription,false,cohort': '<cohort string="Subscriptions" date_start="start" date_stop="stop" measure="__count__" interval="week" />',
|
||||
'subscription,my_list_view,list': '<tree>' +
|
||||
'<field name="start"/>' +
|
||||
'<field name="stop"/>' +
|
||||
'</tree>',
|
||||
'subscription,my_form_view,form': '<form>' +
|
||||
'<field name="start"/>' +
|
||||
'<field name="stop"/>' +
|
||||
'</form>',
|
||||
'subscription,false,list': '<tree>' +
|
||||
'<field name="recurring"/>' +
|
||||
'<field name="start"/>' +
|
||||
'</tree>',
|
||||
'subscription,false,form': '<form>' +
|
||||
'<field name="recurring"/>' +
|
||||
'<field name="start"/>' +
|
||||
'</form>',
|
||||
'subscription,false,search': '<search></search>',
|
||||
},
|
||||
intercepts: {
|
||||
do_action: function (ev) {
|
||||
actionManager.doAction(ev.data.action, ev.data.options);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await actionManager.doAction({
|
||||
name: 'Subscriptions',
|
||||
res_model: 'subscription',
|
||||
type: 'ir.actions.act_window',
|
||||
views: [[false, 'cohort'], ['my_list_view', 'list'], ['my_form_view', 'form']],
|
||||
});
|
||||
|
||||
// Going to the list view, while clicking Period / Count cell
|
||||
await testUtils.dom.click(actionManager.$('td.o_cohort_value:first'));
|
||||
assert.strictEqual(actionManager.$('.o_list_view th:nth(1)').text(), 'Start',
|
||||
"First field in the list view should be start");
|
||||
assert.strictEqual(actionManager.$('.o_list_view th:nth(2)').text(), 'Stop',
|
||||
"Second field in the list view should be stop");
|
||||
|
||||
// Going back to cohort view
|
||||
await testUtils.dom.click(actionManager.$('.o_back_button'));
|
||||
// Going to the list view
|
||||
await testUtils.dom.click(actionManager.$('td div.o_cohort_value:first'));
|
||||
assert.strictEqual(actionManager.$('.o_list_view th:nth(1)').text(), 'Start',
|
||||
"First field in the list view should be start");
|
||||
assert.strictEqual(actionManager.$('.o_list_view th:nth(2)').text(), 'Stop',
|
||||
"Second field in the list view should be stop");
|
||||
|
||||
// Going to the form view
|
||||
await testUtils.dom.click(actionManager.$('.o_list_view .o_data_row'));
|
||||
|
||||
assert.hasAttrValue(actionManager.$('.o_form_view span:first'), 'name', 'start',
|
||||
"First field in the form view should be start");
|
||||
assert.hasAttrValue(actionManager.$('.o_form_view span:nth(1)'), 'name', 'stop',
|
||||
"Second field in the form view should be stop");
|
||||
|
||||
actionManager.destroy();
|
||||
});
|
||||
|
||||
QUnit.test('test mode churn', async function(assert) {
|
||||
assert.expect(3);
|
||||
|
||||
var cohort = await createView({
|
||||
View: CohortView,
|
||||
model: 'lead',
|
||||
data: this.data,
|
||||
arch: '<cohort string="Leads" date_start="start" date_stop="stop" interval="week" mode="churn" />',
|
||||
mockRPC: function(route, args) {
|
||||
assert.strictEqual(args.kwargs.mode, "churn", "churn mode should be sent via RPC");
|
||||
return this._super(route, args);
|
||||
},
|
||||
});
|
||||
|
||||
assert.strictEqual(cohort.$('td .o_cohort_value:first').text().trim(), '0%', 'first col should display 0 percent');
|
||||
assert.strictEqual(cohort.$('td .o_cohort_value:nth(4)').text().trim(), '100%', 'col 5 should display 100 percent');
|
||||
|
||||
cohort.destroy();
|
||||
});
|
||||
|
||||
QUnit.test('test backward timeline', async function (assert) {
|
||||
assert.expect(7);
|
||||
|
||||
var cohort = await createView({
|
||||
View: CohortView,
|
||||
model: 'attendee',
|
||||
data: this.data,
|
||||
arch: '<cohort string="Attendees" date_start="event_begin_date" date_stop="registration_date" interval="day" timeline="backward" mode="churn"/>',
|
||||
mockRPC: function (route, args) {
|
||||
assert.strictEqual(args.kwargs.timeline, "backward", "backward timeline should be sent via RPC");
|
||||
return this._super(route, args);
|
||||
},
|
||||
});
|
||||
assert.ok(cohort.$('.table thead tr:nth-child(2) th:first:contains(-15)').length,
|
||||
'interval should start with -15');
|
||||
assert.ok(cohort.$('.table thead tr:nth-child(2) th:nth-child(16):contains(0)').length,
|
||||
'interval should end with 0');
|
||||
assert.strictEqual(cohort.$('td .o_cohort_value:first').text().trim(), '20%', 'first col should display 20 percent');
|
||||
assert.strictEqual(cohort.$('td .o_cohort_value:nth(5)').text().trim(), '40%', 'col 6 should display 40 percent');
|
||||
assert.strictEqual(cohort.$('td .o_cohort_value:nth(7)').text().trim(), '80%', 'col 8 should display 80 percent');
|
||||
assert.strictEqual(cohort.$('td .o_cohort_value:nth(14)').text().trim(), '100%', 'col 15 should display 100 percent');
|
||||
|
||||
cohort.destroy();
|
||||
});
|
||||
|
||||
QUnit.test('when clicked on cell redirects to the action list/form view passed in context', async function(assert) {
|
||||
assert.expect(6);
|
||||
|
||||
var actionManager = await createActionManager({
|
||||
data: this.data,
|
||||
archs: {
|
||||
'subscription,false,cohort': '<cohort string="Subscriptions" date_start="start" date_stop="stop" measure="__count__" interval="week" />',
|
||||
'subscription,my_list_view,list': '<tree>' +
|
||||
'<field name="start"/>' +
|
||||
'<field name="stop"/>' +
|
||||
'</tree>',
|
||||
'subscription,my_form_view,form': '<form>' +
|
||||
'<field name="start"/>' +
|
||||
'<field name="stop"/>' +
|
||||
'</form>',
|
||||
'subscription,false,list': '<tree>' +
|
||||
'<field name="recurring"/>' +
|
||||
'<field name="start"/>' +
|
||||
'</tree>',
|
||||
'subscription,false,form': '<form>' +
|
||||
'<field name="recurring"/>' +
|
||||
'<field name="start"/>' +
|
||||
'</form>',
|
||||
'subscription,false,search': '<search></search>',
|
||||
},
|
||||
intercepts: {
|
||||
do_action: function (ev) {
|
||||
actionManager.doAction(ev.data.action, ev.data.options);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await actionManager.doAction({
|
||||
name: 'Subscriptions',
|
||||
res_model: 'subscription',
|
||||
type: 'ir.actions.act_window',
|
||||
views: [[false, 'cohort']],
|
||||
context: {list_view_id: 'my_list_view', form_view_id: 'my_form_view'},
|
||||
});
|
||||
|
||||
// Going to the list view, while clicking Period / Count cell
|
||||
await testUtils.dom.click(actionManager.$('td.o_cohort_value:first'));
|
||||
assert.strictEqual(actionManager.$('.o_list_view th:nth(1)').text(), 'Start',
|
||||
"First field in the list view should be start");
|
||||
assert.strictEqual(actionManager.$('.o_list_view th:nth(2)').text(), 'Stop',
|
||||
"Second field in the list view should be stop");
|
||||
|
||||
// Going back to cohort view
|
||||
await testUtils.dom.click($('.o_back_button'));
|
||||
|
||||
// Going to the list view
|
||||
await testUtils.dom.click(actionManager.$('td div.o_cohort_value:first'));
|
||||
assert.strictEqual(actionManager.$('.o_list_view th:nth(1)').text(), 'Start',
|
||||
"First field in the list view should be start");
|
||||
assert.strictEqual(actionManager.$('.o_list_view th:nth(2)').text(), 'Stop',
|
||||
"Second field in the list view should be stop");
|
||||
|
||||
// Going to the form view
|
||||
await testUtils.dom.click(actionManager.$('.o_list_view .o_data_row'));
|
||||
|
||||
assert.hasAttrValue(actionManager.$('.o_form_view span:first'), 'name', 'start',
|
||||
"First field in the form view should be start");
|
||||
assert.hasAttrValue(actionManager.$('.o_form_view span:nth(1)'), 'name', 'stop',
|
||||
"Second field in the form view should be stop");
|
||||
|
||||
actionManager.destroy();
|
||||
});
|
||||
|
||||
QUnit.test('rendering of a cohort view with comparison', async function (assert) {
|
||||
assert.expect(29);
|
||||
|
||||
var unpatchDate = patchDate(2017, 7, 25, 1, 0, 0);
|
||||
|
||||
var actionManager = await createActionManager({
|
||||
data: this.data,
|
||||
archs: {
|
||||
'subscription,false,cohort': '<cohort string="Subscriptions" date_start="start" date_stop="stop" measure="__count__" interval="week" />',
|
||||
'subscription,false,search': `
|
||||
<search>
|
||||
<filter date="start" name="date_filter" string="Date"/>
|
||||
</search>
|
||||
`,
|
||||
},
|
||||
intercepts: {
|
||||
do_action: function (ev) {
|
||||
actionManager.doAction(ev.data.action, ev.data.options);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await actionManager.doAction({
|
||||
name: 'Subscriptions',
|
||||
res_model: 'subscription',
|
||||
type: 'ir.actions.act_window',
|
||||
views: [[false, 'cohort']],
|
||||
});
|
||||
|
||||
function verifyContents(results) {
|
||||
var $tables = actionManager.$('table');
|
||||
assert.strictEqual($tables.length, results.length, 'There should be ' + results.length + ' tables');
|
||||
var result;
|
||||
$tables.each(function () {
|
||||
result = results.shift();
|
||||
var $table = $(this);
|
||||
var rowCount = $table.find('.o_cohort_row_clickable').length;
|
||||
|
||||
if (rowCount) {
|
||||
assert.strictEqual(rowCount, result, 'the table should contain ' + result + ' rows');
|
||||
} else {
|
||||
assert.strictEqual($table.find('th:first').text().trim(), result,
|
||||
'the table should contain the time range description' + result);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// with no comparison, with data (no filter)
|
||||
verifyContents([3]);
|
||||
assert.containsNone(actionManager, '.o_cohort_no_data');
|
||||
assert.containsNone(actionManager, 'div.o_view_nocontent');
|
||||
|
||||
// with no comparison with no data (filter on 'last_year')
|
||||
await cpHelpers.toggleFilterMenu(actionManager);
|
||||
await cpHelpers.toggleMenuItem(actionManager, 'Date');
|
||||
await cpHelpers.toggleMenuItemOption(actionManager, 'Date', '2016');
|
||||
|
||||
verifyContents([]);
|
||||
assert.containsNone(actionManager, '.o_cohort_no_data');
|
||||
assert.containsOnce(actionManager, 'div.o_view_nocontent');
|
||||
|
||||
// with comparison active, data and comparisonData (filter on 'this_month' + 'previous_period')
|
||||
await cpHelpers.toggleMenuItemOption(actionManager, 'Date', '2016');
|
||||
await cpHelpers.toggleMenuItemOption(actionManager, 'Date', 'August');
|
||||
await cpHelpers.toggleComparisonMenu(actionManager);
|
||||
await cpHelpers.toggleMenuItem(actionManager, 'Date: Previous period');
|
||||
|
||||
verifyContents(['August 2017', 2, 'July 2017', 1]);
|
||||
assert.containsNone(actionManager, '.o_cohort_no_data');
|
||||
assert.containsNone(actionManager, 'div.o_view_nocontent');
|
||||
|
||||
// with comparison active, data, no comparisonData (filter on 'this_year' + 'previous_period')
|
||||
await cpHelpers.toggleFilterMenu(actionManager);
|
||||
await cpHelpers.toggleMenuItem(actionManager, 'Date');
|
||||
await cpHelpers.toggleMenuItemOption(actionManager, 'Date', 'August');
|
||||
|
||||
verifyContents(['2017', 3, '2016']);
|
||||
assert.containsOnce(actionManager, '.o_cohort_no_data');
|
||||
assert.containsNone(actionManager, 'div.o_view_nocontent');
|
||||
|
||||
// with comparison active, no data, comparisonData (filter on 'Q4' + 'previous_period')
|
||||
await cpHelpers.toggleMenuItemOption(actionManager, 'Date', 'Q4');
|
||||
|
||||
verifyContents(['Q4 2017', 'Q3 2017', 3]);
|
||||
assert.containsOnce(actionManager, '.o_cohort_no_data');
|
||||
assert.containsNone(actionManager, 'div.o_view_nocontent');
|
||||
|
||||
// with comparison active, no data, no comparisonData (filter on 'last_year' + 'previous_period')
|
||||
await cpHelpers.toggleMenuItemOption(actionManager, 'Date', '2016');
|
||||
await cpHelpers.toggleMenuItemOption(actionManager, 'Date', '2017');
|
||||
|
||||
verifyContents([]);
|
||||
assert.containsNone(actionManager, '.o_cohort_no_data');
|
||||
assert.containsOnce(actionManager, 'div.o_view_nocontent');
|
||||
|
||||
unpatchDate();
|
||||
actionManager.destroy();
|
||||
});
|
||||
|
||||
QUnit.test('verify context', async function (assert) {
|
||||
assert.expect(1);
|
||||
|
||||
var cohort = await createView({
|
||||
View: CohortView,
|
||||
model: 'subscription',
|
||||
data: this.data,
|
||||
arch: '<cohort string="Subscription" date_start="start" date_stop="stop" />',
|
||||
mockRPC: function (route, args) {
|
||||
if (args.method === 'get_cohort_data') {
|
||||
assert.ok(args.kwargs.context);
|
||||
}
|
||||
return this._super.apply(this, arguments);
|
||||
},
|
||||
});
|
||||
|
||||
cohort.destroy();
|
||||
});
|
||||
|
||||
QUnit.test('empty cohort view with action helper', async function (assert) {
|
||||
assert.expect(4);
|
||||
|
||||
const cohort = await createView({
|
||||
View: CohortView,
|
||||
model: "subscription",
|
||||
data: this.data,
|
||||
arch: '<cohort date_start="start" date_stop="stop"/>',
|
||||
domain: [['id', '<', 0]],
|
||||
viewOptions: {
|
||||
action: {
|
||||
context: {},
|
||||
help: '<p class="abc">click to add a foo</p>'
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
assert.containsOnce(cohort, '.o_view_nocontent .abc');
|
||||
assert.containsNone(cohort, 'table');
|
||||
|
||||
await cohort.reload({ domain: [] });
|
||||
|
||||
assert.containsNone(cohort, '.o_view_nocontent .abc');
|
||||
assert.containsOnce(cohort, 'table');
|
||||
|
||||
cohort.destroy();
|
||||
});
|
||||
|
||||
QUnit.test('empty cohort view with sample data', async function (assert) {
|
||||
assert.expect(7);
|
||||
|
||||
const cohort = await createView({
|
||||
View: CohortView,
|
||||
model: "subscription",
|
||||
data: this.data,
|
||||
arch: '<cohort sample="1" date_start="start" date_stop="stop"/>',
|
||||
domain: [['id', '<', 0]],
|
||||
viewOptions: {
|
||||
action: {
|
||||
context: {},
|
||||
help: '<p class="abc">click to add a foo</p>'
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
assert.hasClass(cohort.el, 'o_view_sample_data');
|
||||
assert.containsOnce(cohort, '.o_view_nocontent .abc');
|
||||
assert.containsOnce(cohort, 'table.o_sample_data_disabled');
|
||||
|
||||
await cohort.reload({ domain: [] });
|
||||
|
||||
assert.doesNotHaveClass(cohort.el, 'o_view_sample_data');
|
||||
assert.containsNone(cohort, '.o_view_nocontent .abc');
|
||||
assert.containsOnce(cohort, 'table');
|
||||
assert.doesNotHaveClass(cohort.$('table'), 'o_sample_data_disabled');
|
||||
|
||||
cohort.destroy();
|
||||
});
|
||||
|
||||
QUnit.test('non empty cohort view with sample data', async function (assert) {
|
||||
assert.expect(7);
|
||||
|
||||
const cohort = await createView({
|
||||
View: CohortView,
|
||||
model: "subscription",
|
||||
data: this.data,
|
||||
arch: '<cohort sample="1" date_start="start" date_stop="stop"/>',
|
||||
viewOptions: {
|
||||
action: {
|
||||
context: {},
|
||||
help: '<p class="abc">click to add a foo</p>'
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
assert.doesNotHaveClass(cohort.el, 'o_view_sample_data');
|
||||
assert.containsNone(cohort, '.o_view_nocontent .abc');
|
||||
assert.containsOnce(cohort, 'table');
|
||||
assert.doesNotHaveClass(cohort.$('table'), 'o_sample_data_disabled');
|
||||
|
||||
await cohort.reload({ domain: [['id', '<', 0]] });
|
||||
|
||||
assert.doesNotHaveClass(cohort.el, 'o_view_sample_data');
|
||||
assert.containsOnce(cohort, '.o_view_nocontent .abc');
|
||||
assert.containsNone(cohort, 'table');
|
||||
|
||||
cohort.destroy();
|
||||
});
|
||||
});
|
||||
});
|
||||
171
web_cohort/static/tests/mock_server.js
Executable file
171
web_cohort/static/tests/mock_server.js
Executable file
@@ -0,0 +1,171 @@
|
||||
odoo.define('web_cohort.MockServer', function (require) {
|
||||
'use strict';
|
||||
|
||||
var MockServer = require('web.MockServer');
|
||||
|
||||
MockServer.include({
|
||||
//--------------------------------------------------------------------------
|
||||
// Private
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @override
|
||||
* @private
|
||||
* @returns {Promise}
|
||||
*/
|
||||
_performRpc: function (route, args) {
|
||||
if (args.method === 'get_cohort_data') {
|
||||
return this._mockGetCohortData(args.model, args.kwargs);
|
||||
} else {
|
||||
return this._super(route, args);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {string} model
|
||||
* @param {Object} kwargs
|
||||
* @returns {Promise}
|
||||
*/
|
||||
_mockGetCohortData: function (model, kwargs) {
|
||||
var self = this;
|
||||
var displayFormats = {
|
||||
'day': 'DD MMM YYYY',
|
||||
'week': 'ww YYYY',
|
||||
'month': 'MMMM YYYY',
|
||||
'year': 'Y',
|
||||
};
|
||||
var rows = [];
|
||||
var totalValue = 0;
|
||||
var initialChurnValue = 0;
|
||||
var columnsAvg = {};
|
||||
|
||||
var groups = this._mockReadGroup(model, {
|
||||
domain: kwargs.domain,
|
||||
fields: [kwargs.date_start],
|
||||
groupby: [kwargs.date_start + ':' + kwargs.interval],
|
||||
});
|
||||
var totalCount = groups.length;
|
||||
_.each(groups, function (group) {
|
||||
var format;
|
||||
switch (kwargs.interval) {
|
||||
case 'day':
|
||||
format = 'YYYY-MM-DD';
|
||||
break;
|
||||
case 'week':
|
||||
format = 'ww YYYY';
|
||||
break;
|
||||
case 'month':
|
||||
format = 'MMMM YYYY';
|
||||
break;
|
||||
case 'year':
|
||||
format = 'Y';
|
||||
break;
|
||||
}
|
||||
var cohortStartDate = moment(group[kwargs.date_start + ':' + kwargs.interval], format);
|
||||
|
||||
var records = self._mockSearchReadController({
|
||||
model: model,
|
||||
domain: group.__domain,
|
||||
});
|
||||
var value = 0;
|
||||
if (kwargs.measure === '__count__') {
|
||||
value = records.length;
|
||||
} else {
|
||||
if (records.length) {
|
||||
value = _.pluck(records.records, kwargs.measure).reduce(function (a, b) {
|
||||
return a + b;
|
||||
});
|
||||
}
|
||||
}
|
||||
totalValue += value;
|
||||
var initialValue = value;
|
||||
|
||||
var columns = [];
|
||||
var colStartDate = cohortStartDate.clone();
|
||||
if (kwargs.timeline === 'backward') {
|
||||
colStartDate = colStartDate.subtract(15, kwargs.interval);
|
||||
}
|
||||
for (var column = 0; column <= 15; column++) {
|
||||
if (!columnsAvg[column]) {
|
||||
columnsAvg[column] = {'percentage': 0, 'count': 0};
|
||||
}
|
||||
if (column !== 0) {
|
||||
colStartDate.add(1, kwargs.interval);
|
||||
}
|
||||
if (colStartDate > moment()) {
|
||||
columnsAvg[column]['percentage'] += 0;
|
||||
columnsAvg[column]['count'] += 0;
|
||||
columns.push({
|
||||
'value': '-',
|
||||
'churn_value': '-',
|
||||
'percentage': '',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
var compareDate = colStartDate.format(displayFormats[kwargs.interval]);
|
||||
var colRecords = _.filter(records.records, function (record) {
|
||||
return record[kwargs.date_stop] && moment(record[kwargs.date_stop], 'YYYY-MM-DD').format(displayFormats[kwargs.interval]) == compareDate;
|
||||
});
|
||||
var colValue = 0;
|
||||
if (kwargs.measure === '__count__') {
|
||||
colValue = colRecords.length;
|
||||
} else {
|
||||
if (colRecords.length) {
|
||||
colValue = _.pluck(colRecords, kwargs.measure).reduce(function (a, b) {
|
||||
return a + b;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (kwargs.timeline === 'backward' && column === 0) {
|
||||
colRecords = _.filter(records.records, function (record) {
|
||||
return record[kwargs.date_stop] && moment(record[kwargs.date_stop], 'YYYY-MM-DD') >= colStartDate;
|
||||
});
|
||||
if (kwargs.measure === '__count__') {
|
||||
initialValue = colRecords.length;
|
||||
} else {
|
||||
if (colRecords.length) {
|
||||
initialValue = _.pluck(colRecords, kwargs.measure).reduce(function (a, b) {
|
||||
return a + b;
|
||||
});
|
||||
}
|
||||
}
|
||||
initialChurnValue = value - initialValue;
|
||||
}
|
||||
var previousValue = column === 0 ? initialValue : columns[column - 1]['value'];
|
||||
var remainingValue = previousValue - colValue;
|
||||
var previousChurnValue = column === 0 ? initialChurnValue : columns[column - 1]['churn_value'];
|
||||
var churnValue = colValue + previousChurnValue;
|
||||
var percentage = value ? parseFloat(remainingValue / value) : 0;
|
||||
if (kwargs.mode === 'churn') {
|
||||
percentage = 1 - percentage;
|
||||
}
|
||||
percentage = Number((100 * percentage).toFixed(1));
|
||||
columnsAvg[column]['percentage'] += percentage;
|
||||
columnsAvg[column]['count'] += 1;
|
||||
columns.push({
|
||||
'value': remainingValue,
|
||||
'churn_value': churnValue,
|
||||
'percentage': percentage,
|
||||
'domain': [],
|
||||
'period': compareDate,
|
||||
});
|
||||
}
|
||||
rows.push({
|
||||
'date': cohortStartDate.format(displayFormats[kwargs.interval]),
|
||||
'value': value,
|
||||
'domain': group.__domain,
|
||||
'columns': columns,
|
||||
});
|
||||
});
|
||||
|
||||
return Promise.resolve({
|
||||
'rows': rows,
|
||||
'avg': {'avg_value': totalCount ? (totalValue / totalCount) : 0, 'columns_avg': columnsAvg},
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
});
|
||||
Reference in New Issue
Block a user