diff --git a/addons/hr_timesheet/__manifest__.py b/addons/hr_timesheet/__manifest__.py index 65b65e27c7f..c88c3de9d29 100644 --- a/addons/hr_timesheet/__manifest__.py +++ b/addons/hr_timesheet/__manifest__.py @@ -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/**/*', diff --git a/addons/hr_timesheet/static/src/js/timesheet_graph.js b/addons/hr_timesheet/static/src/js/timesheet_graph.js deleted file mode 100644 index d4c87383151..00000000000 --- a/addons/hr_timesheet/static/src/js/timesheet_graph.js +++ /dev/null @@ -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; -}); - diff --git a/addons/hr_timesheet/static/src/js/timesheet_graph_model.js b/addons/hr_timesheet/static/src/js/timesheet_graph_model.js new file mode 100644 index 00000000000..b65559a80d6 --- /dev/null +++ b/addons/hr_timesheet/static/src/js/timesheet_graph_model.js @@ -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"]; diff --git a/addons/hr_timesheet/static/src/js/timesheet_graph_view.js b/addons/hr_timesheet/static/src/js/timesheet_graph_view.js new file mode 100644 index 00000000000..3745139699c --- /dev/null +++ b/addons/hr_timesheet/static/src/js/timesheet_graph_view.js @@ -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); diff --git a/addons/hr_timesheet/static/tests/timesheet_graph_tests.js b/addons/hr_timesheet/static/tests/timesheet_graph_tests.js index a6fd3bc690d..56a3dcf9275 100644 --- a/addons/hr_timesheet/static/tests/timesheet_graph_tests.js +++ b/addons/hr_timesheet/static/tests/timesheet_graph_tests.js @@ -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: '', - 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": ` + + + + + `, + } + } + 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)'); }); }); -}); diff --git a/addons/project/__manifest__.py b/addons/project/__manifest__.py index 831a8482575..2b659091cee 100644 --- a/addons/project/__manifest__.py +++ b/addons/project/__manifest__.py @@ -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/*', diff --git a/addons/project/static/src/burndown_chart/burndown_chart_renderer.js b/addons/project/static/src/burndown_chart/burndown_chart_renderer.js new file mode 100644 index 00000000000..ab977fde34b --- /dev/null +++ b/addons/project/static/src/burndown_chart/burndown_chart_renderer.js @@ -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 }; + } +} diff --git a/addons/project/static/src/burndown_chart/burndown_chart_view.js b/addons/project/static/src/burndown_chart/burndown_chart_view.js new file mode 100644 index 00000000000..ccda4998d83 --- /dev/null +++ b/addons/project/static/src/burndown_chart/burndown_chart_view.js @@ -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); diff --git a/addons/project/static/src/burndown_chart/burndown_chart_view.xml b/addons/project/static/src/burndown_chart/burndown_chart_view.xml new file mode 100644 index 00000000000..75605aa1428 --- /dev/null +++ b/addons/project/static/src/burndown_chart/burndown_chart_view.xml @@ -0,0 +1,12 @@ + + + + + + + + true + + + + diff --git a/addons/project/static/src/js/burndown_chart/controller.js b/addons/project/static/src/js/burndown_chart/controller.js deleted file mode 100644 index 45b19a7105b..00000000000 --- a/addons/project/static/src/js/burndown_chart/controller.js +++ /dev/null @@ -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; - } -}); diff --git a/addons/project/static/src/js/burndown_chart/renderer.js b/addons/project/static/src/js/burndown_chart/renderer.js deleted file mode 100644 index 7eb4700a389..00000000000 --- a/addons/project/static/src/js/burndown_chart/renderer.js +++ /dev/null @@ -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 }; - } -} diff --git a/addons/project/static/src/js/burndown_chart/view.js b/addons/project/static/src/js/burndown_chart/view.js deleted file mode 100644 index 0c0bc235126..00000000000 --- a/addons/project/static/src/js/burndown_chart/view.js +++ /dev/null @@ -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); diff --git a/addons/project/static/src/js/project_graph.js b/addons/project/static/src/js/project_graph.js deleted file mode 100644 index acfc5c79055..00000000000 --- a/addons/project/static/src/js/project_graph.js +++ /dev/null @@ -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); diff --git a/addons/project/static/src/js/project_graph_view.js b/addons/project/static/src/js/project_graph_view.js new file mode 100644 index 00000000000..d7d711aafd4 --- /dev/null +++ b/addons/project/static/src/js/project_graph_view.js @@ -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); diff --git a/addons/project/static/src/js/project_rating_graph_view.js b/addons/project/static/src/js/project_rating_graph_view.js new file mode 100644 index 00000000000..4c98d62a001 --- /dev/null +++ b/addons/project/static/src/js/project_rating_graph_view.js @@ -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); diff --git a/addons/project/static/src/js/project_rating_reporting.js b/addons/project/static/src/js/project_rating_reporting.js index 737795c917e..164e751b5ad 100644 --- a/addons/project/static/src/js/project_rating_reporting.js +++ b/addons/project/static/src/js/project_rating_reporting.js @@ -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); diff --git a/addons/project/static/tests/burndown_chart_tests.js b/addons/project/static/tests/burndown_chart_tests.js index 7c421affe88..b80ef82036e 100644 --- a/addons/project/static/tests/burndown_chart_tests.js +++ b/addons/project/static/tests/burndown_chart_tests.js @@ -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": ` + + + + + + `, }, - 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: ` - - - - `, - }; - } - }, 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(); }); }); }); diff --git a/addons/stock/static/src/xml/report_stock_forecasted.xml b/addons/stock/static/src/xml/report_stock_forecasted.xml index 7fad33ec827..8f8110cc3d2 100644 --- a/addons/stock/static/src/xml/report_stock_forecasted.xml +++ b/addons/stock/static/src/xml/report_stock_forecasted.xml @@ -1,7 +1,7 @@ - + 300 diff --git a/addons/web/__manifest__.py b/addons/web/__manifest__.py index 0133195d3cc..29e0e0485bd 100644 --- a/addons/web/__manifest__.py +++ b/addons/web/__manifest__.py @@ -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', diff --git a/addons/web/static/src/legacy/js/views/graph/graph_model.js b/addons/web/static/src/legacy/js/views/graph/graph_model.js index b1bcddb47c2..1e02c729b40 100644 --- a/addons/web/static/src/legacy/js/views/graph/graph_model.js +++ b/addons/web/static/src/legacy/js/views/graph/graph_model.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) { diff --git a/addons/web/static/src/legacy/js/views/graph/graph_renderer.js b/addons/web/static/src/legacy/js/views/graph/graph_renderer.js index d14f52accf9..ba35e0d976b 100644 --- a/addons/web/static/src/legacy/js/views/graph/graph_renderer.js +++ b/addons/web/static/src/legacy/js/views/graph/graph_renderer.js @@ -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, diff --git a/addons/web/static/src/legacy/xml/graph.xml b/addons/web/static/src/legacy/xml/graph.xml index 01ba3d14379..d194f10dba7 100644 --- a/addons/web/static/src/legacy/xml/graph.xml +++ b/addons/web/static/src/legacy/xml/graph.xml @@ -1,7 +1,7 @@ - +
- -
- - - - - - - - - - -
-
- - - -
-
-
-
diff --git a/addons/web/static/src/views/graph/colors.js b/addons/web/static/src/views/graph/colors.js new file mode 100644 index 00000000000..ab0dff98cde --- /dev/null +++ b/addons/web/static/src/views/graph/colors.js @@ -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})`; +} diff --git a/addons/web/static/src/views/graph/graph_arch_parser.js b/addons/web/static/src/views/graph/graph_arch_parser.js new file mode 100644 index 00000000000..a3f1180b5ec --- /dev/null +++ b/addons/web/static/src/views/graph/graph_arch_parser.js @@ -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; + } +} diff --git a/addons/web/static/src/views/graph/graph_model.js b/addons/web/static/src/views/graph/graph_model.js new file mode 100644 index 00000000000..07fddc83516 --- /dev/null +++ b/addons/web/static/src/views/graph/graph_model.js @@ -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"]; diff --git a/addons/web/static/src/views/graph/graph_renderer.js b/addons/web/static/src/views/graph/graph_renderer.js new file mode 100644 index 00000000000..da53bed9c89 --- /dev/null +++ b/addons/web/static/src/views/graph/graph_renderer.js @@ -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, +}; diff --git a/addons/web/static/src/views/graph/graph_renderer.xml b/addons/web/static/src/views/graph/graph_renderer.xml new file mode 100644 index 00000000000..67eb1259550 --- /dev/null +++ b/addons/web/static/src/views/graph/graph_renderer.xml @@ -0,0 +1,34 @@ + + + + +
+ + + + + + + + + + +
+
+ + + +
+
+
+ + +
+ +
+ +
+
+
+ +
\ No newline at end of file diff --git a/addons/web/static/src/views/graph/graph_view.js b/addons/web/static/src/views/graph/graph_view.js new file mode 100644 index 00000000000..512bb3ff9f8 --- /dev/null +++ b/addons/web/static/src/views/graph/graph_view.js @@ -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); diff --git a/addons/web/static/src/views/graph/graph_view.scss b/addons/web/static/src/views/graph/graph_view.scss new file mode 100644 index 00000000000..e88aaaa2939 --- /dev/null +++ b/addons/web/static/src/views/graph/graph_view.scss @@ -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; + } + } +} diff --git a/addons/web/static/src/views/graph/graph_view.xml b/addons/web/static/src/views/graph/graph_view.xml new file mode 100644 index 00000000000..4a72f77dc7c --- /dev/null +++ b/addons/web/static/src/views/graph/graph_view.xml @@ -0,0 +1,75 @@ + + + + + + + + + + + +
+ + + + + +
+ + + + + + + + + Invalid data + Pie chart cannot mix positive and negative numbers. Try to change your domain to only display positive results + +
+
+
+ +
diff --git a/addons/web/static/tests/legacy/views/graph_tests.js b/addons/web/static/tests/legacy/views/graph_tests.js index 6fe528c9961..ae16c812920 100644 --- a/addons/web/static/tests/legacy/views/graph_tests.js +++ b/addons/web/static/tests/legacy/views/graph_tests.js @@ -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', { `, }; + 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'; diff --git a/addons/web/static/tests/views/graph_view_tests.js b/addons/web/static/tests/views/graph_view_tests.js new file mode 100644 index 00000000000..0663027869f --- /dev/null +++ b/addons/web/static/tests/views/graph_view_tests.js @@ -0,0 +1,3291 @@ +/** @odoo-module **/ + +import { BORDER_WHITE, DEFAULT_BG } from "@web/views/graph/colors"; +import { click, triggerEvent } from "@web/../tests/helpers/utils"; +import { createWebClient, doAction } from "@web/../tests/webclient/helpers"; +import { dialogService } from "@web/core/dialog/dialog_service"; +import { GraphArchParser } from "@web/views/graph/graph_arch_parser"; +import { makeView } from "./helpers"; +import { registry } from "@web/core/registry"; +import { setupControlPanelServiceRegistry } from "@web/../tests/search/helpers"; +import { + editFavoriteName, + saveFavorite, + switchView, + toggleFavoriteMenu, + toggleFilterMenu, + toggleGroupByMenu, + toggleMenu, + toggleMenuItem, + toggleSaveFavorite, +} from "@web/../tests/search/helpers"; + +const serviceRegistry = registry.category("services"); + +function getGraphModelMetaData(graph) { + return graph.model.metaData; +} + +export function getGraphRenderer(graph) { + return Object.values(graph.__owl__.children).find((c) => c.chart); +} + +function getChart(graph) { + return getGraphRenderer(graph).chart; +} + +function checkDatasets(assert, graph, keys, expectedDatasets) { + keys = keys instanceof Array ? keys : [keys]; + expectedDatasets = expectedDatasets instanceof Array ? expectedDatasets : [expectedDatasets]; + const datasets = getChart(graph).data.datasets; + const actualValues = []; + for (const dataset of datasets) { + const partialDataset = {}; + for (const key of keys) { + partialDataset[key] = dataset[key]; + } + actualValues.push(partialDataset); + } + assert.deepEqual(actualValues, expectedDatasets); +} + +function checkLabels(assert, graph, expectedLabels) { + const labels = getChart(graph).data.labels.map((l) => l.toString()); + assert.deepEqual(labels, expectedLabels); +} + +function checkLegend(assert, graph, expectedLegendLabels) { + expectedLegendLabels = + expectedLegendLabels instanceof Array ? expectedLegendLabels : [expectedLegendLabels]; + const chart = getChart(graph); + const actualLegendLabels = chart.config.options.legend.labels + .generateLabels(chart) + .map((o) => o.text); + assert.deepEqual(actualLegendLabels, expectedLegendLabels); +} + +function checkTooltip(assert, graph, expectedTooltipContent, index, datasetIndex) { + // If the tooltip options are changed, this helper should change: we construct the dataPoints + // similarly to Chart.js according to the values set for the tooltips options 'mode' and 'intersect'. + const { datasets } = getChart(graph).data; + const dataPoints = []; + for (let i = 0; i < datasets.length; i++) { + const dataset = datasets[i]; + const yLabel = dataset.data[index]; + if (yLabel !== undefined && (datasetIndex === undefined || datasetIndex === i)) { + dataPoints.push({ + datasetIndex: i, + index, + yLabel, + }); + } + } + const tooltipModel = { opacity: 1, x: 1, y: 1, dataPoints }; + getChart(graph).config.options.tooltips.custom(tooltipModel); + const { title, lines } = expectedTooltipContent; + const lineLabels = []; + const lineValues = []; + for (const line of lines) { + lineLabels.push(line.label); + lineValues.push(`${line.value}`); + } + assert.containsOnce(graph, "div.o_graph_custom_tooltip"); + const tooltipTitle = graph.el.querySelector("table thead tr th.o_measure"); + assert.strictEqual(tooltipTitle.innerText, title || "Count", `Tooltip title`); + assert.deepEqual( + [...graph.el.querySelectorAll("table tbody tr td span.o_label")].map((td) => td.innerText), + lineLabels, + `Tooltip line labels` + ); + assert.deepEqual( + [...graph.el.querySelectorAll("table tbody tr td.o_value")].map((td) => td.innerText), + lineValues, + `Tooltip line values` + ); +} + +function getModeButton(comp, mode) { + return comp.el.querySelector(`.o_graph_button[data-mode="${mode}"`); +} + +async function selectMode(comp, mode) { + await click(getModeButton(comp, mode)); +} + +function checkModeIs(assert, graph, mode) { + assert.strictEqual(getGraphModelMetaData(graph).mode, mode); + assert.strictEqual(getChart(graph).config.type, mode); + assert.hasClass(getModeButton(graph, mode), "active"); +} + +function getXAxeLabel(graph) { + return getChart(graph).config.options.scales.xAxes[0].scaleLabel.labelString; +} + +function getYAxeLabel(graph) { + return getChart(graph).config.options.scales.yAxes[0].scaleLabel.labelString; +} + +async function clickOnDataset(graph) { + const chart = getChart(graph); + const meta = chart.getDatasetMeta(0); + const rectangle = chart.canvas.getBoundingClientRect(); + const point = meta.data[0].getCenterPoint(); + await triggerEvent(chart.canvas, null, "click", { + pageX: rectangle.left + point.x, + pageY: rectangle.top + point.y, + }); +} + +let serverData; +QUnit.module("Views", (hooks) => { + hooks.beforeEach(async () => { + serverData = { + models: { + foo: { + fields: { + id: { string: "Id", type: "integer" }, + foo: { string: "Foo", type: "integer", store: true, group_operator: "sum" }, + bar: { string: "bar", type: "boolean", store: true }, + product_id: { + string: "Product", + type: "many2one", + relation: "product", + store: true, + }, + color_id: { + string: "Color", + type: "many2one", + relation: "color", + store: true, + }, + date: { string: "Date", type: "date", store: true, sortable: true }, + revenue: { + string: "Revenue", + type: "float", + store: true, + group_operator: "sum", + }, + }, + records: [ + { + id: 1, + foo: 3, + bar: true, + product_id: 37, + date: "2016-01-01", + revenue: 1, + }, + { + id: 2, + foo: 53, + bar: true, + product_id: 37, + color_id: 7, + date: "2016-01-03", + revenue: 2, + }, + { + id: 3, + foo: 2, + bar: true, + product_id: 37, + date: "2016-03-04", + revenue: 3, + }, + { + id: 4, + foo: 24, + bar: false, + product_id: 37, + date: "2016-03-07", + revenue: 4, + }, + { + id: 5, + foo: 4, + bar: false, + product_id: 41, + date: "2016-05-01", + revenue: 5, + }, + { id: 6, foo: 63, bar: false, product_id: 41 }, + { id: 7, foo: 42, bar: false, product_id: 41 }, + { + id: 8, + foo: 48, + bar: false, + product_id: 41, + date: "2016-04-01", + revenue: 8, + }, + ], + }, + product: { + fields: { + id: { string: "Id", type: "integer" }, + name: { string: "Product Name", type: "char" }, + }, + records: [ + { + id: 37, + display_name: "xphone", + }, + { + id: 41, + display_name: "xpad", + }, + ], + }, + color: { + fields: { + id: { string: "Id", type: "integer" }, + name: { string: "Color", type: "char" }, + }, + records: [ + { + id: 7, + display_name: "red", + }, + { + id: 14, + display_name: "black", + }, + ], + }, + }, + views: { + "foo,false,graph": ``, + "foo,false,search": ` + + + + + + + `, + }, + }; + setupControlPanelServiceRegistry(); + serviceRegistry.add("dialog", dialogService); + }); + + QUnit.module("GraphView"); + + QUnit.test("simple bar chart rendering", async function (assert) { + assert.expect(12); + const graph = await makeView({ serverData, type: "graph", resModel: "foo" }); + const { measure, mode, order, stacked } = getGraphModelMetaData(graph); + assert.containsOnce(graph, "div.o_graph_canvas_container canvas"); + assert.strictEqual(measure, "__count", `the active measure should be "__count" by default`); + assert.strictEqual(mode, "bar", "should be in bar chart mode by default"); + assert.strictEqual(order, null, "should not be ordered by default"); + assert.strictEqual(stacked, true, "bar charts should be stacked by default"); + checkLabels(assert, graph, ["Total"]); + checkDatasets(assert, graph, ["backgroundColor", "borderColor", "data", "label", "stack"], { + backgroundColor: "#1f77b4", + borderColor: undefined, + data: [8], + label: "Count", + stack: "", + }); + checkLegend(assert, graph, "Count"); + checkTooltip(assert, graph, { lines: [{ label: "Total", value: "8" }] }, 0); + }); + + QUnit.test("simple bar chart rendering with no data", async function (assert) { + assert.expect(4); + serverData.models.foo.records = []; + const graph = await makeView({ serverData, type: "graph", resModel: "foo" }); + assert.containsOnce(graph, "div.o_graph_canvas_container canvas"); + assert.containsNone(graph, ".o_nocontent_help"); + checkLabels(assert, graph, []); + checkDatasets(assert, graph, [], []); + }); + + QUnit.test("simple bar chart rendering (one groupBy)", async function (assert) { + assert.expect(12); + const graph = await makeView({ + serverData, + type: "graph", + resModel: "foo", + arch: ``, + }); + assert.containsOnce(graph.el, "div.o_graph_canvas_container canvas"); + checkLabels(assert, graph, ["true", "false"]); + checkDatasets(assert, graph, ["backgroundColor", "borderColor", "data", "label"], { + backgroundColor: "#1f77b4", + borderColor: undefined, + data: [3, 5], + label: "Count", + }); + checkLegend(assert, graph, "Count"); + checkTooltip(assert, graph, { lines: [{ label: "true", value: "3" }] }, 0); + checkTooltip(assert, graph, { lines: [{ label: "false", value: "5" }] }, 1); + }); + + QUnit.test("simple bar chart rendering (two groupBy)", async function (assert) { + assert.expect(20); + const graph = await makeView({ + serverData, + type: "graph", + resModel: "foo", + arch: ` + + + + + `, + }); + assert.containsOnce(graph.el, "div.o_graph_canvas_container canvas"); + checkLabels(assert, graph, ["true", "false"]); + checkDatasets( + assert, + graph, + ["backgroundColor", "borderColor", "data", "label"], + [ + { + backgroundColor: "#1f77b4", + borderColor: undefined, + data: [3, 1], + label: "xphone", + }, + { + backgroundColor: "#ff7f0e", + borderColor: undefined, + data: [0, 4], + label: "xpad", + }, + ] + ); + checkLegend(assert, graph, ["xphone", "xpad"]); + checkTooltip(assert, graph, { lines: [{ label: "true / xphone", value: "3" }] }, 0, 0); + checkTooltip(assert, graph, { lines: [{ label: "false / xphone", value: "1" }] }, 1, 0); + checkTooltip(assert, graph, { lines: [{ label: "true / xpad", value: "0" }] }, 0, 1); + checkTooltip(assert, graph, { lines: [{ label: "false / xpad", value: "4" }] }, 1, 1); + }); + + QUnit.test("bar chart rendering (no groupBy, several domains)", async function (assert) { + assert.expect(11); + const graph = await makeView({ + serverData, + type: "graph", + resModel: "foo", + arch: ` + + + + `, + groupBy: [], + domains: [ + { arrayRepr: [["bar", "=", true]], description: "True group" }, + { arrayRepr: [["bar", "=", false]], description: "False group" }, + ], + }); + checkLabels(assert, graph, ["Total"]); + checkDatasets( + assert, + graph, + ["backgroundColor", "borderColor", "data", "label"], + [ + { + backgroundColor: "#1f77b4", + borderColor: undefined, + data: [6], + label: "True group", + }, + { + backgroundColor: "#ff7f0e", + borderColor: undefined, + data: [17], + label: "False group", + }, + ] + ); + checkLegend(assert, graph, ["True group", "False group"]); + checkTooltip( + assert, + graph, + { + title: "Revenue", + lines: [{ label: "Total / True group", value: "6" }], + }, + 0, + 0 + ); + checkTooltip( + assert, + graph, + { + title: "Revenue", + lines: [{ label: "Total / False group", value: "17" }], + }, + 0, + 1 + ); + }); + + QUnit.test("bar chart rendering (one groupBy, several domains)", async function (assert) { + assert.expect(19); + serverData.models.foo.records = [ + { bar: true, foo: 1, revenue: 14 }, + { bar: true, foo: 2, revenue: false }, + { bar: false, foo: 1, revenue: 12 }, + { bar: false, foo: 2, revenue: -4 }, + { bar: false, foo: 3, revenue: 2 }, + { bar: false, foo: 4, revenue: 0 }, + ]; + const graph = await makeView({ + serverData, + type: "graph", + resModel: "foo", + arch: ` + + + + + `, + domains: [ + { arrayRepr: [["bar", "=", true]], description: "True group" }, + { arrayRepr: [["bar", "=", false]], description: "False group" }, + ], + }); + checkLabels(assert, graph, ["1", "2", "3", "4"]); + checkDatasets( + assert, + graph, + ["backgroundColor", "borderColor", "data", "label"], + [ + { + backgroundColor: "#1f77b4", + borderColor: undefined, + data: [14, 0, 0, 0], + label: "True group", + }, + { + backgroundColor: "#ff7f0e", + borderColor: undefined, + data: [12, -4, 2, 0], + label: "False group", + }, + ] + ); + checkLegend(assert, graph, ["True group", "False group"]); + checkTooltip( + assert, + graph, + { + title: "Revenue", + lines: [{ label: "1 / True group", value: "14" }], + }, + 0, + 0 + ); + checkTooltip( + assert, + graph, + { + title: "Revenue", + lines: [{ label: "1 / False group", value: "12" }], + }, + 0, + 1 + ); + checkTooltip( + assert, + graph, + { + title: "Revenue", + lines: [{ label: "2 / False group", value: "-4" }], + }, + 1, + 1 + ); + checkTooltip( + assert, + graph, + { + title: "Revenue", + lines: [{ label: "3 / False group", value: "2" }], + }, + 2, + 1 + ); + }); + + QUnit.test( + "bar chart rendering (one groupBy, several domains with date identification)", + async function (assert) { + assert.expect(23); + serverData.models.foo.records = [ + { date: "2021-01-04", revenue: 12 }, + { date: "2021-01-12", revenue: 5 }, + { date: "2021-01-19", revenue: 15 }, + { date: "2021-01-26", revenue: 2 }, + { date: "2021-02-04", revenue: 14 }, + { date: "2021-02-17", revenue: false }, + { date: false, revenue: 0 }, + ]; + const domains = [ + { + arrayRepr: [ + ["date", ">=", "2021-02-01"], + ["date", "<=", "2021-02-28"], + ], + description: "February 2021", + }, + { + arrayRepr: [ + ["date", ">=", "2021-01-01"], + ["date", "<=", "2021-01-31"], + ], + description: "January 2021", + }, + ]; + domains.fieldName = "date"; + const graph = await makeView({ + serverData, + type: "graph", + resModel: "foo", + arch: ` + + + + + `, + domains, + }); + checkLabels(assert, graph, ["W05 2021", "W07 2021", "", ""]); + checkDatasets( + assert, + graph, + ["backgroundColor", "borderColor", "data", "label"], + [ + { + backgroundColor: "#1f77b4", + borderColor: undefined, + data: [14, 0], + label: "February 2021", + }, + { + backgroundColor: "#ff7f0e", + borderColor: undefined, + data: [12, 5, 15, 2], + label: "January 2021", + }, + ] + ); + checkLegend(assert, graph, ["February 2021", "January 2021"]); + checkTooltip( + assert, + graph, + { + title: "Revenue", + lines: [{ label: "W05 2021 / February 2021", value: "14" }], + }, + 0, + 0 + ); + checkTooltip( + assert, + graph, + { + title: "Revenue", + lines: [{ label: "W01 2021 / January 2021", value: "12" }], + }, + 0, + 1 + ); + checkTooltip( + assert, + graph, + { + title: "Revenue", + lines: [{ label: "W02 2021 / January 2021", value: "5" }], + }, + 1, + 1 + ); + checkTooltip( + assert, + graph, + { + title: "Revenue", + lines: [{ label: "W03 2021 / January 2021", value: "15" }], + }, + 2, + 1 + ); + checkTooltip( + assert, + graph, + { + title: "Revenue", + lines: [{ label: "W04 2021 / January 2021", value: "2" }], + }, + 3, + 1 + ); + } + ); + + QUnit.test( + "bar chart rendering (two groupBy, several domains with no date identification)", + async function (assert) { + assert.expect(15); + serverData.models.foo.records = [ + { date: "2021-01-04", bar: true, revenue: 12 }, + { date: "2021-01-12", bar: false, revenue: 5 }, + { date: "2021-02-04", bar: true, revenue: 14 }, + { date: "2021-02-17", bar: false, revenue: false }, + { date: false, bar: true, revenue: 0 }, + ]; + const domains = [ + { + arrayRepr: [ + ["date", ">=", "2021-02-01"], + ["date", "<=", "2021-02-28"], + ], + description: "February 2021", + }, + { + arrayRepr: [ + ["date", ">=", "2021-01-01"], + ["date", "<=", "2021-01-31"], + ], + description: "January 2021", + }, + ]; + domains.fieldName = "date"; + const graph = await makeView({ + serverData, + type: "graph", + resModel: "foo", + arch: ` + + + + + + `, + domains, + }); + checkLabels(assert, graph, ["true", "false"]); + checkDatasets( + assert, + graph, + ["backgroundColor", "borderColor", "data", "label"], + [ + { + backgroundColor: "#1f77b4", + borderColor: undefined, + data: [14, 0], + label: "February 2021 / W05 2021", + }, + { + backgroundColor: "#ff7f0e", + borderColor: undefined, + data: [0, 0], + label: "February 2021 / W07 2021", + }, + { + backgroundColor: "#aec7e8", + borderColor: undefined, + data: [12, 0], + label: "January 2021 / W01 2021", + }, + { + backgroundColor: "#ffbb78", + borderColor: undefined, + data: [0, 5], + label: "January 2021 / W02 2021", + }, + ] + ); + checkLegend(assert, graph, [ + "February 2021 / W05 2021", + "February 2021 / W07 2021", + "January 2021 / W01 2021", + "January 2021 / W02 2021", + ]); + checkTooltip( + assert, + graph, + { + title: "Revenue", + lines: [{ label: "true / February 2021 / W05 2021", value: "14" }], + }, + 0, + 0 + ); + checkTooltip( + assert, + graph, + { + title: "Revenue", + lines: [{ label: "true / January 2021 / W01 2021", value: "12" }], + }, + 0, + 2 + ); + checkTooltip( + assert, + graph, + { + title: "Revenue", + lines: [{ label: "false / January 2021 / W02 2021", value: "5" }], + }, + 1, + 3 + ); + } + ); + + QUnit.test("line chart rendering (no groupBy)", async function (assert) { + assert.expect(9); + const graph = await makeView({ + serverData, + type: "graph", + resModel: "foo", + arch: ``, + }); + assert.containsOnce(graph.el, "div.o_graph_canvas_container canvas"); + const { mode } = getGraphModelMetaData(graph); + assert.strictEqual(mode, "line"); + checkLabels(assert, graph, ["", "Total", ""]); + checkDatasets(assert, graph, ["backgroundColor", "borderColor", "data", "label", "stack"], { + backgroundColor: "rgba(31,119,180,0.4)", + borderColor: "#1f77b4", + data: [undefined, 8], + label: "Count", + stack: undefined, + }); + checkLegend(assert, graph, "Count"); + checkTooltip(assert, graph, { lines: [{ label: "Total", value: "8" }] }, 1); + }); + + QUnit.test("line chart rendering (one groupBy)", async function (assert) { + assert.expect(12); + const graph = await makeView({ + serverData, + type: "graph", + resModel: "foo", + arch: ` + + + + `, + }); + assert.containsOnce(graph.el, "div.o_graph_canvas_container canvas"); + checkLabels(assert, graph, ["true", "false"]); + checkDatasets(assert, graph, ["backgroundColor", "borderColor", "data", "label"], { + backgroundColor: "rgba(31,119,180,0.4)", + borderColor: "#1f77b4", + data: [3, 5], + label: "Count", + }); + checkLegend(assert, graph, "Count"); + checkTooltip(assert, graph, { lines: [{ label: "true", value: "3" }] }, 0); + checkTooltip(assert, graph, { lines: [{ label: "false", value: "5" }] }, 1); + }); + + QUnit.test("line chart rendering (two groupBy)", async function (assert) { + assert.expect(12); + const graph = await makeView({ + serverData, + type: "graph", + resModel: "foo", + arch: ` + + + + + `, + }); + assert.containsOnce(graph.el, "div.o_graph_canvas_container canvas"); + checkLabels(assert, graph, ["true", "false"]); + checkDatasets( + assert, + graph, + ["backgroundColor", "borderColor", "data", "label"], + [ + { + backgroundColor: undefined, + borderColor: "#1f77b4", + data: [3, 1], + label: "xphone", + }, + { + backgroundColor: undefined, + borderColor: "#ff7f0e", + data: [0, 4], + label: "xpad", + }, + ] + ); + checkLegend(assert, graph, ["xphone", "xpad"]); + checkTooltip( + assert, + graph, + { + lines: [ + { label: "true / xphone", value: "3" }, + { label: "true / xpad", value: "0" }, + ], + }, + 0 + ); + checkTooltip( + assert, + graph, + { + lines: [ + { label: "false / xpad", value: "4" }, + { label: "false / xphone", value: "1" }, + ], + }, + 1 + ); + }); + + QUnit.test("line chart rendering (no groupBy, several domains)", async function (assert) { + assert.expect(7); + const graph = await makeView({ + serverData, + resModel: "foo", + type: "graph", + arch: ` + + + + `, + domains: [ + { arrayRepr: [["bar", "=", true]], description: "True group" }, + { arrayRepr: [["bar", "=", false]], description: "False group" }, + ], + }); + checkLabels(assert, graph, ["", "Total", ""]); + checkDatasets( + assert, + graph, + ["backgroundColor", "borderColor", "data", "label"], + [ + { + backgroundColor: "rgba(31,119,180,0.4)", + borderColor: "#1f77b4", + data: [undefined, 6], + label: "True group", + }, + { + backgroundColor: undefined, + borderColor: "#ff7f0e", + data: [undefined, 17], + label: "False group", + }, + ] + ); + checkLegend(assert, graph, ["True group", "False group"]); + checkTooltip( + assert, + graph, + { + title: "Revenue", + lines: [ + { label: "Total / False group", value: "17" }, + { label: "Total / True group", value: "6" }, + ], + }, + 1 + ); + }); + + QUnit.test("line chart rendering (one groupBy, several domains)", async function (assert) { + assert.expect(19); + serverData.models.foo.records = [ + { bar: true, foo: 1, revenue: 14 }, + { bar: true, foo: 2, revenue: false }, + { bar: false, foo: 1, revenue: 12 }, + { bar: false, foo: 2, revenue: -4 }, + { bar: false, foo: 3, revenue: 2 }, + { bar: false, foo: 4, revenue: 0 }, + ]; + const graph = await makeView({ + serverData, + type: "graph", + resModel: "foo", + arch: ` + + + + + `, + domains: [ + { arrayRepr: [["bar", "=", true]], description: "True group" }, + { arrayRepr: [["bar", "=", false]], description: "False group" }, + ], + }); + checkLabels(assert, graph, ["1", "2", "3", "4"]); + checkDatasets( + assert, + graph, + ["backgroundColor", "borderColor", "data", "label"], + [ + { + backgroundColor: "rgba(31,119,180,0.4)", + borderColor: "#1f77b4", + data: [14, 0, 0, 0], + label: "True group", + }, + { + backgroundColor: undefined, + borderColor: "#ff7f0e", + data: [12, -4, 2, 0], + label: "False group", + }, + ] + ); + checkLegend(assert, graph, ["True group", "False group"]); + checkTooltip( + assert, + graph, + { + title: "Revenue", + lines: [ + { label: "1 / True group", value: "14" }, + { label: "1 / False group", value: "12" }, + ], + }, + 0 + ); + checkTooltip( + assert, + graph, + { + title: "Revenue", + lines: [ + { label: "2 / True group", value: "0" }, + { label: "2 / False group", value: "-4" }, + ], + }, + 1 + ); + checkTooltip( + assert, + graph, + { + title: "Revenue", + lines: [ + { label: "3 / False group", value: "2" }, + { label: "3 / True group", value: "0" }, + ], + }, + 2 + ); + checkTooltip( + assert, + graph, + { + title: "Revenue", + lines: [ + { label: "4 / True group", value: "0" }, + { label: "4 / False group", value: "0" }, + ], + }, + 3 + ); + }); + + QUnit.test( + "line chart rendering (one groupBy, several domains with date identification)", + async function (assert) { + assert.expect(19); + serverData.models.foo.records = [ + { date: "2021-01-04", revenue: 12 }, + { date: "2021-01-12", revenue: 5 }, + { date: "2021-01-19", revenue: 15 }, + { date: "2021-01-26", revenue: 2 }, + { date: "2021-02-04", revenue: 14 }, + { date: "2021-02-17", revenue: false }, + { date: false, revenue: 0 }, + ]; + const domains = [ + { + arrayRepr: [ + ["date", ">=", "2021-02-01"], + ["date", "<=", "2021-02-28"], + ], + description: "February 2021", + }, + { + arrayRepr: [ + ["date", ">=", "2021-01-01"], + ["date", "<=", "2021-01-31"], + ], + description: "January 2021", + }, + ]; + domains.fieldName = "date"; + const graph = await makeView({ + serverData, + type: "graph", + resModel: "foo", + arch: ` + + + + + `, + domains, + }); + checkLabels(assert, graph, ["W05 2021", "W07 2021", "", ""]); + checkDatasets( + assert, + graph, + ["backgroundColor", "borderColor", "data", "label"], + [ + { + backgroundColor: "rgba(31,119,180,0.4)", + borderColor: "#1f77b4", + data: [14, 0], + label: "February 2021", + }, + { + backgroundColor: undefined, + borderColor: "#ff7f0e", + data: [12, 5, 15, 2], + label: "January 2021", + }, + ] + ); + checkLegend(assert, graph, ["February 2021", "January 2021"]); + checkTooltip( + assert, + graph, + { + title: "Revenue", + lines: [ + { label: "W05 2021 / February 2021", value: "14" }, + { label: "W01 2021 / January 2021", value: "12" }, + ], + }, + 0 + ); + checkTooltip( + assert, + graph, + { + title: "Revenue", + lines: [ + { label: "W02 2021 / January 2021", value: "5" }, + { label: "W07 2021 / February 2021", value: "0" }, + ], + }, + 1 + ); + checkTooltip( + assert, + graph, + { + title: "Revenue", + lines: [{ label: "W03 2021 / January 2021", value: "15" }], + }, + 2 + ); + checkTooltip( + assert, + graph, + { + title: "Revenue", + lines: [{ label: "W04 2021 / January 2021", value: "2" }], + }, + 3 + ); + } + ); + + QUnit.test( + "line chart rendering (two groupBy, several domains with no date identification)", + async function (assert) { + assert.expect(11); + serverData.models.foo.records = [ + { date: "2021-01-04", bar: true, revenue: 12 }, + { date: "2021-01-12", bar: false, revenue: 5 }, + { date: "2021-02-04", bar: true, revenue: 14 }, + { date: "2021-02-17", bar: false, revenue: false }, + { date: false, bar: true, revenue: 0 }, + ]; + const domains = [ + { + arrayRepr: [ + ["date", ">=", "2021-02-01"], + ["date", "<=", "2021-02-28"], + ], + description: "February 2021", + }, + { + arrayRepr: [ + ["date", ">=", "2021-01-01"], + ["date", "<=", "2021-01-31"], + ], + description: "January 2021", + }, + ]; + domains.fieldName = "date"; + const graph = await makeView({ + serverData, + type: "graph", + resModel: "foo", + arch: ` + + + + + + `, + domains, + }); + checkLabels(assert, graph, ["true", "false"]); + checkDatasets( + assert, + graph, + ["backgroundColor", "borderColor", "data", "label"], + [ + { + backgroundColor: undefined, + borderColor: "#1f77b4", + data: [14, 0], + label: "February 2021 / W05 2021", + }, + { + backgroundColor: undefined, + borderColor: "#ff7f0e", + data: [0, 0], + label: "February 2021 / W07 2021", + }, + { + backgroundColor: undefined, + borderColor: "#aec7e8", + data: [12, 0], + label: "January 2021 / W01 2021", + }, + { + backgroundColor: undefined, + borderColor: "#ffbb78", + data: [0, 5], + label: "January 2021 / W02 2021", + }, + ] + ); + checkLegend(assert, graph, [ + "February 2021 / W05 2021", + "February 2021 / W07 2021", + "January 2021 / W01 2021", + "January 2021 / W02 2021", + ]); + checkTooltip( + assert, + graph, + { + title: "Revenue", + lines: [ + { label: "true / February 2021 / W05 2021", value: "14" }, + { label: "true / January 2021 / W01 2021", value: "12" }, + { label: "true / February 2021 / W07 2021", value: "0" }, + { label: "true / January 2021 / W02 2021", value: "0" }, + ], + }, + 0 + ); + checkTooltip( + assert, + graph, + { + title: "Revenue", + lines: [ + { label: "false / January 2021 / W02 2021", value: "5" }, + { label: "false / February 2021 / W05 2021", value: "0" }, + { label: "false / February 2021 / W07 2021", value: "0" }, + { label: "false / January 2021 / W01 2021", value: "0" }, + ], + }, + 1 + ); + } + ); + + QUnit.test("displaying line chart with only 1 data point", async function (assert) { + assert.expect(1); + // this test makes sure the line chart does not crash when only one data + // point is displayed. + serverData.models.foo.records = serverData.models.foo.records.slice(0, 1); + const graph = await makeView({ + serverData, + type: "graph", + resModel: "foo", + arch: ``, + }); + assert.containsOnce(graph, "canvas", "should have a canvas"); + }); + + QUnit.test("pie chart rendering (no groupBy)", async function (assert) { + assert.expect(9); + const graph = await makeView({ + serverData, + type: "graph", + resModel: "foo", + arch: ``, + }); + assert.containsOnce(graph.el, "div.o_graph_canvas_container canvas"); + const { mode } = getGraphModelMetaData(graph); + assert.strictEqual(mode, "pie"); + checkLabels(assert, graph, ["Total"]); + checkDatasets(assert, graph, ["backgroundColor", "borderColor", "data", "label", "stack"], { + backgroundColor: ["#1f77b4"], + borderColor: BORDER_WHITE, + data: [8], + label: "", + stack: undefined, + }); + checkLegend(assert, graph, "Total"); + checkTooltip(assert, graph, { lines: [{ label: "Total", value: "8" }] }, 0); + }); + + QUnit.test("pie chart rendering (one groupBy)", async function (assert) { + assert.expect(12); + const graph = await makeView({ + serverData, + type: "graph", + resModel: "foo", + arch: ` + + + + `, + }); + assert.containsOnce(graph.el, "div.o_graph_canvas_container canvas"); + checkLabels(assert, graph, ["true", "false"]); + checkDatasets(assert, graph, ["backgroundColor", "borderColor", "data"], { + backgroundColor: ["#1f77b4", "#ff7f0e"], + borderColor: BORDER_WHITE, + data: [3, 5], + }); + checkLegend(assert, graph, ["true", "false"]); + checkTooltip(assert, graph, { lines: [{ label: "true", value: "3" }] }, 0); + checkTooltip(assert, graph, { lines: [{ label: "false", value: "5" }] }, 1); + }); + + QUnit.test("pie chart rendering (two groupBy)", async function (assert) { + assert.expect(16); + const graph = await makeView({ + serverData, + type: "graph", + resModel: "foo", + arch: ` + + + + + `, + }); + assert.containsOnce(graph.el, "div.o_graph_canvas_container canvas"); + checkLabels(assert, graph, ["true / xphone", "false / xphone", "false / xpad"]); + checkDatasets(assert, graph, ["backgroundColor", "borderColor", "data", "label"], { + backgroundColor: ["#1f77b4", "#ff7f0e", "#aec7e8"], + borderColor: BORDER_WHITE, + data: [3, 1, 4], + label: "", + }); + checkLegend(assert, graph, ["true / xphone", "false / xphone", "false / xpad"]); + checkTooltip(assert, graph, { lines: [{ label: "true / xphone", value: "3" }] }, 0); + checkTooltip(assert, graph, { lines: [{ label: "false / xphone", value: "1" }] }, 1); + checkTooltip(assert, graph, { lines: [{ label: "false / xpad", value: "4" }] }, 2); + }); + + QUnit.test("pie chart rendering (no groupBy, several domains)", async function (assert) { + assert.expect(11); + const graph = await makeView({ + serverData, + type: "graph", + resModel: "foo", + arch: ` + + + + `, + domains: [ + { arrayRepr: [["bar", "=", true]], description: "True group" }, + { arrayRepr: [["bar", "=", false]], description: "False group" }, + ], + }); + checkLabels(assert, graph, ["Total"]); + checkDatasets( + assert, + graph, + ["backgroundColor", "borderColor", "data", "label"], + [ + { + backgroundColor: ["#1f77b4"], + borderColor: BORDER_WHITE, + data: [6], + label: "True group", + }, + { + backgroundColor: ["#1f77b4"], + borderColor: BORDER_WHITE, + data: [17], + label: "False group", + }, + ] + ); + checkLegend(assert, graph, ["Total"]); + checkTooltip( + assert, + graph, + { + title: "Revenue", + lines: [{ label: "True group / Total", value: "6" }], + }, + 0, + 0 + ); + checkTooltip( + assert, + graph, + { + title: "Revenue", + lines: [{ label: "False group / Total", value: "17" }], + }, + 0, + 1 + ); + }); + + QUnit.test("pie chart rendering (one groupBy, several domains)", async function (assert) { + assert.expect(19); + serverData.models.foo.records = [ + { bar: true, foo: 1, revenue: 14 }, + { bar: true, foo: 2, revenue: false }, + { bar: false, foo: 1, revenue: 12 }, + { bar: false, foo: 2, revenue: 5 }, + { bar: false, foo: 3, revenue: 0 }, + { bar: false, foo: 4, revenue: 2 }, + ]; + const graph = await makeView({ + serverData, + type: "graph", + resModel: "foo", + arch: ` + + + + + `, + domains: [ + { arrayRepr: [["bar", "=", true]], description: "True group" }, + { arrayRepr: [["bar", "=", false]], description: "False group" }, + ], + }); + checkLabels(assert, graph, ["1", "2", "4"]); + checkDatasets( + assert, + graph, + ["backgroundColor", "borderColor", "data", "label"], + [ + { + backgroundColor: ["#1f77b4", "#ff7f0e", "#aec7e8"], + borderColor: BORDER_WHITE, + data: [14, 0, 0], + label: "True group", + }, + { + backgroundColor: ["#1f77b4", "#ff7f0e", "#aec7e8"], + borderColor: BORDER_WHITE, + data: [12, 5, 2], + label: "False group", + }, + ] + ); + checkLegend(assert, graph, ["1", "2", "4"]); + checkTooltip( + assert, + graph, + { + title: "Revenue", + lines: [{ label: "True group / 1", value: "14" }], + }, + 0, + 0 + ); + checkTooltip( + assert, + graph, + { + title: "Revenue", + lines: [{ label: "False group / 1", value: "12" }], + }, + 0, + 1 + ); + checkTooltip( + assert, + graph, + { + title: "Revenue", + lines: [{ label: "False group / 2", value: "5" }], + }, + 1, + 1 + ); + checkTooltip( + assert, + graph, + { + title: "Revenue", + lines: [{ label: "False group / 4", value: "2" }], + }, + 2, + 1 + ); + }); + + QUnit.test( + "pie chart rendering (one groupBy, several domains with date identification)", + async function (assert) { + assert.expect(27); + serverData.models.foo.records = [ + { date: "2021-01-04" }, + { date: "2021-01-12" }, + { date: "2021-01-19" }, + { date: "2021-01-26" }, + { date: "2021-02-04" }, + { date: "2021-02-17" }, + { date: false }, + ]; + const domains = [ + { + arrayRepr: [ + ["date", ">=", "2021-02-01"], + ["date", "<=", "2021-02-28"], + ], + description: "February 2021", + }, + { + arrayRepr: [ + ["date", ">=", "2021-01-01"], + ["date", "<=", "2021-01-31"], + ], + description: "January 2021", + }, + ]; + domains.fieldName = "date"; + const graph = await makeView({ + serverData, + type: "graph", + resModel: "foo", + arch: ` + + + + `, + domains, + }); + checkLabels(assert, graph, [ + "W05 2021, W01 2021", + "W07 2021, W02 2021", + "W03 2021", + "W04 2021", + ]); + checkDatasets( + assert, + graph, + ["backgroundColor", "borderColor", "data", "label"], + [ + { + backgroundColor: ["#1f77b4", "#ff7f0e", "#aec7e8", "#ffbb78"], + borderColor: BORDER_WHITE, + data: [1, 1, 0, 0], + label: "February 2021", + }, + { + backgroundColor: ["#1f77b4", "#ff7f0e", "#aec7e8", "#ffbb78"], + borderColor: BORDER_WHITE, + data: [1, 1, 1, 1], + label: "January 2021", + }, + ] + ); + checkLegend(assert, graph, [ + "W05 2021, W01 2021", + "W07 2021, W02 2021", + "W03 2021", + "W04 2021", + ]); + checkTooltip( + assert, + graph, + { + lines: [{ label: "February 2021 / W05 2021", value: "1" }], + }, + 0, + 0 + ); + checkTooltip( + assert, + graph, + { + lines: [{ label: "January 2021 / W01 2021", value: "1" }], + }, + 0, + 1 + ); + checkTooltip( + assert, + graph, + { + lines: [{ label: "February 2021 / W07 2021", value: "1" }], + }, + 1, + 0 + ); + checkTooltip( + assert, + graph, + { + lines: [{ label: "January 2021 / W02 2021", value: "1" }], + }, + 1, + 1 + ); + checkTooltip( + assert, + graph, + { + lines: [{ label: "January 2021 / W03 2021", value: "1" }], + }, + 2, + 1 + ); + checkTooltip( + assert, + graph, + { + lines: [{ label: "January 2021 / W04 2021", value: "1" }], + }, + 3, + 1 + ); + } + ); + + QUnit.test( + "pie chart rendering (two groupBy, several domains with no date identification)", + async function (assert) { + assert.expect(15); + serverData.models.foo.records = [ + { date: "2021-01-04", bar: true, revenue: 12 }, + { date: "2021-01-12", bar: false, revenue: 5 }, + { date: "2021-02-04", bar: true, revenue: 14 }, + { date: "2021-02-17", bar: false, revenue: false }, + { date: false, bar: true, revenue: 0 }, + ]; + const domains = [ + { + arrayRepr: [ + ["date", ">=", "2021-02-01"], + ["date", "<=", "2021-02-28"], + ], + description: "February 2021", + }, + { + arrayRepr: [ + ["date", ">=", "2021-01-01"], + ["date", "<=", "2021-01-31"], + ], + description: "January 2021", + }, + ]; + domains.fieldName = "date"; + const graph = await makeView({ + serverData, + type: "graph", + resModel: "foo", + arch: ` + + + + + + `, + domains, + }); + checkLabels(assert, graph, ["true / W05 2021", "true / W01 2021", "false / W02 2021"]); + checkDatasets( + assert, + graph, + ["backgroundColor", "borderColor", "data", "label"], + [ + { + backgroundColor: ["#1f77b4", "#ff7f0e", "#aec7e8"], + borderColor: BORDER_WHITE, + data: [14, 0, 0], + label: "February 2021", + }, + { + backgroundColor: ["#1f77b4", "#ff7f0e", "#aec7e8"], + borderColor: BORDER_WHITE, + data: [0, 12, 5], + label: "January 2021", + }, + ] + ); + checkLegend(assert, graph, ["true / W05 2021", "true / W01 2021", "false / W02 2021"]); + checkTooltip( + assert, + graph, + { + title: "Revenue", + lines: [{ label: "February 2021 / true / W05 2021", value: "14" }], + }, + 0, + 0 + ); + checkTooltip( + assert, + graph, + { + title: "Revenue", + lines: [{ label: "January 2021 / true / W01 2021", value: "12" }], + }, + 1, + 1 + ); + checkTooltip( + assert, + graph, + { + title: "Revenue", + lines: [{ label: "January 2021 / false / W02 2021", value: "5" }], + }, + 2, + 1 + ); + } + ); + + QUnit.test("pie chart rendering (no data)", async function (assert) { + assert.expect(7); + serverData.models.foo.records = []; + const graph = await makeView({ + serverData, + type: "graph", + resModel: "foo", + arch: ``, + }); + checkLabels(assert, graph, ["No data"]); + checkDatasets( + assert, + graph, + ["backgroundColor", "borderColor", "data", "label"], + [ + { + backgroundColor: [DEFAULT_BG], + borderColor: BORDER_WHITE, + data: [1], + label: null, + }, + ] + ); + checkLegend(assert, graph, ["No data"]); + checkTooltip(assert, graph, { lines: [{ label: "No data", value: "0" }] }, 0); + }); + + QUnit.test("pie chart rendering (no data, several domains)", async function (assert) { + assert.expect(11); + serverData.models.foo.records = [{ product_id: 37, bar: true }]; + const graph = await makeView({ + serverData, + type: "graph", + resModel: "foo", + arch: ` + + + + `, + domains: [ + { arrayRepr: [["bar", "=", true]], description: "True group" }, + { arrayRepr: [["bar", "=", false]], description: "False group" }, + ], + }); + checkLabels(assert, graph, ["xphone", "No data"]); + checkDatasets( + assert, + graph, + ["backgroundColor", "borderColor", "data", "label"], + [ + { + backgroundColor: ["#1f77b4"], + borderColor: BORDER_WHITE, + data: [1], + label: "True group", + }, + { + backgroundColor: ["#1f77b4", DEFAULT_BG], + borderColor: BORDER_WHITE, + data: [undefined, 1], + label: "False group", + }, + ] + ); + checkLegend(assert, graph, ["xphone", "No data"]); + checkTooltip( + assert, + graph, + { lines: [{ label: "True group / xphone", value: "1" }] }, + 0, + 0 + ); + checkTooltip( + assert, + graph, + { lines: [{ label: "False group / No data", value: "0" }] }, + 1, + 1 + ); + }); + + QUnit.test( + "pie chart rendering (mix of positive and negative values)", + async function (assert) { + assert.expect(3); + serverData.models.foo.records = [ + { bar: true, revenue: 2 }, + { bar: false, revenue: -3 }, + ]; + const graph = await makeView({ + serverData, + type: "graph", + resModel: "foo", + arch: ` + + + + + `, + }); + assert.containsOnce(graph, ".o_view_nocontent"); + assert.strictEqual( + graph.el.querySelector(".o_view_nocontent").innerText.replace(/[\s\n]/g, " "), + `Invalid data Pie chart cannot mix positive and negative numbers. Try to change your domain to only display positive results` + ); + assert.containsNone(graph, ".o_graph_canvas_container"); + } + ); + + QUnit.test("mode props", async function (assert) { + assert.expect(2); + const graph = await makeView({ + serverData, + type: "graph", + resModel: "foo", + arch: ``, + }); + assert.strictEqual(getGraphModelMetaData(graph).mode, "pie", "should be in pie chart mode"); + assert.strictEqual(getChart(graph).config.type, "pie"); + }); + + QUnit.test("field id not in groupBy", async function (assert) { + assert.expect(3); + const graph = await makeView({ + serverData, + type: "graph", + resModel: "foo", + arch: ` + + + + `, + }); + checkLabels(assert, graph, ["Total"]); + checkDatasets(assert, graph, ["backgroundColor", "data", "label", "originIndex", "stack"], { + backgroundColor: "#1f77b4", + data: [8], + label: "Count", + originIndex: 0, + stack: "", + }); + checkLegend(assert, graph, "Count"); + }); + + QUnit.test("props modifications", async function (assert) { + assert.expect(16); + const graph = await makeView({ + serverData, + type: "graph", + resModel: "foo", + arch: ` + + + + `, + searchViewArch: ` + + + + `, + }); + checkModeIs(assert, graph, "bar"); + assert.strictEqual(getXAxeLabel(graph), "bar"); + assert.strictEqual(getYAxeLabel(graph), "Count"); + await selectMode(graph, "line"); + checkModeIs(assert, graph, "line"); + assert.strictEqual(getXAxeLabel(graph), "bar"); + await toggleMenu(graph, "Measures"); + await toggleMenuItem(graph, "Revenue"); + assert.strictEqual(getYAxeLabel(graph), "Revenue"); + assert.ok(true, "Message"); + await toggleGroupByMenu(graph); + await toggleMenuItem(graph, "Color"); + checkModeIs(assert, graph, "line"); + assert.strictEqual(getXAxeLabel(graph), "Color"); + assert.strictEqual(getYAxeLabel(graph), "Revenue"); + }); + + QUnit.test("switching mode", async function (assert) { + assert.expect(12); + const graph = await makeView({ serverData, type: "graph", resModel: "foo" }); + checkModeIs(assert, graph, "bar"); + await selectMode(graph, "bar"); // click on the active mode does not change anything + checkModeIs(assert, graph, "bar"); + await selectMode(graph, "line"); + checkModeIs(assert, graph, "line"); + await selectMode(graph, "pie"); + checkModeIs(assert, graph, "pie"); + }); + + QUnit.test("switching measure", async function (assert) { + assert.expect(6); + const graph = await makeView({ serverData, type: "graph", resModel: "foo" }); + function checkMeasure(measure) { + const yAxe = getChart(graph).config.options.scales.yAxes[0]; + assert.strictEqual(yAxe.scaleLabel.labelString, measure); + const item = [...graph.el.querySelectorAll(".o_menu_item")].find( + (el) => el.innerText === measure + ); + assert.hasClass(item, "selected"); + } + await toggleMenu(graph, "Measures"); + checkMeasure("Count"); + checkLegend(assert, graph, "Count"); + await toggleMenuItem(graph, "Foo"); + checkMeasure("Foo"); + checkLegend(assert, graph, "Foo"); + }); + + QUnit.test("process default view description", async function (assert) { + assert.expect(1); + const propsFromArch = new GraphArchParser().parse(); + assert.deepEqual(propsFromArch, { fields: {}, fieldAttrs: {}, groupBy: [] }); + }); + + QUnit.test("process simple arch (no field tag)", async function (assert) { + assert.expect(2); + const fields = serverData.models.foo.fields; + const arch1 = ``; + let propsFromArch = new GraphArchParser().parse(arch1, fields); + + assert.deepEqual(propsFromArch, { + disableLinking: true, + fields, + fieldAttrs: {}, + groupBy: [], + mode: "line", + order: "ASC", + }); + let arch2 = ``; + propsFromArch = new GraphArchParser().parse(arch2, fields); + + assert.deepEqual(propsFromArch, { + disableLinking: false, + fields, + fieldAttrs: {}, + groupBy: [], + stacked: false, + title: "Title", + }); + }); + + QUnit.test("process arch with field tags", async function (assert) { + assert.expect(1); + const fields = serverData.models.foo.fields; + fields.fighters = { type: "text", string: "Fighters" }; + let arch = ` + + + + + + + + + `; + let propsFromArch = new GraphArchParser().parse(arch, fields); + assert.deepEqual(propsFromArch, { + fields, + fieldAttrs: { + bar: { isInvisible: true, string: "My invisible field" }, + fighters: { string: "FooFighters" }, + }, + measure: "revenue", + groupBy: ["date:day", "foo"], + mode: "pie", + }); + }); + + QUnit.test("displaying chart data with three groupbys", async function (assert) { + // this test makes sure the line chart shows all data labels (X axis) when + // it is grouped by several fields + assert.expect(6); + const graph = await makeView({ + serverData, + type: "graph", + resModel: "foo", + arch: ` + + + + + + `, + }); + + checkLabels(assert, graph, ["xphone", "xpad"]); + checkLegend(assert, graph, ["true / Undefined", "true / red", "false / Undefined"]); + + await selectMode(graph, "line"); + + checkLabels(assert, graph, ["xphone", "xpad"]); + checkLegend(assert, graph, ["true / Undefined", "true / red", "false / Undefined"]); + + await selectMode(graph, "pie"); + + checkLabels(assert, graph, [ + "xphone / true / Undefined", + "xphone / true / red", + "xphone / false / Undefined", + "xpad / false / Undefined", + ]); + checkLegend(assert, graph, [ + "xphone / true / Undefined", + "xphone / true / red", + "xphone / false / Undefined", + "xpad / false / Undefined", + ]); + }); + + QUnit.test("no content helper", async function (assert) { + assert.expect(3); + serverData.models.foo.records = []; + const graph = await makeView({ + serverData, + type: "graph", + resModel: "foo", + noContentHelp: '

This helper should not be displayed in graph views

', + }); + assert.containsOnce(graph, "div.o_graph_canvas_container canvas"); + assert.containsNone(graph, "div.o_view_nocontent"); + assert.containsNone(graph, ".abc"); + }); + + QUnit.test("no content helper after update", async function (assert) { + assert.expect(6); + const graph = await makeView({ + serverData, + type: "graph", + resModel: "foo", + noContentHelp: '

This helper should not be displayed in graph views

', + views: [[false, "search"]], + }); + assert.containsOnce(graph, "div.o_graph_canvas_container canvas"); + assert.containsNone(graph, "div.o_view_nocontent"); + assert.containsNone(graph, ".abc"); + await toggleFilterMenu(graph); + await toggleMenuItem(graph, "False Domain"); + assert.containsOnce(graph, "div.o_graph_canvas_container canvas"); + assert.containsNone(graph, "div.o_view_nocontent"); + assert.containsNone(graph, ".abc"); + }); + + QUnit.test("no content helper after update", async function (assert) { + assert.expect(6); + const graph = await makeView({ + serverData, + type: "graph", + resModel: "foo", + noContentHelp: '

This helper should not be displayed in graph views

', + views: [[false, "search"]], + }); + assert.containsOnce(graph, "div.o_graph_canvas_container canvas"); + assert.containsNone(graph, "div.o_view_nocontent"); + assert.containsNone(graph, ".abc"); + await toggleFilterMenu(graph); + await toggleMenuItem(graph, "False Domain"); + assert.containsOnce(graph, "div.o_graph_canvas_container canvas"); + assert.containsNone(graph, "div.o_view_nocontent"); + assert.containsNone(graph, ".abc"); + }); + + QUnit.test("can reload with other group by", async function (assert) { + assert.expect(2); + const graph = await makeView({ + serverData, + type: "graph", + resModel: "foo", + arch: ` + + + + `, + searchViewArch: ` + + + + `, + }); + checkLabels(assert, graph, ["xphone", "xpad"]); + await toggleGroupByMenu(graph); + await toggleMenuItem(graph, "Color"); + checkLabels(assert, graph, ["Undefined", "red"]); + }); + + QUnit.test("save params succeeds", async function (assert) { + assert.expect(4); + const expectedContexts = [ + { + graph_mode: "bar", + graph_measure: "__count", + graph_groupbys: ["product_id"], + group_by: [], + }, + { + graph_mode: "bar", + graph_measure: "foo", + graph_groupbys: ["product_id"], + group_by: [], + }, + { + graph_mode: "line", + graph_measure: "foo", + graph_groupbys: ["product_id"], + group_by: [], + }, + { + graph_mode: "line", + graph_measure: "foo", + graph_groupbys: ["product_id", "color_id"], + group_by: ["product_id", "color_id"], + }, + ]; + + let serverId = 1; + const graph = await makeView({ + mockRPC: function (_, args) { + if (args.method === "create_or_replace") { + const favorite = args.args[0]; + assert.deepEqual(favorite.context, expectedContexts.shift()); + return serverId++; + } + }, + serverData, + resModel: "foo", + type: "graph", + arch: ` + + + + `, + searchViewId: false, + searchViewArch: ` + + + + + + + `, + }); + + await toggleFavoriteMenu(graph); + await toggleSaveFavorite(graph); + await editFavoriteName(graph, "First Favorite"); + await saveFavorite(graph); + + await toggleMenu(graph, "Measures"); + await toggleMenuItem(graph, "Foo"); + + await toggleFavoriteMenu(graph); + await toggleSaveFavorite(graph); + await editFavoriteName(graph, "Second Favorite"); + await saveFavorite(graph); + + await selectMode(graph, "line"); + + await toggleFavoriteMenu(graph); + await toggleSaveFavorite(graph); + await editFavoriteName(graph, "Third Favorite"); + await saveFavorite(graph); + + await toggleGroupByMenu(graph); + await toggleMenuItem(graph, "Product"); + await toggleMenuItem(graph, "Color"); + + await toggleFavoriteMenu(graph); + await toggleSaveFavorite(graph); + await editFavoriteName(graph, "Fourth Favorite"); + await saveFavorite(graph); + }); + + QUnit.test("correctly uses graph_ keys from the context", async function (assert) { + assert.expect(8); + const recs = serverData.models.foo.records; + const lastOne = recs[recs.length - 1]; + lastOne.color_id = 14; + const graph = await makeView({ + serverData, + type: "graph", + resModel: "foo", + arch: '', + context: { + graph_measure: "foo", + graph_mode: "line", + graph_groupbys: ["color_id"], + }, + }); + checkLabels(assert, graph, ["red", "black"]); + checkLegend(assert, graph, "Foo"); + checkModeIs(assert, graph, "line"); + assert.strictEqual(getXAxeLabel(graph), "Color"); + assert.strictEqual(getYAxeLabel(graph), "Foo"); + const { mode } = getGraphModelMetaData(graph); + assert.strictEqual(mode, "line"); + }); + + QUnit.test("correctly use group_by key from the context", async function (assert) { + assert.expect(8); + const recs = serverData.models.foo.records; + const lastOne = recs[recs.length - 1]; + lastOne.color_id = 14; + const graph = await makeView({ + serverData, + type: "graph", + resModel: "foo", + arch: ` + + + + `, + searchViewArch: ` + + + + `, + context: { + search_default_filter_with_context: 1, + }, + }); + checkLabels(assert, graph, ["red", "black"]); + checkLegend(assert, graph, "Foo"); + checkModeIs(assert, graph, "line"); + assert.strictEqual(getXAxeLabel(graph), "Color"); + assert.strictEqual(getYAxeLabel(graph), "Foo"); + const mode = getGraphModelMetaData(graph).mode; + assert.strictEqual(mode, "line"); + }); + + QUnit.test("an invisible field should not be used as groupBy", async function (assert) { + assert.expect(1); + const graph = await makeView({ + serverData, + type: "graph", + resModel: "foo", + arch: ` + + + + `, + }); + checkLabels(assert, graph, ["Total"]); + }); + + QUnit.test( + "format values as float in case at least one value is not an integer", + async function (assert) { + assert.expect(10); + serverData.models.foo.records = [ + { id: 1, bar: true, revenue: 1.5 }, + { id: 2, bar: false, revenue: 2 }, + ]; + const graph = await makeView({ + serverData, + type: "graph", + resModel: "foo", + arch: ` + + + + + `, + }); + checkDatasets(assert, graph, "data", { data: [1.5, 2] }); + checkLabels(assert, graph, ["true", "false"]); + checkTooltip( + assert, + graph, + { title: "Revenue", lines: [{ label: "true", value: "1.50" }] }, + 0 + ); + checkTooltip( + assert, + graph, + { title: "Revenue", lines: [{ label: "false", value: "2.00" }] }, + 1 + ); + } + ); + + QUnit.test( + "the active measure description is the arch string attribute in priority", + async function (assert) { + assert.expect(8); + const graph = await makeView({ + serverData, + type: "graph", + resModel: "foo", + arch: ` + + + + + `, + }); + checkTooltip( + assert, + graph, + { title: "FooFighters", lines: [{ label: "Total", value: "239" }] }, + 0 + ); + await toggleMenu(graph, "Measures"); + await toggleMenuItem(graph, "Nirvana"); + checkTooltip( + assert, + graph, + { title: "Nirvana", lines: [{ label: "Total", value: "23" }] }, + 0 + ); + } + ); + + QUnit.test("correctly uses graph_ keys from the context (at reload)", async function (assert) { + assert.expect(10); + + const graph = await makeView({ + serverData, + type: "graph", + resModel: "foo", + arch: '', + searchViewArch: ` + + + + `, + }); + checkLegend(assert, graph, "Count"); + assert.strictEqual(getYAxeLabel(graph), "Count"); + checkModeIs(assert, graph, "bar"); + await toggleFilterMenu(graph); + await toggleMenuItem(graph, "Context"); + checkLegend(assert, graph, "Foo"); + assert.strictEqual(getYAxeLabel(graph), "Foo"); + checkModeIs(assert, graph, "line"); + }); + + QUnit.test("reload graph with correct fields", async function (assert) { + assert.expect(2); + const graph = await makeView({ + serverData, + mockRPC: function (_, args) { + if (args.method === "web_read_group") { + assert.deepEqual(args.kwargs.fields, ["__count", "foo:sum"]); + } + }, + type: "graph", + resModel: "foo", + arch: ` + + + + `, + searchViewArch: ` + + + + `, + }); + await toggleFilterMenu(graph); + await toggleMenuItem(graph, "False Domain"); + }); + + QUnit.test("initial groupby is kept when reloading", async function (assert) { + assert.expect(12); + const graph = await makeView({ + serverData, + mockRPC: function (_, args) { + if (args.method === "web_read_group") { + assert.deepEqual(args.kwargs.groupby, ["product_id"]); + } + }, + type: "graph", + resModel: "foo", + arch: ` + + + + + `, + searchViewArch: ` + + + + `, + }); + checkLabels(assert, graph, ["xphone", "xpad"]); + checkLegend(assert, graph, "Foo"); + checkDatasets(assert, graph, "data", { data: [82, 157] }); + assert.strictEqual(getXAxeLabel(graph), "Product"); + assert.strictEqual(getYAxeLabel(graph), "Foo"); + await toggleFilterMenu(graph); + await toggleMenuItem(graph, "False Domain"); + checkLabels(assert, graph, []); + checkLegend(assert, graph, []); + checkDatasets(assert, graph, "data", []); + assert.strictEqual(getXAxeLabel(graph), "Product"); + assert.strictEqual(getYAxeLabel(graph), "Foo"); + }); + + QUnit.test( + "use a many2one as a measure should work (without groupBy)", + async function (assert) { + assert.expect(5); + const graph = await makeView({ + serverData, + type: "graph", + resModel: "foo", + arch: ` + + + + `, + }); + checkLabels(assert, graph, ["Total"]); + checkLegend(assert, graph, "Product"); + checkDatasets(assert, graph, "data", { data: [2] }); + assert.strictEqual(getXAxeLabel(graph), ""); + assert.strictEqual(getYAxeLabel(graph), "Product"); + } + ); + + QUnit.test("use a many2one as a measure should work (with groupBy)", async function (assert) { + assert.expect(3); + const graph = await makeView({ + serverData, + type: "graph", + resModel: "foo", + arch: ` + + + + + `, + }); + checkLabels(assert, graph, ["true", "false"]); + checkLegend(assert, graph, "Product"); + checkDatasets(assert, graph, "data", { data: [1, 2] }); + }); + + QUnit.test("use a many2one as a measure and as a groupby should work", async function (assert) { + assert.expect(5); + const graph = await makeView({ + serverData, + type: "graph", + resModel: "foo", + arch: ` + + + + + `, + }); + checkLabels(assert, graph, ["xphone", "xpad"]); + checkLegend(assert, graph, "Product"); + checkDatasets(assert, graph, "data", { data: [1, 1] }); + assert.strictEqual(getXAxeLabel(graph), "Product"); + assert.strictEqual(getYAxeLabel(graph), "Product"); + }); + + QUnit.test("differentiate many2one values with same label", async function (assert) { + assert.expect(1); + serverData.models.product.records.push({ id: 39, display_name: "xphone" }); + serverData.models.foo.records.push({ id: 18, product_id: 39 }); + const graph = await makeView({ + serverData, + type: "graph", + resModel: "foo", + arch: ` + + + + `, + }); + checkLabels(assert, graph, ["xphone", "xpad", "xphone (2)"]); + }); + + QUnit.test("not use a many2one as a measure by default", async function (assert) { + assert.expect(1); + const graph = await makeView({ + serverData, + type: "graph", + resModel: "foo", + arch: "", + }); + await toggleMenu(graph, "Measures"); + assert.deepEqual( + [...graph.el.querySelectorAll(".o_cp_bottom_left li.o_menu_item")].map( + (el) => el.innerText + ), + ["Foo", "Revenue", "Count"] + ); + }); + + QUnit.test( + "graph view crash when moving from search view using Down key", + async function (assert) { + assert.expect(1); + const graph = await makeView({ + serverData, + type: "graph", + resModel: "foo", + arch: ``, + }); + await triggerEvent(graph.el, ".o_searchview input", "keydown", { key: "ArrowDown" }); + assert.ok(true, "should not generate any error"); + } + ); + + QUnit.test( + "graph measures should be alphabetically sorted (exception: 'Count' is last)", + async function (assert) { + assert.expect(1); + serverData.models.foo.fields.bouh = { + string: "Bouh", + type: "integer", + store: true, + group_operator: "sum", + }; + const graph = await makeView({ + serverData, + type: "graph", + resModel: "foo", + arch: ` + + + + + `, + }); + await toggleMenu(graph, "Measures"); + assert.deepEqual( + [...graph.el.querySelectorAll(".o_cp_bottom_left li.o_menu_item")].map( + (el) => el.innerText + ), + ["Bouh", "Foo", "Revenue", "Count"] + ); + } + ); + + QUnit.test("a many2one field can be added as measure in arch", async function (assert) { + assert.expect(2); + + const graph = await makeView({ + serverData, + type: "graph", + resModel: "foo", + arch: ` + + + + `, + }); + checkLegend(assert, graph, "Product"); + assert.strictEqual(getYAxeLabel(graph), "Product"); + }); + + QUnit.test( + "a many2one field can be added as measure in additionalMeasures", + async function (assert) { + assert.expect(2); + + const graph = await makeView({ + serverData, + type: "graph", + resModel: "foo", + arch: ``, + additionalMeasures: ["product_id"], + }); + await toggleMenu(graph, "Measures"); + await toggleMenuItem(graph, "Product"); + checkLegend(assert, graph, "Product"); + assert.strictEqual(getYAxeLabel(graph), "Product"); + } + ); + + QUnit.test('graph view "graph_measure" field in context', async function (assert) { + assert.expect(6); + const graph = await makeView({ + serverData, + type: "graph", + resModel: "foo", + arch: "", + context: { + graph_measure: "product_id", + }, + }); + checkLegend(assert, graph, "Product"); + assert.strictEqual(getYAxeLabel(graph), "Product"); + checkTooltip( + assert, + graph, + { title: "Product", lines: [{ label: "Total", value: "2" }] }, + 0 + ); + }); + + QUnit.test( + '"graph_measure" in context is prefered to measure in arch', + async function (assert) { + assert.expect(6); + const graph = await makeView({ + serverData, + type: "graph", + resModel: "foo", + arch: '', + context: { + graph_measure: "product_id", + }, + }); + checkLegend(assert, graph, "Product"); + assert.strictEqual(getYAxeLabel(graph), "Product"); + checkTooltip( + assert, + graph, + { title: "Product", lines: [{ label: "Total", value: "2" }] }, + 0 + ); + } + ); + + QUnit.test( + "Undefined should appear in bar, pie graph but not in line graph with multiple groupbys", + async function (assert) { + assert.expect(4); + const graph = await makeView({ + serverData, + type: "graph", + resModel: "foo", + arch: ` + + + + + `, + }); + function someUndefined() { + return getChart(graph).data.labels.some((l) => /Undefined/.test(l)); + } + assert.notOk(someUndefined()); + await selectMode(graph, "bar"); + assert.ok(someUndefined()); + await selectMode(graph, "pie"); + assert.ok(someUndefined()); + // Undefined should not appear after switching back to line chart + await selectMode(graph, "line"); + assert.notOk(someUndefined()); + } + ); + + QUnit.test( + "an invisible field in additional measure can be found in the 'Measures' menu", + async function (assert) { + assert.expect(8); + const graph = await makeView({ + serverData, + type: "graph", + resModel: "foo", + arch: ` + + + + `, + additionalMeasures: ["revenue"], + }); + checkTooltip(assert, graph, { lines: [{ label: "Total", value: "8" }] }, 0); + await toggleMenu(graph, "Measures"); + await toggleMenuItem(graph, "Revenue"); + checkTooltip( + assert, + graph, + { title: "Revenue", lines: [{ label: "Total", value: "23" }] }, + 0 + ); + } + ); + + QUnit.test( + "an invisible field not in additional measure can not be found in the 'Measures' menu", + async function (assert) { + assert.expect(5); + const graph = await makeView({ + serverData, + type: "graph", + resModel: "foo", + arch: ` + + + + `, + }); + checkTooltip(assert, graph, { lines: [{ label: "Total", value: "8" }] }, 0); + await toggleMenu(graph, "Measures"); + assert.notOk( + [...graph.el.querySelectorAll(".o_menu_item")].find( + (el) => el.innerText.trim() === "Revenue" + ), + `"Revenue" can not be found in the "Measures" menu` + ); + } + ); + + QUnit.test( + "graph view only keeps finer groupby filter option for a given groupby", + async function (assert) { + assert.expect(3); + const graph = await makeView({ + serverData, + type: "graph", + resModel: "foo", + groupBy: ["date:year", "product_id", "date", "date:quarter"], + arch: ``, + views: [[false, "search"]], + }); + checkLabels(assert, graph, ["January 2016", "March 2016", "May 2016", "April 2016"]); + // mockReadGroup does not always sort groups -> May 2016 is before April 2016 for that reason. + checkLegend(assert, graph, ["xphone", "xpad"]); + checkDatasets( + assert, + graph, + ["label", "data"], + [ + { + label: "xphone", + data: [2, 2, 0, 0], + }, + { + label: "xpad", + data: [0, 0, 1, 1], + }, + ] + ); + } + ); + + QUnit.test("action name is displayed in breadcrumbs", async function (assert) { + assert.expect(1); + const graph = await makeView({ + serverData, + type: "graph", + resModel: "foo", + displayName: "Glou glou", + }); + assert.strictEqual( + graph.el.querySelector(".o_control_panel .breadcrumb-item.active").innerText, + "Glou glou" + ); + }); + + QUnit.test("clicking on bar charts triggers a do_action", async function (assert) { + assert.expect(6); + + serviceRegistry.add( + "action", + { + start() { + return { + doAction(actionRequest, options) { + assert.deepEqual(actionRequest, { + context: { + lang: "en", + tz: "taht", + uid: 7, + }, + domain: [["bar", "=", true]], + name: "Foo Analysis", + res_model: "foo", + target: "current", + type: "ir.actions.act_window", + views: [ + [false, "list"], + [false, "form"], + ], + }); + assert.deepEqual(options, { viewType: "list" }); + }, + }; + }, + }, + { force: true } + ); + const graph = await makeView({ + serverData, + type: "graph", + resModel: "foo", + arch: ` + + + + `, + }); + checkModeIs(assert, graph, "bar"); + checkDatasets(assert, graph, ["domains"], { + domains: [[["bar", "=", true]], [["bar", "=", false]]], + }); + await clickOnDataset(graph); + }); + + QUnit.test( + "clicking on a pie chart trigger a do_action with correct views", + async function (assert) { + assert.expect(6); + serverData.views["foo,364,list"] = ``; + serverData.views["foo,29,form"] = `
`; + + serviceRegistry.add( + "action", + { + start() { + return { + doAction(actionRequest, options) { + assert.deepEqual(actionRequest, { + context: { + lang: "en", + tz: "taht", + uid: 7, + }, + domain: [["bar", "=", true]], + name: "Foo Analysis", + res_model: "foo", + target: "current", + type: "ir.actions.act_window", + views: [ + [364, "list"], + [29, "form"], + ], + }); + assert.deepEqual(options, { viewType: "list" }); + }, + }; + }, + }, + { force: true } + ); + + const graph = await makeView({ + serverData, + type: "graph", + resModel: "foo", + arch: ` + + + + `, + views: [ + [364, "list"], + [29, "form"], + ], + }); + checkModeIs(assert, graph, "pie"); + checkDatasets(assert, graph, ["domains"], { + domains: [[["bar", "=", true]], [["bar", "=", false]]], + }); + await clickOnDataset(graph); + } + ); + + QUnit.test('graph view with attribute disable_linking="1"', async function (assert) { + assert.expect(4); + + serviceRegistry.add( + "action", + { + start() { + return { + doAction() { + throw new Error("Should not perform a do_action"); + }, + }; + }, + }, + { force: true } + ); + + const graph = await makeView({ + serverData, + type: "graph", + resModel: "foo", + arch: ` + + + + `, + }); + checkModeIs(assert, graph, "bar"); + checkDatasets(assert, graph, ["domains"], { + domains: [[["bar", "=", true]], [["bar", "=", false]]], + }); + await clickOnDataset(graph); + }); + + QUnit.test("graph view without invisible attribute on field", async function (assert) { + assert.expect(4); + const graph = await makeView({ + serverData, + type: "graph", + resModel: "foo", + arch: ``, + }); + await toggleMenu(graph, "Measures"); + assert.containsN( + graph, + "li.o_menu_item", + 3, + "there should be three menu item in the measures dropdown (count, revenue and foo)" + ); + assert.containsOnce(graph, 'li.o_menu_item:contains("Revenue")'); + assert.containsOnce(graph, 'li.o_menu_item:contains("Foo")'); + assert.containsOnce(graph, 'li.o_menu_item:contains("Count")'); + }); + + QUnit.test("graph view with invisible attribute on field", async function (assert) { + assert.expect(2); + const graph = await makeView({ + serverData, + type: "graph", + resModel: "foo", + arch: ` + + + + `, + }); + await toggleMenu(graph, "Measures"); + assert.containsN( + graph, + "li.o_menu_item", + 2, + "there should be only two menu item in the measures dropdown (count and foo)" + ); + assert.containsNone(graph, 'li.o_menu_item:contains("Revenue")'); + }); + + QUnit.test("graph view sort by measure", async function (assert) { + assert.expect(20); + + // change first record from foo as there are 4 records count for each product + serverData.models.product.records.push({ id: 38, display_name: "zphone" }); + serverData.models.foo.records[7].product_id = 38; + + const graph = await makeView({ + serverData, + type: "graph", + resModel: "foo", + arch: ` + + + + `, + }); + + assert.containsOnce(graph, "button.fa-sort-amount-asc"); + assert.containsOnce(graph, "button.fa-sort-amount-desc"); + + checkLegend(assert, graph, "Count", "measure should be by count"); + assert.hasClass( + graph.el.querySelector("button.fa-sort-amount-desc"), + "active", + 'sorting should be applie on descending order by default when sorting="desc"' + ); + checkDatasets(assert, graph, "data", { data: [4, 3, 1] }); + + await click(graph.el, "button.fa-sort-amount-asc"); + assert.hasClass( + graph.el.querySelector("button.fa-sort-amount-asc"), + "active", + "ascending order should be applied" + ); + checkDatasets(assert, graph, "data", { data: [1, 3, 4] }); + + await click(graph.el, "button.fa-sort-amount-desc"); + assert.hasClass( + graph.el.querySelector("button.fa-sort-amount-desc"), + "active", + "descending order button should be active" + ); + checkDatasets(assert, graph, "data", { data: [4, 3, 1] }); + + // again click on descending button to deactivate order button + await click(graph.el, "button.fa-sort-amount-desc"); + assert.doesNotHaveClass( + graph.el.querySelector("button.fa-sort-amount-desc"), + "active", + "descending order button should not be active" + ); + checkDatasets(assert, graph, "data", { data: [4, 3, 1] }); + + // set line mode + await selectMode(graph, "line"); + assert.containsOnce(graph, "button.fa-sort-amount-asc"); + assert.containsOnce(graph, "button.fa-sort-amount-desc"); + + checkLegend(assert, graph, "Count", "measure should be by count"); + assert.doesNotHaveClass( + graph.el.querySelector("button.fa-sort-amount-desc"), + "active", + "descending order should be applied" + ); + checkDatasets(assert, graph, "data", { data: [4, 3, 1] }); + + await click(graph.el, "button.fa-sort-amount-asc"); + assert.hasClass( + graph.el.querySelector("button.fa-sort-amount-asc"), + "active", + "ascending order button should be active" + ); + checkDatasets(assert, graph, "data", { data: [1, 3, 4] }); + + await click(graph.el, "button.fa-sort-amount-desc"); + assert.hasClass( + graph.el.querySelector("button.fa-sort-amount-desc"), + "active", + "descending order button should be active" + ); + checkDatasets(assert, graph, "data", { data: [4, 3, 1] }); + }); + + QUnit.test("graph view sort by measure for grouped data", async function (assert) { + assert.expect(8); + + // change first record from foo as there are 4 records count for each product + serverData.models.product.records.push({ id: 38, display_name: "zphone" }); + serverData.models.foo.records[7].product_id = 38; + + const graph = await makeView({ + serverData, + type: "graph", + resModel: "foo", + arch: ` + + + + + `, + }); + + checkLegend(assert, graph, ["true", "false"], "measure should be by count"); + checkDatasets(assert, graph, "data", [{ data: [3, 0, 0] }, { data: [1, 3, 1] }]); + + await click(graph.el, "button.fa-sort-amount-asc"); + assert.hasClass( + graph.el.querySelector("button.fa-sort-amount-asc"), + "active", + "ascending order should be applied by default" + ); + checkDatasets(assert, graph, "data", [{ data: [1, 3, 1] }, { data: [0, 0, 3] }]); + + await click(graph.el, "button.fa-sort-amount-desc"); + assert.hasClass( + graph.el.querySelector("button.fa-sort-amount-desc"), + "active", + "ascending order button should be active" + ); + checkDatasets(assert, graph, "data", [{ data: [3, 0, 0] }, { data: [1, 3, 1] }]); + + // again click on descending button to deactivate order button + await click(graph.el, "button.fa-sort-amount-desc"); + assert.doesNotHaveClass( + graph.el.querySelector("button.fa-sort-amount-desc"), + "active", + "descending order button should not be active" + ); + checkDatasets(assert, graph, "data", [{ data: [3, 0, 0] }, { data: [1, 3, 1] }]); + }); + + QUnit.test("graph view sort by measure for multiple grouped data", async function (assert) { + assert.expect(8); + + // change first record from foo as there are 4 records count for each product + serverData.models.product.records.push({ id: 38, display_name: "zphone" }); + serverData.models.foo.records[7].product_id = 38; + serverData.models.foo.records.splice( + 0, + 4, + { id: 9, foo: 48, bar: false, product_id: 41, date: "2016-04-01" }, + { id: 10, foo: 49, bar: false, product_id: 41, date: "2016-04-01" }, + { id: 11, foo: 50, bar: true, product_id: 37, date: "2016-01-03" }, + { id: 12, foo: 50, bar: true, product_id: 41, date: "2016-01-03" } + ); + + const graph = await makeView({ + serverData, + type: "graph", + resModel: "foo", + arch: ` + + + + + `, + }); + + checkLegend(assert, graph, ["xpad", "xphone", "zphone"], "measure should be by count"); + checkDatasets(assert, graph, "data", [ + { data: [2, 1, 1, 2] }, + { data: [0, 1, 0, 0] }, + { data: [1, 0, 0, 0] }, + ]); + + await click(graph.el, "button.fa-sort-amount-asc"); + assert.hasClass( + graph.el.querySelector("button.fa-sort-amount-asc"), + "active", + "ascending order should be applied by default" + ); + checkDatasets(assert, graph, "data", [ + { data: [1, 1, 2, 2] }, + { data: [0, 1, 0, 0] }, + { data: [0, 0, 0, 1] }, + ]); + + await click(graph.el, "button.fa-sort-amount-desc"); + assert.hasClass( + graph.el.querySelector("button.fa-sort-amount-desc"), + "active", + "descending order button should be active" + ); + checkDatasets(assert, graph, "data", [ + { data: [2, 1, 2, 1] }, + { data: [1, 0, 0, 0] }, + { data: [0, 1, 0, 0] }, + ]); + + // again click on descending button to deactivate order button + await click(graph.el, "button.fa-sort-amount-desc"); + assert.doesNotHaveClass( + graph.el.querySelector("button.fa-sort-amount-desc"), + "active", + "descending order button should not be active" + ); + checkDatasets(assert, graph, "data", [ + { data: [2, 1, 1, 2] }, + { data: [0, 1, 0, 0] }, + { data: [1, 0, 0, 0] }, + ]); + }); + + QUnit.test("empty graph view with sample data", async function (assert) { + assert.expect(8); + const graph = await makeView({ + serverData, + type: "graph", + resModel: "foo", + arch: ` + + + + + `, + context: { search_default_false_domain: 1 }, + searchViewArch: ` + + + + `, + noContentHelp: '

click to add a foo

', + }); + + assert.hasClass(graph.el, "o_view_sample_data"); + assert.containsOnce(graph, ".o_view_nocontent"); + assert.containsOnce(graph, ".o_graph_canvas_container canvas"); + assert.hasClass(graph.el.querySelector(".o_graph_renderer"), "o_sample_data_disabled"); + + await toggleFilterMenu(graph); + await toggleMenuItem(graph, "False Domain"); + assert.doesNotHaveClass(graph.el, "o_view_sample_data"); + assert.containsNone(graph, ".o_view_nocontent"); + assert.containsOnce(graph, ".o_graph_canvas_container canvas"); + assert.doesNotHaveClass( + graph.el.querySelector(".o_graph_renderer"), + "o_sample_data_disabled" + ); + }); + + QUnit.test("non empty graph view with sample data", async function (assert) { + assert.expect(8); + const graph = await makeView({ + serverData, + type: "graph", + resModel: "foo", + arch: ` + + + + + `, + searchViewArch: ` + + + + `, + noContentHelp: '

click to add a foo

', + }); + assert.doesNotHaveClass(graph.el, "o_view_sample_data"); + assert.containsNone(graph, ".o_view_nocontent"); + assert.containsOnce(graph, ".o_graph_canvas_container canvas"); + assert.doesNotHaveClass( + graph.el.querySelector(".o_graph_canvas_container"), + "o_sample_data_disabled" + ); + await toggleFilterMenu(graph); + await toggleMenuItem(graph, "False Domain"); + assert.doesNotHaveClass(graph.el, "o_view_sample_data"); + assert.containsOnce(graph, ".o_graph_canvas_container canvas"); + assert.doesNotHaveClass( + graph.el.querySelector(".o_graph_canvas_container"), + "o_sample_data_disabled" + ); + assert.containsNone(graph, ".o_view_nocontent"); + }); + + QUnit.test("reload chart with switchView button keep internal state", async function (assert) { + assert.expect(3); + serverData.views["foo,false,list"] = ``; + const webClient = await createWebClient({ serverData }); + await doAction(webClient, { + name: "Foo Action 1", + res_model: "foo", + type: "ir.actions.act_window", + views: [ + [false, "graph"], + [false, "list"], + ], + }); + assert.hasClass(getModeButton(webClient, "bar"), "active"); + await selectMode(webClient, "line"); + assert.hasClass(getModeButton(webClient, "line"), "active"); + await switchView(webClient, "graph"); + assert.hasClass(getModeButton(webClient, "line"), "active"); + }); + + QUnit.test( + "fallback on initial groupby when the groupby from control panel has 0 length", + async function (assert) { + assert.expect(2); + const graph = await makeView({ + serverData, + type: "graph", + resModel: "foo", + arch: ` + + + + `, + searchViewArch: ` + + + + `, + context: { + search_default_group_by_foo: 1, + }, + }); + checkLabels(assert, graph, ["3", "53", "2", "24", "4", "63", "42", "48"]); + await toggleGroupByMenu(graph); + await toggleMenuItem(graph, "Foo"); + checkLabels(assert, graph, ["xphone", "xpad"]); + } + ); + + QUnit.test( + "change mode, stacked, or order via the graph buttons does not reload datapoints, change measure does", + async function (assert) { + assert.expect(13); + const graph = await makeView({ + serverData, + type: "graph", + resModel: "foo", + arch: ` + + + + `, + mockRPC: function (_, args) { + if (args.method === "web_read_group") { + assert.step(JSON.stringify(args.kwargs.fields)); + } + }, + }); + + checkModeIs(assert, graph, "line"); + + await selectMode(graph, "bar"); + + checkModeIs(assert, graph, "bar"); + assert.hasClass(graph.el.querySelector(`[data-tooltip="Stacked"]`), "active"); + + await click(graph.el.querySelector(`[data-tooltip="Stacked"]`)); + + assert.doesNotHaveClass(graph.el.querySelector(`[data-tooltip="Stacked"]`), "active"); + assert.doesNotHaveClass(graph.el.querySelector(`[data-tooltip="Ascending"]`), "active"); + + await click(graph.el.querySelector(`[data-tooltip="Ascending"]`)); + + assert.hasClass(graph.el.querySelector(`[data-tooltip="Ascending"]`), "active"); + + await toggleMenu(graph, "Measures"); + await toggleMenuItem(graph, "Foo"); + + assert.verifySteps([ + `["__count"]`, // first load + `["__count","foo:sum"]`, // reload due to change in measure + ]); + } + ); +});