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

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