Initial commit: Report packages

This commit is contained in:
Ernad Husremovic 2025-08-29 15:20:51 +02:00
commit bc5e1e9efa
604 changed files with 474102 additions and 0 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

View file

@ -0,0 +1,23 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="70" height="70" viewBox="0 0 70 70">
<defs>
<path id="icon-a" d="M4,5.35309892e-14 C36.4160122,9.87060235e-15 58.0836068,-3.97961823e-14 65,5.07020818e-14 C69,6.733808e-14 70,1 70,5 C70,43.0488877 70,62.4235458 70,65 C70,69 69,70 65,70 C61,70 9,70 4,70 C1,70 7.10542736e-15,69 7.10542736e-15,65 C7.25721566e-15,62.4676575 3.83358709e-14,41.8005206 3.60818146e-14,5 C-1.13686838e-13,1 1,5.75716207e-14 4,5.35309892e-14 Z"/>
<linearGradient id="icon-c" x1="100%" x2="0%" y1="0%" y2="100%">
<stop offset="0%" stop-color="#CD7690"/>
<stop offset="100%" stop-color="#CA5377"/>
</linearGradient>
<path id="icon-d" d="M18.0450069,57.225 C16.6239398,57.2249541 15.319401,56.4292666 14.6550625,55.1573449 C12.9601701,51.9125391 12,48.2137078 12,44.2875 C12,31.4261695 22.2974514,21 35,21 C47.7025486,21 58,31.4261695 58,44.2875 C58,48.2137078 57.0398299,51.9125391 55.3449375,55.1573449 C54.6806259,56.4292924 53.3760701,57.2249902 51.9549931,57.225 L18.0450069,57.225 Z M52.8888889,41.7 C51.4775035,41.7 50.3333333,42.8584723 50.3333333,44.2875 C50.3333333,45.7165277 51.4775035,46.875 52.8888889,46.875 C54.3002743,46.875 55.4444444,45.7165277 55.4444444,44.2875 C55.4444444,42.8584723 54.3002743,41.7 52.8888889,41.7 Z M35,28.7625 C36.4113854,28.7625 37.5555556,27.6040277 37.5555556,26.175 C37.5555556,24.7459723 36.4113854,23.5875 35,23.5875 C33.5886146,23.5875 32.4444444,24.7459723 32.4444444,26.175 C32.4444444,27.6040277 33.5886146,28.7625 35,28.7625 Z M17.1111111,41.7 C15.6997257,41.7 14.5555556,42.8584723 14.5555556,44.2875 C14.5555556,45.7165277 15.6997257,46.875 17.1111111,46.875 C18.5224965,46.875 19.6666667,45.7165277 19.6666667,44.2875 C19.6666667,42.8584723 18.5224965,41.7 17.1111111,41.7 Z M22.3506389,28.8925219 C20.9392535,28.8925219 19.7950833,30.0509941 19.7950833,31.4800219 C19.7950833,32.9090496 20.9392535,34.0675219 22.3506389,34.0675219 C23.7620243,34.0675219 24.9061944,32.9090496 24.9061944,31.4800219 C24.9061944,30.0509941 23.7620243,28.8925219 22.3506389,28.8925219 Z M47.6493611,28.8925219 C46.2379757,28.8925219 45.0938056,30.0509941 45.0938056,31.4800219 C45.0938056,32.9090496 46.2379757,34.0675219 47.6493611,34.0675219 C49.0607465,34.0675219 50.2049167,32.9090496 50.2049167,31.4800219 C50.2049167,30.0509941 49.0607465,28.8925219 47.6493611,28.8925219 Z M40.6952153,31.4423414 C39.686809,31.1156695 38.6082049,31.6784508 38.285566,32.6992195 L34.6181042,44.3034293 C31.9739028,44.501373 29.8888889,46.7346281 29.8888889,49.4625 C29.8888889,52.3205555 32.1772292,54.6375 35,54.6375 C37.8227708,54.6375 40.1111111,52.3205555 40.1111111,49.4625 C40.1111111,47.8636676 39.3946771,46.434559 38.269434,45.4852699 L41.9365764,33.8821113 C42.2591354,32.8612617 41.7033819,31.7690133 40.6952153,31.4423414 Z"/>
</defs>
<g fill="none" fill-rule="evenodd">
<mask id="icon-b" fill="#fff">
<use xlink:href="#icon-a"/>
</mask>
<g mask="url(#icon-b)">
<rect width="70" height="70" fill="url(#icon-c)"/>
<path fill="#FFF" fill-opacity=".383" d="M4,1.8 L65,1.8 C67.6666667,1.8 69.3333333,1.13333333 70,-0.2 C70,2.46666667 70,3.46666667 70,2.8 L1.10547097e-14,2.8 C-1.65952376e-14,3.46666667 -2.9161925e-14,2.46666667 -2.66453526e-14,-0.2 C0.666666667,1.13333333 2,1.8 4,1.8 Z" transform="matrix(1 0 0 -1 0 2.8)"/>
<path fill="#393939" d="M4,50 C2,50 -7.10542736e-15,49.851312 0,45.8367347 L0,26.3942795 L16.3536575,8.86200565 C29.4512192,-0.488174988 39.6666667,-2.3877551 47,3.16326531 C54.3333333,8.71428571 58,14.9591837 58,21.8979592 C55.8677728,29.7827578 54.7719047,33.7755585 54.7123959,33.8763613 C54.6528871,33.9771642 49.9857922,39.3517104 40.7111111,50 L4,50 Z" opacity=".324" transform="translate(0 20)"/>
<path fill="#000" fill-opacity=".383" d="M4,4 L65,4 C67.6666667,4 69.3333333,3 70,1 C70,3.66666667 70,5 70,5 L1.77635684e-15,5 C1.77635684e-15,5 1.77635684e-15,3.66666667 1.77635684e-15,1 C0.666666667,3 2,4 4,4 Z" transform="translate(0 65)"/>
<use fill="#000" fill-rule="nonzero" opacity=".3" xlink:href="#icon-d"/>
<path fill="#FFF" fill-rule="nonzero" d="M18.0450069,55.225 C16.6239398,55.2249541 15.319401,54.4292666 14.6550625,53.1573449 C12.9601701,49.9125391 12,46.2137078 12,42.2875 C12,29.4261695 22.2974514,19 35,19 C47.7025486,19 58,29.4261695 58,42.2875 C58,46.2137078 57.0398299,49.9125391 55.3449375,53.1573449 C54.6806259,54.4292924 53.3760701,55.2249902 51.9549931,55.225 L18.0450069,55.225 Z M52.8888889,39.7 C51.4775035,39.7 50.3333333,40.8584723 50.3333333,42.2875 C50.3333333,43.7165277 51.4775035,44.875 52.8888889,44.875 C54.3002743,44.875 55.4444444,43.7165277 55.4444444,42.2875 C55.4444444,40.8584723 54.3002743,39.7 52.8888889,39.7 Z M35,26.7625 C36.4113854,26.7625 37.5555556,25.6040277 37.5555556,24.175 C37.5555556,22.7459723 36.4113854,21.5875 35,21.5875 C33.5886146,21.5875 32.4444444,22.7459723 32.4444444,24.175 C32.4444444,25.6040277 33.5886146,26.7625 35,26.7625 Z M17.1111111,39.7 C15.6997257,39.7 14.5555556,40.8584723 14.5555556,42.2875 C14.5555556,43.7165277 15.6997257,44.875 17.1111111,44.875 C18.5224965,44.875 19.6666667,43.7165277 19.6666667,42.2875 C19.6666667,40.8584723 18.5224965,39.7 17.1111111,39.7 Z M22.3506389,26.8925219 C20.9392535,26.8925219 19.7950833,28.0509941 19.7950833,29.4800219 C19.7950833,30.9090496 20.9392535,32.0675219 22.3506389,32.0675219 C23.7620243,32.0675219 24.9061944,30.9090496 24.9061944,29.4800219 C24.9061944,28.0509941 23.7620243,26.8925219 22.3506389,26.8925219 Z M47.6493611,26.8925219 C46.2379757,26.8925219 45.0938056,28.0509941 45.0938056,29.4800219 C45.0938056,30.9090496 46.2379757,32.0675219 47.6493611,32.0675219 C49.0607465,32.0675219 50.2049167,30.9090496 50.2049167,29.4800219 C50.2049167,28.0509941 49.0607465,26.8925219 47.6493611,26.8925219 Z M40.6952153,29.4423414 C39.686809,29.1156695 38.6082049,29.6784508 38.285566,30.6992195 L34.6181042,42.3034293 C31.9739028,42.501373 29.8888889,44.7346281 29.8888889,47.4625 C29.8888889,50.3205555 32.1772292,52.6375 35,52.6375 C37.8227708,52.6375 40.1111111,50.3205555 40.1111111,47.4625 C40.1111111,45.8636676 39.3946771,44.434559 38.269434,43.4852699 L41.9365764,31.8821113 C42.2591354,30.8612617 41.7033819,29.7690133 40.6952153,29.4423414 Z"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 6.3 KiB

