[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:
committed by
Aaron Bohy
co-authored by
Julien Mougenot
Jorge Pinna Puissant
Stefano Rigano
parent
23e84c1750
commit
242b1caa65
@@ -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)');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,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
Reference in New Issue
Block a user