[IMP] web,*: new GraphView component

*hr_timesheet,project,stock

Part-of: odoo/odoo#73311
Co-authored-by: Mathieu Duckerts-Antoine <dam@odoo.com>
Co-authored-by: Julien Mougenot <jum@odoo.com>
Co-authored-by: Jorge Pinna Puissant <jpp@odoo.com>
Co-authored-by: Stefano Rigano <sri@odoo.com>
This commit is contained in:
Mathieu Duckerts-Antoine
2021-09-07 10:04:38 +00:00
committed by Aaron Bohy
co-authored by Julien Mougenot Jorge Pinna Puissant Stefano Rigano
parent 23e84c1750
commit 242b1caa65
32 changed files with 5315 additions and 345 deletions
+2 -1
View File
@@ -54,7 +54,8 @@ up a management by affair.
'hr_timesheet/static/src/js/timesheet_factor.js',
'hr_timesheet/static/src/js/timesheet_config_form_view.js',
'hr_timesheet/static/src/js/qr_code_action.js',
'hr_timesheet/static/src/js/timesheet_graph.js',
'hr_timesheet/static/src/js/timesheet_graph_view.js',
'hr_timesheet/static/src/js/timesheet_graph_model.js',
],
'web.qunit_suite_tests': [
'hr_timesheet/static/tests/**/*',
@@ -1,40 +0,0 @@
odoo.define('hr_timesheet.GraphView', function (require) {
"use strict";
const viewRegistry = require('web.view_registry');
const GraphView = require('web.GraphView');
const GraphModel = require('web.GraphModel');
const hrTimesheetGraphModel = GraphModel.extend({
/*
* Override the _processData to take into account the analytic line uom.
*/
_processData: function (originIndex, rawData) {
this._super.apply(this, arguments);
const session = this.getSession();
const currentCompanyId = session.user_context.allowed_company_ids[0];
const currentCompany = session.user_companies.allowed_companies[currentCompanyId];
const currentCompanyTimesheetUOMFactor = currentCompany.timesheet_uom_factor || 1;
const fields = ['unit_amount', 'effective_hours', 'planned_hours', 'remaining_hours', 'total_hours_spent', 'subtask_effective_hours',
'overtime', 'number_hours', 'difference', 'hours_effective', 'hours_planned', 'timesheet_unit_amount'];
if (fields.includes(this.chart.measure) && currentCompanyTimesheetUOMFactor !== 1) {
// recalculate the Duration values according to the timesheet_uom_factor
this.chart.dataPoints.forEach(function (dataPt) {
dataPt.value *= currentCompanyTimesheetUOMFactor;
});
}
},
});
const hrTimesheetGraphView = GraphView.extend({
config: Object.assign({}, GraphView.prototype.config, {
Model: hrTimesheetGraphModel,
}),
});
viewRegistry.add('hr_timesheet_graphview', hrTimesheetGraphView);
return hrTimesheetGraphView;
});
@@ -0,0 +1,39 @@
/** @odoo-module **/
import { GraphModel } from "@web/views/graph/graph_model";
const FIELDS = [
'unit_amount', 'effective_hours', 'planned_hours', 'remaining_hours', 'total_hours_spent', 'subtask_effective_hours',
'overtime', 'number_hours', 'difference', 'hours_effective', 'hours_planned', 'timesheet_unit_amount'
];
export class hrTimesheetGraphModel extends GraphModel {
/**
* @override
*/
setup(params, services) {
super.setup(...arguments);
this.companyService = services.company;
}
//--------------------------------------------------------------------------
// Private
//--------------------------------------------------------------------------
/**
* Override processDataPoints to take into account the analytic line uom.
* @override
*/
_getProcessedDataPoints() {
const currentCompany = this.companyService.currentCompany;
const factor = currentCompany.timesheet_uom_factor || 1;
if (factor !== 1 && FIELDS.includes(this.metaData.measure)) {
// recalculate the Duration values according to the timesheet_uom_factor
for (const dataPt of this.dataPoints) {
dataPt.value *= factor;
}
}
return super._getProcessedDataPoints(...arguments);
}
}
hrTimesheetGraphModel.services = [...GraphModel.services, "company"];
@@ -0,0 +1,12 @@
/** @odoo-module **/
import { GraphView } from "@web/views/graph/graph_view";
import { hrTimesheetGraphModel } from "./timesheet_graph_model";
import { registry } from "@web/core/registry";
const viewRegistry = registry.category("views");
export class hrTimesheetGraphView extends GraphView {}
hrTimesheetGraphView.Model = hrTimesheetGraphModel;
viewRegistry.add("hr_timesheet_graphview", hrTimesheetGraphView);
@@ -1,73 +1,78 @@
odoo.define('hr_timesheet.timesheet_graph_tests', function (require) {
"use strict";
/** @odoo-module **/
const session = require('web.session');
const SetupTimesheetUOMWidgetsTestEnvironment = require('hr_timesheet.timesheet_uom_tests_env');
const GraphView = require('hr_timesheet.GraphView');
import { companyService } from "@web/webclient/company_service";
import { dialogService } from "@web/core/dialog/dialog_service";
import { getGraphRenderer } from "@web/../tests/views/graph_view_tests";
import { makeView } from "@web/../tests/views/helpers";
import { patchWithCleanup } from "@web/../tests/helpers/utils";
import { session } from "@web/session";
import { registry } from "@web/core/registry";
import { setupControlPanelServiceRegistry } from "@web/../tests/search/helpers";
QUnit.module('Timesheet UOM Widgets', function (hooks) {
let env;
let sessionUserCompaniesBackup;
let sessionUserContextBackup;
let sessionUOMIdsBackup;
let sessionUIDBackup;
hooks.before(function (assert) {
env = new SetupTimesheetUOMWidgetsTestEnvironment();
// Backups session parts that this testing module will alter in order to restore it at the end.
sessionUserCompaniesBackup = session.user_companies || false;
sessionUserContextBackup = session.user_context || false;
sessionUOMIdsBackup = session.uom_ids || false;
sessionUIDBackup = session.uid || false;
});
hooks.after(async function (assert) {
// Restores the session
const sessionToApply = Object.assign(
{ },
sessionUserCompaniesBackup && {
user_companies: sessionUserCompaniesBackup,
} || { },
sessionUserContextBackup && {
user_context: sessionUserContextBackup,
} || { },
sessionUOMIdsBackup && {
uom_ids: sessionUOMIdsBackup,
} || { },
sessionUIDBackup && {
uid: sessionUIDBackup,
} || { });
await env.patchSessionAndStartServices(sessionToApply, true);
});
QUnit.module('GraphView', function (hooks) {
QUnit.test('the timesheet_graph view data are multiplied by a factor that is company related', async function (assert) {
assert.expect(2);
const serviceRegistry = registry.category("services");
let options = {
View: GraphView,
arch: '<graph><field name="unit_amount"/></graph>',
viewOptions: {
context: {
graph_measure: 'unit_amount',
QUnit.module('Views', function (hooks) {
let serverData;
hooks.beforeEach(() => {
serverData = {
models: {
'account.analytic.line': {
fields: {
unit_amount: { string: "Unit Amount", type: "float", group_operator: "sum", store: true },
},
records: [
{ id: 1, unit_amount: 8 }
],
},
};
let graph = await env.createView(options);
let renderedData = graph.renderer.componentRef.comp.chart.data.datasets[0].data[0];
assert.strictEqual(renderedData, 8, 'The timesheet_graph is taking the timesheet_uom_factor into account');
graph.destroy();
},
views: {
// unit_amount is used as group_by and measure
"account.analytic.line,false,graph": `
<graph>
<field name="unit_amount"/>
<field name="unit_amount" type="measure"/>
</graph>
`,
}
}
setupControlPanelServiceRegistry();
serviceRegistry.add("company", companyService);
serviceRegistry.add("dialog", dialogService);
});
options = Object.assign(
{ },
options,
{
session: {
user_context: env.singleCompanyDayUOMUser,
},
});
graph = await env.createView(options);
renderedData = graph.renderer.componentRef.comp.chart.data.datasets[0].data[0];
assert.strictEqual(renderedData, 1, 'The timesheet_graph is taking the timesheet_uom_factor into account');
graph.destroy();
QUnit.module("hrTimesheetGraphView");
QUnit.test('the timesheet graph view data are not multiplied by a factor that is company related (factor = 1)', async function (assert) {
assert.expect(1);
patchWithCleanup(session.user_companies.allowed_companies[1], {
timesheet_uom_factor: 1,
});
const graph = await makeView({
serverData,
resModel: "account.analytic.line",
type: "hr_timesheet_graphview",
});
const renderedData = getGraphRenderer(graph).chart.data.datasets[0].data;
assert.deepEqual(renderedData, [8], 'The timesheet graph view is taking the timesheet_uom_factor into account (factor === 1)');
});
QUnit.test('the timesheet graph view data are multiplied by a factor that is company related (factor !== 1)', async function (assert) {
assert.expect(1);
patchWithCleanup(session.user_companies.allowed_companies[1], {
timesheet_uom_factor: 0.125,
});
const graph = await makeView({
serverData,
resModel: "account.analytic.line",
type: "hr_timesheet_graphview",
});
const renderedData = getGraphRenderer(graph).chart.data.datasets[0].data;
assert.deepEqual(renderedData, [1], 'The timesheet graph view is taking the timesheet_uom_factor into account (factor !== 1)');
});
});
});
+3 -2
View File
@@ -59,20 +59,21 @@
'post_init_hook': '_project_post_init',
'assets': {
'web.assets_backend': [
'project/static/src/burndown_chart/*',
'project/static/src/project_control_panel/*',
'project/static/src/css/project.css',
'project/static/src/js/project_activity.js',
'project/static/src/js/project_control_panel.js',
'project/static/src/js/project_form.js',
'project/static/src/js/project_graph.js',
'project/static/src/js/project_graph_view.js',
'project/static/src/js/project_kanban.js',
'project/static/src/js/project_list.js',
'project/static/src/js/project_pivot.js',
'project/static/src/js/project_rating_graph_view.js',
'project/static/src/js/project_rating_reporting.js',
'project/static/src/js/project_task_kanban_examples.js',
'project/static/src/js/tours/project.js',
'project/static/src/js/project_calendar.js',
'project/static/src/js/burndown_chart/*',
'project/static/src/js/right_panel/*',
'project/static/src/js/update/*',
'project/static/src/js/widgets/*',
@@ -0,0 +1,46 @@
/** @odoo-module **/
import { hexToRGBA } from "@web/views/graph/colors";
import { GraphRenderer } from "@web/views/graph/graph_renderer";
export class BurndownChartRenderer extends GraphRenderer {
/**
* @override
*/
getLineChartData() {
const data = super.getLineChartData();
const { stacked } = this.props.metaData;
if (stacked) {
for (const dataset of data.datasets) {
dataset.backgroundColor = hexToRGBA(dataset.borderColor, 0.4);
}
}
return data;
}
/**
* @override
*/
getElementOptions() {
const elementOptions = super.getElementOptions();
const { mode, stacked } = this.props.metaData;
if (mode === "line") {
elementOptions.line.fill = stacked;
}
return elementOptions;
}
/**
* @override
*/
getScaleOptions() {
const { xAxes, yAxes } = super.getScaleOptions();
const { mode, stacked } = this.props.metaData;
if (mode === "line") {
for (const y of yAxes) {
y.stacked = stacked;
}
}
return { xAxes, yAxes };
}
}
@@ -0,0 +1,13 @@
/** @odoo-module **/
import { BurndownChartRenderer } from "./burndown_chart_renderer";
import { GraphView } from "@web/views/graph/graph_view";
import { registry } from "@web/core/registry";
const viewRegistry = registry.category("views");
class BurndownChartView extends GraphView {}
BurndownChartView.components = { ...GraphView.components, Renderer: BurndownChartRenderer };
BurndownChartView.buttonTemplate = "project.BurndownChartView.Buttons";
viewRegistry.add("burndown_chart", BurndownChartView);
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="project.BurndownChartView.Buttons" t-inherit="web.GraphView.Buttons" t-inherit-mode="primary" owl="1">
<xpath expr="//button[@data-mode='pie']" position="replace">
</xpath>
<xpath expr="//div[@role='toolbar'][3]" position="attributes">
<attribute name="t-if">true</attribute>
</xpath>
</t>
</templates>
@@ -1,23 +0,0 @@
/** @odoo-module alias=project.BurndownChartController **/
import * as GraphController from 'web.GraphController';
export default GraphController.extend({
/**
* @override
*/
updateButtons: function() {
const result = this._super.apply(this, arguments);
if (this.$buttons) {
const state = this.model.get();
this.$buttons
.find('.o_graph_button[data-mode="pie"]')
.addClass('d-none');
this.$buttons
.find('.o_graph_button[data-mode="stack"]')
.data('stacked', state.stacked)
.toggleClass('active', state.stacked)
.toggleClass('o_hidden', state.mode === 'pie');
}
return result;
}
});
@@ -1,49 +0,0 @@
/** @odoo-module alias=project.BurndownChartRenderer **/
import * as GraphRenderer from 'web/static/src/js/views/graph/graph_renderer';
import { hexToRGBA } from "web/static/src/js/views/graph/graph_utils";
export class BurndownChartRenderer extends GraphRenderer {
/**
* @override
* @private
*/
_createLineChartConfig() {
const { data, options, type } = super._createLineChartConfig();
for (const dataset of data.datasets) {
if (this.props.stacked) {
dataset.backgroundColor = hexToRGBA(dataset.borderColor, 0.4);
}
}
return { data, options, type };
}
/**
* @override
* @private
* @returns {Object}
*/
_getElementOptions() {
const elementOptions = super._getElementOptions();
if (this.props.mode === 'line') {
elementOptions.line.fill = this.props.stacked;
}
return elementOptions;
}
/**
* @override
* @private
*/
_getScaleOptions() {
const scaleOptions = super._getScaleOptions();
if (this.props.mode !== "line") {
return scaleOptions;
}
const { xAxes, yAxes } = scaleOptions;
for (const y of yAxes) {
y.stacked = this.props.stacked;
}
return { xAxes, yAxes };
}
}
@@ -1,14 +0,0 @@
/** @odoo-module alias=project.BurndownChartView **/
import GraphView from 'web.GraphView';
import viewRegistry from 'web.view_registry';
import BurndownChartController from './controller';
import { BurndownChartRenderer } from './renderer';
export const BurndownChartView = GraphView.extend({
config: Object.assign({}, GraphView.prototype.config, {
Controller: BurndownChartController,
Renderer: BurndownChartRenderer,
}),
});
viewRegistry.add('burndown_chart', BurndownChartView);
@@ -1,13 +0,0 @@
/** @odoo-module **/
import GraphView from 'web.GraphView';
import { ProjectControlPanel } from '@project/js/project_control_panel';
import viewRegistry from 'web.view_registry';
export const ProjectGraphView = GraphView.extend({
config: Object.assign({}, GraphView.prototype.config, {
ControlPanel: ProjectControlPanel,
}),
});
viewRegistry.add('project_graph', ProjectGraphView);
@@ -0,0 +1,12 @@
/** @odoo-module **/
import { GraphView } from "@web/views/graph/graph_view";
import { ProjectControlPanel } from "@project/project_control_panel/project_control_panel";
import { registry } from "@web/core/registry";
const viewRegistry = registry.category("views");
class ProjectGraphView extends GraphView {}
ProjectGraphView.components = { ...GraphView, ControlPanel: ProjectControlPanel };
viewRegistry.add("project_graph", ProjectGraphView);
@@ -0,0 +1,34 @@
/** @odoo-module **/
import { _lt } from "@web/core/l10n/translation";
import { GraphView } from "@web/views/graph/graph_view";
import { GraphArchParser } from "@web/views/graph/graph_arch_parser";
import { registry } from "@web/core/registry";
const viewRegistry = registry.category("views");
const MEASURE_STRINGS = {
parent_res_id: _lt("Project"),
rating: _lt("Rating Value (/5)"),
res_id: _lt("Task"),
};
class ProjectRatingArchParser extends GraphArchParser {
parse() {
const archInfo = super.parse(...arguments);
for (const [key, val] of Object.entries(MEASURE_STRINGS)) {
archInfo.fieldAttrs[key] = {
...archInfo.fieldAttrs[key],
string: val.toString(),
};
}
return archInfo;
}
}
// Would it be not better achiedved by using a proper arch directly?
class ProjectRatingGraphView extends GraphView {}
ProjectRatingGraphView.archParser = ProjectRatingArchParser;
viewRegistry.add("project_rating_graph", ProjectRatingGraphView);
@@ -1,8 +1,6 @@
/** @odoo-module **/
import { _t } from 'web.core';
import GraphController from 'web.GraphController';
import GraphView from 'web.GraphView';
import PivotController from 'web.PivotController';
import PivotView from 'web.PivotView';
import viewRegistry from 'web.view_registry';
@@ -34,29 +32,3 @@ const ProjectPivotView = PivotView.extend({
});
viewRegistry.add('project_rating_pivot', ProjectPivotView);
const ProjectGraphController = GraphController.extend({
/**
* @override
*/
init: function () {
this._super.apply(this, arguments);
_.each(this.measures, measure => {
if (measure.fieldName === 'res_id') {
measure.description = _t('Task');
} else if (measure.fieldName === 'parent_res_id') {
measure.description = _t('Project');
} else if (measure.fieldName === 'rating') {
measure.description = _t('Rating Value (/5)');
}
});
},
});
const ProjectGraphView = GraphView.extend({
config: Object.assign({}, GraphView.prototype.config, {
Controller: ProjectGraphController,
}),
});
viewRegistry.add('project_rating_graph', ProjectGraphView);
@@ -1,74 +1,85 @@
/** @odoo-module alias=project.BurndownView */
import { dom, createView, nextTick } from 'web.test_utils';
import { COLORS, hexToRGBA } from 'web/static/src/js/views/graph/graph_utils';
import { BurndownChartView } from '../src/js/burndown_chart/view';
/** @odoo-module */
QUnit.module('Project', {}, function () {
QUnit.module('Views', {
beforeEach() {
this.data = {
burndown_chart: {
fields: {
date: { string: "Date", type: 'date', store: true, sortable: true },
project_id: { string: "Project", type: "many2one", relation: 'project', store: true },
stage_id: { string: "Stage", type: "many2one", relation: 'stage', store: true },
nb_tasks: { string: "Number of Tasks", type: 'integer', store: true }
import { click } from "@web/../tests/helpers/utils";
import { COLORS, hexToRGBA } from "@web/views/graph/colors";
import { dialogService } from "@web/core/dialog/dialog_service";
import { getGraphRenderer } from "@web/../tests/views/graph_view_tests";
import { makeView } from "@web/../tests/views/helpers";
import { registry } from "@web/core/registry";
import { setupControlPanelServiceRegistry } from "@web/../tests/search/helpers";
const serviceRegistry = registry.category("services");
QUnit.module("Project", {}, () => {
QUnit.module("Views", (hooks) => {
let serverData;
hooks.beforeEach(async () => {
serverData = {
models: {
burndown_chart: {
fields: {
date: { string: "Date", type: "date", store: true, sortable: true },
project_id: { string: "Project", type: "many2one", relation: "project", store: true },
stage_id: { string: "Stage", type: "many2one", relation: "stage", store: true },
nb_tasks: { string: "Number of Tasks", type: "integer", store: true, group_operator: "sum" }
},
records: [
{ id: 1, project_id: 1, stage_id: 1, date: "2020-01-01", nb_tasks: 10 },
{ id: 2, project_id: 1, stage_id: 2, date: "2020-02-01", nb_tasks: 5 },
{ id: 3, project_id: 1, stage_id: 3, date: "2020-03-01", nb_tasks: 2 },
],
},
records: [
{ id: 1, project_id: 1, stage_id: 1, date: "2020-01-01", nb_tasks: 10 },
{ id: 2, project_id: 1, stage_id: 2, date: "2020-02-01", nb_tasks: 5 },
{ id: 3, project_id: 1, stage_id: 3, date: "2020-03-01", nb_tasks: 2 },
],
project: {
fields: {
name: { string: "Project Name", type: "char" },
},
records: [{ id: 1, name: "Project A" }]
},
stage: {
fields: {
name: { string: "Stage Name", type: "char" },
},
records: [
{ id: 1, name: "Todo" },
{ id: 2, name: "In Progress" },
{ id: 3, name: "Done" },
],
}
},
project: {
fields: {
name: { string: 'Project Name', type: "char" },
},
records: [
{ id: 1, name: 'Project A' },
]
views: {
"burndown_chart,false,graph": `
<graph type="line">
<field name="date" string="Date" interval="month"/>
<field name="stage_id"/>
<field name="nb_tasks" type="measure"/>
</graph>
`,
},
stage: {
fields: {
name: {string: 'Stage Name', type: 'char' },
},
records: [
{ id: 1, name: 'Todo' },
{ id: 2, name: 'In Progress' },
{ id: 3, name: 'Done' },
]
}
};
setupControlPanelServiceRegistry();
serviceRegistry.add("dialog", dialogService);
});
this.burndown_chart = {
View: BurndownChartView,
model: 'burndown_chart',
data: this.data,
arch: `<graph type="line">
<field name="date" string="Date" interval="month" type="row"/>
<field name="stage_id"/>
<field name="nb_tasks" type="measure"/>
</graph>`,
};
}
}, function () {
QUnit.module('BurndownChart');
QUnit.module("BurndownChart");
QUnit.test('check if default mode is line chart and line chart is stacked for burndown chart', async function (assert) {
QUnit.test("check if default mode is line chart and line chart is stacked for burndown chart", async function (assert) {
assert.expect(5);
const burndown_chart = await createView(this.burndown_chart);
const burndownChart = await makeView({
serverData,
resModel: "burndown_chart",
type: "burndown_chart",
});
assert.strictEqual(burndown_chart.renderer.props.mode, "line", "should be in line chart mode by default.");
assert.ok(burndown_chart.renderer.props.stacked, "should be stacked by default.");
assert.strictEqual(burndownChart.model.metaData.mode, "line", "should be in line chart mode.");
assert.ok(burndownChart.model.metaData.stacked, "should be stacked by default.");
assert.ok(burndown_chart.renderer.componentRef.comp._getScaleOptions().yAxes.every(y => y.stacked), "the stacked property in y axes should be true when the stacked is enabled in line chart");
assert.ok(burndown_chart.renderer.componentRef.comp._getElementOptions().line.fill, "The fill property should be true to add backgroundColor in line chart.");
assert.ok(getGraphRenderer(burndownChart).getScaleOptions().yAxes.every(y => y.stacked), "the stacked property in y axes should be true when the stacked is enabled in line chart");
assert.ok(getGraphRenderer(burndownChart).getElementOptions().line.fill, "The fill property should be true to add backgroundColor in line chart.");
const actualDatasets = [];
const expectedDatasets = [];
const keysToEvaluate = ['backgroundColor', 'borderColor', 'originIndex', 'pointBackgroundColor'];
const datasets = burndown_chart.renderer.componentRef.comp.chart.data.datasets;
const keysToEvaluate = ["backgroundColor", "borderColor", "originIndex", "pointBackgroundColor"];
const datasets = getGraphRenderer(burndownChart).chart.data.datasets;
for (let i = 0; i < datasets.length; i++) {
const dataset = datasets[i];
@@ -88,43 +99,43 @@ QUnit.module('Project', {}, function () {
pointBackgroundColor: expectedColor,
});
}
assert.deepEqual(actualDatasets, expectedDatasets);
burndown_chart.destroy();
});
QUnit.test('check if the stacked button is visible in the line chart', async function (assert) {
QUnit.test("check if the stacked button is visible in the line chart", async function (assert) {
assert.expect(3);
const burndown_chart = await createView(this.burndown_chart);
assert.containsN(burndown_chart, '.o_graph_button[data-mode="stack"]', 1, "should find the stacked button in the controller of the view.");
const $stackButton = burndown_chart.$buttons.find('.o_graph_button[data-mode="stack"]');
assert.doesNotHaveClass($stackButton, 'o_hidden', "the stacked button should not have the .o_hidden class when we are in the line chart.");
await dom.click($stackButton);
assert.notOk(burndown_chart.renderer.props.stacked, "should be disabled and display a classic line chart.");
burndown_chart.destroy();
const burndownChart = await makeView({
serverData,
resModel: "burndown_chart",
type: "burndown_chart",
});
assert.ok(burndownChart.model.metaData.stacked, "graph should be a burndown chart.");
assert.containsOnce(burndownChart, `button.o_graph_button[data-tooltip="Stacked"]`);
const stackButton = burndownChart.el.querySelector(`button.o_graph_button[data-tooltip="Stacked"]`);
await click(stackButton);
assert.notOk(burndownChart.model.metaData.stacked, "graph should be a classic line chart.");
});
QUnit.test('check if it is classic line chart when stacked prop is false in line chart', async function (assert) {
QUnit.test("check if it is classic line chart when stacked prop is false in line chart", async function (assert) {
assert.expect(4);
const burndown_chart = await createView(this.burndown_chart);
const burndownChart = await makeView({
serverData,
resModel: "burndown_chart",
type: "burndown_chart",
});
const $stackButton = burndown_chart.$buttons.find('.o_graph_button[data-mode="stack"]');
await dom.click($stackButton);
assert.notOk(burndown_chart.renderer.props.stacked, "should be disabled and display a classic line chart.");
const stackButton = burndownChart.el.querySelector(`button.o_graph_button[data-tooltip="Stacked"]`);
await click(stackButton);
assert.notOk(burndownChart.model.metaData.stacked, "graph should be a classic line chart.");
assert.notOk(burndown_chart.renderer.componentRef.comp._getScaleOptions().yAxes.every(y => y.stacked), "the y axes should have a stacked property set to false since the stacked property in line chart is false.");
assert.notOk(burndown_chart.renderer.componentRef.comp._getElementOptions().line.fill, "The fill property should be false since the stacked property is false.");
assert.notOk(getGraphRenderer(burndownChart).getScaleOptions().yAxes.every(y => y.stacked), "the y axes should have a stacked property set to false since the stacked property in line chart is false.");
assert.notOk(getGraphRenderer(burndownChart).getElementOptions().line.fill, "The fill property should be false since the stacked property is false.");
const actualDatasets = [];
const expectedDatasets = [];
const keysToEvaluate = ['backgroundColor', 'borderColor', 'originIndex', 'pointBackgroundColor'];
const datasets = burndown_chart.renderer.componentRef.comp.chart.data.datasets;
const keysToEvaluate = ["backgroundColor", "borderColor", "originIndex", "pointBackgroundColor"];
const datasets = getGraphRenderer(burndownChart).chart.data.datasets;
for (let i = 0; i < datasets.length; i++) {
const dataset = datasets[i];
@@ -145,8 +156,6 @@ QUnit.module('Project', {}, function () {
}
assert.deepEqual(actualDatasets, expectedDatasets);
burndown_chart.destroy();
});
});
});
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<templates id="template" xml:space="preserve">
<t t-name="stock.GraphRenderer" owl="1" t-inherit="web.GraphRenderer" t-inherit-mode="primary">
<t t-name="stock.GraphRenderer" owl="1" t-inherit="web.Legacy.GraphRenderer" t-inherit-mode="primary">
<xpath expr="//canvas[@t-ref='canvas']" position="attributes">
<attribute name="height">300</attribute>
</xpath>
-1
View File
@@ -451,7 +451,6 @@ This module provides the core of the Odoo Web Client.
'web/static/lib/ace/mode-python.js',
'web/static/lib/ace/mode-xml.js',
'web/static/lib/ace/mode-js.js',
'web/static/lib/Chart/Chart.js',
'web/static/lib/nearest/jquery.nearest.js',
'web/static/lib/daterangepicker/daterangepicker.js',
'web/static/lib/stacktracejs/stacktrace.js',
@@ -75,6 +75,10 @@ return AbstractModel.extend({
orderBy: params.orderBy
});
if (this.chart.measure === "__count") {
this.chart.measure = "__count__";
}
this._computeDerivedParams();
return this._loadGraph();
@@ -117,6 +121,10 @@ return AbstractModel.extend({
this.chart.timeRanges = params.timeRanges;
}
if (this.chart.measure === "__count") {
this.chart.measure = "__count__";
}
this._computeDerivedParams();
if ('mode' in params) {
@@ -956,7 +956,7 @@ odoo.define("web/static/src/js/views/graph/graph_renderer", function (require) {
}
}
GraphRenderer.template = "web.GraphRenderer";
GraphRenderer.template = "web.Legacy.GraphRenderer";
GraphRenderer.props = {
arch: {
type: Object,
+1 -22
View File
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<templates>
<t t-name="web.GraphRenderer" owl="1">
<t t-name="web.Legacy.GraphRenderer" owl="1">
<div class="o_graph_renderer">
<label t-if="props.title" t-esc="props.title"/>
<t t-if="noContentHelperData" t-call="web.NoContentHelper">
@@ -22,25 +22,4 @@
</div>
</t>
<t t-name="web.GraphRenderer.CustomTooltip" owl="1">
<div class="o_graph_custom_tooltip">
<table>
<thead>
<tr>
<th class="o_measure" t-esc="measure"/>
</tr>
</thead>
<tbody>
<tr t-foreach="tooltipItems" t-as="tooltipItem" t-key="tooltipItem.id">
<td>
<span class="o_square" t-attf-style="background-color: {{ tooltipItem.boxColor }}"/>
<span class="o_label" t-attf-style="max-width: {{ maxWidth }}" t-esc="tooltipItem.label"/>
</td>
<td class="o_value" t-esc="tooltipItem.value"/>
</tr>
</tbody>
</table>
</div>
</t>
</templates>
@@ -0,0 +1,51 @@
/** @odoo-module **/
export const COLORS = [
"#1f77b4",
"#ff7f0e",
"#aec7e8",
"#ffbb78",
"#2ca02c",
"#98df8a",
"#d62728",
"#ff9896",
"#9467bd",
"#c5b0d5",
"#8c564b",
"#c49c94",
"#e377c2",
"#f7b6d2",
"#7f7f7f",
"#c7c7c7",
"#bcbd22",
"#dbdb8d",
"#17becf",
"#9edae5",
];
/**
* @param {number} index
* @returns {string}
*/
export function getColor(index) {
return COLORS[index % COLORS.length];
}
export const DEFAULT_BG = "#d3d3d3";
export const BORDER_WHITE = "rgba(255,255,255,0.6)";
const RGB_REGEX = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i;
/**
* @param {string} hex
* @param {number} opacity
* @returns {string}
*/
export function hexToRGBA(hex, opacity) {
const rgb = RGB_REGEX.exec(hex)
.slice(1, 4)
.map((n) => parseInt(n, 16))
.join(",");
return `rgba(${rgb},${opacity})`;
}
@@ -0,0 +1,80 @@
/** @odoo-module **/
import { evaluateExpr } from "@web/core/py_js/py";
import { GROUPABLE_TYPES } from "@web/search/utils/misc";
import { XMLParser } from "@web/core/utils/xml";
import { archParseBoolean } from "../helpers/utils";
export const MODES = ["bar", "line", "pie"];
export const ORDERS = ["ASC", "DESC", null];
export class GraphArchParser extends XMLParser {
parse(arch, fields = {}) {
const archInfo = { fields, fieldAttrs: {}, groupBy: [] };
this.visitXML(arch, (node) => {
switch (node.tagName) {
case "graph":
if (node.hasAttribute("disable_linking")) {
archInfo.disableLinking = archParseBoolean(
node.getAttribute("disable_linking")
);
}
if (node.hasAttribute("stacked")) {
archInfo.stacked = archParseBoolean(node.getAttribute("stacked"));
}
const mode = node.getAttribute("type");
if (mode && MODES.includes(mode)) {
archInfo.mode = mode;
}
const order = node.getAttribute("order");
if (order && ORDERS.includes(order)) {
archInfo.order = order;
}
const title = node.getAttribute("string");
if (title) {
archInfo.title = title;
}
break;
case "field":
let fieldName = node.getAttribute("name"); // exists (rng validation)
if (fieldName === "id") {
break;
}
const string = node.getAttribute("string");
if (string) {
if (!archInfo.fieldAttrs[fieldName]) {
archInfo.fieldAttrs[fieldName] = {};
}
archInfo.fieldAttrs[fieldName].string = string;
}
const isInvisible = Boolean(
evaluateExpr(node.getAttribute("invisible") || "0")
);
if (isInvisible) {
if (!archInfo.fieldAttrs[fieldName]) {
archInfo.fieldAttrs[fieldName] = {};
}
archInfo.fieldAttrs[fieldName].isInvisible = true;
break;
}
const isMeasure = node.getAttribute("type") === "measure";
if (isMeasure) {
// the last field with type="measure" (if any) will be used as measure else __count
archInfo.measure = fieldName;
} else {
const { type } = archInfo.fields[fieldName]; // exists (rng validation)
if (GROUPABLE_TYPES.includes(type)) {
let groupBy = fieldName;
const interval = node.getAttribute("interval");
if (interval) {
groupBy += `:${interval}`;
}
archInfo.groupBy.push(groupBy);
}
}
break;
}
});
return archInfo;
}
}
@@ -0,0 +1,535 @@
/** @odoo-module **/
import { sortBy } from "@web/core/utils/arrays";
import { KeepLast } from "@web/core/utils/concurrency";
import { rankInterval } from "@web/search/utils/dates";
import { getGroupBy } from "@web/search/utils/group_by";
import { GROUPABLE_TYPES } from "@web/search/utils/misc";
import { Model } from "@web/views/helpers/model";
import { buildSampleORM } from "@web/views/helpers/sample_server";
import { computeReportMeasures, processMeasure } from "@web/views/helpers/utils";
export const SEP = " / ";
class DateClasses {
// We view the param "array" as a matrix of values and undefined.
// An equivalence class is formed of defined values of a column.
// So nothing has to do with dates but we only use Dateclasses to manage
// identification of dates.
/**
* @param {(any[])[]} array
*/
constructor(array) {
this.__referenceIndex = null;
this.__array = array;
for (let i = 0; i < this.__array.length; i++) {
const arr = this.__array[i];
if (arr.length && this.__referenceIndex === null) {
this.__referenceIndex = i;
}
}
}
/**
* @param {number} index
* @param {any} o
* @returns {string}
*/
classLabel(index, o) {
return `${this.__array[index].indexOf(o)}`;
}
/**
* @param {string} classLabel
* @returns {any[]}
*/
classMembers(classLabel) {
const classNumber = Number(classLabel);
const classMembers = new Set();
for (const arr of this.__array) {
if (arr[classNumber] !== undefined) {
classMembers.add(arr[classNumber]);
}
}
return [...classMembers];
}
/**
* @param {string} classLabel
* @param {number} [index]
* @returns {any}
*/
representative(classLabel, index) {
const classNumber = Number(classLabel);
const i = index === undefined ? this.__referenceIndex : index;
if (i === null) {
return null;
}
return this.__array[i][classNumber];
}
/**
* @param {number} index
* @returns {number}
*/
arrayLength(index) {
return this.__array[index].length;
}
}
export class GraphModel extends Model {
/**
* @override
*/
setup(params, services) {
const { orm, user } = services;
this.orm = orm;
this.user = user;
this.keepLast = new KeepLast();
this._fakeORM = null;
this.initialGroupBy = null;
this.metaData = params;
this.data = null;
}
//--------------------------------------------------------------------------
// Public
//--------------------------------------------------------------------------
/**
* @param {Object} [searchParams]
* @param {Object} [searchParams.context]
* @param {Object[]} [searchParams.domains]
* @param {string[]} [searchParams.groupBy]
*/
async load(searchParams) {
const { context, domains, groupBy } = searchParams;
const metaData = Object.assign({}, this.metaData, { context, domains });
metaData.measure = context.graph_measure || metaData.measure;
metaData.mode = context.graph_mode || metaData.mode;
if (!this.initialGroupBy) {
this.initialGroupBy = context.graph_groupbys || this.metaData.groupBy; // = arch groupBy --> change that
}
metaData.groupBy = groupBy.length ? groupBy : this.initialGroupBy;
this._normalize(metaData);
metaData.measures = computeReportMeasures(
metaData.fields,
metaData.fieldAttrs,
[metaData.measure],
metaData.additionalMeasures
);
await this._fetchDataPoints(metaData);
this._prepareData();
}
/**
* Only supposed to be called to change one or several parameters among
* "measure", "mode", "order", and "stacked".
* @param {Object} params
*/
async updateMetaData(params) {
const metaData = Object.assign({}, this.metaData, params);
if ("measure" in params) {
await this._fetchDataPoints(metaData);
} else {
await this.keepLast.add(Promise.resolve());
this.metaData = metaData;
}
this._prepareData();
this.notify();
}
//--------------------------------------------------------------------------
// Protected
//--------------------------------------------------------------------------
/**
* Fetch the data points (possibly sample ones) determined by the metaData.
* This function has several side effects. It can alter metaData
* (e.g. change metaData.useSampleModel value) and set this.metaData and
* this.dataPoints (sometimes this._fakeORM).
* @protected
* @param {Object} metaData
*/
async _fetchDataPoints(metaData) {
let dataPoints = await this.keepLast.add(this._loadDataPoints(metaData));
if (metaData.useSampleModel && dataPoints.length === 0) {
dataPoints = await this.keepLast.add(this._loadDataPoints(metaData, true));
} else {
metaData.useSampleModel = false;
}
this.metaData = metaData;
this.dataPoints = dataPoints;
}
/**
* Separates dataPoints coming from the read_group(s) into different
* datasets. This function returns the parameters data and labels used
* to produce the charts.
* @protected
* @param {Object[]}
* @returns {Object}
*/
_getData(dataPoints) {
const { domains, groupBy, mode } = this.metaData;
let identify = false;
if (domains.length && groupBy.length && groupBy[0].fieldName === domains.fieldName) {
identify = true;
}
const dateClasses = identify ? this._getDateClasses(dataPoints) : null;
// dataPoints --> labels
let labels = [];
const labelMap = {};
for (const dataPt of dataPoints) {
const x = dataPt.labels.slice(0, mode === "pie" ? undefined : 1);
const trueLabel = x.length ? x.join(SEP) : this.env._t("Total");
if (dateClasses) {
x[0] = dateClasses.classLabel(dataPt.originIndex, x[0]);
}
const key = JSON.stringify(x);
if (labelMap[key] === undefined) {
labelMap[key] = labels.length;
if (dateClasses) {
if (mode === "pie") {
x[0] = dateClasses.classMembers(x[0]).join(", ");
} else {
x[0] = dateClasses.representative(x[0]);
}
}
const label = x.length ? x.join(SEP) : this.env._t("Total");
labels.push(label);
}
dataPt.labelIndex = labelMap[key];
dataPt.trueLabel = trueLabel;
}
// dataPoints + labels --> datasetsTmp --> datasets
const datasetsTmp = {};
for (const dataPt of dataPoints) {
const { domain, labelIndex, originIndex, trueLabel, value } = dataPt;
const datasetLabel = this._getDatasetLabel(dataPt);
if (!(datasetLabel in datasetsTmp)) {
let dataLength = labels.length;
if (mode !== "pie" && dateClasses) {
dataLength = dateClasses.arrayLength(originIndex);
}
datasetsTmp[datasetLabel] = {
data: new Array(dataLength).fill(0),
trueLabels: labels.slice(0, dataLength), // should be good // check this in case identify = true
domains: new Array(dataLength).fill([]),
label: datasetLabel,
originIndex: originIndex,
};
}
datasetsTmp[datasetLabel].data[labelIndex] = value;
datasetsTmp[datasetLabel].domains[labelIndex] = domain;
datasetsTmp[datasetLabel].trueLabels[labelIndex] = trueLabel;
}
// sort by origin
let datasets = sortBy(Object.values(datasetsTmp), "originIndex");
if (mode === "pie") {
// We kinda have a matrix. We remove the zero columns and rows. This is a global operation.
// That's why it cannot be done before.
datasets = datasets.filter((dataset) => dataset.data.some((v) => Boolean(v)));
const labelsToKeepIndexes = {};
labels.forEach((_, index) => {
if (datasets.some((dataset) => Boolean(dataset.data[index]))) {
labelsToKeepIndexes[index] = true;
}
});
labels = labels.filter((_, index) => labelsToKeepIndexes[index]);
for (const dataset of datasets) {
dataset.data = dataset.data.filter((_, index) => labelsToKeepIndexes[index]);
dataset.domains = dataset.domains.filter((_, index) => labelsToKeepIndexes[index]);
dataset.trueLabels = dataset.trueLabels.filter(
(_, index) => labelsToKeepIndexes[index]
);
}
}
return { datasets, labels };
}
/**
* Determines the dataset to which the data point belongs.
* @protected
* @param {Object} dataPoint
* @returns {string}
*/
_getDatasetLabel(dataPoint) {
const { measure, measures, domains, mode } = this.metaData;
const { labels, originIndex } = dataPoint;
if (mode === "pie") {
return domains[originIndex].description || "";
}
// ([origin] + second to last groupBys) or measure
let datasetLabel = labels.slice(1).join(SEP);
if (domains.length > 1) {
datasetLabel =
domains[originIndex].description + (datasetLabel ? SEP + datasetLabel : "");
}
datasetLabel = datasetLabel || measures[measure].string;
return datasetLabel;
}
/**
* @protected
* @param {Object[]} dataPoints
* @returns {DateClasses}
*/
_getDateClasses(dataPoints) {
const { domains } = this.metaData;
const dateSets = domains.map(() => new Set());
for (const { labels, originIndex } of dataPoints) {
const date = labels[0];
dateSets[originIndex].add(date);
}
const arrays = dateSets.map((dateSet) => [...dateSet]);
return new DateClasses(arrays);
}
/**
* Eventually filters and sort data points.
* @protected
* @returns {Object[]}
*/
_getProcessedDataPoints() {
const { domains, groupBy, mode, order } = this.metaData;
let processedDataPoints = [];
if (mode === "line") {
processedDataPoints = this.dataPoints.filter(
(dataPoint) => dataPoint.labels[0] !== this.env._t("Undefined")
);
} else {
processedDataPoints = this.dataPoints.filter((dataPoint) => dataPoint.count !== 0);
}
if (order !== null && mode !== "pie" && domains.length === 1 && groupBy.length > 0) {
// group data by their x-axis value, and then sort datapoints
// based on the sum of values by group in ascending/descending order
const groupedDataPoints = {};
for (const dataPt of processedDataPoints) {
const key = dataPt.labels[0]; // = x-axis value under the current assumptions
if (!groupedDataPoints[key]) {
groupedDataPoints[key] = [];
}
groupedDataPoints[key].push(dataPt);
}
const groups = Object.values(groupedDataPoints);
const groupTotal = (group) => group.reduce((sum, dataPt) => sum + dataPt.value, 0);
processedDataPoints = sortBy(groups, groupTotal, order.toLowerCase()).flat();
}
return processedDataPoints;
}
/**
* Determines whether the set of data points is good. If not, this.data will be (re)set to null
* @protected
* @param {Object[]}
* @returns {boolean}
*/
_isValidData(dataPoints) {
const { mode } = this.metaData;
let somePositive = false;
let someNegative = false;
if (mode === "pie") {
for (const dataPt of dataPoints) {
if (dataPt.value > 0) {
somePositive = true;
} else if (dataPt.value < 0) {
someNegative = true;
}
}
if (someNegative && somePositive) {
return false;
}
}
return true;
}
/**
* Fetch and process graph data. It is basically a(some) read_group(s)
* with correct fields for each domain. We have to do some light processing
* to separate date groups in the field list, because they can be defined
* with an aggregation function, such as my_date:week.
* @protected
* @param {Object} metaData
* @param {boolean} [sample=false]
* @returns {Object[]}
*/
async _loadDataPoints(metaData, sample = false) {
const { measure, domains, fields, groupBy, resModel } = metaData;
if (sample && !this._fakeORM) {
this._fakeORM = buildSampleORM(
resModel,
Object.assign({ __count: { type: "integer" } }, fields),
this.user
);
}
const orm2use = sample ? this._fakeORM : this.orm;
const measures = ["__count"];
if (measure !== "__count") {
let { group_operator, type } = fields[measure];
if (type === "many2one") {
group_operator = "count_distinct";
}
if (group_operator === undefined) {
throw new Error(
`No aggregate function has been provided for the measure '${measure}'`
);
}
measures.push(`${measure}:${group_operator}`);
}
const proms = [];
const numbering = {}; // used to avoid ambiguity with many2one with values with same labels:
// for instance [1, "ABC"] [3, "ABC"] should be distinguished.
domains.forEach((domain, originIndex) => {
proms.push(
orm2use
.webReadGroup(
resModel,
domain.arrayRepr,
measures,
groupBy.map((gb) => gb.spec),
{ lazy: false }, // what is this thing???
{ fill_temporal: true } // + old this.chart.context
)
.then((data) => {
const dataPoints = [];
for (const group of data.groups) {
const { __domain, __count } = group;
const labels = [];
for (const gb of groupBy) {
let label;
const val = group[gb.spec];
const fieldName = gb.fieldName;
const { type } = fields[fieldName];
if (type === "boolean") {
label = `${val}`; // toUpperCase?
} else if (val === false) {
label = this.env._t("Undefined");
} else if (type === "many2one") {
const [id, name] = val;
const key = JSON.stringify([fieldName, name]);
if (!numbering[key]) {
numbering[key] = {};
}
const numbers = numbering[key];
if (!numbers[id]) {
numbers[id] = Object.keys(numbers).length + 1;
}
const num = numbers[id];
label = num === 1 ? name : `${name} (${num})`;
} else if (type === "selection") {
const selected = fields[fieldName].selection.find(
(s) => s[0] === val
);
label = selected[1];
} else {
label = val;
}
labels.push(label);
}
let value = group[measure];
if (value instanceof Array) {
// case where measure is a many2one and is used as groupBy
value = 1;
}
if (!Number.isInteger(value)) {
metaData.allIntegers = false;
}
dataPoints.push({
count: __count,
domain: __domain,
value,
labels,
originIndex,
});
}
return dataPoints;
})
);
});
const promResults = await Promise.all(proms);
return promResults.flat();
}
/**
* Process metaData.groupBy in order to keep only the finest interval option for
* elements based on date/datetime field (e.g. 'date:year'). This means that
* 'week' is prefered to 'month'. The field stays at the place of its first occurence.
* For instance,
* ['foo', 'date:month', 'bar', 'date:week'] becomes ['foo', 'date:week', 'bar'].
* @protected
* @param {Object} metaData
*/
_normalize(metaData) {
const { fields } = metaData;
const groupBy = [];
for (const gb of metaData.groupBy) {
let ngb = gb;
if (typeof gb === "string") {
ngb = getGroupBy(gb, fields);
}
groupBy.push(ngb);
}
const processedGroupBy = [];
for (const gb of groupBy) {
const { fieldName, interval } = gb;
const { store, type } = fields[fieldName];
if (
!store ||
["id", "__count"].includes(fieldName) ||
!GROUPABLE_TYPES.includes(type)
) {
continue;
}
const index = processedGroupBy.findIndex((gb) => gb.fieldName === fieldName);
if (index === -1) {
processedGroupBy.push(gb);
} else if (interval) {
const registeredInterval = processedGroupBy[index].interval;
if (rankInterval(registeredInterval) < rankInterval(interval)) {
processedGroupBy.splice(index, 1, gb);
}
}
}
metaData.groupBy = processedGroupBy;
metaData.measure = processMeasure(metaData.measure);
}
/**
* @protected
*/
async _prepareData() {
const processedDataPoints = this._getProcessedDataPoints();
this.data = null;
if (this._isValidData(processedDataPoints)) {
this.data = this._getData(processedDataPoints);
}
}
}
GraphModel.services = ["orm", "user"];
@@ -0,0 +1,636 @@
/** @odoo-module **/
import { _lt } from "@web/core/l10n/translation";
import { BORDER_WHITE, DEFAULT_BG, getColor, hexToRGBA } from "./colors";
import { formatFloat } from "@web/fields/formatters";
import { SEP } from "./graph_model";
import { sortBy } from "@web/core/utils/arrays";
import { useAssets } from "@web/core/assets";
const { Component, hooks } = owl;
const { useRef } = hooks;
const NO_DATA = _lt("No data");
/**
* @param {Object} chartArea
* @returns {string}
*/
function getMaxWidth(chartArea) {
const { left, right } = chartArea;
return Math.floor((right - left) / 1.618) + "px";
}
/**
* Used to avoid too long legend items.
* @param {string|Strin} label
* @returns {string} shortened version of the input label
*/
function shortenLabel(label) {
// string returned could be wrong if a groupby value contain a " / "!
const groups = label.toString().split(SEP);
let shortLabel = groups.slice(0, 3).join(SEP);
if (shortLabel.length > 30) {
shortLabel = `${shortLabel.slice(0, 30)}...`;
} else if (groups.length > 3) {
shortLabel = `${shortLabel}${SEP}...`;
}
return shortLabel;
}
export class GraphRenderer extends Component {
setup() {
this.canvasRef = useRef("canvas");
this.containerRef = useRef("container");
this.chart = null;
this.tooltip = null;
this.legendTooltip = null;
useAssets({ jsLibs: ["/web/static/lib/Chart/Chart.js"] });
}
mounted() {
this.renderChart();
}
patched() {
this.renderChart();
}
willUnmount() {
if (this.chart) {
this.chart.destroy();
}
}
/**
* This function aims to remove a suitable number of lines from the
* tooltip in order to make it reasonably visible. A message indicating
* the number of lines is added if necessary.
* @param {HTMLElement} tooltip
* @param {number} maxTooltipHeight this the max height in pixels of the tooltip
*/
adjustTooltipHeight(tooltip, maxTooltipHeight) {
const sizeOneLine = tooltip.querySelector("tbody tr").clientHeight;
const tbodySize = tooltip.querySelector("tbody").clientHeight;
const toKeep = Math.max(
0,
Math.floor((maxTooltipHeight - (tooltip.clientHeight - tbodySize)) / sizeOneLine) - 1
);
const lines = tooltip.querySelectorAll("tbody tr");
const toRemove = lines.length - toKeep;
if (toRemove > 0) {
for (let index = toKeep; index < lines.length; ++index) {
lines[index].remove();
}
const tr = document.createElement("tr");
const td = document.createElement("td");
tr.classList.add("o_show_more");
td.innerText = this.env._t("...");
tr.appendChild(td);
tooltip.querySelector("tbody").appendChild(tr);
}
}
/**
* Creates a custom HTML tooltip.
* @param {Object} data
* @param {Object} metaData
* @param {Object} tooltipModel see chartjs documentation
*/
customTooltip(data, metaData, tooltipModel) {
const { measure, measures, disableLinking, mode } = metaData;
this.el.style.cursor = "";
this.removeTooltips();
if (tooltipModel.opacity === 0 || tooltipModel.dataPoints.length === 0) {
return;
}
if (!disableLinking && mode !== "line") {
this.el.style.cursor = "pointer";
}
const chartAreaTop = this.chart.chartArea.top;
const viewContentTop = this.el.getBoundingClientRect().top;
const innerHTML = this.env.qweb.renderToString("web.GraphRenderer.CustomTooltip", {
maxWidth: getMaxWidth(this.chart.chartArea),
measure: measures[measure].string,
tooltipItems: this.getTooltipItems(data, metaData, tooltipModel),
});
const template = Object.assign(document.createElement("template"), { innerHTML });
const tooltip = template.content.firstChild;
this.containerRef.el.prepend(tooltip);
let top;
const tooltipHeight = tooltip.clientHeight;
const minTopAllowed = Math.floor(chartAreaTop);
const maxTopAllowed = Math.floor(window.innerHeight - (viewContentTop + tooltipHeight)) - 2;
const y = Math.floor(tooltipModel.y);
if (minTopAllowed <= maxTopAllowed) {
// Here we know that the full tooltip can fit in the screen.
// We put it in the position where Chart.js would put it
// if two conditions are respected:
// 1: the tooltip is not cut (because we know it is possible to not cut it)
// 2: the tooltip does not hide the legend.
// If it is not possible to use the Chart.js proposition (y)
// we use the best approximated value.
if (y <= maxTopAllowed) {
if (y >= minTopAllowed) {
top = y;
} else {
top = minTopAllowed;
}
} else {
top = maxTopAllowed;
}
} else {
// Here we know that we cannot satisfy condition 1 above,
// so we position the tooltip at the minimal position and
// cut it the minimum possible.
top = minTopAllowed;
const maxTooltipHeight = window.innerHeight - (viewContentTop + chartAreaTop) - 2;
this.adjustTooltipHeight(tooltip, maxTooltipHeight);
}
this.fixTooltipLeftPosition(tooltip, tooltipModel.x);
tooltip.style.top = Math.floor(top) + "px";
this.tooltip = tooltip;
}
/**
* Sets best left position of a tooltip approaching the proposal x.
* @param {HTMLElement} tooltip
* @param {number} x
*/
fixTooltipLeftPosition(tooltip, x) {
let left;
const tooltipWidth = tooltip.clientWidth;
const minLeftAllowed = Math.floor(this.chart.chartArea.left + 2);
const maxLeftAllowed = Math.floor(this.chart.chartArea.right - tooltipWidth - 2);
x = Math.floor(x);
if (x < minLeftAllowed) {
left = minLeftAllowed;
} else if (x > maxLeftAllowed) {
left = maxLeftAllowed;
} else {
left = x;
}
tooltip.style.left = `${left}px`;
}
/**
* Used to format correctly the values in tooltips and yAxes.
* @param {number} value
* @param {boolean} [allIntegers=true]
* @returns {string}
*/
formatValue(value, allIntegers = true) {
const largeNumber = Math.abs(value) >= 1000;
if (allIntegers && !largeNumber) {
return String(value);
}
if (largeNumber) {
return formatFloat(value, { humanReadable: true, decimals: 2, minDigits: 1 });
}
return formatFloat(value);
}
/**
* Returns the bar chart data
* @returns {Object}
*/
getBarChartData() {
// style data
const { domains, stacked } = this.props.metaData;
const { data } = this.props;
for (let index = 0; index < data.datasets.length; ++index) {
const dataset = data.datasets[index];
// used when stacked
if (stacked) {
dataset.stack = domains[dataset.originIndex].description || "";
}
// set dataset color
dataset.backgroundColor = getColor(index);
}
return data;
}
/**
* Returns the chart config.
* @returns {Object}
*/
getChartConfig() {
const { mode } = this.props.metaData;
let data;
switch (mode) {
case "bar":
data = this.getBarChartData();
break;
case "line":
data = this.getLineChartData();
break;
case "pie":
data = this.getPieChartData();
}
const options = this.prepareOptions();
return { data, options, type: mode };
}
/**
* Returns an object used to style chart elements independently from
* the datasets.
* @returns {Object}
*/
getElementOptions() {
const { mode } = this.props.metaData;
const elementOptions = {};
if (mode === "bar") {
elementOptions.rectangle = { borderWidth: 1 };
} else if (mode === "line") {
elementOptions.line = { fill: false, tension: 0 };
}
return elementOptions;
}
/**
* @returns {Object}
*/
getLegendOptions() {
const { display, mode } = this.props.metaData;
const { data } = this.props;
const refLength = mode === "pie" ? data.labels.length : data.datasets.length;
const displayLegend = "legend" in display ? display.legend : true;
const legendOptions = {
display: refLength <= 20 && displayLegend,
position: "top",
onHover: this.onlegendHover.bind(this),
onLeave: this.onLegendLeave.bind(this),
};
if (mode === "line") {
legendOptions.onClick = this.onLegendClick.bind(this);
}
if (mode === "pie") {
legendOptions.labels = {
generateLabels: (chart) => {
const { data } = chart;
const metaData = data.datasets.map(
(_, index) => chart.getDatasetMeta(index).data
);
const labels = data.labels.map((label, index) => {
const hidden = metaData.some((data) => data[index] && data[index].hidden);
const fullText = label;
const text = shortenLabel(fullText);
const fillStyle = label === NO_DATA ? DEFAULT_BG : getColor(index);
return { text, fullText, fillStyle, hidden, index };
});
return labels;
},
};
} else {
const referenceColor = mode === "bar" ? "backgroundColor" : "borderColor";
legendOptions.labels = {
generateLabels: (chart) => {
const { data } = chart;
const labels = data.datasets.map((dataset, index) => {
return {
text: shortenLabel(dataset.label),
fullText: dataset.label,
fillStyle: dataset[referenceColor],
hidden: !chart.isDatasetVisible(index),
lineCap: dataset.borderCapStyle,
lineDash: dataset.borderDash,
lineDashOffset: dataset.borderDashOffset,
lineJoin: dataset.borderJoinStyle,
lineWidth: dataset.borderWidth,
strokeStyle: dataset[referenceColor],
pointStyle: dataset.pointStyle,
datasetIndex: index,
};
});
return labels;
},
};
}
return legendOptions;
}
/**
* Returns line chart data.
* @returns {Object}
*/
getLineChartData() {
const { groupBy, domains } = this.props.metaData;
const { data } = this.props;
for (let index = 0; index < data.datasets.length; ++index) {
const dataset = data.datasets[index];
if (groupBy.length <= 1 && domains.length > 1) {
if (dataset.originIndex === 0) {
dataset.fill = "origin";
dataset.backgroundColor = hexToRGBA(getColor(0), 0.4);
dataset.borderColor = getColor(0);
} else if (dataset.originIndex === 1) {
dataset.borderColor = getColor(1);
} else {
dataset.borderColor = getColor(index);
}
} else {
dataset.borderColor = getColor(index);
}
if (data.labels.length === 1) {
// shift of the real value to right. This is done to
// center the points in the chart. See data.labels below in
// Chart parameters
dataset.data.unshift(undefined);
dataset.trueLabels.unshift(undefined);
dataset.domains.unshift(undefined);
}
dataset.pointBackgroundColor = dataset.borderColor;
dataset.pointBorderColor = "rgba(0,0,0,0.2)";
}
if (data.datasets.length === 1) {
const dataset = data.datasets[0];
dataset.fill = "origin";
dataset.backgroundColor = hexToRGBA(getColor(0), 0.4);
}
// center the points in the chart (without that code they are put
// on the left and the graph seems empty)
data.labels = data.labels.length > 1 ? data.labels : ["", ...data.labels, ""];
return data;
}
/**
* Returns pie chart data.
* @returns {Object}
*/
getPieChartData() {
const { domains } = this.props.metaData;
const { data } = this.props;
// style/complete data
// give same color to same groups from different origins
const colors = data.labels.map((_, index) => getColor(index));
for (const dataset of data.datasets) {
dataset.backgroundColor = colors;
dataset.borderColor = BORDER_WHITE;
}
// make sure there is a zone associated with every origin
const representedOriginIndexes = new Set(
data.datasets.map((dataset) => dataset.originIndex)
);
let addNoDataToLegend = false;
const fakeData = new Array(data.labels.length + 1);
fakeData[data.labels.length] = 1;
const fakeTrueLabels = new Array(data.labels.length + 1);
fakeTrueLabels[data.labels.length] = NO_DATA;
for (let index = 0; index < domains.length; ++index) {
if (!representedOriginIndexes.has(index)) {
data.datasets.push({
label: domains[index].description,
data: fakeData,
trueLabels: fakeTrueLabels,
backgroundColor: [...colors, DEFAULT_BG],
borderColor: BORDER_WHITE,
});
addNoDataToLegend = true;
}
}
if (addNoDataToLegend) {
data.labels.push(NO_DATA);
}
return data;
}
/**
* Returns the options used to generate the chart axes.
* @returns {Object}
*/
getScaleOptions() {
const {
allIntegers,
display,
fields,
groupBy,
measure,
measures,
mode,
} = this.props.metaData;
if (mode === "pie") {
return {};
}
const displayScaleLabels = "scaleLabels" in display ? display.scaleLabels : true;
const xAxe = {
type: "category",
scaleLabel: {
display: Boolean(groupBy.length && displayScaleLabels),
labelString: groupBy.length ? fields[groupBy[0].fieldName].string : "",
},
};
const yAxe = {
type: "linear",
scaleLabel: {
display: displayScaleLabels,
labelString: measures[measure].string,
},
ticks: {
callback: (value) => this.formatValue(value, allIntegers),
suggestedMax: 0,
suggestedMin: 0,
},
};
return { xAxes: [xAxe], yAxes: [yAxe] };
}
/**
* This function extracts the information from the data points in
* tooltipModel.dataPoints (corresponding to datapoints over a given
* label determined by the mouse position) that will be displayed in a
* custom tooltip.
* @param {Object} data
* @param {Object} metaData
* @param {Object} tooltipModel see chartjs documentation
* @returns {Object[]}
*/
getTooltipItems(data, metaData, tooltipModel) {
const { allIntegers, domains, mode, groupBy } = metaData;
const sortedDataPoints = sortBy(tooltipModel.dataPoints, "yLabel", "desc");
const items = [];
for (const item of sortedDataPoints) {
const id = item.index;
const dataset = data.datasets[item.datasetIndex];
let label = dataset.trueLabels[id];
let value = this.formatValue(dataset.data[id], allIntegers);
let boxColor;
if (mode === "pie") {
if (label === NO_DATA) {
value = this.formatValue(0, allIntegers);
}
if (domains.length > 1) {
label = `${dataset.label} / ${label}`;
}
boxColor = dataset.backgroundColor[id];
} else {
if (groupBy.length > 1 || domains.length > 1) {
label = `${label} / ${dataset.label}`;
}
boxColor = mode === "bar" ? dataset.backgroundColor : dataset.borderColor;
}
items.push({ id, label, value, boxColor });
}
return items;
}
/**
* Returns the options used to generate chart tooltips.
* @returns {Object}
*/
getTooltipOptions() {
const { data, metaData } = this.props;
const { mode } = metaData;
const tooltipOptions = {
enabled: false,
custom: this.customTooltip.bind(this, data, metaData),
};
if (mode === "line") {
tooltipOptions.mode = "index";
tooltipOptions.intersect = false;
}
return tooltipOptions;
}
/**
* If a group has been clicked on, display a view of its records.
* @param {MouseEvent} ev
*/
onGraphClicked(ev) {
const [activeElement] = this.chart.getElementAtEvent(ev);
if (!activeElement) {
return;
}
const { _datasetIndex, _index } = activeElement;
const { domains } = this.chart.data.datasets[_datasetIndex];
if (domains) {
const domain = domains[_index];
this.trigger("inspect-domain-records", { domain });
}
}
/**
* Overrides the default legend 'onClick' behaviour. This is done to
* remove all existing tooltips right before updating the chart.
* @param {Event} ev
* @param {Object} legendItem
*/
onLegendClick(ev, legendItem) {
this.removeTooltips();
// Default 'onClick' fallback. See web/static/lib/Chart/Chart.js#15138
const index = legendItem.datasetIndex;
const meta = this.chart.getDatasetMeta(index);
meta.hidden = meta.hidden === null ? !this.chart.data.datasets[index].hidden : null;
this.chart.update();
}
/**
* If the text of a legend item has been shortened and the user mouse
* hovers that item (actually the event type is mousemove), a tooltip
* with the item full text is displayed.
* @param {Event} ev
* @param {Object} legendItem
*/
onlegendHover(ev, legendItem) {
this.canvasRef.el.style.cursor = "pointer";
/**
* The string legendItem.text is an initial segment of legendItem.fullText.
* If the two coincide, no need to generate a tooltip. If a tooltip
* for the legend already exists, it is already good and does not
* need to be recreated.
*/
const { fullText, text } = legendItem;
if (this.legendTooltip || text === fullText) {
return;
}
const viewContentTop = this.el.getBoundingClientRect().top;
const legendTooltip = Object.assign(document.createElement("div"), {
className: "o_tooltip_legend",
innerText: fullText,
});
legendTooltip.style.top = `${ev.clientY - viewContentTop}px`;
legendTooltip.style.maxWidth = getMaxWidth(this.chart.chartArea);
this.containerRef.el.appendChild(legendTooltip);
this.fixTooltipLeftPosition(legendTooltip, ev.clientX);
this.legendTooltip = legendTooltip;
}
/**
* If there's a legend tooltip and the user mouse out of the
* corresponding legend item, the tooltip is removed.
*/
onLegendLeave() {
this.canvasRef.el.style.cursor = "";
this.removeLegendTooltip();
}
/**
* Prepares options for the chart according to the current mode
* (= chart type). This function returns the parameter options used to
* instantiate the chart.
*/
prepareOptions() {
const { disableLinking, mode } = this.props.metaData;
const options = {
maintainAspectRatio: false,
scales: this.getScaleOptions(),
legend: this.getLegendOptions(),
tooltips: this.getTooltipOptions(),
elements: this.getElementOptions(),
};
if (!disableLinking && mode !== "line") {
options.onClick = this.onGraphClicked.bind(this);
}
return options;
}
/**
* Removes the legend tooltip (if any).
*/
removeLegendTooltip() {
if (this.legendTooltip) {
this.legendTooltip.remove();
this.legendTooltip = null;
}
}
/**
* Removes all existing tooltips (if any).
*/
removeTooltips() {
if (this.tooltip) {
this.tooltip.remove();
this.tooltip = null;
}
this.removeLegendTooltip();
}
/**
* Instantiates a Chart (Chart.js lib) to render the graph according to
* the current config.
*/
renderChart() {
if (this.chart) {
this.chart.destroy();
}
const config = this.getChartConfig();
this.chart = new Chart(this.canvasRef.el, config);
// To perform its animations, ChartJS will perform each animation
// step in the next animation frame. The initial rendering itself
// is delayed for consistency. We can avoid this by manually
// advancing the animation service.
Chart.animationService.advance();
}
}
GraphRenderer.template = "web.GraphRenderer";
GraphRenderer.props = {
data: Object,
metaData: Object,
};
@@ -0,0 +1,34 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="web.GraphRenderer.CustomTooltip" owl="1">
<div class="o_graph_custom_tooltip">
<table>
<thead>
<tr>
<th class="o_measure" t-esc="measure"/>
</tr>
</thead>
<tbody>
<tr t-foreach="tooltipItems" t-as="tooltipItem" t-key="tooltipItem.id">
<td>
<span class="o_square" t-attf-style="background-color: {{ tooltipItem.boxColor }}"/>
<span class="o_label" t-attf-style="max-width: {{ maxWidth }}" t-esc="tooltipItem.label"/>
</td>
<td class="o_value" t-esc="tooltipItem.value"/>
</tr>
</tbody>
</table>
</div>
</t>
<t t-name="web.GraphRenderer" owl="1">
<div class="o_graph_renderer">
<!-- <label t-if="props.metaData.title" t-esc="props.metaData.title"/> -->
<div class="o_graph_canvas_container" t-ref="container">
<canvas t-ref="canvas"/>
</div>
</div>
</t>
</templates>
@@ -0,0 +1,177 @@
/** @odoo-module **/
import { _lt } from "@web/core/l10n/translation";
import { ControlPanel } from "@web/search/control_panel/control_panel";
import { SearchPanel } from "@web/search/search_panel/search_panel";
import { GraphArchParser, MODES, ORDERS } from "./graph_arch_parser";
import { GraphModel } from "./graph_model";
import { GraphRenderer } from "./graph_renderer";
import { GroupByMenu } from "@web/search/group_by_menu/group_by_menu";
import { registry } from "@web/core/registry";
import { standardViewProps } from "@web/views/helpers/standard_view_props";
import { useModel } from "../helpers/model";
import { useService } from "@web/core/utils/hooks";
import { useSetupView } from "@web/views/helpers/view_hook";
const viewRegistry = registry.category("views");
const { Component } = owl;
const KEYS = [
"additionalMeasures",
"disableLinking",
"display",
"fields",
"fieldAttrs",
"groupBy",
"measure",
"mode",
"order",
"resModel",
"stacked",
"title",
"useSampleModel",
];
export class GraphView extends Component {
setup() {
this.actionService = useService("action");
let modelParams;
if (this.props.state) {
modelParams = this.props.state;
} else {
const { arch, fields } = this.props;
const parser = new this.constructor.archParser();
const archInfo = parser.parse(arch, fields);
modelParams = {};
for (const key of KEYS) {
modelParams[key] = key in archInfo ? archInfo[key] : this.props[key];
}
}
this.model = useModel(this.constructor.Model, modelParams);
useSetupView({
exportLocalState: () => this.model.metaData,
saveParams: () => this.saveParams(),
});
}
/**
* @param {CustomEvent} ev
*/
onInspectDomainRecords(ev) {
const { domain } = ev.detail;
const { context, resModel, title } = this.model.metaData;
const views = {};
for (const [viewId, viewType] of this.props.info.views || []) {
views[viewType] = viewId;
}
function getView(viewType) {
return [views[viewType] || false, viewType];
}
const actionViews = [getView("list"), getView("form")];
this.actionService.doAction(
{
context,
domain,
name: title,
res_model: resModel,
target: "current",
type: "ir.actions.act_window",
views: actionViews,
},
{
viewType: "list",
}
);
}
/**
* @param {CustomEvent} ev
*/
onMeasureSelected(ev) {
const { measure } = ev.detail.payload;
this.model.updateMetaData({ measure });
}
/**
* @param {"bar"|"line"|"pie"} mode
*/
onModeSelected(mode) {
this.model.updateMetaData({ mode });
}
/**
* @returns {Object}
*/
saveParams() {
// expand context object? change keys?
const { measure, groupBy, mode } = this.model.metaData;
return {
context: {
graph_measure: measure,
graph_mode: mode,
graph_groupbys: groupBy.map((gb) => gb.spec),
},
};
}
/**
* @param {"ASC"|"DESC"} order
*/
toggleOrder(order) {
const { order: currentOrder } = this.model.metaData;
const nextOrder = currentOrder === order ? null : order;
this.model.updateMetaData({ order: nextOrder });
}
toggleStacked() {
const { stacked } = this.model.metaData;
this.model.updateMetaData({ stacked: !stacked });
}
}
GraphView.template = "web.GraphView";
GraphView.buttonTemplate = "web.GraphView.Buttons";
GraphView.components = { ControlPanel, GroupByMenu, Renderer: GraphRenderer, SearchPanel };
GraphView.defaultProps = {
additionalMeasures: [],
disableLinking: false,
display: {},
measure: "__count",
mode: "bar",
order: null,
stacked: true,
};
GraphView.props = {
...standardViewProps,
additionalMeasures: { type: Array, elements: String, optional: true },
disableLinking: { type: Boolean, optional: true },
display: { type: Object, optional: true },
measure: { type: String, optional: true },
mode: { validate: (m) => MODES.includes(m), optional: true },
order: { validate: (o) => ORDERS.includes(o), optional: true },
stacked: { type: Boolean, optional: true },
title: { type: String, optional: true },
};
GraphView.type = "graph";
GraphView.display_name = _lt("Graph");
GraphView.icon = "fa-bar-chart";
GraphView.multiRecord = true;
GraphView.Model = GraphModel;
GraphView.archParser = GraphArchParser;
GraphView.searchMenuTypes = ["filter", "groupBy", "comparison", "favorite"];
viewRegistry.add("graph", GraphView);
@@ -0,0 +1,81 @@
.o_graph_view {
height: 100%;
.o_graph_canvas_container {
padding-top: 5px;
position: relative;
height: 100%;
width: 100%;
top: 0px;
left: 0px;
canvas {
background-color: $o-view-background-color;
}
}
div.o_tooltip_legend {
background-color: white;
color: black;
font-size: 12px;
border: black solid 0.5px;
border-radius: 3px;
opacity: 1;
position: absolute;
padding: 10px;
z-index: 2;
pointer-events: none;
word-break: break-all;
}
div.o_graph_custom_tooltip {
border-radius: 3px;
background-color: white;
border: black solid 0.5px;
position: absolute;
z-index: 1;
padding: 6px;
pointer-events: none;
opacity: 0.9;
font-family: Arial;
table {
font-size: 12px;
overflow: hidden;
display: block;
border-collapse: collapse;
}
th.o_measure {
color: black;
font-weight: bolder;
vertical-align: baseline;
padding-bottom: 1px;
}
td {
span.o_square {
height: 12px;
width: 12px;
display: inline-block;
vertical-align: middle;
margin-right: 5px;
}
span.o_label {
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
color: black;
display: inline-block;
vertical-align: middle;
}
&.o_value {
color: black;
padding-left: 5px;
text-align: right;
font-weight: bold;
}
}
tr.o_show_more {
text-align: center;
color: black;
font-weight: bolder;
}
}
}
@@ -0,0 +1,75 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="web.GraphView.Buttons" owl="1">
<div class="btn-group" role="toolbar" aria-label="Main actions">
<div t-on-dropdown-item-selected="onMeasureSelected">
<t t-call="web.ReportViewMeasures">
<t t-set="measures" t-value="model.metaData.measures"/>
<t t-set="activeMeasures" t-value="[model.metaData.measure]"/>
</t>
</div>
<GroupByMenu t-if="props.display and props.display['bottom-right'] === false"/>
</div>
<div class="btn-group ml-4" role="toolbar" aria-label="Change graph">
<button class="btn btn-secondary fa fa-bar-chart-o o_graph_button" data-tooltip="Bar Chart" aria-label="Bar Chart" data-mode="bar"
t-on-click="onModeSelected('bar')"
t-att-class="{ active: model.metaData.mode === 'bar' }"
/>
<button class="btn btn-secondary fa fa-area-chart o_graph_button" data-tooltip="Line Chart" aria-label="Line Chart" data-mode="line"
t-on-click="onModeSelected('line')"
t-att-class="{ active: model.metaData.mode === 'line' }"
/>
<button class="btn btn-secondary fa fa-pie-chart o_graph_button" data-tooltip="Pie Chart" aria-label="Pie Chart" data-mode="pie"
t-on-click="onModeSelected('pie')"
t-att-class="{ active: model.metaData.mode === 'pie' }"
/>
</div>
<div t-if="model.metaData.mode === 'bar'" class="btn-group ml-3" role="toolbar" aria-label="Change graph">
<button class="btn btn-secondary fa fa-database o_graph_button" data-tooltip="Stacked" aria-label="Stacked"
t-on-click="toggleStacked"
t-att-class="{ active: model.metaData.stacked }"
/>
</div>
<div t-if="model.metaData.mode !== 'pie' and env.searchModel.domains.length === 1" class="btn-group ml-3" role="toolbar" aria-label="Sort graph">
<button class="btn btn-secondary fa fa-sort-amount-desc o_graph_button" data-tooltip="Descending" aria-label="Descending"
t-on-click="toggleOrder('DESC')"
t-att-class="{ active: model.metaData.order === 'DESC' }"
/>
<button class="btn btn-secondary fa fa-sort-amount-asc o_graph_button" data-tooltip="Ascending" aria-label="Ascending"
t-on-click="toggleOrder('ASC')"
t-att-class="{ active: model.metaData.order === 'ASC' }"
/>
</div>
</t>
<t t-name="web.GraphView" owl="1">
<div class="o_graph_view" t-att-class="{ o_view_sample_data: model.metaData.useSampleModel }">
<ControlPanel t-if="env.searchModel.display.controlPanel" t-props="props.info">
<t t-set-slot="control-panel-bottom-left">
<t t-call="{{ constructor.buttonTemplate }}"/>
</t>
</ControlPanel>
<div class="o_content">
<SearchPanel t-if="env.searchModel.display.searchPanel" />
<t t-if="model.data">
<t t-if="model.metaData.useSampleModel and props.info.noContentHelp" t-call="web.ActionHelper">
<t t-set="noContentHelp" t-value="props.info.noContentHelp"/>
</t>
<Renderer
class="o_renderer"
data="model.data"
metaData="model.metaData"
t-att-class="{ o_sample_data_disabled: model.metaData.useSampleModel }"
t-on-inspect-domain-records="onInspectDomainRecords"
/>
</t>
<t t-else="" t-call="web.NoContentHelper">
<t t-set="title">Invalid data</t>
<t t-set="description">Pie chart cannot mix positive and negative numbers. Try to change your domain to only display positive results</t>
</t>
</div>
</div>
</t>
</templates>
@@ -5,14 +5,17 @@ const searchUtils = require('web.searchUtils');
const GraphView = require('web.GraphView');
const testUtils = require('web.test_utils');
const { sortBy } = require('web.utils');
const { legacyExtraNextTick } = require("@web/../tests/helpers/utils");
const { createWebClient, doAction } = require('@web/../tests/webclient/helpers');
const legacyViewRegistry = require("web.view_registry");
const { registry } = require("@web/core/registry");
const { createView } = testUtils;
const cpHelpers = testUtils.controlPanel;
const patchDate = testUtils.mock.patchDate;
const viewRegistry = registry.category("views");
const { INTERVAL_OPTIONS, PERIOD_OPTIONS, COMPARISON_OPTIONS } = searchUtils;
const INTERVAL_OPTION_IDS = Object.keys(INTERVAL_OPTIONS);
@@ -129,7 +132,7 @@ QUnit.module('Views', {
}
}, function () {
QUnit.module('GraphView');
QUnit.module('GraphView (legacy)');
QUnit.test('simple graph rendering', async function (assert) {
assert.expect(5);
@@ -808,6 +811,10 @@ QUnit.module('Views', {
</kanban>`,
};
viewRegistry.remove("graph");
legacyViewRegistry.add("graph", GraphView); // We want to test the legacy view that was not added to viewRegistry!
// (see end of registerView in legacy_views.js)
const webClient = await createWebClient({ serverData });
await doAction(webClient, {
@@ -1555,7 +1562,7 @@ QUnit.module('Views', {
graph.destroy();
});
QUnit.module('GraphView: comparison mode', {
QUnit.module('GraphView: comparison mode (legacy)', {
beforeEach: async function () {
this.data.foo.records[0].date = '2016-12-15';
this.data.foo.records[1].date = '2016-12-17';
File diff suppressed because it is too large Load Diff