View file

@ -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);

View file

@ -0,0 +1,147 @@
/** @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 });
}
/**
* @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 });

View file

@ -0,0 +1,87 @@
.o_spreadsheet_dashboard_search_panel {
width: fit-content;
max-width: 200px;
align-items: center;
ul {
padding-inline-start: 0px
}
li {
padding: 4px 8px 4px 12px;
list-style-type: none;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
&:hover:not(.active) {
background-color: $o-gray-100;
}
}
}
.o_spreadsheet_dashboard_action {
background-color: white;
.o_renderer {
height: 100%;
.o-spreadsheet {
height: 100%;
.o-grid {
background-color: white;
}
canvas {
border-top: 0px;
}
}
}
.o_side_panel_filter_icon {
padding: 0;
}
.dashboard-loading-status {
margin: auto;
}
.o_cp_top_left {
flex: 1;
min-width: 0;
}
.o_cp_top_right {
display: flex;
align-items: flex-start;
flex: 4;
row-gap: 8px;
min-width: 0;
flex-wrap: wrap;
.o_filter_value_container {
width: 235px;
padding-right: 18px;
}
.o-filter-value {
min-height: 25px;
margin-left: 8px;
margin-right: 8px;
min-width: 100px;
.o_field_many2many_tags {
width: 100%;
}
.o_field_many2manytags,
.date_filter_values {
display: flex;
gap: 3px;
align-items: baseline;
}
}
}
}

View file

@ -0,0 +1,66 @@
<?xml version="1.0" encoding="utf-8"?>
<templates>
<div t-name="spreadsheet_dashboard.DashboardAction" owl="1" class="o_action o_spreadsheet_dashboard_action o_field_highlight">
<ControlPanel display="controlPanelDisplay">
<t t-set-slot="control-panel-top-right">
<t t-set="status" t-value="state.activeDashboard and state.activeDashboard.status"/>
<div t-if="status === Status.Loaded"
class="o_filter_value_container"
t-foreach="filters"
t-key="activeDashboardId + '_' + filter.id"
t-as="filter">
<FilterValue
filter="filter"
model="state.activeDashboard.model"
showTitle="true"
/>
</div>
</t>
</ControlPanel>
<t t-set="dashboard" t-value="state.activeDashboard"/>
<div class="o_content o_component_with_search_panel" t-att-class="{ o_mobile_dashboard: env.isSmall }">
<!-- Dashboard selection -->
<t t-if="env.isSmall">
<DashboardMobileSearchPanel
onDashboardSelected="(dashboardId) => this.openDashboard(dashboardId)"
activeDashboard="dashboard"
groups="getDashboardGroups()"/>
</t>
<t t-else="">
<div class="o_spreadsheet_dashboard_search_panel o_search_panel flex-grow-0 border-end flex-shrink-0 pe-2 pb-5 ps-4 h-100 bg-view overflow-auto">
<section t-foreach="getDashboardGroups()" t-as="group" t-key="group.id" class="o_search_panel_section o_search_panel_category">
<header class="o_search_panel_section_header pt-4 pb-2 text-uppercase o_cursor_default user-select-none">
<b t-esc="group.name"/>
</header>
<ul class="list-group d-block o_search_panel_field">
<li t-foreach="group.dashboards" t-as="dashboard" t-key="dashboard.id"
t-on-click="() => this.openDashboard(dashboard.id)"
t-esc="dashboard.displayName"
t-att-data-name="dashboard.displayName"
t-att-title="dashboard.displayName"
class="o_search_panel_category_value list-group-item cursor-pointer border-0"
t-att-class="{'active': dashboard.id === state.activeDashboard.id}"/>
</ul>
</section>
</div>
</t>
<!-- Main content -->
<h3 t-if="!dashboard" class="dashboard-loading-status">No available dashboard</h3>
<t t-else="">
<t t-set="status" t-value="dashboard.status"/>
<h3 t-if="status === Status.Loading" class="dashboard-loading-status">Loading...</h3>
<div t-elif="status === Status.Error" class="dashboard-loading-status error">
An error occured while loading the dashboard
</div>
<t t-else="">
<MobileFigureContainer t-if="env.isSmall" spreadsheetModel="dashboard.model" t-key="dashboard.id"/>
<div t-else="" class="o_renderer">
<Spreadsheet
model="dashboard.model"
t-key="dashboard.id"/>
</div>
</t>
</t>
</div>
</div>
</templates>

View file

@ -0,0 +1,225 @@
/** @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<number>} dashboardIds
*
* @typedef DashboardGroup
* @property {number} id
* @property {string} name
* @property {Array<Dashboard>} 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<DashboardGroupData>} */
this.groups = [];
/** @private @type {Object<number, Dashboard>} */
this.dashboards = {};
/** @private */
this.fetchDashboardData = fetchDashboardData;
}
/**
* @param {Array<DashboardGroupData>} groups
* @param {Object<number, Dashboard>} 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) {
dashboard.promise = this._loadDashboardData(dashboardId);
}
return dashboard;
}
/**
* @returns {Array<DashboardGroup>}
*/
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<DashboardGroupData>} groups
* @returns {Promise}
*/
_fetchDashboardNames(groups) {
return this.orm.read(
"spreadsheet.dashboard",
groups.map((group) => group.dashboardIds).flat(),
["name"]
);
}
/**
* @private
* @param {number} id
* @returns {Dashboard}
*/
_getDashboard(id) {
if (!this.dashboards[id]) {
this.dashboards[id] = { status: Status.NotLoaded, id, displayName: "" };
}
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;
throw error;
}
}
/**
* Activate the first sheet of a model
*
* @param {Model} model
*/
_activateFirstSheet(model) {
const sheetId = model.getters.getActiveSheetId();
const firstSheetId = model.getters.getSheetIds()[0];
if (firstSheetId !== sheetId) {
model.dispatch("ACTIVATE_SHEET", {
sheetIdFrom: sheetId,
sheetIdTo: firstSheetId,
});
}
}
/**
* @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
);
this._activateFirstSheet(model);
dataSources.addEventListener("data-source-updated", () => model.dispatch("EVALUATE_CELLS"));
return model;
}
}

View file

@ -0,0 +1,38 @@
/** @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,
}));
}
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";

View file

@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<templates>
<t t-name="documents_spreadsheet.MobileFigureContainer" owl="1">
<t t-if="!figures.length">
Only chart figures are displayed in small screens but this dashboard doesn't contain any
</t>
<div
t-foreach="figures" t-as="figure"
t-key="figure.id"
t-attf-style="min-height: #{figure.height}px;"
>
<t t-component="getFigureComponent(figure)" figure="figure"/>
</div>
</t>
</templates>

View file

@ -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,
},
};

View file

@ -0,0 +1,43 @@
<?xml version="1.0" encoding="utf-8"?>
<templates>
<div t-name="documents_spreadsheet.DashboardMobileSearchPanel" class="o_search_panel o_search_panel_summary btn w-100 overflow-visible" owl="1">
<t t-if="state.isOpen">
<t t-portal="'body'">
<div class="o_spreadsheet_dashboard_search_panel o_search_panel o_searchview o_mobile_search">
<div class="o_mobile_search_header">
<button type="button" class="o_mobile_search_button btn" t-on-click="() => this.state.isOpen = false">
<i class="fa fa-arrow-left" />
<strong class="ml8">BACK</strong>
</button>
</div>
<div class="o_mobile_search_content">
<div class="o_search_panel flex-grow-0 flex-shrink-0 border-end pe-2 pb-5 ps-4 h-100 bg-view overflow-auto">
<section t-foreach="props.groups" t-as="group" t-key="group.id" class="o_search_panel_section o_search_panel_category">
<header class="o_search_panel_section_header pt-4 pb-2 text-uppercase cursor-default">
<b t-esc="group.name"/>
</header>
<ul class="list-group d-block o_search_panel_field">
<li t-foreach="group.dashboards" t-as="dashboard" t-key="dashboard.id" t-on-click="() => this.onDashboardSelected(dashboard.id)" class="o_search_panel_category_value list-group-item py-1 o_cursor_pointer border-0 ps-0 pe-2">
<header class="list-group-item list-group-item-action d-flex align-items-center p-0 border-0" t-att-class="{'active text-900 fw-bold': dashboard.id === props.activeDashboard.id}">
<div class="o_search_panel_label d-flex align-items-center overflow-hidden w-100 o_cursor_pointer mb-0">
<!-- empty button to mimick the standard search panel -->
<button class="o_toggle_fold btn p-0 flex-shrink-0 text-center"/>
<span t-esc="dashboard.displayName" class="o_search_panel_label_title text-truncate"/>
</div>
</header>
</li>
</ul>
</section>
</div>
</div>
</div>
</t>
</t>
<div t-elif="props.groups.length" t-on-click="openDashboardSelection" class="d-flex align-items-center">
<i class="fa fa-fw fa-filter"/>
<div t-esc="searchBarText" class="o_search_panel_current_selection text-truncate ms-2 me-auto"/>
</div>
</div>
</templates>

View file

@ -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);
}
}
}
}
}

View file

@ -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);

View file

@ -0,0 +1,12 @@
/** @odoo-module */
import { SEE_RECORD_LIST, SEE_RECORD_LIST_VISIBLE } from "@spreadsheet/list/list_actions";
import spreadsheet from "@spreadsheet/o_spreadsheet/o_spreadsheet_extended";
const { clickableCellRegistry } = spreadsheet.registries;
clickableCellRegistry.add("list", {
condition: SEE_RECORD_LIST_VISIBLE,
action: SEE_RECORD_LIST,
sequence: 10,
});

