From cb5cca73a825b1420f44701f883ad026f1b13383 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucas=20Lef=C3=A8vre?= Date: Tue, 6 Sep 2022 15:05:09 +0000 Subject: [PATCH] [ADD] spreadsheet_dashboard: introduce spreadsheet based dashboards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Our reporting dashboards are too tedious to use for the average user, and doesn't allow for edition or advanced computations. Using spreadsheet formulas allows almost endless possibilities. Task-id 2799682 Part-of: odoo/odoo#97606 Co-authored-by: Lucas Lefèvre Co-authored-by: Pierre Rousseau --- .../spreadsheet/static/src/helpers/helpers.js | 11 + addons/spreadsheet/static/tests/utils/data.js | 1 + addons/spreadsheet_dashboard/__init__.py | 4 + addons/spreadsheet_dashboard/__manifest__.py | 46 ++++ .../spreadsheet_dashboard/data/dashboard.xml | 14 + .../spreadsheet_dashboard/models/__init__.py | 4 + .../models/spreadsheet_dashboard.py | 41 +++ .../models/spreadsheet_dashboard_group.py | 11 + .../security/ir.model.access.csv | 5 + .../security/security.xml | 9 + .../static/description/icon.png | Bin 0 -> 6085 bytes .../static/description/icon.svg | 23 ++ .../src/assets/dashboard_action_loader.js | 18 ++ .../dashboard_action/dashboard_action.js | 154 +++++++++++ .../dashboard_action/dashboard_action.scss | 40 +++ .../dashboard_action/dashboard_action.xml | 63 +++++ .../dashboard_action/dashboard_loader.js | 208 +++++++++++++++ .../mobile_figure_container.js | 39 +++ .../mobile_figure_container.xml | 14 + .../mobile_search_panel.js | 42 +++ .../mobile_search_panel.xml | 43 ++++ .../src/bundle/links/dashboard_link_plugin.js | 32 +++ .../static/src/bundle/links/index.js | 8 + .../static/src/bundle/list/index.js | 15 ++ .../static/src/bundle/pivot/index.js | 15 ++ .../tests/dashboard/dashboard_action_test.js | 162 ++++++++++++ .../tests/dashboard/dashboard_loader_test.js | 242 ++++++++++++++++++ .../links/dashboard_links_plugin_tests.js | 113 ++++++++ .../mobile/mobile_dashboard_action_test.js | 115 +++++++++ .../static/tests/utils/dashboard_action.js | 25 ++ .../static/tests/utils/data.js | 57 +++++ .../static/tests/utils/tour.js | 32 +++ .../spreadsheet_dashboard/tests/__init__.py | 1 + .../tests/test_spreadsheet_dashboard.py | 19 ++ .../views/menu_views.xml | 43 ++++ .../views/spreadsheet_dashboard_views.xml | 48 ++++ 36 files changed, 1717 insertions(+) create mode 100644 addons/spreadsheet_dashboard/__init__.py create mode 100644 addons/spreadsheet_dashboard/__manifest__.py create mode 100644 addons/spreadsheet_dashboard/data/dashboard.xml create mode 100644 addons/spreadsheet_dashboard/models/__init__.py create mode 100644 addons/spreadsheet_dashboard/models/spreadsheet_dashboard.py create mode 100644 addons/spreadsheet_dashboard/models/spreadsheet_dashboard_group.py create mode 100644 addons/spreadsheet_dashboard/security/ir.model.access.csv create mode 100644 addons/spreadsheet_dashboard/security/security.xml create mode 100644 addons/spreadsheet_dashboard/static/description/icon.png create mode 100644 addons/spreadsheet_dashboard/static/description/icon.svg create mode 100644 addons/spreadsheet_dashboard/static/src/assets/dashboard_action_loader.js create mode 100644 addons/spreadsheet_dashboard/static/src/bundle/dashboard_action/dashboard_action.js create mode 100644 addons/spreadsheet_dashboard/static/src/bundle/dashboard_action/dashboard_action.scss create mode 100644 addons/spreadsheet_dashboard/static/src/bundle/dashboard_action/dashboard_action.xml create mode 100644 addons/spreadsheet_dashboard/static/src/bundle/dashboard_action/dashboard_loader.js create mode 100644 addons/spreadsheet_dashboard/static/src/bundle/dashboard_action/mobile_figure_container/mobile_figure_container.js create mode 100644 addons/spreadsheet_dashboard/static/src/bundle/dashboard_action/mobile_figure_container/mobile_figure_container.xml create mode 100644 addons/spreadsheet_dashboard/static/src/bundle/dashboard_action/mobile_search_panel/mobile_search_panel.js create mode 100644 addons/spreadsheet_dashboard/static/src/bundle/dashboard_action/mobile_search_panel/mobile_search_panel.xml create mode 100644 addons/spreadsheet_dashboard/static/src/bundle/links/dashboard_link_plugin.js create mode 100644 addons/spreadsheet_dashboard/static/src/bundle/links/index.js create mode 100644 addons/spreadsheet_dashboard/static/src/bundle/list/index.js create mode 100644 addons/spreadsheet_dashboard/static/src/bundle/pivot/index.js create mode 100644 addons/spreadsheet_dashboard/static/tests/dashboard/dashboard_action_test.js create mode 100644 addons/spreadsheet_dashboard/static/tests/dashboard/dashboard_loader_test.js create mode 100644 addons/spreadsheet_dashboard/static/tests/links/dashboard_links_plugin_tests.js create mode 100644 addons/spreadsheet_dashboard/static/tests/mobile/mobile_dashboard_action_test.js create mode 100644 addons/spreadsheet_dashboard/static/tests/utils/dashboard_action.js create mode 100644 addons/spreadsheet_dashboard/static/tests/utils/data.js create mode 100644 addons/spreadsheet_dashboard/static/tests/utils/tour.js create mode 100644 addons/spreadsheet_dashboard/tests/__init__.py create mode 100644 addons/spreadsheet_dashboard/tests/test_spreadsheet_dashboard.py create mode 100644 addons/spreadsheet_dashboard/views/menu_views.xml create mode 100644 addons/spreadsheet_dashboard/views/spreadsheet_dashboard_views.xml diff --git a/addons/spreadsheet/static/src/helpers/helpers.js b/addons/spreadsheet/static/src/helpers/helpers.js index 3f90a3281c0..93139de8db2 100644 --- a/addons/spreadsheet/static/src/helpers/helpers.js +++ b/addons/spreadsheet/static/src/helpers/helpers.js @@ -4,6 +4,7 @@ import spreadsheet from "../o_spreadsheet/o_spreadsheet_extended"; import { DataSources } from "../data_sources/data_sources"; import { serializeDate } from "@web/core/l10n/dates"; import { migrate } from "@spreadsheet/o_spreadsheet/migration"; +import { loadJS } from "@web/core/assets"; const { DateTime } = luxon; @@ -176,3 +177,13 @@ export function isEmpty(item) { } return false; } + +/** + * Load external libraries required for o-spreadsheet + * @returns {Promise} + */ +export async function loadSpreadsheetDependencies() { + await loadJS("/web/static/lib/Chart/Chart.js"); + // chartjs-gauge should only be loaded when Chart.js is fully loaded ! + await loadJS("/spreadsheet/static/lib/chartjs-gauge/chartjs-gauge.js"); +} diff --git a/addons/spreadsheet/static/tests/utils/data.js b/addons/spreadsheet/static/tests/utils/data.js index 1b1c7466b42..206a690c2ad 100644 --- a/addons/spreadsheet/static/tests/utils/data.js +++ b/addons/spreadsheet/static/tests/utils/data.js @@ -111,6 +111,7 @@ export function getBasicListArchs() { return { "partner,false,list": getBasicListArch(), "partner,false,search": /* xml */ ``, + "partner,false,form": /* xml */ `
`, }; } diff --git a/addons/spreadsheet_dashboard/__init__.py b/addons/spreadsheet_dashboard/__init__.py new file mode 100644 index 00000000000..1c729241399 --- /dev/null +++ b/addons/spreadsheet_dashboard/__init__.py @@ -0,0 +1,4 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import models +from . import tests diff --git a/addons/spreadsheet_dashboard/__manifest__.py b/addons/spreadsheet_dashboard/__manifest__.py new file mode 100644 index 00000000000..4863720fd51 --- /dev/null +++ b/addons/spreadsheet_dashboard/__manifest__.py @@ -0,0 +1,46 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. +{ + "name": "Spreadsheet dashboard", + "version": "1.0", + "category": "Hidden", + "summary": "Spreadsheet", + "description": "Spreadsheet", + "depends": ["spreadsheet"], + "demo": [], + "installable": True, + "auto_install": False, + "license": "LGPL-3", + "data": [ + "security/security.xml", + "security/ir.model.access.csv", + "views/spreadsheet_dashboard_views.xml", + "views/menu_views.xml", + "data/dashboard.xml", + ], + "assets": { + "spreadsheet.o_spreadsheet": [ + "spreadsheet_dashboard/static/src/bundle/**/*.js", + ], + "web.assets_backend": [ + "spreadsheet_dashboard/static/src/assets/**/*.js", + "spreadsheet_dashboard/static/src/**/*.scss", + ], + "web.assets_qweb": [ + "spreadsheet_dashboard/static/src/**/*.xml", + ], + "web.qunit_suite_tests": [ + "spreadsheet_dashboard/static/tests/**/*", + ("include", "spreadsheet.o_spreadsheet"), + ("remove", "spreadsheet_dashboard/static/tests/mobile/**/*.js"), + ], + "web.qunit_mobile_suite_tests": [ + "spreadsheet_dashboard/static/tests/mobile/**/*.js", + "spreadsheet_dashboard/static/tests/utils/**/*.js", + ("include", "spreadsheet.o_spreadsheet"), + ], + 'web.assets_tests': [ + 'spreadsheet_dashboard/static/tests/utils/tour.js', + ], + }, +} diff --git a/addons/spreadsheet_dashboard/data/dashboard.xml b/addons/spreadsheet_dashboard/data/dashboard.xml new file mode 100644 index 00000000000..376e16c64cc --- /dev/null +++ b/addons/spreadsheet_dashboard/data/dashboard.xml @@ -0,0 +1,14 @@ + + + + + Finance + 10 + + + + Sales + 40 + + + diff --git a/addons/spreadsheet_dashboard/models/__init__.py b/addons/spreadsheet_dashboard/models/__init__.py new file mode 100644 index 00000000000..0eb0cddc62c --- /dev/null +++ b/addons/spreadsheet_dashboard/models/__init__.py @@ -0,0 +1,4 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import spreadsheet_dashboard_group +from . import spreadsheet_dashboard diff --git a/addons/spreadsheet_dashboard/models/spreadsheet_dashboard.py b/addons/spreadsheet_dashboard/models/spreadsheet_dashboard.py new file mode 100644 index 00000000000..541b290844a --- /dev/null +++ b/addons/spreadsheet_dashboard/models/spreadsheet_dashboard.py @@ -0,0 +1,41 @@ +import base64 +import json + +from odoo import api, fields, models, _ + +class SpreadsheetDashboard(models.Model): + _name = 'spreadsheet.dashboard' + _description = 'Spreadsheet Dashboard' + _order = 'sequence' + + name = fields.Char(required=True) + dashboard_group_id = fields.Many2one('spreadsheet.dashboard.group', required=True) + data = fields.Binary(required=True, default=lambda self: self._default_data()) + raw = fields.Binary(compute='_compute_raw') + thumbnail = fields.Binary() + sequence = fields.Integer() + group_ids = fields.Many2many('res.groups', default=lambda self: self.env.ref('base.group_user')) + + def _default_data(self): + data = json.dumps(self._empty_workbook_data()) + return base64.b64encode(data.encode()) + + def _empty_workbook_data(self): + """Create an empty spreadsheet workbook. + The sheet name should be the same for all users to allow consistent references + in formulas. It is translated for the user creating the spreadsheet. + """ + return { + "version": 1, + "sheets": [ + { + "id": "sheet1", + "name": _("Sheet1"), + } + ] + } + + @api.depends('data') + def _compute_raw(self): + for dashboard in self: + dashboard.raw = base64.decodebytes(dashboard.data) diff --git a/addons/spreadsheet_dashboard/models/spreadsheet_dashboard_group.py b/addons/spreadsheet_dashboard/models/spreadsheet_dashboard_group.py new file mode 100644 index 00000000000..b1f1badea7f --- /dev/null +++ b/addons/spreadsheet_dashboard/models/spreadsheet_dashboard_group.py @@ -0,0 +1,11 @@ +from odoo import fields, models + + +class SpreadsheetDashboardGroup(models.Model): + _name = 'spreadsheet.dashboard.group' + _description = 'Group of dashboards' + _order = 'sequence' + + name = fields.Char(required=True) + dashboard_ids = fields.One2many('spreadsheet.dashboard', 'dashboard_group_id') + sequence = fields.Integer() diff --git a/addons/spreadsheet_dashboard/security/ir.model.access.csv b/addons/spreadsheet_dashboard/security/ir.model.access.csv new file mode 100644 index 00000000000..55f1aa285e0 --- /dev/null +++ b/addons/spreadsheet_dashboard/security/ir.model.access.csv @@ -0,0 +1,5 @@ +"id","name","model_id:id","group_id:id","perm_read","perm_write","perm_create","perm_unlink" +"access_spreadsheet_dashboard_group_user","access_spreadsheet_dashboard_group_user","model_spreadsheet_dashboard_group","base.group_user",1,0,0,0 +spreadsheet_dashboard_user","spreadsheet_dashboard_user","model_spreadsheet_dashboard","base.group_user",1,0,0,0 +"access_spreadsheet_dashboard_group","access_spreadsheet_dashboard_group","model_spreadsheet_dashboard_group","base.group_system",1,1,1,1 +"spreadsheet_dashboard","spreadsheet_dashboard","model_spreadsheet_dashboard","base.group_system",1,1,1,1 diff --git a/addons/spreadsheet_dashboard/security/security.xml b/addons/spreadsheet_dashboard/security/security.xml new file mode 100644 index 00000000000..46bfcccd965 --- /dev/null +++ b/addons/spreadsheet_dashboard/security/security.xml @@ -0,0 +1,9 @@ + + + Spreadsheet dashboard: groups + + + [('group_ids', 'in', user.groups_id.ids)] + + + diff --git a/addons/spreadsheet_dashboard/static/description/icon.png b/addons/spreadsheet_dashboard/static/description/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..f13f852a62653cacac9bfb696f9f68459a7f4c5d GIT binary patch literal 6085 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 JYL%=b{|`h|Eeiku literal 0 HcmV?d00001 diff --git a/addons/spreadsheet_dashboard/static/description/icon.svg b/addons/spreadsheet_dashboard/static/description/icon.svg new file mode 100644 index 00000000000..ebd019e66b3 --- /dev/null +++ b/addons/spreadsheet_dashboard/static/description/icon.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/addons/spreadsheet_dashboard/static/src/assets/dashboard_action_loader.js b/addons/spreadsheet_dashboard/static/src/assets/dashboard_action_loader.js new file mode 100644 index 00000000000..ad23687b044 --- /dev/null +++ b/addons/spreadsheet_dashboard/static/src/assets/dashboard_action_loader.js @@ -0,0 +1,18 @@ +/** @odoo-module **/ + +import { registry } from "@web/core/registry"; +import { loadSpreadsheetAction } from "@spreadsheet/assets_backend/spreadsheet_action_loader"; + +const actionRegistry = registry.category("actions"); + +const loadDashboardAction = async (env, context) => { + await loadSpreadsheetAction(env, "action_spreadsheet_dashboard", loadDashboardAction); + return { + ...context, + target: "current", + tag: "action_spreadsheet_dashboard", + type: "ir.actions.client", + }; +}; + +actionRegistry.add("action_spreadsheet_dashboard", loadDashboardAction); diff --git a/addons/spreadsheet_dashboard/static/src/bundle/dashboard_action/dashboard_action.js b/addons/spreadsheet_dashboard/static/src/bundle/dashboard_action/dashboard_action.js new file mode 100644 index 00000000000..75baef6c3cd --- /dev/null +++ b/addons/spreadsheet_dashboard/static/src/bundle/dashboard_action/dashboard_action.js @@ -0,0 +1,154 @@ +/** @odoo-module */ + +import { registry } from "@web/core/registry"; +import { ControlPanel } from "@web/search/control_panel/control_panel"; +import { DashboardLoader, Status } from "./dashboard_loader"; +import spreadsheet from "@spreadsheet/o_spreadsheet/o_spreadsheet_extended"; +import { useSetupAction } from "@web/webclient/actions/action_hook"; +import { DashboardMobileSearchPanel } from "./mobile_search_panel/mobile_search_panel"; +import { MobileFigureContainer } from "./mobile_figure_container/mobile_figure_container"; +import { FilterValue } from "@spreadsheet/global_filters/components/filter_value/filter_value"; +import { loadSpreadsheetDependencies } from "@spreadsheet/helpers/helpers"; +import { useService } from "@web/core/utils/hooks"; + +const { Spreadsheet } = spreadsheet; +const { Component, onWillStart, useState, useEffect } = owl; + +export class SpreadsheetDashboardAction extends Component { + setup() { + this.Status = Status; + this.controlPanelDisplay = { + "top-left": true, + "top-right": true, + "bottom-left": false, + "bottom-right": false, + }; + this.orm = useService("orm"); + this.router = useService("router"); + // Use the non-protected orm service (`this.env.services.orm` instead of `useService("orm")`) + // because spreadsheets models are preserved across multiple components when navigating + // with the breadcrumb + // TODO write a test + /** @type {DashboardLoader}*/ + this.loader = useState( + new DashboardLoader(this.env, this.env.services.orm, this._fetchDashboardData) + ); + onWillStart(async () => { + await loadSpreadsheetDependencies(); + if (this.props.state && this.props.state.dashboardLoader) { + const { groups, dashboards } = this.props.state.dashboardLoader; + this.loader.restoreFromState(groups, dashboards); + } else { + await this.loader.load(); + } + const activeDashboardId = this.getInitialActiveDashboard(); + if (activeDashboardId) { + this.openDashboard(activeDashboardId); + } + }); + useEffect( + () => this.router.pushState({ dashboard_id: this.activeDashboardId }), + () => [this.activeDashboardId] + ); + useEffect( + () => { + const dashboard = this.state.activeDashboard; + if (dashboard && dashboard.status === Status.Loaded) { + const render = () => this.render(true); + dashboard.model.on("update", this, render); + return () => dashboard.model.off("update", this, render); + } + }, + () => { + const dashboard = this.state.activeDashboard; + return [dashboard && dashboard.model, dashboard && dashboard.status]; + } + ); + useSetupAction({ + getLocalState: () => { + return { + activeDashboardId: this.activeDashboardId, + dashboardLoader: this.loader.getState(), + }; + }, + }); + /** @type {{ activeDashboard: import("./dashboard_loader").Dashboard}} */ + this.state = useState({ activeDashboard: undefined }); + } + + getStyle(model) { + const sheetId = model.getters.getActiveSheetId(); + const { right } = model.getters.getSheetZone(sheetId); + const { end } = model.getters.getColDimensions(sheetId, right); + return `max-width: ${end}px;`; + } + + /** + * @returns {number | undefined} + */ + get activeDashboardId() { + return this.state.activeDashboard ? this.state.activeDashboard.id : undefined; + } + + /** + * @returns {object[]} + */ + get filters() { + const dashboard = this.state.activeDashboard; + if (!dashboard || dashboard.status !== Status.Loaded) { + return []; + } + return dashboard.model.getters.getGlobalFilters(); + } + + /** + * @private + * @returns {number | undefined} + */ + getInitialActiveDashboard() { + if (this.props.state && this.props.state.activeDashboardId) { + return this.props.state.activeDashboardId; + } + const params = this.props.action.params || this.props.action.context.params; + if (params && params.dashboard_id) { + return params.dashboard_id; + } + const [firstSection] = this.getDashboardGroups(); + if (firstSection && firstSection.dashboards.length) { + return firstSection.dashboards[0].id; + } + } + + getDashboardGroups() { + return this.loader.getDashboardGroups(); + } + + /** + * @param {number} dashboardId + */ + openDashboard(dashboardId) { + this.state.activeDashboard = this.loader.getDashboard(dashboardId); + } + + /** + * @private + * @param {number} dashboardId + * @returns {Promise<{ data: string, revisions: object[] }>} + */ + async _fetchDashboardData(dashboardId) { + const [record] = await this.orm.read("spreadsheet.dashboard", [dashboardId], ["raw"]); + return { data: record.raw, revisions: [] }; + } +} +SpreadsheetDashboardAction.template = "spreadsheet_dashboard.DashboardAction"; +SpreadsheetDashboardAction.components = { + ControlPanel, + Spreadsheet, + FilterValue, + DashboardMobileSearchPanel, + MobileFigureContainer, +}; + +registry + .category("actions") + .add("action_spreadsheet_dashboard", SpreadsheetDashboardAction, { force: true }); diff --git a/addons/spreadsheet_dashboard/static/src/bundle/dashboard_action/dashboard_action.scss b/addons/spreadsheet_dashboard/static/src/bundle/dashboard_action/dashboard_action.scss new file mode 100644 index 00000000000..527bec6d9d4 --- /dev/null +++ b/addons/spreadsheet_dashboard/static/src/bundle/dashboard_action/dashboard_action.scss @@ -0,0 +1,40 @@ +.o_spreadsheet_dashboard_search_panel ul { + padding-inline-start: 0px +} +.o_spreadsheet_dashboard_search_panel li { + padding: 4px 8px 4px 16px; + list-style-type: none; + &:hover:not(.active) { + background-color: $o-gray-100; + } +} +.o_spreadsheet_dashboard_action { + .o_renderer { + height: 100%; + margin: auto; + .o-spreadsheet { + height: 100%; + } + } + + .o_side_panel_filter_icon { + padding: 0; + } + + .dashboard-loading-status { + margin: auto; + } + + .o_cp_top_right { + display: flex; + justify-content: flex-end; + + .o_field_many2manytags, .date_filter_values { + margin-left: 3px; + margin-right: 3px; + display: flex; + gap: 3px; + align-items: baseline; + } + } +} diff --git a/addons/spreadsheet_dashboard/static/src/bundle/dashboard_action/dashboard_action.xml b/addons/spreadsheet_dashboard/static/src/bundle/dashboard_action/dashboard_action.xml new file mode 100644 index 00000000000..aab11e1c9ea --- /dev/null +++ b/addons/spreadsheet_dashboard/static/src/bundle/dashboard_action/dashboard_action.xml @@ -0,0 +1,63 @@ + + +
+ + + + + + + +
+ + + + + +
+
+
+ +
+
    +
  • +
+
+
+
+ +

No available dashboard

+ + +

Loading...

+
+ An error occured while loading the dashboard +
+ + +
+ +
+
+
+
+
+
+ diff --git a/addons/spreadsheet_dashboard/static/src/bundle/dashboard_action/dashboard_loader.js b/addons/spreadsheet_dashboard/static/src/bundle/dashboard_action/dashboard_loader.js new file mode 100644 index 00000000000..218be113da0 --- /dev/null +++ b/addons/spreadsheet_dashboard/static/src/bundle/dashboard_action/dashboard_loader.js @@ -0,0 +1,208 @@ +/** @odoo-module */ + +import { DataSources } from "@spreadsheet/data_sources/data_sources"; +import { migrate } from "@spreadsheet/o_spreadsheet/migration"; +import spreadsheet from "@spreadsheet/o_spreadsheet/o_spreadsheet_extended"; + +const { Model } = spreadsheet; + +/** + * @type {{ + * NotLoaded: "NotLoaded", + * Loading: "Loading", + * Loaded: "Loaded", + * Error: "Error", + * }} + */ +export const Status = { + NotLoaded: "NotLoaded", + Loading: "Loading", + Loaded: "Loaded", + Error: "Error", +}; + +/** + * @typedef Dashboard + * @property {number} id + * @property {string} displayName + * @property {string} status + * @property {Model} [model] + * @property {Error} [error] + * + * @typedef DashboardGroupData + * @property {number} id + * @property {string} name + * @property {Array} dashboardIds + * + * @typedef DashboardGroup + * @property {number} id + * @property {string} name + * @property {Array} dashboards + * + * @typedef {(dashboardId: number) => Promise<{ data: string, revisions: object[] }>} FetchDashboardData + * + * @typedef {import("@web/env").OdooEnv} OdooEnv + * + * @typedef {import("@web/core/orm_service").ORM} ORM + */ + +export class DashboardLoader { + /** + * @param {OdooEnv} env + * @param {ORM} orm + * @param {FetchDashboardData} fetchDashboardData + */ + constructor(env, orm, fetchDashboardData) { + /** @private */ + this.env = env; + /** @private */ + this.orm = orm; + /** @private @type {Array} */ + this.groups = []; + /** @private @type {Object} */ + this.dashboards = {}; + /** @private */ + this.fetchDashboardData = fetchDashboardData; + } + + /** + * @param {Array} groups + * @param {Object} dashboards + */ + restoreFromState(groups, dashboards) { + this.groups = groups; + this.dashboards = dashboards; + } + + /** + * Return data needed to restore a dashboard loader + */ + getState() { + return { + groups: this.groups, + dashboards: this.dashboards, + }; + } + + async load() { + const groups = await this._fetchGroups(); + this.groups = groups + .filter((group) => group.dashboard_ids.length) + .map((group) => ({ + id: group.id, + name: group.name, + dashboardIds: group.dashboard_ids, + })); + const dashboards = await this._fetchDashboardNames(this.groups); + for (const dashboard of dashboards) { + this.dashboards[dashboard.id] = { + id: dashboard.id, + displayName: dashboard.name, + status: Status.NotLoaded, + }; + } + } + + /** + * @param {number} dashboardId + * @returns {Dashboard} + */ + getDashboard(dashboardId) { + const dashboard = this._getDashboard(dashboardId); + if (dashboard.status === Status.NotLoaded) { + this._loadDashboardData(dashboardId); + } + return dashboard; + } + + /** + * @returns {Array} + */ + getDashboardGroups() { + return this.groups.map((section) => ({ + id: section.id, + name: section.name, + dashboards: section.dashboardIds.map((dashboardId) => ({ + id: dashboardId, + displayName: this._getDashboard(dashboardId).displayName, + status: this._getDashboard(dashboardId).status, + })), + })); + } + + /** + * @private + * @returns {Promise<{id: number, name: string, dashboard_ids: number[]}[]>} + */ + _fetchGroups() { + return this.orm.searchRead( + "spreadsheet.dashboard.group", + [["dashboard_ids", "!=", false]], + ["id", "name", "dashboard_ids"] + ); + } + + /** + * @private + * @param {Array} groups + * @returns {Promise} + */ + _fetchDashboardNames(groups) { + return this.orm.read( + "spreadsheet.dashboard", + groups.map((group) => group.dashboardIds).flat(), + ["name"] + ); + } + + /** + * @private + * @param {number} id + * @returns {Dashboard|undefined} + */ + _getDashboard(id) { + if (!this.dashboards[id]) { + throw new Error(`Dashboard ${id} does not exist`); + } + return this.dashboards[id]; + } + + /** + * @private + * @param {number} dashboardId + */ + async _loadDashboardData(dashboardId) { + const dashboard = this._getDashboard(dashboardId); + dashboard.status = Status.Loading; + try { + const { data, revisions } = await this.fetchDashboardData(dashboardId); + dashboard.model = this._createSpreadsheetModel(data, revisions); + dashboard.status = Status.Loaded; + } catch (error) { + dashboard.error = error; + dashboard.status = Status.Error; + } + } + + /** + * @private + * @param {string} data + * @param {object[]} revisions + * @returns {Model} + */ + _createSpreadsheetModel(data, revisions = []) { + const dataSources = new DataSources(this.orm); + const model = new Model( + migrate(JSON.parse(data)), + { + evalContext: { env: this.env, orm: this.orm }, + mode: "dashboard", + dataSources, + }, + revisions + ); + + dataSources.addEventListener("data-source-updated", () => model.dispatch("EVALUATE_CELLS")); + return model; + } +} diff --git a/addons/spreadsheet_dashboard/static/src/bundle/dashboard_action/mobile_figure_container/mobile_figure_container.js b/addons/spreadsheet_dashboard/static/src/bundle/dashboard_action/mobile_figure_container/mobile_figure_container.js new file mode 100644 index 00000000000..af397be0acb --- /dev/null +++ b/addons/spreadsheet_dashboard/static/src/bundle/dashboard_action/mobile_figure_container/mobile_figure_container.js @@ -0,0 +1,39 @@ +/** @odoo-module */ + +import spreadsheet from "@spreadsheet/o_spreadsheet/o_spreadsheet_extended"; + +const { Component, useSubEnv } = owl; +const { registries } = spreadsheet; +const { figureRegistry } = registries; + +export class MobileFigureContainer extends Component { + setup() { + useSubEnv({ + model: this.props.spreadsheetModel, + isDashboard: () => this.props.spreadsheetModel.getters.isDashboard(), + }); + } + + get figures() { + const sheetId = this.props.spreadsheetModel.getters.getActiveSheetId(); + return this.props.spreadsheetModel.getters + .getFigures(sheetId) + .sort((f1, f2) => (this.isBefore(f1, f2) ? -1 : 1)) + .map((figure) => ({ + ...figure, + width: window.innerWidth, + height: 300, + })); + } + + getFigureComponent(figure) { + return figureRegistry.get(figure.tag).Component; + } + + isBefore(f1, f2) { + // TODO be smarter + return f1.x < f2.x ? f1.y < f2.y : f1.y < f2.y; + } +} + +MobileFigureContainer.template = "documents_spreadsheet.MobileFigureContainer"; diff --git a/addons/spreadsheet_dashboard/static/src/bundle/dashboard_action/mobile_figure_container/mobile_figure_container.xml b/addons/spreadsheet_dashboard/static/src/bundle/dashboard_action/mobile_figure_container/mobile_figure_container.xml new file mode 100644 index 00000000000..0a3e229094e --- /dev/null +++ b/addons/spreadsheet_dashboard/static/src/bundle/dashboard_action/mobile_figure_container/mobile_figure_container.xml @@ -0,0 +1,14 @@ + + + + + Only chart figures are displayed in small screens but this dashboard doesn't contain any + + + + + diff --git a/addons/spreadsheet_dashboard/static/src/bundle/dashboard_action/mobile_search_panel/mobile_search_panel.js b/addons/spreadsheet_dashboard/static/src/bundle/dashboard_action/mobile_search_panel/mobile_search_panel.js new file mode 100644 index 00000000000..afbae44ab09 --- /dev/null +++ b/addons/spreadsheet_dashboard/static/src/bundle/dashboard_action/mobile_search_panel/mobile_search_panel.js @@ -0,0 +1,42 @@ +/** @odoo-module */ + +import { _t } from "@web/core/l10n/translation"; + +const { Component, useState } = owl; + +export class DashboardMobileSearchPanel extends Component { + setup() { + this.state = useState({ isOpen: false }); + } + + get searchBarText() { + return this.props.activeDashboard + ? this.props.activeDashboard.displayName + : _t("Choose a dashboard...."); + } + + onDashboardSelected(dashboardId) { + this.props.onDashboardSelected(dashboardId); + this.state.isOpen = false; + } + + openDashboardSelection() { + const dashboards = this.props.groups.map((group) => group.dashboards).flat(); + if (dashboards.length > 1) { + this.state.isOpen = true; + } + } +} + +DashboardMobileSearchPanel.template = "documents_spreadsheet.DashboardMobileSearchPanel"; +DashboardMobileSearchPanel.props = { + /** + * (dashboardId: number) => void + */ + onDashboardSelected: Function, + groups: Object, + activeDashboard: { + type: Object, + optional: true, + }, +}; diff --git a/addons/spreadsheet_dashboard/static/src/bundle/dashboard_action/mobile_search_panel/mobile_search_panel.xml b/addons/spreadsheet_dashboard/static/src/bundle/dashboard_action/mobile_search_panel/mobile_search_panel.xml new file mode 100644 index 00000000000..a1520cc0893 --- /dev/null +++ b/addons/spreadsheet_dashboard/static/src/bundle/dashboard_action/mobile_search_panel/mobile_search_panel.xml @@ -0,0 +1,43 @@ + + +
+ + + + + +
+ +
+
+
+ + diff --git a/addons/spreadsheet_dashboard/static/src/bundle/links/dashboard_link_plugin.js b/addons/spreadsheet_dashboard/static/src/bundle/links/dashboard_link_plugin.js new file mode 100644 index 00000000000..7d33e6302e2 --- /dev/null +++ b/addons/spreadsheet_dashboard/static/src/bundle/links/dashboard_link_plugin.js @@ -0,0 +1,32 @@ +/** @odoo-module */ + +import spreadsheet from "@spreadsheet/o_spreadsheet/o_spreadsheet_extended"; + +export default class DashboardLinkPlugin extends spreadsheet.UIPlugin { + constructor(getters, state, dispatch, config, selection) { + super(...arguments); + this.env = config.evalContext.env; + this.selection.observe(this, { + handleEvent: this.handleEvent.bind(this), + }); + } + + /** + * @private + */ + handleEvent(event) { + if (!this.getters.isDashboard()) { + return; + } + switch (event.type) { + case "ZonesSelected": { + const sheetId = this.getters.getActiveSheetId(); + const { col, row } = event.anchor.cell; + const cell = this.getters.getCell(sheetId, col, row); + if (cell !== undefined && cell.isLink()) { + cell.action(this.env); + } + } + } + } +} diff --git a/addons/spreadsheet_dashboard/static/src/bundle/links/index.js b/addons/spreadsheet_dashboard/static/src/bundle/links/index.js new file mode 100644 index 00000000000..7b2b8fb28b1 --- /dev/null +++ b/addons/spreadsheet_dashboard/static/src/bundle/links/index.js @@ -0,0 +1,8 @@ +/** @odoo-module */ + +import spreadsheet from "@spreadsheet/o_spreadsheet/o_spreadsheet_extended"; +import DashboardLinkPlugin from "./dashboard_link_plugin"; + +const { uiPluginRegistry } = spreadsheet.registries; + +uiPluginRegistry.add("odooDashboardClickLink", DashboardLinkPlugin); diff --git a/addons/spreadsheet_dashboard/static/src/bundle/list/index.js b/addons/spreadsheet_dashboard/static/src/bundle/list/index.js new file mode 100644 index 00000000000..64a696ca0cf --- /dev/null +++ b/addons/spreadsheet_dashboard/static/src/bundle/list/index.js @@ -0,0 +1,15 @@ +/** @odoo-module */ +import { _lt } from "@web/core/l10n/translation"; + +import { SEE_RECORD_LIST, SEE_RECORD_LIST_VISIBLE } from "@spreadsheet/list/list_actions"; +import spreadsheet from "@spreadsheet/o_spreadsheet/o_spreadsheet_extended"; + +const { dashboardMenuRegistry } = spreadsheet.registries; + +dashboardMenuRegistry.add("see_records_list", { + name: _lt("See record"), + sequence: 20, + action: SEE_RECORD_LIST, + isReadonlyAllowed: true, + isVisible: SEE_RECORD_LIST_VISIBLE, +}); diff --git a/addons/spreadsheet_dashboard/static/src/bundle/pivot/index.js b/addons/spreadsheet_dashboard/static/src/bundle/pivot/index.js new file mode 100644 index 00000000000..ffcd42e0a88 --- /dev/null +++ b/addons/spreadsheet_dashboard/static/src/bundle/pivot/index.js @@ -0,0 +1,15 @@ +/** @odoo-module */ +import { _lt } from "@web/core/l10n/translation"; + +import { SEE_RECORDS_PIVOT, SEE_RECORDS_PIVOT_VISIBLE } from "@spreadsheet/pivot/pivot_actions"; +import spreadsheet from "@spreadsheet/o_spreadsheet/o_spreadsheet_extended"; + +const { dashboardMenuRegistry } = spreadsheet.registries; + +dashboardMenuRegistry.add("see_records_pivot", { + name: _lt("See records"), + sequence: 10, + action: SEE_RECORDS_PIVOT, + isReadonlyAllowed: true, + isVisible: SEE_RECORDS_PIVOT_VISIBLE, +}); diff --git a/addons/spreadsheet_dashboard/static/tests/dashboard/dashboard_action_test.js b/addons/spreadsheet_dashboard/static/tests/dashboard/dashboard_action_test.js new file mode 100644 index 00000000000..0300ba4744b --- /dev/null +++ b/addons/spreadsheet_dashboard/static/tests/dashboard/dashboard_action_test.js @@ -0,0 +1,162 @@ +/** @odoo-module */ + +import { getFixture, click, triggerEvent, legacyExtraNextTick } from "@web/../tests/helpers/utils"; +import spreadsheet from "@spreadsheet/o_spreadsheet/o_spreadsheet_extended"; +import { getDashboardServerData } from "../utils/data"; +import { getBasicData, getBasicListArchs } from "@spreadsheet/../tests/utils/data"; +import { createSpreadsheetDashboard } from "../utils/dashboard_action"; + +const { dashboardMenuRegistry } = spreadsheet.registries; + +QUnit.module("spreadsheet_dashboard > Dashboard > Dashboard action"); + +QUnit.test("display available spreadsheets", async (assert) => { + await createSpreadsheetDashboard(); + assert.containsN(getFixture(), ".o_search_panel section", 2); + assert.containsN(getFixture(), ".o_search_panel li", 3); +}); + +QUnit.test("display the active spreadsheet", async (assert) => { + await createSpreadsheetDashboard(); + assert.containsOnce( + getFixture(), + ".o_search_panel li.active", + "It should have one active element" + ); + assert.containsOnce(getFixture(), ".o-spreadsheet", "It should display the spreadsheet"); +}); + +QUnit.test("load action with specific dashboard", async (assert) => { + await createSpreadsheetDashboard({ spreadsheetId: 3 }); + const active = getFixture().querySelector(".o_search_panel li.active"); + assert.strictEqual(active.innerText, "Dashboard Accounting 1"); +}); + +QUnit.test("can switch spreadsheet", async (assert) => { + await createSpreadsheetDashboard(); + const fixture = getFixture(); + const spreadsheets = fixture.querySelectorAll(".o_search_panel li"); + assert.ok(spreadsheets[0].className.includes("active")); + assert.notOk(spreadsheets[1].className.includes("active")); + assert.notOk(spreadsheets[2].className.includes("active")); + await click(spreadsheets[1]); + assert.notOk(spreadsheets[0].className.includes("active")); + assert.ok(spreadsheets[1].className.includes("active")); + assert.notOk(spreadsheets[2].className.includes("active")); +}); + +QUnit.test("display no dashboard message", async (assert) => { + await createSpreadsheetDashboard({ + mockRPC: function (route, { model, method, args }) { + if (method === "search_read" && model === "spreadsheet.dashboard.group") { + return []; + } + }, + }); + const fixture = getFixture(); + assert.containsNone(fixture, ".o_search_panel li", "It should not display any spreadsheet"); + assert.strictEqual( + fixture.querySelector(".dashboard-loading-status").innerText, + "No available dashboard", + "It should display no dashboard message" + ); +}); + +QUnit.test("display error message", async (assert) => { + await createSpreadsheetDashboard({ + mockRPC: function (route, args) { + if ( + args.model === "spreadsheet.dashboard" && + ((args.method === "read" && args.args[0][0] === 2 && args.args[1][0] === "raw") || + // this is not correct from a module dependency POV but it's required for the test + // to pass when `spreadsheet_dashboard_edition` module is installed + (args.method === "join_spreadsheet_session" && args.args[0] === 2)) + ) { + throw new Error("Bip"); + } + }, + }); + const fixture = getFixture(); + const spreadsheets = fixture.querySelectorAll(".o_search_panel li"); + assert.containsOnce(fixture, ".o-spreadsheet", "It should display the spreadsheet"); + await click(spreadsheets[1]); + assert.containsOnce( + fixture, + ".o_spreadsheet_dashboard_action .dashboard-loading-status.error", + "It should display an error" + ); + await click(spreadsheets[0]); + assert.containsOnce(fixture, ".o-spreadsheet", "It should display the spreadsheet"); + assert.containsNone(fixture, ".o_renderer .error", "It should not display an error"); +}); + +QUnit.test("Dashboard registry contains see records of pivots and lists", (assert) => { + const records = dashboardMenuRegistry.getAll(); + assert.strictEqual(records.length, 2); + assert.strictEqual(records[0].id, "see_records_pivot"); + assert.ok(records[0].isReadonlyAllowed); + assert.strictEqual(records[1].id, "see_records_list"); + assert.ok(records[1].isReadonlyAllowed); +}); + +QUnit.test( + "Last selected spreadsheet is kept when go back from breadcrumb", + async function (assert) { + const spreadsheetData = { + sheets: [ + { + id: "sheet1", + cells: { A1: { content: `=PIVOT("1", "probability")` } }, + }, + ], + pivots: { + 1: { + id: 1, + colGroupBys: ["foo"], + domain: [], + measures: [{ field: "probability", operator: "avg" }], + model: "partner", + rowGroupBys: ["bar"], + }, + }, + }; + const serverData = getDashboardServerData(); + serverData.models = { + ...serverData.models, + ...getBasicData(), + }; + serverData.views = getBasicListArchs(); + serverData.models["spreadsheet.dashboard.group"].records = [ + { + dashboard_ids: [789], + id: 1, + name: "Pivot", + }, + ]; + serverData.models["spreadsheet.dashboard"].records = [ + { + id: 789, + name: "Spreadsheet with Pivot", + json_data: JSON.stringify(spreadsheetData), + raw: JSON.stringify(spreadsheetData), + dashboard_group_id: 1, + }, + ]; + const fixture = getFixture(); + await createSpreadsheetDashboard({ serverData }); + await click(fixture, ".o_search_panel li:last-child"); + const rect = fixture + .querySelector(".o-spreadsheet .o-grid-overlay") + .getBoundingClientRect(); + await triggerEvent(fixture, ".o-spreadsheet .o-grid-overlay", "contextmenu", { + pageX: 5 + rect.left, + pageY: 5 + rect.top, + }); + await click(fixture, ".o-menu-item[data-name='see_records_pivot']"); + await legacyExtraNextTick(); + assert.containsOnce(fixture, ".o_list_view"); + await click(document.body.querySelector(".o_back_button")); + await legacyExtraNextTick(); + assert.hasClass(fixture.querySelector(".o_search_panel li:last-child"), "active"); + } +); diff --git a/addons/spreadsheet_dashboard/static/tests/dashboard/dashboard_loader_test.js b/addons/spreadsheet_dashboard/static/tests/dashboard/dashboard_loader_test.js new file mode 100644 index 00000000000..c0ce2a72e50 --- /dev/null +++ b/addons/spreadsheet_dashboard/static/tests/dashboard/dashboard_loader_test.js @@ -0,0 +1,242 @@ +/** @odoo-module */ + +import { ormService } from "@web/core/orm_service"; +import { registry } from "@web/core/registry"; +import { makeTestEnv } from "@web/../tests/helpers/mock_env"; +import { + DashboardLoader, + Status, +} from "@spreadsheet_dashboard/bundle/dashboard_action/dashboard_loader"; +import { nextTick } from "@web/../tests/helpers/utils"; +import { getDashboardServerData } from "../utils/data"; + +import { waitForDataSourcesLoaded } from "@spreadsheet/../tests/utils/model"; +import { getCellValue } from "@spreadsheet/../tests/utils/getters"; + +/** + * @param {object} [params] + * @param {object} [params.serverData] + * @param {function} [params.mockRPC] + * @returns {Promise} + */ +async function createDashboardLoader(params = {}) { + registry.category("services").add("orm", ormService); + const env = await makeTestEnv({ + serverData: params.serverData || getDashboardServerData(), + mockRPC: params.mockRPC, + }); + return new DashboardLoader(env, env.services.orm, async (dashboardId) => { + const [record] = await env.services.orm.read( + "spreadsheet.dashboard", + [dashboardId], + ["raw"] + ); + return { data: record.raw, revisions: [] }; + }); +} + +QUnit.module("spreadsheet_dashboard > Dashboard loader"); + +QUnit.test("load all dashboards of all containers", async (assert) => { + const loader = await createDashboardLoader(); + loader.load(); + assert.deepEqual(loader.getDashboardGroups(), []); + await nextTick(); + assert.deepEqual(loader.getDashboardGroups(), [ + { + id: 1, + name: "Container 1", + dashboards: [ + { + id: 1, + displayName: "Dashboard CRM 1", + status: Status.NotLoaded, + }, + { + id: 2, + displayName: "Dashboard CRM 2", + status: Status.NotLoaded, + }, + ], + }, + { + id: 2, + name: "Container 2", + dashboards: [ + { + id: 3, + displayName: "Dashboard Accounting 1", + status: Status.NotLoaded, + }, + ], + }, + ]); +}); + +QUnit.test("load twice does not duplicate spreadsheets", async (assert) => { + const loader = await createDashboardLoader(); + await loader.load(); + assert.deepEqual(loader.getDashboardGroups()[1].dashboards, [ + { id: 3, displayName: "Dashboard Accounting 1", status: Status.NotLoaded }, + ]); + await loader.load(); + assert.deepEqual(loader.getDashboardGroups()[1].dashboards, [ + { id: 3, displayName: "Dashboard Accounting 1", status: Status.NotLoaded }, + ]); +}); + +QUnit.test("load spreadsheet data", async (assert) => { + const loader = await createDashboardLoader(); + await loader.load(); + const result = loader.getDashboard(3); + assert.strictEqual(result.status, Status.Loading); + await nextTick(); + assert.strictEqual(result.status, Status.Loaded); + assert.ok(result.model); +}); + +QUnit.test("load spreadsheet data only once", async (assert) => { + const loader = await createDashboardLoader({ + mockRPC: function (route, args) { + if (args.method === "read") { + assert.step(`spreadsheet ${args.args[0]} loaded`); + } + }, + }); + await loader.load(); + let result = loader.getDashboard(3); + await nextTick(); + assert.strictEqual(result.status, Status.Loaded); + assert.verifySteps(["spreadsheet 1,2,3 loaded", "spreadsheet 3 loaded"]); + result = loader.getDashboard(3); + await nextTick(); + assert.strictEqual(result.status, Status.Loaded); + assert.verifySteps([]); +}); + +QUnit.test("don't return empty dashboard group", async (assert) => { + const loader = await createDashboardLoader({ + mockRPC: async function (route, args) { + if (args.method === "search_read" && args.model === "spreadsheet.dashboard.group") { + return [ + { + id: 45, + name: "Group A", + dashboard_ids: [1], + }, + { + id: 46, + name: "Group B", + dashboard_ids: [], + }, + ]; + } + }, + }); + await loader.load(); + assert.deepEqual(loader.getDashboardGroups(), [ + { + id: 45, + name: "Group A", + dashboards: [ + { + id: 1, + displayName: "Dashboard CRM 1", + status: Status.NotLoaded, + }, + ], + }, + ]); +}); + +QUnit.test("load multiple spreadsheets", async (assert) => { + const loader = await createDashboardLoader({ + mockRPC: function (route, args) { + if (args.method === "read") { + assert.step(`spreadsheet ${args.args[0]} loaded`); + } + }, + }); + await loader.load(); + assert.verifySteps(["spreadsheet 1,2,3 loaded"]); + loader.getDashboard(1); + await nextTick(); + assert.verifySteps(["spreadsheet 1 loaded"]); + loader.getDashboard(2); + await nextTick(); + assert.verifySteps(["spreadsheet 2 loaded"]); + loader.getDashboard(1); + await nextTick(); + assert.verifySteps([]); +}); + +QUnit.test("load spreadsheet data with error", async (assert) => { + const loader = await createDashboardLoader({ + mockRPC: function (route, args) { + if ( + args.method === "read" && + args.model === "spreadsheet.dashboard" && + args.args[1][0] === "raw" + ) { + throw new Error("Bip"); + } + }, + }); + await loader.load(); + const result = loader.getDashboard(3); + assert.strictEqual(result.status, Status.Loading); + await nextTick(); + assert.strictEqual(result.status, Status.Error); + assert.strictEqual(result.error.message, "Bip"); +}); + +QUnit.test("async formulas are correctly evaluated", async (assert) => { + const spreadsheetData = { + sheets: [ + { + id: "sheet1", + cells: { + A1: { content: `=ODOO.CURRENCY.RATE("EUR","USD")` }, // an async formula + }, + }, + ], + }; + const serverData = getDashboardServerData(); + const dashboardId = 15; + serverData.models["spreadsheet.dashboard"].records = [ + { + id: dashboardId, + raw: JSON.stringify(spreadsheetData), + json_data: JSON.stringify(spreadsheetData), + name: "Dashboard Accounting 1", + dashboard_group_id: 2, + }, + ]; + serverData.models["spreadsheet.dashboard.group"].records = [ + { id: 1, name: "Container 1", dashboard_ids: [dashboardId] }, + ]; + const loader = await createDashboardLoader({ + serverData, + mockRPC: function (route, args) { + if (args.method === "get_rates_for_spreadsheet") { + const info = args.args[0][0]; + return [{ ...info, rate: 0.9 }]; + } + }, + }); + await loader.load(); + loader.getDashboard(dashboardId); + await nextTick(); + const { model } = loader.getDashboard(dashboardId); + await waitForDataSourcesLoaded(model); + assert.strictEqual(await getCellValue(model, "A1"), 0.9); +}); + +QUnit.test("Model is in dashboard mode", async (assert) => { + const loader = await createDashboardLoader(); + await loader.load(); + loader.getDashboard(3); + await nextTick(); + const { model } = loader.getDashboard(3); + assert.strictEqual(model.config.mode, "dashboard"); +}); diff --git a/addons/spreadsheet_dashboard/static/tests/links/dashboard_links_plugin_tests.js b/addons/spreadsheet_dashboard/static/tests/links/dashboard_links_plugin_tests.js new file mode 100644 index 00000000000..db4cba501dd --- /dev/null +++ b/addons/spreadsheet_dashboard/static/tests/links/dashboard_links_plugin_tests.js @@ -0,0 +1,113 @@ +/** @odoo-module */ + +import spreadsheet from "@spreadsheet/o_spreadsheet/o_spreadsheet_extended"; +import { registry } from "@web/core/registry"; +import { actionService } from "@web/webclient/actions/action_service"; +import { menuService } from "@web/webclient/menus/menu_service"; +import { spreadsheetLinkMenuCellService } from "@spreadsheet/ir_ui_menu/index"; +import { makeTestEnv } from "@web/../tests/helpers/mock_env"; +import { selectCell } from "@spreadsheet/../tests/utils/commands"; +import { viewService } from "@web/views/view_service"; +import { ormService } from "@web/core/orm_service"; +import { getMenuServerData } from "@spreadsheet/../tests/links/menu_data_utils"; +import { patchWithCleanup } from "@web/../tests/helpers/utils"; + +const { Model } = spreadsheet; + +function beforeEach() { + registry + .category("services") + .add("menu", menuService) + .add("action", actionService) + .add("spreadsheetLinkMenuCell", spreadsheetLinkMenuCellService); + registry.category("services").add("view", viewService, { force: true }); // #action-serv-leg-compat-js-class + registry.category("services").add("orm", ormService, { force: true }); // #action-serv-leg-compat-js-class +} + +QUnit.module("spreadsheet_dashboard > link", { beforeEach }); + +QUnit.test("click a web link", async (assert) => { + patchWithCleanup(window, { + open: (href) => { + assert.step(href.toString()); + }, + }); + const env = await makeTestEnv(); + const data = { + sheets: [ + { + cells: { A1: { content: "[Odoo](https://odoo.com)" } }, + }, + ], + }; + const model = new Model(data, { mode: "dashboard", evalContext: { env } }); + selectCell(model, "A1"); + assert.verifySteps(["https://odoo.com"]); +}); + +QUnit.test("click a menu link", async (assert) => { + const fakeActionService = { + name: "action", + start() { + return { + doAction(action) { + assert.step(action); + }, + }; + }, + }; + registry.category("services").add("action", fakeActionService, { force: true }); + const env = await makeTestEnv({ serverData: getMenuServerData() }); + const data = { + sheets: [ + { + cells: { A1: { content: "[label](odoo://ir_menu_xml_id/test_menu)" } }, + }, + ], + }; + const model = new Model(data, { mode: "dashboard", evalContext: { env } }); + selectCell(model, "A1"); + assert.verifySteps(["action1"]); +}); + +QUnit.test("click a menu link", async (assert) => { + const fakeActionService = { + name: "action", + start() { + return { + doAction(action) { + assert.step("do-action"); + assert.deepEqual(action, { + context: undefined, + domain: undefined, + name: "an odoo view", + res_model: "partner", + target: "current", + type: "ir.actions.act_window", + views: [[false, "list"]], + }); + }, + }; + }, + }; + registry.category("services").add("action", fakeActionService, { force: true }); + const env = await makeTestEnv({ serverData: getMenuServerData() }); + const view = { + name: "an odoo view", + viewType: "list", + action: { + modelName: "partner", + views: [[false, "list"]], + }, + }; + const data = { + sheets: [ + { + cells: { A1: { content: `[a view](odoo://view/${JSON.stringify(view)})` } }, + }, + ], + }; + const model = new Model(data, { mode: "dashboard", evalContext: { env } }); + selectCell(model, "A1"); + assert.verifySteps(["do-action"]); +}); diff --git a/addons/spreadsheet_dashboard/static/tests/mobile/mobile_dashboard_action_test.js b/addons/spreadsheet_dashboard/static/tests/mobile/mobile_dashboard_action_test.js new file mode 100644 index 00000000000..2ab4bf3caa4 --- /dev/null +++ b/addons/spreadsheet_dashboard/static/tests/mobile/mobile_dashboard_action_test.js @@ -0,0 +1,115 @@ +/** @odoo-module */ + +import { click, getFixture } from "@web/../tests/helpers/utils"; +import { createSpreadsheetDashboard } from "../utils/dashboard_action"; +import { getDashboardServerData } from "../utils/data"; + +QUnit.module("spreadsheet_dashboard > Mobile Dashboard action"); + +QUnit.test("is empty with no figures", async (assert) => { + await createSpreadsheetDashboard(); + const fixture = getFixture(); + assert.containsOnce(fixture, ".o_mobile_dashboard"); + const content = fixture.querySelector(".o_mobile_dashboard"); + assert.deepEqual(content.innerText.split("\n"), [ + "Dashboard CRM 1", + "Only chart figures are displayed in small screens but this dashboard doesn't contain any", + ]); +}); + +QUnit.test("with no available dashboard", async (assert) => { + const serverData = getDashboardServerData(); + serverData.models["spreadsheet.dashboard"].records = []; + serverData.models["spreadsheet.dashboard.group"].records = []; + await createSpreadsheetDashboard({ serverData }); + const fixture = getFixture(); + const content = fixture.querySelector(".o_mobile_dashboard"); + assert.deepEqual(content.innerText, "No available dashboard"); +}); + +QUnit.test("displays figures in first sheet", async (assert) => { + const figure = { + tag: "chart", + height: 500, + width: 500, + x: 100, + y: 100, + data: { + type: "line", + dataSetsHaveTitle: false, + dataSets: ["A1"], + legendPosition: "top", + verticalAxisPosition: "left", + title: "", + }, + }; + const spreadsheetData = { + sheets: [ + { + id: "sheet1", + figures: [{ ...figure, id: "figure1" }], + }, + { + id: "sheet2", + figures: [{ ...figure, id: "figure2" }], + }, + ], + }; + const serverData = getDashboardServerData(); + serverData.models["spreadsheet.dashboard.group"].records = [ + { + dashboard_ids: [789], + id: 1, + name: "Chart", + }, + ]; + serverData.models["spreadsheet.dashboard"].records = [ + { + id: 789, + name: "Spreadsheet with chart figure", + json_data: JSON.stringify(spreadsheetData), + raw: JSON.stringify(spreadsheetData), + dashboard_group_id: 1, + }, + ]; + const fixture = getFixture(); + await createSpreadsheetDashboard({ serverData }); + assert.containsOnce(fixture, ".o-chart-container"); +}); + +QUnit.test("can switch dashboard", async (assert) => { + await createSpreadsheetDashboard(); + const fixture = getFixture(); + assert.strictEqual( + fixture.querySelector(".o_search_panel_summary").innerText, + "Dashboard CRM 1" + ); + await click(fixture, ".o_search_panel_current_selection"); + const dashboardElements = [...document.querySelectorAll("section header.list-group-item")]; + assert.strictEqual(dashboardElements[0].classList.contains("active"), true); + assert.deepEqual( + dashboardElements.map((el) => el.innerText), + ["Dashboard CRM 1", "Dashboard CRM 2", "Dashboard Accounting 1"] + ); + await click(dashboardElements[1]); + assert.strictEqual( + fixture.querySelector(".o_search_panel_summary").innerText, + "Dashboard CRM 2" + ); +}); + +QUnit.test("can go back from dashboard selection", async (assert) => { + await createSpreadsheetDashboard(); + const fixture = getFixture(); + assert.containsOnce(fixture, ".o_mobile_dashboard"); + assert.strictEqual( + fixture.querySelector(".o_search_panel_summary").innerText, + "Dashboard CRM 1" + ); + await click(fixture, ".o_search_panel_current_selection"); + await click(document, ".o_mobile_search_button"); + assert.strictEqual( + fixture.querySelector(".o_search_panel_summary").innerText, + "Dashboard CRM 1" + ); +}); diff --git a/addons/spreadsheet_dashboard/static/tests/utils/dashboard_action.js b/addons/spreadsheet_dashboard/static/tests/utils/dashboard_action.js new file mode 100644 index 00000000000..91912122cd7 --- /dev/null +++ b/addons/spreadsheet_dashboard/static/tests/utils/dashboard_action.js @@ -0,0 +1,25 @@ +/** @odoo-module */ + +import { createWebClient, doAction } from "@web/../tests/webclient/helpers"; +import { getDashboardServerData } from "./data"; + +/** + * @param {object} params + * @param {object} [params.serverData] + * @param {function} [params.mockRPC] + * @param {number} [params.spreadsheetId] + * @returns {Promise} + */ +export async function createSpreadsheetDashboard(params = {}) { + const webClient = await createWebClient({ + serverData: params.serverData || getDashboardServerData(), + mockRPC: params.mockRPC, + }); + return await doAction(webClient, { + type: "ir.actions.client", + tag: "action_spreadsheet_dashboard", + params: { + dashboard_id: params.spreadsheetId, + }, + }); +} diff --git a/addons/spreadsheet_dashboard/static/tests/utils/data.js b/addons/spreadsheet_dashboard/static/tests/utils/data.js new file mode 100644 index 00000000000..f102e406bf2 --- /dev/null +++ b/addons/spreadsheet_dashboard/static/tests/utils/data.js @@ -0,0 +1,57 @@ +/** @odoo-module */ + +export function getDashboardServerData() { + return { + models: { + "spreadsheet.dashboard": { + fields: { + json_data: { type: "char" }, + raw: { type: "char " }, + name: { type: "char" }, + dashboard_group_id: { + type: "many2one", + relation: "spreadsheet.dashboard.group", + }, + }, + records: [ + { + id: 1, + raw: "{}", + json_data: "{}", + name: "Dashboard CRM 1", + dashboard_group_id: 1, + }, + { + id: 2, + raw: "{}", + json_data: "{}", + name: "Dashboard CRM 2", + dashboard_group_id: 1, + }, + { + id: 3, + raw: "{}", + json_data: "{}", + name: "Dashboard Accounting 1", + dashboard_group_id: 2, + }, + ], + }, + "spreadsheet.dashboard.group": { + fields: { + name: { type: "char" }, + dashboard_ids: { + type: "one2many", + relation: "spreadsheet.dashboard", + relation_field: "dashboard_group_id", + }, + }, + records: [ + { id: 1, name: "Container 1", dashboard_ids: [1, 2] }, + { id: 2, name: "Container 2", dashboard_ids: [3] }, + ], + }, + }, + views: {}, + }; +} diff --git a/addons/spreadsheet_dashboard/static/tests/utils/tour.js b/addons/spreadsheet_dashboard/static/tests/utils/tour.js new file mode 100644 index 00000000000..c7d5c09c397 --- /dev/null +++ b/addons/spreadsheet_dashboard/static/tests/utils/tour.js @@ -0,0 +1,32 @@ +/** @odoo-module */ + +import tour from "web_tour.tour"; + +/** + * @param {string} tourName + * @param {string} dashboardDisplayName + */ +export function registerDashboardTour(tourName, dashboardDisplayName) { + tour.register( + tourName, + { + test: true, + url: "/web", + }, + [ + ...tour.stepUtils.goToAppSteps( + "spreadsheet_dashboard.spreadsheet_dashboard_menu_root", + "Open dashboard app" + ), + { + trigger: `.o_search_panel li[data-name="${dashboardDisplayName}"]`, + content: `click ${dashboardDisplayName} dashboard`, + run: "click", + }, + { + trigger: ".o-spreadsheet", + content: "dashboard is displayed", + }, + ] + ); +} diff --git a/addons/spreadsheet_dashboard/tests/__init__.py b/addons/spreadsheet_dashboard/tests/__init__.py new file mode 100644 index 00000000000..7535870d0b0 --- /dev/null +++ b/addons/spreadsheet_dashboard/tests/__init__.py @@ -0,0 +1 @@ +from . import test_spreadsheet_dashboard diff --git a/addons/spreadsheet_dashboard/tests/test_spreadsheet_dashboard.py b/addons/spreadsheet_dashboard/tests/test_spreadsheet_dashboard.py new file mode 100644 index 00000000000..f1b2d53f162 --- /dev/null +++ b/addons/spreadsheet_dashboard/tests/test_spreadsheet_dashboard.py @@ -0,0 +1,19 @@ +from odoo.tests.common import TransactionCase + + +class TestSpreadsheetDashboard(TransactionCase): + def test_create_with_default_values(self): + group = self.env["spreadsheet.dashboard.group"].create( + {"name": "a group"} + ) + dashboard = self.env["spreadsheet.dashboard"].create( + { + "name": "a dashboard", + "dashboard_group_id": group.id, + } + ) + self.assertEqual(dashboard.group_ids, self.env.ref("base.group_user")) + self.assertEqual( + dashboard.raw, + b'{"version": 1, "sheets": [{"id": "sheet1", "name": "Sheet1"}]}', + ) diff --git a/addons/spreadsheet_dashboard/views/menu_views.xml b/addons/spreadsheet_dashboard/views/menu_views.xml new file mode 100644 index 00000000000..c095e469a58 --- /dev/null +++ b/addons/spreadsheet_dashboard/views/menu_views.xml @@ -0,0 +1,43 @@ + + + + Dashboards + action_spreadsheet_dashboard + + + + + + + + + + Dashboards + spreadsheet.dashboard.group + tree,form + + + + + + + diff --git a/addons/spreadsheet_dashboard/views/spreadsheet_dashboard_views.xml b/addons/spreadsheet_dashboard/views/spreadsheet_dashboard_views.xml new file mode 100644 index 00000000000..fabc434f517 --- /dev/null +++ b/addons/spreadsheet_dashboard/views/spreadsheet_dashboard_views.xml @@ -0,0 +1,48 @@ + + + + + spreadsheet.dashboard.view.list + spreadsheet.dashboard + + + + + + + + + + + spreadsheet.dashboard.group.view.list + spreadsheet.dashboard.group + + + + + + + + + + spreadsheet.dashboard.group.view.form + spreadsheet.dashboard.group + + + +
+

+ +

+
+ + + + + +
+ +
+
+ +