View file

@ -0,0 +1,28 @@
/** @odoo-module */
import spreadsheet from "@spreadsheet/o_spreadsheet/o_spreadsheet_extended";
import { SEE_RECORDS_PIVOT, SEE_RECORDS_PIVOT_VISIBLE } from "@spreadsheet/pivot/pivot_actions";
import { getFirstPivotFunction } from "@spreadsheet/pivot/pivot_helpers";
const { clickableCellRegistry } = spreadsheet.registries;
clickableCellRegistry.add("pivot", {
condition: SEE_RECORDS_PIVOT_VISIBLE,
action: SEE_RECORDS_PIVOT,
sequence: 3,
});
clickableCellRegistry.add("pivot_set_filter_matching", {
condition: (cell, env) => {
return (
SEE_RECORDS_PIVOT_VISIBLE(cell, env) &&
getFirstPivotFunction(cell.content).functionName === "ODOO.PIVOT.HEADER" &&
env.model.getters.getFiltersMatchingPivot(cell.content).length > 0
);
},
action: (cell, env) => {
const filters = env.model.getters.getFiltersMatchingPivot(cell.content);
env.model.dispatch("SET_MANY_GLOBAL_FILTER_VALUE", { filters });
},
sequence: 2,
});

View file

@ -0,0 +1,96 @@
/** @odoo-module */
import spreadsheet from "@spreadsheet/o_spreadsheet/o_spreadsheet_extended";
import { getFixture } from "@web/../tests/helpers/utils";
import { getDashboardServerData } from "../utils/data";
import { createSpreadsheetDashboard } from "../utils/dashboard_action";
import { getBasicData } from "@spreadsheet/../tests/utils/data";
const { Model } = spreadsheet;
async function createDashboardWithModel(model) {
return createDashboardWithData(model.exportData());
}
async function createDashboardWithData(spreadsheetData) {
const serverData = getDashboardServerData();
const json = JSON.stringify(spreadsheetData);
const dashboard = serverData.models["spreadsheet.dashboard"].records[0];
dashboard.raw = json;
dashboard.json_data = json;
serverData.models = {
...serverData.models,
...getBasicData(),
};
await createSpreadsheetDashboard({ serverData, spreadsheetId: dashboard.id });
return getFixture();
}
QUnit.module("spreadsheet_dashboard > clickable cells");
QUnit.test("A link in a dashboard should be clickable", async (assert) => {
const data = {
sheets: [
{
cells: { A1: { content: "[Odoo](https://odoo.com)" } },
},
],
};
const model = new Model(data, { mode: "dashboard" });
const target = await createDashboardWithModel(model);
assert.containsOnce(target, ".o-dashboard-clickable-cell");
});
QUnit.test("Invalid pivot/list formulas should not be clickable", async (assert) => {
const data = {
sheets: [
{
cells: {
A1: { content: `=ODOO.PIVOT("1", "measure")` },
A2: { content: `=ODOO.LIST("1", 1, "name")` },
},
},
],
};
const model = new Model(data, { mode: "dashboard" });
const target = await createDashboardWithModel(model);
assert.containsNone(target, ".o-dashboard-clickable-cell");
});
QUnit.test("pivot/list formulas should be clickable", async (assert) => {
const data = {
sheets: [
{
cells: {
A1: { content: '=ODOO.PIVOT(1,"probability")' },
A2: { content: '=ODOO.LIST(1, 1, "foo")' },
},
},
],
pivots: {
1: {
id: 1,
colGroupBys: [],
domain: [],
measures: [{ field: "probability", operator: "avg" }],
model: "partner",
rowGroupBys: [],
context: {},
fieldMatching: {},
},
},
lists: {
1: {
id: 1,
columns: ["foo", "contact_name"],
domain: [],
model: "partner",
orderBy: [],
context: {},
fieldMatching: {},
},
},
};
const target = await createDashboardWithData(data);
assert.containsN(target, ".o-dashboard-clickable-cell", 2);
});

View file

@ -0,0 +1,254 @@
/** @odoo-module */
import {
getFixture,
click,
legacyExtraNextTick,
nextTick,
editInput,
} from "@web/../tests/helpers/utils";
import { getDashboardServerData } from "../utils/data";
import { getBasicData, getBasicListArchs } from "@spreadsheet/../tests/utils/data";
import { createSpreadsheetDashboard } from "../utils/dashboard_action";
import { registry } from "@web/core/registry";
import { errorService } from "@web/core/errors/error_service";
import { RPCError } from "@web/core/network/rpc_service";
QUnit.module("spreadsheet_dashboard > Dashboard > Dashboard action");
function getServerData(spreadsheetData) {
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,
},
];
return serverData;
}
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) => {
registry.category("services").add("error", errorService);
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))
) {
const error = new RPCError();
error.data = {};
throw error;
}
},
});
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("load dashboard that doesn't exist", async (assert) => {
registry.category("services").add("error", errorService);
await createSpreadsheetDashboard({
spreadsheetId: 999,
});
const fixture = getFixture();
assert.containsOnce(
fixture,
".o_spreadsheet_dashboard_action .dashboard-loading-status.error",
"It should display an error"
);
});
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 = getServerData(spreadsheetData);
const fixture = getFixture();
await createSpreadsheetDashboard({ serverData });
await click(fixture, ".o_search_panel li:last-child");
await click(fixture, ".o-dashboard-clickable-cell");
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");
}
);
QUnit.test(
"Can clear filter date filter value that defaults to current period",
async function (assert) {
const spreadsheetData = {
globalFilters: [
{
id: "1",
type: "date",
label: "Date Filter",
rangeType: "year",
defaultValue: {},
defaultsToCurrentPeriod: true,
pivotFields: {},
},
],
};
const serverData = getServerData(spreadsheetData);
const fixture = getFixture();
await createSpreadsheetDashboard({ serverData });
const year = fixture.querySelector(".o_cp_top_right input.o_datepicker_input");
const this_year = luxon.DateTime.local().year;
assert.equal(year.value, String(this_year));
const input = fixture.querySelector(
"input.o_datepicker_input.o_input.datetimepicker-input"
);
await click(input);
await editInput(input, null, String(this_year - 1));
await nextTick();
assert.equal(year.value, String(this_year - 1));
assert.containsOnce(fixture, ".o_cp_top_right .fa-times");
await click(fixture.querySelector(".o_cp_top_right .fa-times"));
assert.containsNone(fixture, ".o_cp_top_right .fa-times");
assert.equal(year.value, "");
}
);
QUnit.test("Global filter with same id is not shared between dashboards", async function (assert) {
const spreadsheetData = {
globalFilters: [
{
id: "1",
type: "relation",
label: "Relation Filter",
modelName: "product",
},
],
};
const serverData = getServerData(spreadsheetData);
serverData.models["spreadsheet.dashboard"].records.push({
id: 790,
name: "Spreadsheet dup. with Pivot",
json_data: JSON.stringify(spreadsheetData),
raw: JSON.stringify(spreadsheetData),
dashboard_group_id: 1,
});
serverData.models["spreadsheet.dashboard.group"].records[0].dashboard_ids = [789, 790];
const fixture = getFixture();
await createSpreadsheetDashboard({ serverData });
assert.containsNone(
fixture,
".o-filter-value .o_tag_badge_text",
"It should not display any filter value"
);
await click(fixture.querySelector(".o-autocomplete--input.o_input"));
await click(fixture.querySelector(".dropdown-item"));
assert.containsN(
fixture,
".o-filter-value .o_tag_badge_text",
1,
"It should not display any filter value"
);
await click(fixture.querySelector(".o_search_panel li:last-child"));
assert.containsNone(
fixture,
".o-filter-value .o_tag_badge_text",
"It should not display any filter value"
);
});

View file

@ -0,0 +1,259 @@
/** @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, patchWithCleanup } from "@web/../tests/helpers/utils";
import { getDashboardServerData } from "../utils/data";
import { waitForDataSourcesLoaded } from "@spreadsheet/../tests/utils/model";
import { getCellValue } from "@spreadsheet/../tests/utils/getters";
import { RPCError } from "@web/core/network/rpc_service";
/**
* @param {object} [params]
* @param {object} [params.serverData]
* @param {function} [params.mockRPC]
* @returns {Promise<DashboardLoader>}
*/
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"
) {
const error = new RPCError();
error.data = { message: "Bip" };
throw error;
}
},
});
await loader.load();
const result = loader.getDashboard(3);
assert.strictEqual(result.status, Status.Loading);
await result.promise.catch(() => assert.step("error"));
assert.strictEqual(result.status, Status.Error);
assert.strictEqual(result.error.data.message, "Bip");
assert.verifySteps(["error"], "error is thrown");
});
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");
});
QUnit.test("Model is in dashboard mode", async (assert) => {
patchWithCleanup(DashboardLoader.prototype, {
_activateFirstSheet: () => {
assert.step("activate sheet");
},
});
const loader = await createDashboardLoader();
await loader.load();
loader.getDashboard(3);
await nextTick();
assert.verifySteps(["activate sheet"]);
});

View file

@ -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"]);
});

View file

@ -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"
);
});

View file

@ -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,
},
});
}

View file

@ -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: {},
};
}