19.0 vanilla

This commit is contained in:
Ernad Husremovic 2026-03-09 09:32:02 +01:00
parent 62d197ac8b
commit 184bb0e321
667 changed files with 691406 additions and 239886 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.9 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Before After
Before After

View file

@ -1,23 +1 @@
<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>
<svg width="50" height="50" viewBox="0 0 50 50" xmlns="http://www.w3.org/2000/svg"><path d="M4 8a4 4 0 0 1 4-4h17v17a4 4 0 0 1-4 4H4V8Z" fill="#985184"/><path d="M4 35a4 4 0 0 1 4-4h11v11a4 4 0 0 1-4 4H4V35Z" fill="#088BF5"/><path d="M25 46h4a4 4 0 0 0 4-4V31h-4a4 4 0 0 0-4 4v11Z" fill="#1AD3BB"/><path d="M31 17v4a4 4 0 0 0 4 4h11v-4a4 4 0 0 0-4-4H31Z" fill="#F9464C"/><path d="M31 4v4a4 4 0 0 0 4 4h11V8a4 4 0 0 0-4-4H31Z" fill="#FC868B"/><path d="M38 46h4a4 4 0 0 0 4-4V31h-4a4 4 0 0 0-4 4v11Z" fill="#03AF89"/></svg>

Before

Width:  |  Height:  |  Size: 6.3 KiB

After

Width:  |  Height:  |  Size: 522 B

Before After
Before After

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

View file

@ -1,18 +1,4 @@
/** @odoo-module **/
import { _t } from "@web/core/l10n/translation";
import { addSpreadsheetActionLazyLoader } from "@spreadsheet/assets_backend/spreadsheet_action_loader";
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);
addSpreadsheetActionLazyLoader("action_spreadsheet_dashboard", "dashboards", _t("Dashboards"));

View file

@ -0,0 +1,17 @@
import { components } from "@odoo/o-spreadsheet";
import { patch } from "@web/core/utils/patch";
const { ChartDashboardMenu } = components;
patch(ChartDashboardMenu.prototype, {
get granularityOptions() {
return this.env.model.getters.getAvailableChartGranularities(this.props.chartId);
},
onGranularitySelected(ev) {
this.env.model.dispatch("UPDATE_CHART_GRANULARITY", {
chartId: this.props.chartId,
granularity: ev.target.value,
});
},
});

View file

@ -0,0 +1,53 @@
.o-spreadsheet .o-figure {
select {
// Firefox does not support custom select styles, it uses a native picker.
// reset a few things to un-break it with Firefox
appearance: none;
--form-select-bg-img: none;
border: none;
box-shadow: none;
}
select, ::picker(select) {
appearance: base-select;
}
select:hover, select:open {
color: $black;
opacity: 1 !important;
}
::picker(select) {
right: anchor(100%);
border: 1px solid #d8dadd;
border-radius: $border-radius;
}
select:not(select:open) {
border-color: transparent;
}
option:hover {
background-color: $dropdown-link-hover-bg;
}
option::checkmark {
font: .7em/1em FontAwesome;
color: $link-color;
content: "\f00c";
}
option:checked {
font-weight: bold;
}
select::picker-icon {
content: $o-caret-down;
opacity: 0.6;
transform: translateY(-12%);
}
select:open::picker-icon {
transform: rotate(180deg);
}
}

View file

@ -0,0 +1,16 @@
<odoo>
<div t-name="spreadsheet_dashboard.ChartDashboardMenu" t-inherit="o-spreadsheet-ChartDashboardMenu" t-inherit-mode="extension">
<xpath expr="//div[hasclass('o-dashboard-chart-select')]/div/button[last()]" position="before">
<t t-set="granularity" t-value="env.model.getters.getChartGranularity(props.chartId)?.granularity"/>
<t t-if="granularity and granularityOptions.length">
<select t-on-change="onGranularitySelected" class="position-relative o-chart-dashboard-item w-auto form-select rounded py-0 px-1 text-muted" name="chart-granularity">
<t t-foreach="granularityOptions" t-as="option" t-key="option.value">
<option t-att-value="option.value" t-att-selected="option.value === granularity">
<span t-esc="option.label"/>
</option>
</t>
</select>
</t>
</xpath>
</div>
</odoo>

View file

@ -0,0 +1,24 @@
import { components } from "@odoo/o-spreadsheet";
import { patch } from "@web/core/utils/patch";
patch(components.ChartJsComponent.prototype, {
createChart(chartData) {
if (this.env.model.getters.isDashboard()) {
chartData = this.addOdooMenuPluginToChartData(chartData);
}
super.createChart(chartData);
},
updateChartJs(chartData) {
if (this.env.model.getters.isDashboard()) {
chartData = this.addOdooMenuPluginToChartData(chartData);
}
super.updateChartJs(chartData);
},
addOdooMenuPluginToChartData(chartData) {
chartData.chartJsConfig.options.plugins.chartOdooMenuPlugin = {
env: this.env,
menu: this.env.model.getters.getChartOdooMenu(this.props.chartId),
};
return chartData;
},
});

View file

@ -1,43 +1,48 @@
/** @odoo-module */
import { _t } from "@web/core/l10n/translation";
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 { Status } from "./dashboard_loader_service";
import { SpreadsheetComponent } from "@spreadsheet/actions/spreadsheet_component";
import { useSetupAction } from "@web/search/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";
import { standardActionServiceProps } from "@web/webclient/actions/action_service";
import { SpreadsheetShareButton } from "@spreadsheet/components/share_button/share_button";
import { useSpreadsheetPrint } from "@spreadsheet/hooks";
import { Registry } from "@odoo/o-spreadsheet";
import { router } from "@web/core/browser/router";
import { useSearchBarToggler } from "@web/search/search_bar/search_bar_toggler";
const { Spreadsheet } = spreadsheet;
const { Component, onWillStart, useState, useEffect } = owl;
import { Component, onWillStart, useState, useEffect, useExternalListener } from "@odoo/owl";
import { DashboardSearchBar } from "./dashboard_search_bar/dashboard_search_bar";
export const dashboardActionRegistry = new Registry();
export class SpreadsheetDashboardAction extends Component {
static template = "spreadsheet_dashboard.DashboardAction";
static path = "dashboards";
static components = {
ControlPanel,
SpreadsheetComponent,
DashboardMobileSearchPanel,
MobileFigureContainer,
SpreadsheetShareButton,
DashboardSearchBar,
};
static props = { ...standardActionServiceProps };
static displayName = _t("Dashboards");
setup() {
this.Status = Status;
this.controlPanelDisplay = {
"top-left": true,
"top-right": true,
"bottom-left": false,
"bottom-right": false,
};
this.controlPanelDisplay = {};
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)
);
this.actionService = useService("action");
this.loader = useService("spreadsheet_dashboard_loader");
onWillStart(async () => {
await loadSpreadsheetDependencies();
if (this.props.state && this.props.state.dashboardLoader) {
const { groups, dashboards } = this.props.state.dashboardLoader;
this.loader.restoreFromState(groups, dashboards);
const state = this.props.state.dashboardLoader;
this.loader.restoreFromState(state);
} else {
await this.loader.load();
}
@ -47,12 +52,12 @@ export class SpreadsheetDashboardAction extends Component {
}
});
useEffect(
() => this.router.pushState({ dashboard_id: this.activeDashboardId }),
() => router.pushState({ dashboard_id: this.activeDashboardId }),
() => [this.activeDashboardId]
);
useEffect(
() => {
const dashboard = this.state.activeDashboard;
const dashboard = this.loader.getActiveDashboard();
if (dashboard && dashboard.status === Status.Loaded) {
const render = () => this.render(true);
dashboard.model.on("update", this, render);
@ -60,55 +65,71 @@ export class SpreadsheetDashboardAction extends Component {
}
},
() => {
const dashboard = this.state.activeDashboard;
return [dashboard && dashboard.model, dashboard && dashboard.status];
const dashboard = this.loader.getActiveDashboard();
return [dashboard?.model, dashboard?.status];
}
);
useExternalListener(window, "afterprint", this.logExport.bind(this));
useSetupAction({
getLocalState: () => {
return {
activeDashboardId: this.activeDashboardId,
dashboardLoader: this.loader.getState(),
};
},
getLocalState: () => ({
dashboardLoader: this.loader.getState(),
}),
});
/** @type {{ activeDashboard: import("./dashboard_loader").Dashboard}} */
this.state = useState({ activeDashboard: undefined });
useSpreadsheetPrint(() => this.loader.getActiveDashboard()?.model);
/** @type {{ sidebarExpanded: boolean}} */
this.state = useState({ sidebarExpanded: true });
this.searchBarToggler = useSearchBarToggler();
}
get dashboardButton() {
return dashboardActionRegistry.getAll()[0];
}
/**
* @returns {number | undefined}
*/
get activeDashboardId() {
return this.state.activeDashboard ? this.state.activeDashboard.id : undefined;
return this.loader.getActiveDashboard()
? this.loader.getActiveDashboard().data.id
: undefined;
}
/**
* @returns {object[]}
*/
get filters() {
const dashboard = this.state.activeDashboard;
const dashboard = this.loader.getActiveDashboard();
if (!dashboard || dashboard.status !== Status.Loaded) {
return [];
}
return dashboard.model.getters.getGlobalFilters();
}
setGlobalFilterValue(id, value, displayNames) {
this.loader.getActiveDashboard().model.dispatch("SET_GLOBAL_FILTER_VALUE", {
id,
value,
displayNames,
});
}
/**
* @private
* @returns {number | undefined}
*/
getInitialActiveDashboard() {
if (this.props.state && this.props.state.activeDashboardId) {
return this.props.state.activeDashboardId;
const activeDashboardId = this.props.state?.dashboardLoader?.activeDashboardId;
if (activeDashboardId) {
return activeDashboardId;
}
const params = this.props.action.params || this.props.action.context.params;
const params = this.props.action.params;
if (params && params.dashboard_id) {
return params.dashboard_id;
}
const [firstSection] = this.getDashboardGroups();
if (firstSection && firstSection.dashboards.length) {
return firstSection.dashboards[0].id;
return firstSection.dashboards[0].data.id;
}
}
@ -120,27 +141,62 @@ export class SpreadsheetDashboardAction extends Component {
* @param {number} dashboardId
*/
openDashboard(dashboardId) {
this.state.activeDashboard = this.loader.getDashboard(dashboardId);
this.loader.activateDashboard(dashboardId);
}
/**
* @private
* @param {number} dashboardId
* @returns {Promise<{ data: string, revisions: object[] }>}
* @param {number} id - The ID of the dashboard to be edited.
* @returns {Promise<void>}
*/
async _fetchDashboardData(dashboardId) {
const [record] = await this.orm.read("spreadsheet.dashboard", [dashboardId], ["raw"]);
return { data: record.raw, revisions: [] };
async editDashboard(id) {
const action = await this.env.services.orm.call(
"spreadsheet.dashboard",
"action_edit_dashboard",
[id]
);
this.actionService.doAction(action);
}
async shareSpreadsheet(data, excelExport) {
const url = await this.orm.call("spreadsheet.dashboard.share", "action_get_share_url", [
{
dashboard_id: this.activeDashboardId,
spreadsheet_data: JSON.stringify(data),
excel_files: excelExport.files,
},
]);
return url;
}
async toggleFavorite() {
if (!this.loader.getActiveDashboard()) {
return;
}
const { id, is_favorite } = this.loader.getActiveDashboard().data;
await this.orm.call("spreadsheet.dashboard", "action_toggle_favorite", [id]);
this.loader.getActiveDashboard().data.is_favorite = !is_favorite;
}
toggleSidebar() {
this.state.sidebarExpanded = !this.state.sidebarExpanded;
}
get activeDashboardGroupName() {
return this.getDashboardGroups().find(
(group) =>
group.id !== "favorites" && // Skip the FAVORITES group
group.dashboards.some(({ data }) => data.id === this.activeDashboardId)
)?.name;
}
logExport() {
const dashboard = this.state.activeDashboard;
if (!dashboard || dashboard.status !== Status.Loaded) {
return;
}
this.model.dispatch("LOG_DATASOURCE_EXPORT", { action: "print" });
}
}
SpreadsheetDashboardAction.template = "spreadsheet_dashboard.DashboardAction";
SpreadsheetDashboardAction.components = {
ControlPanel,
Spreadsheet,
FilterValue,
DashboardMobileSearchPanel,
MobileFigureContainer,
};
registry
.category("actions")

View file

@ -4,21 +4,37 @@
align-items: center;
ul {
padding-inline-start: 0px
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_dashboard_name {
overflow: hidden;
text-overflow: ellipsis;
padding-right: 8px;
}
.o_edit_dashboard {
padding: 0;
visibility: hidden;
opacity: 0;
transition: visibility 0.2s, opacity 0.2s ease-in-out;
}
}
.o_search_panel_category_value:hover .o_edit_dashboard,
.o_search_panel_category_value:focus-within .o_edit_dashboard {
visibility: visible;
opacity: 1;
}
}
.o_spreadsheet_dashboard_action {
@ -40,10 +56,6 @@
}
}
.o_side_panel_filter_icon {
padding: 0;
}
.dashboard-loading-status {
margin: auto;
}
@ -59,29 +71,51 @@
flex: 4;
row-gap: 8px;
min-width: 0;
flex-wrap: wrap;
}
.o_filter_value_container {
width: 235px;
padding-right: 18px;
/* In mobile */
@media (max-width: 768px){
.o_control_panel {
/* We don't want the control panel to disappear when scrolling */
top: 0px !important
}
.o_control_panel_main {
/* We don't want a gap before the DashboardMobileSearchPanel */
column-gap: 0px !important;
}
}
.o_control_panel_actions {
gap: map-get($spacers, 2);
overflow: auto;
flex-wrap: wrap;
justify-content: start !important;
align-items: start !important;
.o-filter-value {
min-height: 25px;
margin-left: 8px;
margin-right: 8px;
min-width: 100px;
flex-basis: 100px;
flex-grow: 1;
.o_field_many2many_tags {
.o_multi_record_selector {
width: 100%;
}
.o_field_many2manytags,
.date_filter_values {
display: flex;
gap: 3px;
align-items: baseline;
.o_input {
flex: 1 0 1rem;
}
}
}
}
.o_dashboard_star {
margin: auto 0;
cursor: pointer;
&.fa-star-o {
@include o-hover-text-color($o-main-color-muted, $o-main-favorite-color);
}
&.fa-star {
color: $o-main-favorite-color;
}
}
}

View file

@ -1,49 +1,43 @@
<?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">
<div t-name="spreadsheet_dashboard.DashboardAction" 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"
<t t-set-slot="layout-actions" t-if="!loader.getActiveDashboard()?.isSample">
<t t-set="status" t-value="loader.getActiveDashboard() and loader.getActiveDashboard().status"/>
<div class="d-flex flex-wrap w-100">
<DashboardSearchBar t-if="status === Status.Loaded and loader.getActiveDashboard().model.getters.getGlobalFilters().length"
t-key="activeDashboardId"
model="loader.getActiveDashboard().model"
toggler="searchBarToggler"
/>
</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>
<t t-set-slot="control-panel-navigation-additional" t-if="!loader.getActiveDashboard()?.isSample">
<!-- Dashboard selection -->
<t t-if="env.isSmall">
<DashboardMobileSearchPanel
onDashboardSelected="(dashboardId) => this.openDashboard(dashboardId)"
activeDashboard="dashboard"
groups="getDashboardGroups()"/>
</t>
<SpreadsheetShareButton t-key="activeDashboardId" model="loader.getActiveDashboard()?.model" onSpreadsheetShared.bind="shareSpreadsheet" togglerClass="'btn-light'"/>
<a
t-if="loader.getActiveDashboard()"
title="Toggle favorite"
t-on-click="toggleFavorite"
t-attf-class="o_dashboard_star align-middle lh-base fa fa-lg fa-star{{!loader.getActiveDashboard().data.is_favorite ? '-o' : ''}}"
/>
<div class="ms-1 o_search_toggler" t-if="env.isSmall">
<t t-component="searchBarToggler.component" t-props="searchBarToggler.props"/>
</div>
</t>
</ControlPanel>
<t t-set="dashboard" t-value="loader.getActiveDashboard()"/>
<div class="o_content o_component_with_search_panel" t-att-class="{ o_mobile_dashboard: env.isSmall }">
<t t-if="!env.isSmall">
<t t-if="state.sidebarExpanded" t-call="spreadsheet_dashboard.DashboardAction.Expanded"/>
<t t-else="" t-call="spreadsheet_dashboard.DashboardAction.Collapsed"/>
</t>
<!-- Main content -->
<h3 t-if="!dashboard" class="dashboard-loading-status">No available dashboard</h3>
<t t-else="">
@ -53,14 +47,59 @@
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
<MobileFigureContainer t-if="env.isSmall" spreadsheetModel="dashboard.model" t-key="dashboard.data.id"/>
<div t-else="" class="o_renderer" t-att-class="{'o-sample-dashboard': dashboard.isSample}">
<SpreadsheetComponent
model="dashboard.model"
t-key="dashboard.id"/>
t-key="dashboard.data.id"/>
</div>
</t>
</t>
</div>
</div>
<t t-name="spreadsheet_dashboard.DashboardAction.Collapsed">
<div class="bg-view h-100 o_search_panel_sidebar cursor-pointer" t-on-click="toggleSidebar">
<div class="d-flex">
<button class="btn btn-light btn-sm m-1 mb-2 p-2">
<i class="fa fa-angle-double-right"/>
</button>
<div class="mx-auto" t-if="loader.getActiveDashboard()">
<span class="fw-bolder" t-esc="activeDashboardGroupName"/>
/
<t t-esc="loader.getActiveDashboard().data.name"/>
</div>
</div>
</div>
</t>
<t t-name="spreadsheet_dashboard.DashboardAction.Expanded">
<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 position-relative">
<button t-if="!env.isSmall and loader.getActiveDashboard()" class="btn btn-light btn-sm end-0 m-1 mb-2 position-absolute px-2 py-1 top-0 z-1" t-on-click="toggleSidebar">
<i class="fa fa-fw fa-angle-double-left"/>
</button>
<div class="mt-2"/>
<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.data.id"
t-on-click="() => this.openDashboard(dashboard.data.id)"
t-att-data-name="dashboard.data.name"
t-att-title="dashboard.data.name"
class="o_search_panel_category_value list-group-item cursor-pointer border-0 d-flex justify-content-between align-items-center"
t-att-class="{'active': dashboard.data.id === loader.getActiveDashboard().data.id}">
<div class="o_dashboard_name">
<t t-esc="dashboard.data.name" />
</div>
<t t-set="comp" t-value="dashboardButton"/>
<t t-if="comp">
<t t-component="comp" t-props="{ dashboardId: dashboard.data.id, onClick: this.editDashboard.bind(this), data: dashboard.data }"/>
</t>
</li>
</ul>
</section>
</div>
</t>
</templates>

View file

@ -0,0 +1,49 @@
import { Component } from "@odoo/owl";
import { DateFilterDropdown } from "@spreadsheet/global_filters/components/date_filter_dropdown/date_filter_dropdown";
import {
dateFilterValueToString,
getNextDateFilterValue,
getPreviousDateFilterValue,
} from "@spreadsheet/global_filters/helpers";
import { Dropdown } from "@web/core/dropdown/dropdown";
/**
* This component is used to select a date filter value in a dashboard.
* It allows the user to select a month, quarter, year, or a custom date range.
* It also provides options for relative periods like "last 7 days", and
* buttons to navigate through the previous and next periods.
*/
export class DashboardDateFilter extends Component {
static template = "spreadsheet_dashboard.DashboardDateFilter";
static components = { Dropdown, DateFilterDropdown };
static props = {
value: { type: Object, optional: true },
update: Function,
};
get inputValue() {
return dateFilterValueToString(this.props.value);
}
selectPrevious() {
if (!this.props.value?.type) {
return;
}
const previous = getPreviousDateFilterValue(this.props.value);
if (!previous) {
return;
}
this.props.update(previous);
}
selectNext() {
if (!this.props.value?.type) {
return;
}
const next = getNextDateFilterValue(this.props.value);
if (!next) {
return;
}
this.props.update(next);
}
}

View file

@ -0,0 +1,4 @@
.o-btn-date-filter {
min-width: 150px;
text-align: left;
}

View file

@ -0,0 +1,23 @@
<?xml version="1.0" encoding="utf-8"?>
<templates>
<t t-name="spreadsheet_dashboard.DashboardDateFilter">
<Dropdown navigationOptions="{ 'shouldFocusChildInput': false }">
<button class="btn btn-secondary o-btn-date-filter">
<i class="fa fa-calendar me-2"/>
<span class="o-date-filter-value" t-esc="inputValue"/>
</button>
<t t-set-slot="content">
<DateFilterDropdown
value="props.value"
update="props.update"
/>
</t>
</Dropdown>
<button class="btn btn-secondary ms-1 btn-previous-date" t-on-click="this.selectPrevious" t-att-disabled="this.props.value === undefined">
<i class="fa fa-caret-left"/>
</button>
<button class="btn btn-secondary ms-1 btn-next-date" t-on-click="this.selectNext" t-att-disabled="this.props.value === undefined">
<i class="fa fa-caret-right"/>
</button>
</t>
</templates>

View file

@ -0,0 +1,11 @@
import { Component } from "@odoo/owl";
export class DashboardFacet extends Component {
static template = "spreadsheet_dashboard.DashboardFacet";
static components = {};
static props = {
facet: Object,
clearFilter: Function,
onClick: Function,
};
}

View file

@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<templates>
<t t-name="spreadsheet_dashboard.DashboardFacet">
<div class="o_searchview_facet position-relative d-inline-flex align-items-stretch rounded-2 bg-200 text-nowrap opacity-trigger-hover mw-100 o_facet_with_domain" tabindex="0">
<div class="position-absolute start-0 top-0 bottom-0 end-0 bg-view border rounded-2 shadow opacity-0 opacity-100-hover"></div>
<div class="o_searchview_facet_label position-relative rounded-start-2 px-1 rounded-end-0 p-0 btn btn-primary" t-on-click.stop="props.onClick">
<small class="px-1" t-esc="props.facet.title"/>
<span class="position-absolute start-0 top-0 bottom-0 end-0 bg-inherit opacity-0 opacity-100-hover">
<i class="fa fa-fw fa-cog"></i>
</span>
</div>
<div class="o_facet_values position-relative d-flex flex-wrap align-items-center rounded-end-2 text-wrap overflow-hidden">
<span><small class="o_facet_values_sep small mx-1 opacity-50" t-esc="props.facet.operator" t-att-title="props.facet.operator"/></span>
<span t-foreach="props.facet.values" t-as="value" t-key="value_index" class="d-flex">
<em t-if="!value_first" class="o_facet_values_sep small fw-bold mx-1 opacity-50" t-esc="props.facet.separator"/>
<small class="o_facet_value text-truncate" t-esc="value" t-att-title="value"/>
</span>
</div>
<button class="o_facet_remove oi oi-close btn btn-link py-0 px-2 text-danger d-print-none" title="Remove" t-on-click.stop="props.clearFilter" />
</div>
</t>
</templates>

View file

@ -1,225 +0,0 @@
/** @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,291 @@
import { reactive } from "@odoo/owl";
import { Model } from "@odoo/o-spreadsheet";
import { registry } from "@web/core/registry";
import { OdooDataProvider } from "@spreadsheet/data_sources/odoo_data_provider";
import { createDefaultCurrency } from "@spreadsheet/currency/helpers";
import { _t } from "@web/core/l10n/translation";
/**
* @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 {Object} data
* @property {string} status
* @property {Model} [model]
* @property {Error} [error]
*
* @typedef DashboardGroupData
* @property {number} id
* @property {string} name
* @property {Array<{id: number, name: string}>} dashboards
*
* @typedef DashboardGroup
* @property {number} id
* @property {string} name
* @property {Array<Dashboard>} dashboards
*
* @typedef {import("@web/env").OdooEnv} OdooEnv
*
* @typedef {import("@web/core/orm_service").ORM} ORM
*/
export class DashboardLoader {
/**
* @param {OdooEnv} env
* @param {ORM} orm
*/
constructor(env, orm, geoJsonService) {
/** @private */
this.env = env;
/** @private */
this.orm = orm;
/** @private @type {Array<DashboardGroupData>} */
this.groups = [];
/** @private @type {Object<number, Dashboard>} */
this.dashboards = {};
this.geoJsonService = geoJsonService;
}
/**
* Restore the state of the dashboard loader
* @param {Object} state
* @param {Array<DashboardGroupData>} state.groups
* @param {Record<number, Dashboard>} state.dashboards
* @param {number} state.activeDashboardId
*/
restoreFromState({ groups, dashboards, activeDashboardId }) {
this.groups = groups;
this.dashboards = dashboards;
this.activeDashboardId = activeDashboardId;
}
/**
* Return data needed to restore a dashboard loader
*/
getState() {
return {
groups: this.groups,
dashboards: this.dashboards,
activeDashboardId: this.activeDashboardId,
};
}
async load() {
const groups = await this._fetchGroups();
this.groups = groups
.filter((group) => group.published_dashboard_ids.length)
.map((group) => ({
id: group.id,
name: group.name,
dashboards: group.published_dashboard_ids,
}));
const dashboards = this.groups.map((group) => group.dashboards).flat();
for (const dashboard of dashboards) {
this.dashboards[dashboard.id] = {
data: dashboard,
status: Status.NotLoaded,
};
}
}
activateDashboard(dashboardId) {
this.activeDashboardId = dashboardId;
}
/**
* @returns {Dashboard | undefined}
*/
getActiveDashboard() {
if (!this.activeDashboardId) {
return undefined;
}
return this.getDashboard(this.activeDashboardId);
}
/**
* @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() {
const favoriteDashboards = this._getFavoriteDashboards();
const dashboardGroups = this.groups.map((section) => ({
id: section.id,
name: section.name,
dashboards: section.dashboards.map((dashboard) => ({
data: dashboard,
status: this._getDashboard(dashboard.id).status,
})),
}));
return favoriteDashboards.length
? [
{ id: "favorites", name: _t("FAVORITES"), dashboards: favoriteDashboards },
...dashboardGroups,
]
: dashboardGroups;
}
clear() {
this.groups = [];
this.dashboards = {};
this.activeDashboardId = undefined;
}
/**
* @private
* @returns {Promise<{id: number, name: string, published_dashboard_ids: number[]}[]>}
*/
async _fetchGroups() {
const groups = await this.orm.webSearchRead(
"spreadsheet.dashboard.group",
[["published_dashboard_ids", "!=", false]],
{ specification: this._getFetchGroupsSpecification() }
);
return groups.records;
}
_getFetchGroupsSpecification() {
return {
name: {},
published_dashboard_ids: { fields: { name: {}, is_favorite: {} } },
};
}
/**
* Filters and returns an array of favorite dashboards.
* @returns {Array<Dashboard>}
*/
_getFavoriteDashboards() {
const favoriteDashboards = [];
this.groups
.flatMap((group) => group.dashboards)
.forEach((dashboardData) => {
if (dashboardData.is_favorite) {
favoriteDashboards.push(this._getDashboard(dashboardData.id));
}
});
return favoriteDashboards;
}
/**
* @private
* @param {number} id
* @returns {Dashboard}
*/
_getDashboard(id) {
if (!this.dashboards[id]) {
this.dashboards[id] = { status: Status.NotLoaded, id, data: {} };
}
return this.dashboards[id];
}
/**
* @private
* @param {number} dashboardId
*/
async _loadDashboardData(dashboardId) {
const dashboard = this._getDashboard(dashboardId);
dashboard.status = Status.Loading;
try {
const result = await this.env.services.http.get(
`/spreadsheet/dashboard/data/${dashboardId}`
);
const { snapshot, revisions, default_currency, is_sample, translation_namespace } =
result;
dashboard.translationNamespace = translation_namespace;
dashboard.model = this._createSpreadsheetModel(
snapshot,
revisions,
default_currency,
translation_namespace
);
dashboard.status = Status.Loaded;
dashboard.isSample = is_sample;
} 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 {object} snapshot
* @param {object[]} revisions
* @param {object} [defaultCurrency]
* @returns {Model}
*/
_createSpreadsheetModel(snapshot, revisions = [], currency, translationNamespace) {
const odooDataProvider = new OdooDataProvider(this.env);
const model = new Model(
snapshot,
{
custom: { env: this.env, orm: this.orm, odooDataProvider, translationNamespace },
mode: "dashboard",
defaultCurrency: createDefaultCurrency(currency),
external: { geoJsonService: this.geoJsonService },
},
revisions
);
this._activateFirstSheet(model);
odooDataProvider.addEventListener("data-source-updated", () =>
model.dispatch("EVALUATE_CELLS")
);
return model;
}
}
const dashboardLoaderService = {
dependencies: ["orm", "geo_json_service"],
start(env) {
const loader = new DashboardLoader(env, env.services.orm, env.services.geo_json_service);
env.bus.addEventListener("ACTION_MANAGER:UPDATE", () => {
loader.clear();
});
return reactive(loader);
},
};
registry.category("services").add("spreadsheet_dashboard_loader", dashboardLoaderService);

View file

@ -0,0 +1,32 @@
import { registry } from "@web/core/registry";
import * as spreadsheet from "@odoo/o-spreadsheet";
import { dynamicSpreadsheetTranslate } from "@spreadsheet/o_spreadsheet/translation";
const { urlRegistry } = spreadsheet.registries;
export const dashboardMenuTranslateService = {
dependencies: ["spreadsheet_dashboard_loader", "spreadsheetLinkMenuCell"],
start(env) {
const dashboardLoader = env.services.spreadsheet_dashboard_loader;
for (const key of urlRegistry.getKeys()) {
const linkSpec = urlRegistry.get(key);
urlRegistry.replace(key, {
...linkSpec,
// Override createLink to translate the label
createLink(url, label) {
const dashboard = dashboardLoader.getActiveDashboard();
const translatedLabel = dashboard
? dynamicSpreadsheetTranslate(dashboard.translationNamespace, label)
: label;
return linkSpec.createLink(url, translatedLabel);
},
});
}
},
};
registry
.category("services")
.add("spreadsheet_dashboard_menu_translate", dashboardMenuTranslateService);

View file

@ -0,0 +1,495 @@
import { Component, onWillUpdateProps, onWillStart, useState, status } from "@odoo/owl";
import { DashboardFacet } from "../dashboard_facet/dashboard_facet";
import { useService, useChildRef, useAutofocus } from "@web/core/utils/hooks";
import { useDropdownState } from "@web/core/dropdown/dropdown_hooks";
import { DashboardDateFilter } from "../dashboard_date_filter/dashboard_date_filter";
import { FilterValuesList } from "@spreadsheet/global_filters/components/filter_values_list/filter_values_list";
import { getFacetInfo } from "@spreadsheet/global_filters/helpers";
import { _t } from "@web/core/l10n/translation";
import { fuzzyTest, fuzzyLookup } from "@web/core/utils/search";
import { Dropdown } from "@web/core/dropdown/dropdown";
import { DropdownItem } from "@web/core/dropdown/dropdown_item";
import { KeepLast } from "@web/core/utils/concurrency";
let nextItemId = 1;
const SUB_ITEMS_DEFAULT_LIMIT = 8;
export class DashboardSearchBar extends Component {
static template = "spreadsheet_dashboard.DashboardSearchBar";
static components = {
DashboardFacet,
DashboardDateFilter,
FilterValuesList,
Dropdown,
DropdownItem,
};
static props = { model: Object, toggler: Object };
setup() {
this.facets = [];
this.firstDateFilter = undefined;
this.nameService = useService("name");
this.orm = useService("orm");
this.keepLast = new KeepLast();
this.fields = useService("field");
this.inputRef = useAutofocus("autofocus");
this.state = useState({
showDropdown: false,
expanded: [],
query: "",
subItemsLimits: {},
});
this.visibilityState = useState(this.props.toggler.state);
this.items = useState([]);
this.subItems = {};
this.filtersValuesDropdown = useDropdownState();
this.inputDropdownState = useDropdownState();
this.inputDropdownNavOptions = this.getDropdownNavigation();
this.menuRef = useChildRef();
onWillStart(this.computeState.bind(this));
onWillUpdateProps(this.computeState.bind(this));
}
openFilterValueDropdown() {
this.filtersValuesDropdown.open();
}
closeFilterValueDropdown() {
this.filtersValuesDropdown.close();
}
toggleFilterValueDropdown() {
this.filtersValuesDropdown.isOpen
? this.filtersValuesDropdown.close()
: this.filtersValuesDropdown.open();
}
clearFilter(id) {
this.props.model.dispatch("SET_GLOBAL_FILTER_VALUE", { id });
}
updateFirstDateFilter(value) {
this.props.model.dispatch("SET_GLOBAL_FILTER_VALUE", {
id: this.firstDateFilter.id,
value,
});
}
get firstDateFilterValue() {
if (!this.firstDateFilter) {
return undefined;
}
return this.props.model.getters.getGlobalFilterValue(this.firstDateFilter.id);
}
onSearchClick() {
const query = this.inputRef?.el.value;
if (query.trim()) {
this.inputDropdownState.open();
this.computeState({ query, expanded: [], subItems: [] });
} else {
this.inputDropdownState.close();
this.resetState();
this.filtersValuesDropdown.open();
}
}
onSearchInput(ev) {
const query = ev.target.value;
if (query.trim()) {
this.inputDropdownState.open();
this.computeState({ query, expanded: [], subItems: [] });
} else if (this.items.length) {
this.inputDropdownState.close();
this.resetState();
}
}
onSearchInputKeydown(ev) {
if (ev.key === "Backspace" || ev.key === "Delete") {
const lastFacet = this.facets[this.facets.length - 1];
if (ev.target.selectionStart === 0 && ev.target.selectionEnd === 0 && lastFacet) {
this.clearFilter(lastFacet.id);
}
}
}
onSearchInputPointerDown(ev) {
if (this.env.isSmall) {
// Prevent the input from being focused on mobile, as it opens the keyboard
ev.preventDefault();
}
}
/**
* @param {Object} [options={}]
* @param {number[]} [options.expanded]
* @param {string} [options.query]
* @param {Object[]} [options.subItems]
* @returns {Object[]}
*/
async computeState(options = {}) {
const query = "query" in options ? options.query : this.state.query;
const expanded = "expanded" in options ? options.expanded : this.state.expanded;
const subItems = "subItems" in options ? options.subItems : this.subItems;
const tasks = [];
for (const id of expanded) {
if (!subItems[id]) {
if (!this.state.subItemsLimits[id]) {
this.state.subItemsLimits[id] = SUB_ITEMS_DEFAULT_LIMIT;
}
tasks.push({ id, prom: this.computeSubItems(this.getGlobalFilter(id), query) });
}
}
if (tasks.length) {
const taskResults = await this.keepLast.add(
Promise.all(tasks.map((task) => task.prom))
);
tasks.forEach((task, index) => {
subItems[task.id] = taskResults[index];
});
}
this.state.expanded = expanded;
this.state.query = query;
this.subItems = subItems;
if (this.inputRef.el) {
this.inputRef.el.value = query;
}
const filters = this.props.model.getters.getGlobalFilters();
const firstDateFilterIndex = filters.findIndex((filter) => filter.type === "date");
if (firstDateFilterIndex !== -1) {
this.firstDateFilter = filters.splice(firstDateFilterIndex, 1)[0];
}
this.facets = await Promise.all(
filters
.filter((filter) => this.props.model.getters.isGlobalFilterActive(filter.id))
.map((filter) => this.getFacetFor(filter))
);
this.items.length = 0;
const trimmedQuery = this.state.query.trim();
if (trimmedQuery) {
for (const globalFilter of this.searchableGlobalFilters) {
this.items.push(...this.getItems(globalFilter, trimmedQuery));
}
}
}
async getFacetFor(filter) {
const filterValue = this.props.model.getters.getGlobalFilterValue(filter.id);
return getFacetInfo(this.env, filter, filterValue);
}
getItems(globalFilter, trimmedQuery) {
const items = [];
if (globalFilter.type === "boolean") {
const booleanOptions = [
[true, _t("Yes")],
[false, _t("No")],
];
for (const [value, label] of booleanOptions) {
if (fuzzyTest(trimmedQuery.toLowerCase(), label.toLowerCase())) {
items.push({
id: nextItemId++,
searchItemDescription: this.getTranslatedFilterLabel(globalFilter),
preposition: _t("for"),
globalFilterId: globalFilter.id,
label,
value,
});
}
}
return items;
}
const isParent =
globalFilter.type === "relation" ||
globalFilter.type === "selection" ||
this.getTextFilterAllowedValues(globalFilter);
const isExpanded = isParent && this.state.expanded.includes(globalFilter.id);
const item = {
id: nextItemId++,
searchItemDescription: this.getTranslatedFilterLabel(globalFilter),
preposition: _t("for"),
globalFilterId: globalFilter.id,
label: this.state.query,
value: trimmedQuery,
unselectable: globalFilter.type === "selection",
isParent,
isExpanded,
};
items.push(item);
if (item.isExpanded) {
items.push(...this.subItems[globalFilter.id]);
}
return items;
}
getTextFilterAllowedValues(filter) {
if (filter.type !== "text" || !filter.rangesOfAllowedValues?.length) {
return undefined;
}
return this.props.model.getters.getTextFilterOptions(filter.id);
}
toggleItem(item, shouldExpand) {
const expanded = [...this.state.expanded];
const index = expanded.findIndex((id) => id === item.globalFilterId);
if (shouldExpand === true && index < 0) {
expanded.push(item.globalFilterId);
} else if (shouldExpand === false && index >= 0) {
expanded.splice(index, 1);
}
this.computeState({ expanded });
}
async computeSubItems(globalFilter, query) {
let options = [];
let showLoadMore = false;
const limitToFetch = this.state.subItemsLimits[globalFilter.id] + 1;
switch (globalFilter.type) {
case "relation": {
options = await this.orm.call(globalFilter.modelName, "name_search", [], {
domain: [],
context: {},
limit: limitToFetch,
name: query.trim(),
});
break;
}
case "text": {
const allValues = this.getTextFilterAllowedValues(globalFilter) || [];
options = fuzzyLookup(query, allValues, (value) => value.formattedValue).map(
(value) => [value.value, value.formattedValue]
);
break;
}
case "selection": {
const { resModel, selectionField } = globalFilter;
const fields = await this.fields.loadFields(resModel);
const field = fields[selectionField];
if (!field) {
throw new Error(`Field "${selectionField}" not found in model "${resModel}"`);
}
options = fuzzyLookup(query, field.selection, (value) => value[1]);
break;
}
}
if (options.length >= limitToFetch) {
options = options.slice(0, limitToFetch);
showLoadMore = true;
}
const subItems = [];
if (options.length) {
for (const [value, label] of options) {
subItems.push({
id: nextItemId++,
isChild: true,
globalFilterId: globalFilter.id,
value,
label,
});
}
if (showLoadMore) {
subItems.push({
id: nextItemId++,
isChild: true,
globalFilterId: globalFilter.id,
label: _t("Load more"),
unselectable: true,
loadMore: () => {
this.state.subItemsLimits[globalFilter.id] += SUB_ITEMS_DEFAULT_LIMIT;
const newSubItems = [...this.subItems];
newSubItems[globalFilter.id] = undefined;
this.computeState({ subItems: newSubItems });
},
});
}
} else {
subItems.push({
id: nextItemId++,
isChild: true,
globalFilterId: globalFilter.id,
label: _t("(no result)"),
unselectable: true,
});
}
return subItems;
}
getGlobalFilter(id) {
return this.props.model.getters.getGlobalFilter(id);
}
resetState(options = { focus: true }) {
this.state.subItemsLimits = {};
this.computeState({ expanded: [], query: "", subItems: [] });
if (options.focus && !this.env.isSmall) {
this.inputRef.el.focus();
}
}
/**
* @returns {import("@web/core/navigation/navigation").NavigationOptions}
*/
getDropdownNavigation() {
const isExpansible = (index) => {
const item = this.items[index];
return item && item.isParent;
};
const isCollapsible = (index) => {
const item = this.items[index];
return item && ((item.isParent && item.isExpanded) || item.isChild);
};
return {
virtualFocus: true,
getItems: () => this.menuRef.el?.querySelectorAll(":scope .o-dropdown-item") ?? [],
isNavigationAvailable: ({ navigator, target }) => this.inputDropdownState.isOpen,
onUpdated: (navigator) => (this.navigator = navigator),
hotkeys: {
escape: {
callback: () => {
this.inputDropdownState.close();
this.resetState();
},
},
arrowright: {
bypassEditableProtection: true,
allowRepeat: false,
isAvailable: ({ navigator }) => isExpansible(navigator.activeItemIndex),
callback: (navigator) => {
const item = this.items[navigator.activeItemIndex];
if (item.isParent) {
if (item.isExpanded) {
navigator.next();
} else {
this.toggleItem(item, true);
}
}
},
},
arrowleft: {
bypassEditableProtection: true,
isAvailable: ({ navigator }) => isCollapsible(navigator.activeItemIndex),
callback: (navigator) => {
const item = this.items[navigator.activeItemIndex];
const findIndex = (id) =>
this.items.findIndex(
(item) => item.isParent && item.globalFilterId === id
);
if (item && item.isParent && item.isExpanded) {
this.toggleItem(item, false);
} else if (item && item.isChild) {
navigator.items[findIndex(item.globalFilterId)]?.setActive();
}
},
},
},
};
}
onInputDropdownChanged(isOpen) {
if (!isOpen && status(this) === "mounted") {
this.resetState({ focus: false });
} else if (this.navigator) {
this.navigator.items[0]?.setActive();
}
}
get searchableGlobalFilters() {
return this.props.model.getters
.getGlobalFilters()
.filter((filter) => filter.type !== "date" && filter.type !== "numeric");
}
getTranslatedFilterLabel(filter) {
return _t(filter.label); // Label is extracted from the spreadsheet json file
}
selectItem(item) {
if (item.loadMore) {
item.loadMore();
return;
} else if (item.unselectable) {
return;
}
const filter = this.getGlobalFilter(item.globalFilterId);
const filterValue = this.props.model.getters.getGlobalFilterValue(filter.id);
let newValue = undefined;
switch (filter.type) {
case "boolean":
newValue = item.value === true ? { operator: "set" } : { operator: "not_set" };
break;
case "text": {
const allowedValues = this.getTextFilterAllowedValues(filter)?.map((v) => v.value);
if (allowedValues && !allowedValues.includes(item.value)) {
break;
}
const strings = filterValue?.strings || [];
newValue = {
strings: [...strings, item.value],
operator: filterValue?.operator || "ilike",
};
break;
}
case "selection": {
const selectionValues = filterValue?.selectionValues || [];
newValue = {
selectionValues: [...selectionValues, item.value],
operator: filterValue?.operator || "in",
};
break;
}
case "relation": {
const isILike = filterValue?.operator?.includes("ilike");
if (item.isChild) {
const ids = filterValue?.ids || [];
newValue = {
ids: isILike ? [item.value] : [...ids, item.value],
operator: isILike ? "in" : filterValue?.operator || "in",
};
} else {
const strings = filterValue?.strings || [];
newValue = {
strings: isILike ? [...strings, item.value] : [item.value],
operator: isILike ? filterValue.operator : "ilike",
};
}
break;
}
}
if (newValue) {
this.props.model.dispatch("SET_GLOBAL_FILTER_VALUE", {
id: filter.id,
value: newValue,
});
}
this.inputDropdownState.close();
this.resetState();
}
}

View file

@ -0,0 +1,19 @@
.o_sp_dashboard_search {
min-width: 50vw;
.o_sp_date_filter_button {
height: fit-content;
}
@media (min-width: 768px){
.o_searchview .o_searchview_input_container {
overflow-y: auto;
max-height: 200px;
}
}
.o_searchview_dropdown_toggler {
margin-left: calc(var(--border-width) * -1);
}
}

View file

@ -0,0 +1,69 @@
<?xml version="1.0" encoding="utf-8"?>
<templates>
<t t-name="spreadsheet_dashboard.DashboardSearchBar">
<div class="o_sp_dashboard_search d-flex flex-wrap flex-lg-nowrap gap-1 gap-lg-2 w-100 w-lg-auto">
<div class="d-flex flex-grow-1 input-group w-auto" t-if="visibilityState.showSearchBar" >
<Dropdown
state="inputDropdownState"
manual="true"
noClasses="true"
position="'bottom-fit'"
menuClass="'o_searchview_autocomplete'"
navigationOptions="inputDropdownNavOptions"
onStateChanged.bind="onInputDropdownChanged"
menuRef="menuRef"
bottomSheet="false"
>
<div
class="o_searchview form-control d-flex align-items-center py-1 border-end-0 gap-1">
<button class="btn border-0 p-0" t-on-click="onSearchClick">
<i class="oi oi-search me-2"></i>
</button>
<div
class="o_searchview_input_container d-flex flex-grow-1 flex-wrap gap-1 mw-100">
<t t-foreach="facets" t-as="facet" t-key="facet.id">
<DashboardFacet facet="facet"
clearFilter="() => this.clearFilter(facet.id)"
onClick.bind="openFilterValueDropdown" />
</t>
<input type="text"
class="o_searchview_input o_input d-print-none flex-grow-1 w-auto border-0"
t-att-tabindex="env.isSmall ? -1 : 0"
accesskey="Q"
placeholder="Search..."
role="searchbox"
t-ref="autofocus"
t-on-click="onSearchClick"
t-on-input="onSearchInput"
t-on-keydown="onSearchInputKeydown"
t-on-pointerdown="onSearchInputPointerDown"
/>
</div>
<t t-set-slot="content">
<t t-call="web.SearchBar.QuickSearchItems" />
</t>
</div>
</Dropdown>
<Dropdown menuClass="'pt-0 px-2'"
manual="true"
position="'bottom-end'"
state="filtersValuesDropdown">
<button
class="o_searchview_dropdown_toggler btn btn-outline-secondary o-dropdown-caret rounded-start-0 o-dropdown dropdown-toggle dropdown"
t-on-click="toggleFilterValueDropdown" />
<t t-set-slot="content">
<div class="p-2">
<FilterValuesList model="props.model" close.bind="closeFilterValueDropdown" />
</div>
</t>
</Dropdown>
</div>
<div class="o_sp_date_filter_button d-flex" t-if="firstDateFilter">
<DashboardDateFilter value="firstDateFilterValue"
update.bind="updateFirstDateFilter" />
</div>
</div>
</t>
</templates>

View file

@ -1,28 +1,48 @@
/** @odoo-module */
import * as spreadsheet from "@odoo/o-spreadsheet";
import spreadsheet from "@spreadsheet/o_spreadsheet/o_spreadsheet_extended";
const { Component, useSubEnv } = owl;
const { registries } = spreadsheet;
import { Component, useSubEnv } from "@odoo/owl";
const { registries, stores } = spreadsheet;
const { figureRegistry } = registries;
const { ModelStore, useStoreProvider } = stores;
const EMPTY_FIGURE = { tag: "empty" };
export class MobileFigureContainer extends Component {
static template = "documents_spreadsheet.MobileFigureContainer";
static props = {
spreadsheetModel: Object,
};
setup() {
const stores = useStoreProvider();
stores.inject(ModelStore, this.props.spreadsheetModel);
useSubEnv({
model: this.props.spreadsheetModel,
isDashboard: () => this.props.spreadsheetModel.getters.isDashboard(),
openSidePanel: () => {},
});
}
get figures() {
get figureRows() {
const sheetId = this.props.spreadsheetModel.getters.getActiveSheetId();
return this.props.spreadsheetModel.getters
const sortedFigures = this.props.spreadsheetModel.getters
.getFigures(sheetId)
.sort((f1, f2) => (this.isBefore(f1, f2) ? -1 : 1))
.map((figure) => ({
...figure,
width: window.innerWidth,
}));
.sort((f1, f2) => (this.isBefore(f1, f2) ? -1 : 1));
const figureRows = [];
for (let i = 0; i < sortedFigures.length; i++) {
const figure = sortedFigures[i];
const nextFigure = sortedFigures[i + 1];
if (this.isScorecard(figure) && nextFigure && this.isScorecard(nextFigure)) {
figureRows.push([figure, nextFigure]);
i++;
} else if (this.isScorecard(figure)) {
figureRows.push([figure, EMPTY_FIGURE]);
} else {
figureRows.push([figure]);
}
}
return figureRows;
}
getFigureComponent(figure) {
@ -30,9 +50,18 @@ export class MobileFigureContainer extends Component {
}
isBefore(f1, f2) {
// TODO be smarter
return f1.x < f2.x ? f1.y < f2.y : f1.y < f2.y;
const sheetId = this.props.spreadsheetModel.getters.getActiveSheetId();
const fig1 = this.props.spreadsheetModel.getters.getFigureUI(sheetId, f1);
const fig2 = this.props.spreadsheetModel.getters.getFigureUI(sheetId, f2);
return fig1.x < fig2.x ? fig1.y < fig2.y : fig1.y < fig2.y;
}
isScorecard(figure) {
if (figure.tag !== "chart") {
return false;
}
const chartId = this.props.spreadsheetModel.getters.getChartIdFromFigureId(figure.id);
const definition = this.props.spreadsheetModel.getters.getChartDefinition(chartId);
return definition.type === "scorecard";
}
}
MobileFigureContainer.template = "documents_spreadsheet.MobileFigureContainer";

View file

@ -0,0 +1,18 @@
.o-mobile-figure.o-figure {
padding: 4px;
.o-dashboard-chart-select {
visibility: visible !important;
/* Compensate for the padding of the figure */
top: 4px !important;
right: 4px !important;
}
.o-carousel-full-screen-button,
.o-carousel-tabs-dropdown,
.o-carousel-tab[data-type="carouselDataView"],
.o-chart-dashboard-item:not([name="chart-granularity"]) {
display: none !important;
}
}

View file

@ -1,16 +1,23 @@
<?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"/>
<t t-name="documents_spreadsheet.MobileFigureContainer">
<div class="o-spreadsheet">
<div t-if="!figureRows.length" class="m-3">
Only chart figures are displayed in small screens but this dashboard doesn't contain any
</div>
<t t-foreach="figureRows" t-as="figureRow" t-key="figureRow_index">
<div class="o_figure_row d-flex flex-row">
<t t-foreach="figureRow" t-as="figure" t-key="figure_index">
<div t-if="figure.tag === 'empty'" class="o_empty_figure o-mobile-figure o-figure position-relative"/>
<t t-else="">
<t t-set="minHeight" t-value="figureRow.length === 1 ? `min-height: ${figure.height}px;` : ''"/>
<div t-att-style="minHeight + ' direction:ltr;'" class="o-mobile-figure o-figure position-relative">
<t figureUI="figure" t-component="getFigureComponent(figure)"/>
</div>
</t>
</t>
</div>
</t>
</div>
</t>
</templates>

View file

@ -1,17 +1,28 @@
/** @odoo-module */
import { _t } from "@web/core/l10n/translation";
const { Component, useState } = owl;
import { Component, useState } from "@odoo/owl";
export class DashboardMobileSearchPanel extends Component {
static template = "spreadsheet_dashboard.DashboardMobileSearchPanel";
static props = {
/**
* (dashboardId: number) => void
*/
onDashboardSelected: Function,
groups: Object,
activeDashboard: {
type: Object,
optional: true,
},
};
setup() {
this.state = useState({ isOpen: false });
}
get searchBarText() {
return this.props.activeDashboard
? this.props.activeDashboard.displayName
? this.props.activeDashboard.data.name
: _t("Choose a dashboard....");
}
@ -27,16 +38,3 @@ export class DashboardMobileSearchPanel extends Component {
}
}
}
DashboardMobileSearchPanel.template = "documents_spreadsheet.DashboardMobileSearchPanel";
DashboardMobileSearchPanel.props = {
/**
* (dashboardId: number) => void
*/
onDashboardSelected: Function,
groups: Object,
activeDashboard: {
type: Object,
optional: true,
},
};

View file

@ -1,12 +1,12 @@
<?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">
<div t-name="spreadsheet_dashboard.DashboardMobileSearchPanel" class="btn flex-grow-1 ps-0">
<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" />
<button type="button" class="o_mobile_search_button btn w-100 d-flex justify-content-start align-items-center" t-on-click="() => this.state.isOpen = false">
<i class="oi oi-arrow-left" />
<strong class="ml8">BACK</strong>
</button>
</div>
@ -17,12 +17,12 @@
<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}">
<li t-foreach="group.dashboards" t-as="dashboard" t-key="dashboard.data.id" t-on-click="() => this.onDashboardSelected(dashboard.data.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.data.id === props.activeDashboard.data.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"/>
<span t-esc="dashboard.data.name" class="o_search_panel_label_title text-truncate"/>
</div>
</header>
@ -40,4 +40,3 @@
</div>
</div>
</templates>

View file

@ -1,32 +0,0 @@
/** @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

@ -1,8 +0,0 @@
/** @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

@ -1,12 +1,63 @@
/** @odoo-module */
import { SEE_RECORD_LIST, SEE_RECORD_LIST_VISIBLE } from "@spreadsheet/list/list_actions";
import spreadsheet from "@spreadsheet/o_spreadsheet/o_spreadsheet_extended";
import { registries, components, readonlyAllowedCommands } from "@odoo/o-spreadsheet";
import { _t } from "@web/core/l10n/translation";
const { clickableCellRegistry } = spreadsheet.registries;
const { clickableCellRegistry } = registries;
const { ClickableCellSortIcon } = components;
readonlyAllowedCommands.add("UPDATE_ODOO_LIST");
clickableCellRegistry.add("list", {
condition: SEE_RECORD_LIST_VISIBLE,
action: SEE_RECORD_LIST,
execute: SEE_RECORD_LIST,
sequence: 10,
title: _t("Open record"),
});
function sortList(env, position, direction) {
const field = env.model.getters.getListFieldFromPosition(position);
if (!field) {
return;
}
const listId = env.model.getters.getListIdFromPosition(position);
const definition = env.model.getters.getListModelDefinition(listId);
const orderBy =
direction === "none"
? []
: [
{ name: field.name, asc: direction === "asc" },
...definition.searchParams.orderBy.filter(
(orderBy) => orderBy.name !== field.name
),
];
env.model.dispatch("UPDATE_ODOO_LIST", {
listId,
list: {
...definition,
searchParams: {
...definition.searchParams,
orderBy,
},
},
});
}
clickableCellRegistry.add("dashboard_list_sorting", {
condition: (position, getters) =>
getters.isDashboard() && getters.isSortableListHeader(position),
execute: (position, env) => {
sortList(env, position, getNextSortDirection(env.model.getters, position));
},
component: ClickableCellSortIcon,
componentProps: (position, getters) => ({
position,
sortDirection: getters.getListSortDirection(position),
}),
sequence: 2,
});
const NEXT_SORT_DIRECTION = { asc: "desc", desc: "none", none: "asc" };
function getNextSortDirection(getters, position) {
return NEXT_SORT_DIRECTION[getters.getListSortDirection(position)];
}

View file

@ -0,0 +1,25 @@
import { registries, constants, getCaretUpSvg, getCaretDownSvg } from "@odoo/o-spreadsheet";
const { iconsOnCellRegistry } = registries;
const { GRID_ICON_EDGE_LENGTH, GRID_ICON_MARGIN } = constants;
iconsOnCellRegistry.add("list_dashboard_sorting", (getters, position) => {
if (!getters.isDashboard() || !getters.isSortableListHeader(position)) {
return undefined;
}
const sortDirection = getters.getListSortDirection(position);
if (sortDirection !== "asc" && sortDirection !== "desc") {
return undefined;
}
const cellStyle = getters.getCellComputedStyle(position);
return {
type: `list_dashboard_sorting_${sortDirection}`,
priority: 5,
horizontalAlign: "right",
size: GRID_ICON_EDGE_LENGTH,
margin: GRID_ICON_MARGIN,
svg: sortDirection === "asc" ? getCaretUpSvg(cellStyle) : getCaretDownSvg(cellStyle),
position,
onClick: undefined, // click is managed by ClickableCellSortIcon
};
});

View file

@ -1,28 +1,25 @@
/** @odoo-module */
import * as spreadsheet from "@odoo/o-spreadsheet";
import { _t } from "@web/core/l10n/translation";
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";
import {
SEE_RECORDS_PIVOT,
SEE_RECORDS_PIVOT_VISIBLE,
SET_FILTER_MATCHING,
SET_FILTER_MATCHING_CONDITION,
} from "@spreadsheet/pivot/pivot_actions";
const { clickableCellRegistry } = spreadsheet.registries;
clickableCellRegistry.add("pivot", {
condition: SEE_RECORDS_PIVOT_VISIBLE,
action: SEE_RECORDS_PIVOT,
execute: SEE_RECORDS_PIVOT,
title: _t("See records"),
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 });
},
condition: SET_FILTER_MATCHING_CONDITION,
execute: SET_FILTER_MATCHING,
title: _t("Filter on this value"),
sequence: 2,
});

View file

@ -0,0 +1,8 @@
@media print {
.o_spreadsheet_dashboard_action {
height: auto;
.o_spreadsheet_dashboard_search_panel,.o_control_panel {
display: none !important;
}
}
}

View file

@ -0,0 +1,33 @@
import { describe, expect, test } from "@odoo/hoot";
import { Model } from "@odoo/o-spreadsheet";
import { insertChartInSpreadsheet } from "@spreadsheet/../tests/helpers/chart";
import { makeSpreadsheetMockEnv } from "@spreadsheet/../tests/helpers/model";
import { OdooDataProvider } from "@spreadsheet/data_sources/odoo_data_provider";
import { createDashboardActionWithData } from "@spreadsheet_dashboard/../tests/helpers/dashboard_action";
import { defineSpreadsheetDashboardModels } from "@spreadsheet_dashboard/../tests/helpers/data";
import { contains } from "@web/../tests/web_test_helpers";
describe.current.tags("desktop");
defineSpreadsheetDashboardModels();
test("can change granularity", async () => {
const env = await makeSpreadsheetMockEnv();
const setupModel = new Model({}, { custom: { odooDataProvider: new OdooDataProvider(env) } });
const chartId = insertChartInSpreadsheet(setupModel, "odoo_line", {
metaData: {
groupBy: ["date:month"],
resModel: "partner",
measure: "__count",
order: null,
},
});
const { model } = await createDashboardActionWithData(setupModel.exportData());
expect("select.o-chart-dashboard-item").toHaveValue("month");
await contains("select.o-chart-dashboard-item", { visible: false }).select("quarter");
expect(model.getters.getChartGranularity(chartId)).toEqual({
fieldName: "date",
granularity: "quarter",
});
expect(model.getters.getChartDefinition(chartId).metaData.groupBy).toEqual(["date:quarter"]);
});

View file

@ -0,0 +1,68 @@
import { describe, expect, test } from "@odoo/hoot";
import { animationFrame } from "@odoo/hoot-dom";
import { Model, components } from "@odoo/o-spreadsheet";
import { createBasicChart } from "@spreadsheet/../tests/helpers/commands";
import { makeSpreadsheetMockEnv } from "@spreadsheet/../tests/helpers/model";
import { OdooDataProvider } from "@spreadsheet/data_sources/odoo_data_provider";
import { createDashboardActionWithData } from "@spreadsheet_dashboard/../tests/helpers/dashboard_action";
import { defineSpreadsheetDashboardModels } from "@spreadsheet_dashboard/../tests/helpers/data";
import { patchWithCleanup } from "@web/../tests/web_test_helpers";
describe.current.tags("desktop");
defineSpreadsheetDashboardModels();
function spyCharts() {
const charts = {};
patchWithCleanup(components.ChartJsComponent.prototype, {
createChart(chartData) {
super.createChart(chartData);
charts[this.props.chartId] = this.chart;
},
});
return charts;
}
test("Charts are animated only at first render", async () => {
const env = await makeSpreadsheetMockEnv();
const setupModel = new Model({}, { custom: { odooDataProvider: new OdooDataProvider(env) } });
createBasicChart(setupModel, "chartId");
const charts = spyCharts();
const { model } = await createDashboardActionWithData(setupModel.exportData());
expect(".o-figure").toHaveCount(1);
expect(charts["chartId"].config.options.animation.animateRotate).toBe(true);
// Scroll the figure out of the viewport and back in
model.dispatch("SET_VIEWPORT_OFFSET", { offsetX: 0, offsetY: 500 });
await animationFrame();
await animationFrame();
expect(".o-figure").toHaveCount(0);
model.dispatch("SET_VIEWPORT_OFFSET", { offsetX: 0, offsetY: 0 });
await animationFrame();
expect(".o-figure").toHaveCount(1);
expect(charts["chartId"].config.options.animation).toBe(false);
});
test("Charts are animated when chart type changes", async () => {
const env = await makeSpreadsheetMockEnv();
const setupModel = new Model({}, { custom: { odooDataProvider: new OdooDataProvider(env) } });
createBasicChart(setupModel, "chartId");
const charts = spyCharts();
const { model } = await createDashboardActionWithData(setupModel.exportData());
expect(".o-figure").toHaveCount(1);
expect(charts["chartId"].config.options.animation.animateRotate).toBe(true);
delete charts["chartId"];
const definition = model.getters.getChartDefinition("chartId");
model.dispatch("UPDATE_CHART", {
definition: { ...definition, type: "pie" },
chartId: "chartId",
figureId: model.getters.getFigureIdFromChartId("chartId"),
sheetId: model.getters.getActiveSheetId(),
});
await animationFrame();
expect(charts["chartId"].config.options.animation.animateRotate).toBe(true);
});

View file

@ -0,0 +1,209 @@
import { beforeEach, expect, test } from "@odoo/hoot";
import { click } from "@odoo/hoot-dom";
import { animationFrame } from "@odoo/hoot-mock";
import { components } from "@odoo/o-spreadsheet";
import { insertChartInSpreadsheet } from "@spreadsheet/../tests/helpers/chart";
import {
createBasicChart,
createScorecardChart,
createGaugeChart,
addChartFigureToCarousel,
createCarousel,
} from "@spreadsheet/../tests/helpers/commands";
import { getBasicData } from "@spreadsheet/../tests/helpers/data";
import { createModelWithDataSource } from "@spreadsheet/../tests/helpers/model";
import { mountSpreadsheet } from "@spreadsheet/../tests/helpers/ui";
import { defineSpreadsheetDashboardModels } from "@spreadsheet_dashboard/../tests/helpers/data";
import { contains, mockService, patchWithCleanup } from "@web/../tests/web_test_helpers";
defineSpreadsheetDashboardModels();
/**
* @typedef {import("@spreadsheet/../tests/helpers/data").ServerData} ServerData
*/
const chartId = "uuid1";
let serverData = /** @type {ServerData} */ ({});
function mockActionService(doActionStep) {
const fakeActionService = {
doAction: async (actionRequest, options = {}) => {
if (actionRequest === "menuAction2") {
expect.step(doActionStep);
}
},
};
mockService("action", fakeActionService);
}
beforeEach(() => {
serverData = {};
serverData.menus = {
2: {
id: 2,
name: "test menu 2",
xmlid: "spreadsheet.test.menu2",
appID: 1,
actionID: "menuAction2",
},
};
serverData.models = {
...getBasicData(),
"ir.ui.menu": {
records: [{ id: 2, name: "test menu 2", action: "action2", group_ids: [10] }],
},
"res.group": { records: [{ id: 10, name: "test group" }] },
};
});
test("Click on chart in dashboard mode redirect to the odoo menu", async function () {
const doActionStep = "doAction";
mockActionService(doActionStep);
const { model } = await createModelWithDataSource({ serverData });
const fixture = await mountSpreadsheet(model);
createBasicChart(model, chartId);
model.dispatch("LINK_ODOO_MENU_TO_CHART", {
chartId,
odooMenuId: 2,
});
const chartMenu = model.getters.getChartOdooMenu(chartId);
expect(chartMenu.id).toBe(2, { message: "Odoo menu is linked to chart" });
await animationFrame();
await click(fixture.querySelector(".o-chart-container canvas"));
await animationFrame();
// Clicking on a chart while not dashboard mode do nothing
expect.verifySteps([]);
model.updateMode("dashboard");
await animationFrame();
await click(fixture.querySelector(".o-chart-container canvas"));
await animationFrame();
// Clicking on a chart while on dashboard mode redirect to the odoo menu
expect.verifySteps([doActionStep]);
});
test("Click on chart element in dashboard mode do not redirect twice", async function () {
patchWithCleanup(components.ChartJsComponent.prototype, {
enableAnimationInChartData(chartData) {
return chartData; // disable animation for the test
},
});
mockService("action", {
doAction: async (actionRequest, options = {}) => {
if (actionRequest === "menuAction2") {
expect.step("chartMenuRedirect");
} else if (
actionRequest.type === "ir.actions.act_window" &&
actionRequest.res_model === "partner"
) {
expect.step("chartElementRedirect");
}
},
});
const { model } = await createModelWithDataSource({ serverData });
const fixture = await mountSpreadsheet(model);
const chartId = insertChartInSpreadsheet(model, "odoo_pie");
model.dispatch("LINK_ODOO_MENU_TO_CHART", { chartId, odooMenuId: 2 });
await animationFrame();
model.updateMode("dashboard");
await animationFrame();
// Click pie element
const chartCanvas = fixture.querySelector(".o-chart-container canvas");
const canvasRect = chartCanvas.getBoundingClientRect();
const canvasCenter = {
x: canvasRect.left + canvasRect.width / 2,
y: canvasRect.top + canvasRect.height / 2,
};
await click(".o-chart-container canvas", { position: canvasCenter, relative: true });
await animationFrame();
expect.verifySteps(["chartElementRedirect"]);
// Click outside the pie element
await click(".o-chart-container canvas", { position: "top-left" });
await animationFrame();
expect.verifySteps(["chartMenuRedirect"]);
});
test("Clicking on a scorecard or gauge redirects to the linked menu id", async function () {
mockService("action", {
doAction: async (actionRequest) => expect.step(actionRequest),
});
const { model } = await createModelWithDataSource({ serverData });
await mountSpreadsheet(model);
createScorecardChart(model, "scorecardId");
createGaugeChart(model, "gaugeId");
model.dispatch("LINK_ODOO_MENU_TO_CHART", { chartId: "scorecardId", odooMenuId: 2 });
model.dispatch("LINK_ODOO_MENU_TO_CHART", { chartId: "gaugeId", odooMenuId: 2 });
await animationFrame();
model.updateMode("dashboard");
await animationFrame();
const chartCanvas = document.querySelectorAll(".o-figure canvas");
await click(chartCanvas[0]);
expect.verifySteps(["menuAction2"]);
await click(chartCanvas[1]);
expect.verifySteps(["menuAction2"]);
});
test.tags("desktop");
test("Middle-click on chart in dashboard mode open the odoo menu in a new tab", async function () {
const { model } = await createModelWithDataSource({ serverData });
await mountSpreadsheet(model);
mockService("action", {
doAction(_, options) {
expect.step("doAction");
expect(options).toEqual({
newWindow: true,
});
return Promise.resolve(true);
},
});
createBasicChart(model, chartId);
model.dispatch("LINK_ODOO_MENU_TO_CHART", {
chartId,
odooMenuId: 2,
});
model.updateMode("dashboard");
await animationFrame();
await contains(".o-chart-container canvas").click({ ctrlKey: true });
expect.verifySteps(["doAction"]);
await contains(".o-chart-container canvas").click({ button: 1 }); // middle mouse click
expect.verifySteps(["doAction"]);
});
test("Clicking on the carousel header doesn't redirect to its chart's linked menu", async function () {
mockService("action", {
doAction: async (actionRequest) => expect.step(actionRequest),
});
const { model } = await createModelWithDataSource({ serverData });
await mountSpreadsheet(model);
createBasicChart(model, chartId);
const sheetId = model.getters.getActiveSheetId();
const chartFigureId = model.getters.getFigures(sheetId)[0].id;
createCarousel(model, { items: [] }, "carouselId");
addChartFigureToCarousel(model, "carouselId", chartFigureId);
model.dispatch("LINK_ODOO_MENU_TO_CHART", { chartId, odooMenuId: 2 });
model.updateMode("dashboard");
await animationFrame();
await contains(".o-carousel-header").click();
expect.verifySteps([]);
await contains(".o-carousel canvas").click();
expect.verifySteps(["menuAction2"]);
});

View file

@ -1,96 +0,0 @@
/** @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,208 @@
import { describe, expect, test } from "@odoo/hoot";
import { click, queryAll, queryFirst } from "@odoo/hoot-dom";
import { createDashboardActionWithData } from "@spreadsheet_dashboard/../tests/helpers/dashboard_action";
import { defineSpreadsheetDashboardModels } from "@spreadsheet_dashboard/../tests/helpers/data";
import { Partner } from "@spreadsheet/../tests/helpers/data";
import { getCellIcons } from "@spreadsheet/../tests/helpers/getters";
import { fields } from "@web/../tests/web_test_helpers";
import { animationFrame } from "@odoo/hoot-mock";
describe.current.tags("desktop");
defineSpreadsheetDashboardModels();
test("A link in a dashboard should be clickable", async () => {
const data = {
sheets: [
{
cells: { A1: "[Odoo](https://odoo.com)" },
},
],
};
await createDashboardActionWithData(data);
expect(".o-dashboard-clickable-cell").toHaveCount(1);
});
test("Invalid pivot/list formulas should not be clickable", async () => {
const data = {
sheets: [
{
cells: {
A1: '=PIVOT.VALUE("1", "measure")',
A2: '=ODOO.LIST("1", 1, "name")',
},
},
],
};
await createDashboardActionWithData(data);
expect(".o-dashboard-clickable-cell").toHaveCount(0);
});
test("pivot/list formulas should be clickable", async () => {
const data = {
version: 16,
sheets: [
{
cells: {
A1: { content: '=PIVOT.VALUE("1", "probability", "bar", "false")' },
A2: { content: '=ODOO.LIST(1, 1, "foo")' },
},
},
],
lists: {
1: {
id: 1,
columns: ["foo"],
domain: [],
model: "partner",
orderBy: [],
},
},
pivots: {
1: {
id: 1,
colGroupBys: ["foo"],
domain: [],
measures: [{ field: "probability", operator: "avg" }],
model: "partner",
rowGroupBys: ["bar"],
context: {},
},
},
};
await createDashboardActionWithData(data);
expect(".o-dashboard-clickable-cell").toHaveCount(2);
});
test("list sorting clickable cell", async () => {
Partner._fields.foo = fields.Integer({ sortable: true });
Partner._fields.bar = fields.Boolean({ sortable: false });
const data = {
sheets: [
{
cells: {
A1: '=ODOO.LIST.HEADER(1, "foo")',
A2: '=ODOO.LIST(1, 1, "foo")',
},
},
],
lists: {
1: {
id: 1,
columns: [],
domain: [],
model: "partner",
orderBy: [],
},
},
};
const { model } = await createDashboardActionWithData(data);
expect(getCellIcons(model, "A1")).toHaveLength(0);
expect(".o-dashboard-clickable-cell .fa-sort").toHaveCount(1);
await click(queryFirst(".o-dashboard-clickable-cell .sorting-icon"));
expect(model.getters.getListDefinition(1).orderBy).toEqual([{ name: "foo", asc: true }]);
await animationFrame();
expect(getCellIcons(model, "A1")).toMatchObject([{ type: "list_dashboard_sorting_asc" }]);
await click(queryFirst(".o-dashboard-clickable-cell"));
expect(model.getters.getListDefinition(1).orderBy).toEqual([{ name: "foo", asc: false }]);
await animationFrame();
expect(getCellIcons(model, "A1")).toMatchObject([{ type: "list_dashboard_sorting_desc" }]);
await click(queryFirst(".o-dashboard-clickable-cell"));
expect(getCellIcons(model, "A1")).toHaveLength(0);
expect(model.getters.getListDefinition(1).orderBy).toEqual([]);
});
test("list sort multiple fields", async () => {
Partner._fields.foo = fields.Integer({ sortable: true });
Partner._fields.bar = fields.Boolean({ sortable: true });
const data = {
sheets: [
{
cells: {
A1: '=ODOO.LIST.HEADER(1, "foo")',
A2: '=ODOO.LIST.HEADER(1, "bar")',
},
},
],
lists: {
1: {
id: 1,
columns: [],
domain: [],
model: "partner",
orderBy: [],
},
},
};
const { model } = await createDashboardActionWithData(data);
await click(queryAll(".o-dashboard-clickable-cell")[0]);
expect(model.getters.getListDefinition(1).orderBy).toEqual([{ name: "foo", asc: true }]);
await animationFrame();
await click(queryAll(".o-dashboard-clickable-cell")[1]);
expect(model.getters.getListDefinition(1).orderBy).toEqual([
{ name: "bar", asc: true },
{ name: "foo", asc: true },
]);
await click(queryAll(".o-dashboard-clickable-cell")[0]);
expect(model.getters.getListDefinition(1).orderBy).toEqual([
{ name: "foo", asc: true },
{ name: "bar", asc: true },
]);
await animationFrame();
await click(queryAll(".o-dashboard-clickable-cell")[0]);
expect(model.getters.getListDefinition(1).orderBy).toEqual([
{ name: "foo", asc: false },
{ name: "bar", asc: true },
]);
await animationFrame();
await click(queryAll(".o-dashboard-clickable-cell")[0]);
expect(model.getters.getListDefinition(1).orderBy).toEqual([]);
await animationFrame();
});
test("Clickable ignores spill and empty cells for list sorting", async () => {
const data = {
sheets: [
{
cells: {
A1: "foo",
B1: "bar",
// spill cells
A2: "=ODOO.LIST.HEADER(1, A1:B1)",
A3: '=ODOO.LIST(1, sequence(2), "foo")',
},
},
],
lists: {
1: {
id: 1,
columns: [],
domain: [],
model: "partner",
orderBy: [],
},
},
};
const { model } = await createDashboardActionWithData(data);
expect(getCellIcons(model, "A2")).toHaveLength(0);
expect(".o-dashboard-clickable-cell .fa-sort").toHaveCount(0);
expect(getCellIcons(model, "B2")).toHaveLength(0);
expect(".o-dashboard-clickable-cell .fa-sort").toHaveCount(0);
expect(getCellIcons(model, "A3")).toHaveLength(0);
expect(".o-dashboard-clickable-cell .fa-sort").toHaveCount(0);
expect(getCellIcons(model, "A4")).toHaveLength(0);
expect(".o-dashboard-clickable-cell .fa-sort").toHaveCount(0);
expect(getCellIcons(model, "C10")).toHaveLength(0); // unrelated empty cell
expect(".o-dashboard-clickable-cell .fa-sort").toHaveCount(0);
});

View file

@ -0,0 +1,734 @@
import { describe, expect, test } from "@odoo/hoot";
import { queryAll, press, queryAllTexts } from "@odoo/hoot-dom";
import { animationFrame } from "@odoo/hoot-mock";
import { getBasicData, Product } from "@spreadsheet/../tests/helpers/data";
import { createSpreadsheetDashboard } from "@spreadsheet_dashboard/../tests/helpers/dashboard_action";
import {
defineSpreadsheetDashboardModels,
getDashboardServerData,
} from "@spreadsheet_dashboard/../tests/helpers/data";
import { contains, onRpc, patchWithCleanup } from "@web/../tests/web_test_helpers";
import { browser } from "@web/core/browser/browser";
import { RPCError } from "@web/core/network/rpc";
import { Deferred } from "@web/core/utils/concurrency";
import { range } from "@web/core/utils/numbers";
import { THIS_YEAR_GLOBAL_FILTER } from "@spreadsheet/../tests/helpers/global_filter";
describe.current.tags("desktop");
defineSpreadsheetDashboardModels();
function getServerData(spreadsheetData) {
const serverData = getDashboardServerData();
serverData.models = {
...serverData.models,
...getBasicData(),
};
serverData.models["spreadsheet.dashboard.group"].records = [
{
published_dashboard_ids: [789],
id: 1,
name: "Pivot",
},
];
serverData.models["spreadsheet.dashboard"].records = [
{
id: 789,
name: "Spreadsheet with Pivot",
json_data: JSON.stringify(spreadsheetData),
spreadsheet_data: JSON.stringify(spreadsheetData),
dashboard_group_id: 1,
},
];
return serverData;
}
test("display available spreadsheets", async () => {
await createSpreadsheetDashboard();
expect(".o_search_panel section").toHaveCount(2);
expect(".o_search_panel li").toHaveCount(3);
});
test("display the active spreadsheet", async () => {
await createSpreadsheetDashboard();
expect(".o_search_panel li.active").toHaveCount(1, {
message: "It should have one active element",
});
expect(".o-spreadsheet").toHaveCount(1, { message: "It should display the spreadsheet" });
});
test("Fold/unfold the search panel", async function () {
await createSpreadsheetDashboard();
await contains(".o_spreadsheet_dashboard_search_panel button").click();
expect(".o_spreadsheet_dashboard_search_panel").toHaveCount(0);
expect(".o_search_panel_sidebar").toHaveText("Container 1 / Dashboard CRM 1");
await contains(".o_search_panel_sidebar button").click();
expect(".o_search_panel_sidebar").toHaveCount(0);
expect(".o_spreadsheet_dashboard_search_panel").toHaveCount(1);
});
test("Folding dashboard from 'FAVORITES' group shows correct active dashboard group", async function () {
await createSpreadsheetDashboard({
mockRPC: async function (route, args) {
if (
args.method === "action_toggle_favorite" &&
args.model === "spreadsheet.dashboard"
) {
expect.step("action_toggle_favorite");
return true;
}
},
});
await contains(".o_dashboard_star").click();
expect(".o_search_panel_section").toHaveCount(3);
expect(".o_search_panel_category header b:first").toHaveText("FAVORITES");
expect.verifySteps(["action_toggle_favorite"]);
await contains(".o_spreadsheet_dashboard_search_panel button").click();
expect(".o_spreadsheet_dashboard_search_panel").toHaveCount(0);
expect(".o_search_panel_sidebar").toHaveText("Container 1 / Dashboard CRM 1");
});
test("Fold button invisible in the search panel without any dashboard", async function () {
const serverData = getDashboardServerData();
serverData.models["spreadsheet.dashboard"].records = [];
serverData.models["spreadsheet.dashboard.group"].records = [];
await createSpreadsheetDashboard({ serverData });
expect(".o_spreadsheet_dashboard_search_panel button").toHaveCount(0);
});
test("load action with specific dashboard", async () => {
await createSpreadsheetDashboard({ spreadsheetId: 3 });
expect(".o_search_panel li.active").toHaveText("Dashboard Accounting 1");
});
test("can switch spreadsheet", async () => {
await createSpreadsheetDashboard();
const spreadsheets = queryAll(".o_search_panel li");
expect(spreadsheets[0]).toHaveClass("active");
expect(spreadsheets[1]).not.toHaveClass("active");
expect(spreadsheets[2]).not.toHaveClass("active");
await contains(spreadsheets[1]).click();
expect(spreadsheets[0]).not.toHaveClass("active");
expect(spreadsheets[1]).toHaveClass("active");
expect(spreadsheets[2]).not.toHaveClass("active");
});
test("display no dashboard message", async () => {
await createSpreadsheetDashboard({
mockRPC: function (route, { model, method, args }) {
if (method === "web_search_read" && model === "spreadsheet.dashboard.group") {
return {
records: [],
length: 0,
};
}
},
});
expect(".o_search_panel li").toHaveCount(0, {
message: "It should not display any spreadsheet",
});
expect(".dashboard-loading-status").toHaveText("No available dashboard", {
message: "It should display no dashboard message",
});
});
test("display error message", async () => {
expect.errors(1);
onRpc("/spreadsheet/dashboard/data/2", () => {
const error = new RPCError();
error.data = {};
throw error;
});
await createSpreadsheetDashboard();
expect(".o-spreadsheet").toHaveCount(1, { message: "It should display the spreadsheet" });
await contains(".o_search_panel li:eq(1)").click();
expect(".o_spreadsheet_dashboard_action .dashboard-loading-status.error").toHaveCount(1, {
message: "It should display an error",
});
await contains(".o_search_panel li:eq(0)").click();
expect(".o-spreadsheet").toHaveCount(1, { message: "It should display the spreadsheet" });
expect(".o_renderer .error").toHaveCount(0, { message: "It should not display an error" });
expect.verifyErrors(["RPC_ERROR"]);
});
test("load dashboard that doesn't exist", async () => {
expect.errors(1);
await createSpreadsheetDashboard({
spreadsheetId: 999,
});
expect(".o_spreadsheet_dashboard_action .dashboard-loading-status.error").toHaveCount(1, {
message: "It should display an error",
});
expect.verifyErrors(["RPC_ERROR"]);
});
test("Last selected spreadsheet is kept when go back from breadcrumb", async function () {
const spreadsheetData = {
version: 16,
sheets: [
{
id: "sheet1",
cells: { A1: { content: '=PIVOT.VALUE("1", "probability")' } },
},
],
pivots: {
1: {
id: 1,
colGroupBys: ["foo"],
domain: [],
measures: [{ field: "probability", operator: "avg" }],
model: "partner",
rowGroupBys: ["bar"],
},
},
};
const serverData = getServerData(spreadsheetData);
serverData.models["spreadsheet.dashboard"].records.push({
id: 790,
name: "Second dashboard",
json_data: JSON.stringify(spreadsheetData),
spreadsheet_data: JSON.stringify(spreadsheetData),
dashboard_group_id: 1,
});
serverData.models["spreadsheet.dashboard.group"].records[0].published_dashboard_ids.push(790);
await createSpreadsheetDashboard({ serverData });
await contains(".o_search_panel li:last-child").click();
await contains(".o-dashboard-clickable-cell").click();
expect(".o_list_view").toHaveCount(1);
await contains(".o_back_button").click();
expect(".o_search_panel li:last-child").toHaveClass("active");
});
test("Can clear filter date filter value that defaults to current period", async function () {
const spreadsheetData = {
globalFilters: [
{
id: "1",
type: "date",
label: "Period",
},
{
id: "2",
type: "date",
label: "This Year",
defaultValue: "this_year",
},
],
};
const serverData = getServerData(spreadsheetData);
await createSpreadsheetDashboard({ serverData });
const year = luxon.DateTime.local().year;
expect(".o_control_panel_actions .o_facet_value").toHaveText(String(year));
await contains(".o_searchview_facet_label").click();
await contains('.o-filter-item[data-id="2"] input').click();
await contains(".o-dropdown-item[data-id='year'] .btn-previous").click();
await contains(".o-filter-values-footer .btn-primary").click();
expect(".o_control_panel_actions .o_facet_value").toHaveText(String(year - 1));
expect(".o_control_panel_actions .o_facet_remove").toHaveCount(1);
await contains(".o_control_panel_actions .o_facet_remove").click();
expect(".o_control_panel_actions .o_facet_remove").toHaveCount(0);
});
test("share dashboard from dashboard view", async function () {
patchWithCleanup(browser.navigator.clipboard, {
writeText: (url) => {
expect.step("share url copied");
expect(url).toBe("localhost:8069/share/url/132465");
},
});
const def = new Deferred();
await createSpreadsheetDashboard({
mockRPC: async function (route, args) {
if (args.method === "action_get_share_url") {
await def;
expect.step("dashboard_shared");
expect(args.model).toBe("spreadsheet.dashboard.share");
return "localhost:8069/share/url/132465";
}
},
});
expect(".spreadsheet_share_dropdown").toHaveCount(0);
await contains("i.fa-share-alt").click();
await animationFrame();
expect(".spreadsheet_share_dropdown .o_loading_state").toHaveText("Generating sharing link");
def.resolve();
await animationFrame();
expect(".spreadsheet_share_dropdown .o_loading_state").toHaveCount(0);
expect.verifySteps(["dashboard_shared", "share url copied"]);
expect(".o_field_CopyClipboardChar").toHaveText("localhost:8069/share/url/132465");
await contains(".fa-clipboard").click();
expect.verifySteps(["share url copied"]);
});
test("Changing filter values will create a new share", async function () {
const spreadsheetData = {
globalFilters: [
{
id: "1",
type: "date",
label: "Period",
},
{
id: "2",
type: "date",
label: "This Year",
defaultValue: "this_year",
},
],
};
const serverData = getServerData(spreadsheetData);
let counter = 0;
await createSpreadsheetDashboard({
serverData,
mockRPC: async function (route, args) {
if (args.method === "action_get_share_url") {
return `localhost:8069/share/url/${++counter}`;
}
},
});
await contains("i.fa-share-alt").click();
await animationFrame();
expect(".o_field_CopyClipboardChar").toHaveText(`localhost:8069/share/url/1`);
await contains("i.fa-share-alt").click(); // close share dropdown
await contains("i.fa-share-alt").click();
await animationFrame();
expect(".o_field_CopyClipboardChar").toHaveText(`localhost:8069/share/url/1`);
await contains("i.fa-share-alt").click();
const year = luxon.DateTime.local().year;
expect(".o_control_panel_actions .o_facet_value").toHaveText(String(year));
await contains(".o_searchview_facet_label").click();
await contains(".o-filter-value input").click();
await contains(".o-dropdown-item[data-id='year'] .btn-previous").click();
await contains(".o-filter-values-footer .btn-primary").click();
await contains("i.fa-share-alt").click();
await animationFrame();
expect(".o_field_CopyClipboardChar").toHaveText(`localhost:8069/share/url/2`);
});
test("Should toggle favorite status of a dashboard when the 'Favorite' icon is clicked", async function () {
onRpc("spreadsheet.dashboard", "action_toggle_favorite", ({ method }) => {
expect.step(method);
return true;
});
await createSpreadsheetDashboard();
expect(".o_search_panel_section").toHaveCount(2);
await contains(".o_dashboard_star").click();
expect(".o_dashboard_star").toHaveClass("fa-star", {
message: "The star should be filled",
});
expect(".o_search_panel_section").toHaveCount(3);
expect.verifySteps(["action_toggle_favorite"]);
expect(".o_search_panel_section.o_search_panel_category:first header b:first").toHaveText(
"FAVORITES"
);
await contains(".o_dashboard_star").click();
expect(".o_dashboard_star").not.toHaveClass("fa-star", {
message: "The star should not be filled",
});
expect.verifySteps(["action_toggle_favorite"]);
expect(".o_search_panel_section").toHaveCount(2);
});
test("Global filter with same id is not shared between dashboards", async function () {
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),
spreadsheet_data: JSON.stringify(spreadsheetData),
dashboard_group_id: 1,
});
serverData.models["spreadsheet.dashboard.group"].records[0].published_dashboard_ids = [
789, 790,
];
await createSpreadsheetDashboard({ serverData });
expect(".o_searchview_facet").toHaveCount(0);
await contains(".o_spreadsheet_dashboard_action .dropdown-toggle").click();
await contains(".o-autocomplete--input.o_input").click();
expect(".o-filter-value .o_tag_badge_text").toHaveCount(0);
await contains(".dropdown-item:first").click();
expect(".o-filter-value .o_tag_badge_text").toHaveCount(1);
await contains(".o-filter-values-footer .btn-primary").click();
expect(".o_searchview_facet").toHaveCount(1);
await contains(".o_search_panel li:last-child").click();
expect(".o_searchview_facet").toHaveCount(0);
});
test("Search bar is not present if there is no global filters", async function () {
const spreadsheetData = {
globalFilters: [],
};
const serverData = getServerData(spreadsheetData);
await createSpreadsheetDashboard({ serverData });
expect(".o_sp_dashboard_search").toHaveCount(0);
});
test("Can add a new global filter from the search bar", async function () {
const spreadsheetData = {
globalFilters: [
{
id: "1",
type: "relation",
label: "Relation Filter",
modelName: "product",
},
],
};
const serverData = getServerData(spreadsheetData);
await createSpreadsheetDashboard({ serverData });
await contains(".o_spreadsheet_dashboard_action .dropdown-toggle").click();
expect(".o-autocomplete--input.o_input").toHaveCount(1);
expect(".o-autocomplete--input.o_input").toHaveValue("");
await contains(".o-autocomplete--input.o_input").click();
await contains(".o-autocomplete--dropdown-item").click();
await contains(".o-filter-values-footer .btn-primary").click();
expect(".o_searchview_facet").toHaveCount(1);
expect(".o_searchview_facet .o_searchview_facet_label").toHaveText("Relation Filter");
expect(".o_searchview_facet .o_facet_value").toHaveText("xphone");
});
test("Can open the dialog by clicking on a facet", async function () {
const spreadsheetData = {
globalFilters: [
{
id: "1",
type: "relation",
label: "Relation Filter",
modelName: "product",
defaultValue: { operator: "in", ids: [37] }, // xphone
},
],
};
const serverData = getServerData(spreadsheetData);
await createSpreadsheetDashboard({ serverData });
expect(".o_searchview_facet").toHaveCount(1);
await contains(".o_searchview_facet .o_searchview_facet_label ").click();
expect(".o-filter-values").toHaveCount(1);
});
test("Can open the dialog by clicking on the search bar", async function () {
const spreadsheetData = {
globalFilters: [
{
id: "1",
type: "relation",
label: "Relation Filter",
modelName: "product",
defaultValue: { operator: "in", ids: [37] }, // xphone
},
],
};
const serverData = getServerData(spreadsheetData);
await createSpreadsheetDashboard({ serverData });
await contains(".o_searchview input").click();
expect(".o-filter-values").toHaveCount(1);
});
test("Changes of global filters are not dispatched while inside the dialog", async function () {
const spreadsheetData = {
globalFilters: [
{
id: "1",
type: "relation",
label: "Relation Filter",
modelName: "product",
},
],
};
const serverData = getServerData(spreadsheetData);
const { model } = await createSpreadsheetDashboard({ serverData });
expect(model.getters.getGlobalFilterValue("1")).toBe(undefined);
await contains(".o_spreadsheet_dashboard_action .dropdown-toggle").click();
await contains(".o-autocomplete--input.o_input").click();
await contains(".o-autocomplete--dropdown-item").click();
expect(model.getters.getGlobalFilterValue("1")).toBe(undefined);
await contains(".o-filter-values-footer .btn-primary").click();
expect(model.getters.getGlobalFilterValue("1")).toEqual({ operator: "in", ids: [37] });
});
test("First global filter date is displayed as button", async function () {
const spreadsheetData = {
globalFilters: [
{
id: "1",
type: "relation",
label: "Relation Filter",
modelName: "product",
defaultValue: { operator: "in", ids: [37] },
},
{
id: "2",
type: "date",
label: "Period",
defaultValue: "this_year",
},
],
};
const serverData = getServerData(spreadsheetData);
await createSpreadsheetDashboard({ serverData });
expect(".o_sp_date_filter_button").toHaveCount(1);
expect(".o_searchview_facet").toHaveCount(1);
});
test("No date buttons are displayed if there is no date filter", async function () {
const spreadsheetData = {
globalFilters: [
{
id: "1",
type: "relation",
label: "Relation Filter",
modelName: "product",
},
],
};
const serverData = getServerData(spreadsheetData);
await createSpreadsheetDashboard({ serverData });
expect(".o_sp_date_filter_button").toHaveCount(0);
});
test("Unknown value for relation filter is displayed as inaccessible", async function () {
const spreadsheetData = {
globalFilters: [
{
id: "1",
type: "relation",
label: "Relation Filter",
modelName: "product",
defaultValue: { operator: "in", ids: [9999] }, // unknown product
},
],
};
const serverData = getServerData(spreadsheetData);
await createSpreadsheetDashboard({ serverData });
expect(".o_searchview_facet").toHaveCount(1);
expect(".o_searchview_facet .o_facet_value").toHaveText("Inaccessible/missing record ID");
});
describe("Quick search bar", () => {
const productFilter = {
id: "1",
type: "relation",
label: "Product",
modelName: "product",
};
const selectionFilter = {
id: "55",
type: "selection",
label: "Selection Filter",
resModel: "res.currency",
selectionField: "position",
};
test("Can quick search a string in a relational filter", async function () {
const spreadsheetData = { globalFilters: [productFilter] };
const serverData = getServerData(spreadsheetData);
const { model } = await createSpreadsheetDashboard({ serverData });
await contains(".o_searchview_input").edit("phone");
expect(".o-dropdown-item.focus").toHaveText("Search Product for: phone");
await press("Enter");
const filterValue = model.getters.getGlobalFilterValue(productFilter.id);
expect(filterValue).toEqual({ operator: "ilike", strings: ["phone"] });
});
test("Can quick search a string in a relational filter if a record was already selected", async function () {
const filter = { ...productFilter, defaultValue: { operator: "in", ids: [37] } };
const spreadsheetData = { globalFilters: [filter] };
const serverData = getServerData(spreadsheetData);
const { model } = await createSpreadsheetDashboard({ serverData });
await contains(".o_searchview_input").edit("test");
expect(".o-dropdown-item.focus").toHaveText("Search Product for: test");
await press("Enter");
const filterValue = model.getters.getGlobalFilterValue(productFilter.id);
expect(filterValue).toEqual({ operator: "ilike", strings: ["test"] });
});
test("Can quick search a specific record in a relational filter", async function () {
const spreadsheetData = { globalFilters: [productFilter] };
const serverData = getServerData(spreadsheetData);
const { model } = await createSpreadsheetDashboard({ serverData });
await contains(".o_searchview_input").edit("x");
expect(".o-dropdown-item.focus").toHaveText("Search Product for: x");
await contains(".o-dropdown-item.focus .o_expand").click();
const children = queryAll(".o-dropdown-item.o_indent");
expect(children.map((el) => el.innerText)).toEqual(["xphone", "xpad"]);
await contains(children[0]).click();
const filterValue = model.getters.getGlobalFilterValue(productFilter.id);
expect(filterValue).toEqual({ operator: "in", ids: [37] });
});
test("Can load more records in the quick search", async function () {
for (let i = 0; i < 15; i++) {
Product._records.push({ id: i, display_name: "name" + i });
}
const serverData = getServerData({ globalFilters: [productFilter] });
await createSpreadsheetDashboard({ serverData });
await contains(".o_searchview_input").edit("name");
expect(".o-dropdown-item.focus").toHaveText("Search Product for: name");
await contains(".o-dropdown-item.focus .o_expand").click();
const children = queryAll(".o-dropdown-item.o_indent");
expect(children.map((el) => el.innerText)).toEqual([
...range(0, 9).map((i) => "name" + i),
"Load more",
]);
await contains(children.at(-1)).click();
expect(queryAllTexts(".o-dropdown-item.o_indent")).toEqual(
range(0, 15).map((i) => "name" + i)
);
});
test("Can quick search a string in a text filter", async function () {
const spreadsheetData = { globalFilters: [{ id: "2", type: "text", label: "Text" }] };
const serverData = getServerData(spreadsheetData);
const { model } = await createSpreadsheetDashboard({ serverData });
await contains(".o_searchview_input").edit("phone");
expect(".o-dropdown-item.focus").toHaveText("Search Text for: phone");
await press("Enter");
const filterValue = model.getters.getGlobalFilterValue("2");
expect(filterValue).toEqual({ operator: "ilike", strings: ["phone"] });
});
test("Can quick search a string in a text filter with a range of allowed values", async function () {
const spreadsheetData = {
sheets: [{ id: "sh1", name: "Sh1", cells: { A1: "phone", A2: "tablet", A3: "table" } }],
globalFilters: [
{
id: "2",
type: "text",
label: "Text",
rangesOfAllowedValues: ["Sh1!A1:A5"],
},
],
};
const serverData = getServerData(spreadsheetData);
const { model } = await createSpreadsheetDashboard({ serverData });
await contains(".o_searchview_input").edit("a");
expect(".o-dropdown-item.focus").toHaveText("Search Text for: a");
await press("ArrowRight");
await animationFrame();
const children = queryAll(".o-dropdown-item.o_indent");
expect(children.map((el) => el.innerText)).toEqual(["tablet", "table"]);
await contains(children[1]).click();
const filterValue = model.getters.getGlobalFilterValue("2");
expect(filterValue).toEqual({ operator: "ilike", strings: ["table"] });
});
test("Cannot search for a string that is not in rangesOfAllowedValues", async function () {
const spreadsheetData = {
sheets: [{ id: "sh1", name: "Sh1", cells: { A1: "phone", A2: "tablet", A3: "table" } }],
globalFilters: [
{
id: "2",
type: "text",
label: "Text",
rangesOfAllowedValues: ["Sh1!A1:A5"],
},
],
};
const serverData = getServerData(spreadsheetData);
const { model } = await createSpreadsheetDashboard({ serverData });
await contains(".o_searchview_input").edit("desk");
expect(".o-dropdown-item.focus").toHaveText("Search Text for: desk");
await press("Enter");
const filterValue = model.getters.getGlobalFilterValue("2");
expect(filterValue).toEqual(undefined);
});
test("Can quick search a selection filter value", async function () {
const spreadsheetData = { globalFilters: [selectionFilter] };
const serverData = getServerData(spreadsheetData);
const { model } = await createSpreadsheetDashboard({ serverData });
await contains(".o_searchview_input").edit("a");
expect(".o-dropdown-item.focus").toHaveText("Search Selection Filter for: a");
await contains(".o-dropdown-item.focus .o_expand").click();
const children = queryAll(".o-dropdown-item.o_indent");
expect(children.map((el) => el.innerText)).toEqual(["A"]);
await contains(children[0]).click();
const filterValue = model.getters.getGlobalFilterValue(selectionFilter.id);
expect(filterValue).toEqual({ operator: "in", selectionValues: ["after"] });
});
test("Date and numeric filters are not in the quick search results", async function () {
const numericFilter = { id: "255", type: "numeric", label: "Numeric Filter" };
const spreadsheetData = {
globalFilters: [productFilter, THIS_YEAR_GLOBAL_FILTER, numericFilter, selectionFilter],
};
const serverData = getServerData(spreadsheetData);
await createSpreadsheetDashboard({ serverData });
await contains(".o_searchview_input").edit("phone");
expect(queryAllTexts(".o-dropdown-item")).toEqual([
"Search Product for: phone",
"Search Selection Filter for: phone",
]);
});
test("Pressing backspace will remove the last facet", async function () {
const filter = { ...productFilter, defaultValue: { operator: "in", ids: [37] } };
const spreadsheetData = { globalFilters: [filter] };
const serverData = getServerData(spreadsheetData);
const { model } = await createSpreadsheetDashboard({ serverData });
let filterValue = model.getters.getGlobalFilterValue(productFilter.id);
expect(filterValue).toEqual({ operator: "in", ids: [37] });
await contains(".o_searchview_input").focus();
await press("Backspace");
filterValue = model.getters.getGlobalFilterValue(productFilter.id);
expect(filterValue).toEqual(undefined);
});
});

View file

@ -0,0 +1,67 @@
import { describe, expect, test, getFixture } from "@odoo/hoot";
import { getBasicData } from "@spreadsheet/../tests/helpers/data";
import { createSpreadsheetDashboard } from "@spreadsheet_dashboard/../tests/helpers/dashboard_action";
import {
defineSpreadsheetDashboardModels,
getDashboardServerData,
} from "@spreadsheet_dashboard/../tests/helpers/data";
import { contains } from "@web/../tests/web_test_helpers";
describe.current.tags("mobile");
defineSpreadsheetDashboardModels();
function getServerData(spreadsheetData) {
const serverData = getDashboardServerData();
serverData.models = {
...serverData.models,
...getBasicData(),
};
serverData.models["spreadsheet.dashboard.group"].records = [
{
published_dashboard_ids: [789],
id: 1,
name: "Pivot",
},
];
serverData.models["spreadsheet.dashboard"].records = [
{
id: 789,
name: "Spreadsheet with Pivot",
json_data: JSON.stringify(spreadsheetData),
spreadsheet_data: JSON.stringify(spreadsheetData),
dashboard_group_id: 1,
},
];
return serverData;
}
test("Search input can be toggled", async () => {
const productFilter = { id: "1", type: "relation", label: "Product", modelName: "product" };
const spreadsheetData = { globalFilters: [productFilter] };
const serverData = getServerData(spreadsheetData);
await createSpreadsheetDashboard({ serverData });
expect(".o_searchview_input").toHaveCount(0);
await contains(".o_search_toggler button").click();
expect(".o_searchview_input").toHaveCount(1);
});
test("Search input is not focusable in mobile", async () => {
const productFilter = {
id: "1",
type: "relation",
label: "Product",
modelName: "product",
};
const spreadsheetData = { globalFilters: [productFilter] };
const serverData = getServerData(spreadsheetData);
await createSpreadsheetDashboard({ serverData });
await contains(".o_search_toggler button").click();
await contains(".o_searchview_input").click();
const input = getFixture().querySelector(".o_searchview_input");
expect(document.activeElement).not.toBe(input);
expect(".o_bottom_sheet .o-filter-values").toHaveCount(1);
});

View file

@ -1,254 +0,0 @@
/** @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,51 @@
import { describe, expect, test } from "@odoo/hoot";
import { makeMockEnv, contains, mountWithCleanup } from "@web/../tests/web_test_helpers";
import { defineSpreadsheetModels } from "@spreadsheet/../tests/helpers/data";
import { DashboardDateFilter } from "@spreadsheet_dashboard/bundle/dashboard_action/dashboard_date_filter/dashboard_date_filter";
describe.current.tags("desktop");
defineSpreadsheetModels();
/**
*
* @param {{ model: Model, filter: object}} props
*/
async function mountDashboardFilterValue(env, props) {
await mountWithCleanup(DashboardDateFilter, { props, env });
}
test("Can display the input as a button", async function () {
const env = await makeMockEnv();
await mountDashboardFilterValue(env, {
value: { type: "range", from: "2023-01-01", to: "2023-01-31" },
update: () => {},
});
expect("button").toHaveCount(3);
expect(".o-date-filter-value").toHaveText("January 1 31, 2023");
});
test("Can navigate with buttons to select the next period", async function () {
const env = await makeMockEnv();
await mountDashboardFilterValue(env, {
value: { type: "month", month: 1, year: 2023 },
update: (value) => {
expect.step("update");
expect(value).toEqual({ type: "month", month: 2, year: 2023 });
},
});
await contains(".btn-next-date").click();
expect.verifySteps(["update"]);
});
test("Can navigate with buttons to select the previous period", async function () {
const env = await makeMockEnv();
await mountDashboardFilterValue(env, {
value: { type: "month", month: 1, year: 2023 },
update: (value) => {
expect.step("update");
expect(value).toEqual({ type: "month", month: 12, year: 2022 });
},
});
await contains(".btn-previous-date").click();
expect.verifySteps(["update"]);
});

View file

@ -0,0 +1,287 @@
import { expect, test } from "@odoo/hoot";
import { animationFrame } from "@odoo/hoot-mock";
import { getCellValue } from "@spreadsheet/../tests/helpers/getters";
import { makeSpreadsheetMockEnv } from "@spreadsheet/../tests/helpers/model";
import { waitForDataLoaded } from "@spreadsheet/helpers/model";
import {
defineSpreadsheetDashboardModels,
getDashboardServerData,
} from "@spreadsheet_dashboard/../tests/helpers/data";
import {
DashboardLoader,
Status,
} from "@spreadsheet_dashboard/bundle/dashboard_action/dashboard_loader_service";
import { onRpc, patchWithCleanup } from "@web/../tests/web_test_helpers";
import { RPCError } from "@web/core/network/rpc";
defineSpreadsheetDashboardModels();
/**
* @param {object} [params]
* @param {object} [params.serverData]
* @param {function} [params.mockRPC]
* @returns {Promise<DashboardLoader>}
*/
async function createDashboardLoader(params = {}) {
const env = await makeSpreadsheetMockEnv({
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],
["spreadsheet_data"]
);
return { data: JSON.parse(record.spreadsheet_data), revisions: [] };
});
}
test("load all dashboards of all containers", async () => {
const loader = await createDashboardLoader();
loader.load();
expect(loader.getDashboardGroups()).toEqual([]);
await animationFrame();
expect(loader.getDashboardGroups()).toEqual([
{
id: 1,
name: "Container 1",
dashboards: [
{
data: {
id: 1,
name: "Dashboard CRM 1",
is_favorite: false,
},
status: Status.NotLoaded,
},
{
data: {
id: 2,
name: "Dashboard CRM 2",
is_favorite: false,
},
status: Status.NotLoaded,
},
],
},
{
id: 2,
name: "Container 2",
dashboards: [
{
data: {
id: 3,
name: "Dashboard Accounting 1",
is_favorite: false,
},
status: Status.NotLoaded,
},
],
},
]);
});
test("load twice does not duplicate spreadsheets", async () => {
const loader = await createDashboardLoader();
await loader.load();
expect(loader.getDashboardGroups()[1].dashboards).toMatchObject([{ status: Status.NotLoaded }]);
await loader.load();
expect(loader.getDashboardGroups()[1].dashboards).toMatchObject([{ status: Status.NotLoaded }]);
});
test("load spreadsheet data", async () => {
const loader = await createDashboardLoader();
await loader.load();
const result = loader.getDashboard(3);
expect(result.status).toBe(Status.Loading);
await animationFrame();
expect(result.status).toBe(Status.Loaded);
expect(result.model).not.toBe(undefined);
});
test("load spreadsheet data only once", async () => {
onRpc("/spreadsheet/dashboard/data/3", () => expect.step("spreadsheet 3 loaded"));
const loader = await createDashboardLoader({
mockRPC: function (route, args) {
if (args.model === "spreadsheet.dashboard" && args.method === "read") {
// read names
expect.step(`spreadsheet ${args.args[0]} loaded`);
}
},
});
await loader.load();
let result = loader.getDashboard(3);
await animationFrame();
expect(result.status).toBe(Status.Loaded);
expect.verifySteps(["spreadsheet 3 loaded"]);
result = loader.getDashboard(3);
await animationFrame();
expect(result.status).toBe(Status.Loaded);
expect.verifySteps([]);
});
test("don't return empty dashboard group", async () => {
const loader = await createDashboardLoader({
mockRPC: async function (route, args) {
if (args.method === "web_search_read" && args.model === "spreadsheet.dashboard.group") {
return {
length: 2,
records: [
{
id: 45,
name: "Group A",
published_dashboard_ids: [{ id: 1, name: "Dashboard CRM 1" }],
},
{
id: 46,
name: "Group B",
published_dashboard_ids: [],
},
],
};
}
},
});
await loader.load();
expect(loader.getDashboardGroups()).toEqual([
{
id: 45,
name: "Group A",
dashboards: [
{
data: { id: 1, name: "Dashboard CRM 1" },
status: Status.NotLoaded,
},
],
},
]);
});
test("load multiple spreadsheets", async () => {
onRpc("/spreadsheet/dashboard/data/1", () => expect.step("spreadsheet 1 loaded"));
onRpc("/spreadsheet/dashboard/data/2", () => expect.step("spreadsheet 2 loaded"));
const loader = await createDashboardLoader({
mockRPC: function (route, args) {
if (args.method === "web_search_read" && args.model === "spreadsheet.dashboard.group") {
expect.step("load groups");
}
if (args.method === "read" && args.model === "spreadsheet.dashboard") {
// read names
expect.step(`spreadsheet ${args.args[0]} loaded`);
}
},
});
await loader.load();
expect.verifySteps(["load groups"]);
loader.getDashboard(1);
await animationFrame();
expect.verifySteps(["spreadsheet 1 loaded"]);
loader.getDashboard(2);
await animationFrame();
expect.verifySteps(["spreadsheet 2 loaded"]);
loader.getDashboard(1);
await animationFrame();
expect.verifySteps([]);
});
test("load spreadsheet data with error", async () => {
onRpc("/spreadsheet/dashboard/data/*", () => {
const error = new RPCError();
error.data = { message: "Bip" };
throw error;
});
const loader = await createDashboardLoader();
await loader.load();
const result = loader.getDashboard(3);
expect(result.status).toBe(Status.Loading);
await result.promise.catch(() => expect.step("error"));
expect(result.status).toBe(Status.Error);
expect(result.error.data.message).toBe("Bip");
// error is thrown
expect.verifySteps(["error"]);
});
test("async formulas are correctly evaluated", async () => {
const spreadsheetData = {
sheets: [
{
id: "sheet1",
cells: {
A1: '=ODOO.CURRENCY.RATE("EUR","USD")', // an async formula
},
},
],
};
const serverData = getDashboardServerData();
const dashboardId = 15;
serverData.models["spreadsheet.dashboard.group"].records = [
{ id: 1, name: "Container 1", published_dashboard_ids: [dashboardId] },
];
serverData.models["spreadsheet.dashboard"].records = [
{
id: dashboardId,
spreadsheet_data: JSON.stringify(spreadsheetData),
json_data: JSON.stringify(spreadsheetData),
name: "Dashboard Accounting 1",
dashboard_group_id: 1,
},
];
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 animationFrame();
const { model } = loader.getDashboard(dashboardId);
await waitForDataLoaded(model);
expect(await getCellValue(model, "A1")).toBe(0.9);
});
test("Model is in dashboard mode", async () => {
const loader = await createDashboardLoader();
await loader.load();
loader.getDashboard(3);
await animationFrame();
const { model } = loader.getDashboard(3);
expect(model.config.mode).toBe("dashboard");
});
test("Model is in dashboard mode [2]", async () => {
patchWithCleanup(DashboardLoader.prototype, {
_activateFirstSheet: () => {
expect.step("activate sheet");
},
});
const loader = await createDashboardLoader();
await loader.load();
loader.getDashboard(3);
await animationFrame();
expect.verifySteps(["activate sheet"]);
});
test("default currency format", async () => {
onRpc("/spreadsheet/dashboard/data/*", () => ({
data: {},
revisions: [],
default_currency: {
code: "Odoo",
symbol: "θ",
position: "after",
decimalPlaces: 2,
},
}));
const loader = await createDashboardLoader();
await loader.load();
const result = loader.getDashboard(3);
expect(result.status).toBe(Status.Loading);
await animationFrame();
const { model } = loader.getDashboard(3);
expect(model.getters.getCompanyCurrencyFormat()).toBe("#,##0.00[$θ]");
});

View file

@ -1,259 +0,0 @@
/** @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,55 @@
import { getFixture } from "@odoo/hoot";
import { animationFrame } from "@odoo/hoot-mock";
import { Spreadsheet } from "@odoo/o-spreadsheet";
import { makeSpreadsheetMockEnv } from "@spreadsheet/../tests/helpers/model";
import {
getService,
makeMockServer,
MockServer,
mountWithCleanup,
patchWithCleanup,
} from "@web/../tests/web_test_helpers";
import { loadBundle } from "@web/core/assets";
import { WebClient } from "@web/webclient/webclient";
/**
* @param {object} params
* @param {object} [params.serverData]
* @param {function} [params.mockRPC]
* @param {number} [params.spreadsheetId]
* @returns {Promise}
*/
export async function createSpreadsheetDashboard(params = {}) {
let model = undefined;
patchWithCleanup(Spreadsheet.prototype, {
setup() {
super.setup();
model = this.env.model;
},
});
await makeSpreadsheetMockEnv(params);
await loadBundle("web.chartjs_lib");
await mountWithCleanup(WebClient);
await getService("action").doAction({
type: "ir.actions.client",
tag: "action_spreadsheet_dashboard",
params: {
dashboard_id: params.spreadsheetId,
},
});
return { model, fixture: getFixture() };
}
export async function createDashboardActionWithData(data) {
if (!MockServer.env) {
await makeMockServer();
}
const json = JSON.stringify(data);
const [dashboard] = MockServer.env["spreadsheet.dashboard"];
dashboard.spreadsheet_data = json;
dashboard.json_data = json;
const { fixture, model } = await createSpreadsheetDashboard({ spreadsheetId: dashboard.id });
await animationFrame();
return { fixture, model };
}

View file

@ -0,0 +1,91 @@
import { SpreadsheetModels, defineSpreadsheetModels } from "@spreadsheet/../tests/helpers/data";
import { fields, models, onRpc } from "@web/../tests/web_test_helpers";
import { RPCError } from "@web/core/network/rpc";
export function getDashboardServerData() {
return {
models: {
"spreadsheet.dashboard": {},
"spreadsheet.dashboard.group": {},
},
views: {},
};
}
export class SpreadsheetDashboard extends models.Model {
_name = "spreadsheet.dashboard";
name = fields.Char({ string: "Name" });
spreadsheet_data = fields.Char({});
json_data = fields.Char({});
is_published = fields.Boolean({ string: "Is published" });
dashboard_group_id = fields.Many2one({ relation: "spreadsheet.dashboard.group" });
favorite_user_ids = fields.Many2many({ relation: "res.users", string: "Favorite Users" });
is_favorite = fields.Boolean({ compute: "_compute_is_favorite", string: "Is Favorite" });
_compute_is_favorite() {
for (const record of this) {
record.is_favorite = record.favorite_user_ids.includes(this.env.uid);
}
}
_records = [
{
id: 1,
spreadsheet_data: "{}",
json_data: "{}",
name: "Dashboard CRM 1",
dashboard_group_id: 1,
},
{
id: 2,
spreadsheet_data: "{}",
json_data: "{}",
name: "Dashboard CRM 2",
dashboard_group_id: 1,
},
{
id: 3,
spreadsheet_data: "{}",
json_data: "{}",
name: "Dashboard Accounting 1",
dashboard_group_id: 2,
},
];
}
export class SpreadsheetDashboardGroup extends models.Model {
_name = "spreadsheet.dashboard.group";
name = fields.Char({ string: "Name" });
published_dashboard_ids = fields.One2many({
relation: "spreadsheet.dashboard",
relation_field: "dashboard_group_id",
});
_records = [
{ id: 1, name: "Container 1", published_dashboard_ids: [1, 2] },
{ id: 2, name: "Container 2", published_dashboard_ids: [3] },
];
}
function mockDashboardDataController(_request, { res_id }) {
const [record] = this.env["spreadsheet.dashboard"].search_read([["id", "=", parseInt(res_id)]]);
if (!record) {
const error = new RPCError(`Dashboard ${res_id} does not exist`);
error.data = {};
throw error;
}
return {
snapshot: JSON.parse(record.spreadsheet_data),
revisions: [],
};
}
onRpc("/spreadsheet/dashboard/data/<int:res_id>", mockDashboardDataController);
export function defineSpreadsheetDashboardModels() {
const SpreadsheetDashboardModels = [SpreadsheetDashboard, SpreadsheetDashboardGroup];
Object.assign(SpreadsheetModels, SpreadsheetDashboardModels);
defineSpreadsheetModels();
}

View file

@ -1,113 +0,0 @@
/** @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,198 @@
import { describe, expect, test } from "@odoo/hoot";
import { dblclick, queryAll, queryAllTexts } from "@odoo/hoot-dom";
import { animationFrame } from "@odoo/hoot-mock";
import { createSpreadsheetDashboard } from "@spreadsheet_dashboard/../tests/helpers/dashboard_action";
import {
defineSpreadsheetDashboardModels,
getDashboardServerData,
} from "@spreadsheet_dashboard/../tests/helpers/data";
import { contains } from "@web/../tests/web_test_helpers";
describe.current.tags("mobile");
defineSpreadsheetDashboardModels();
const TEST_LINE_CHART_DATA = {
type: "line",
dataSetsHaveTitle: false,
dataSets: [{ dataRange: "A1" }],
legendPosition: "top",
verticalAxisPosition: "left",
title: { text: "" },
};
const TEST_SCORECARD_CHART_DATA = {
type: "scorecard",
title: { text: "test" },
keyValue: "A1",
background: "#fff",
baselineMode: "absolute",
};
test("is empty with no figures", async () => {
await createSpreadsheetDashboard();
expect(".o_mobile_dashboard").toHaveCount(1);
expect(".o_mobile_dashboard").toHaveText(
"Only chart figures are displayed in small screens but this dashboard doesn't contain any"
);
});
test("with no available dashboard", async () => {
const serverData = getDashboardServerData();
serverData.models["spreadsheet.dashboard"].records = [];
serverData.models["spreadsheet.dashboard.group"].records = [];
await createSpreadsheetDashboard({ serverData });
expect(".o_mobile_dashboard").toHaveText("No available dashboard");
});
test("displays figures in first sheet", async () => {
const figure = {
tag: "chart",
height: 500,
width: 500,
col: 0,
row: 0,
offset: { x: 100, y: 100 },
data: TEST_LINE_CHART_DATA,
};
const spreadsheetData = {
sheets: [
{ id: "sheet1", figures: [{ ...figure, id: "figure1" }] },
{ id: "sheet2", figures: [{ ...figure, id: "figure2" }] },
],
};
const serverData = getDashboardServerData();
serverData.models["spreadsheet.dashboard.group"].records = [
{
published_dashboard_ids: [789],
id: 1,
name: "Chart",
},
];
serverData.models["spreadsheet.dashboard"].records = [
{
id: 789,
name: "Spreadsheet with chart figure",
json_data: JSON.stringify(spreadsheetData),
spreadsheet_data: JSON.stringify(spreadsheetData),
dashboard_group_id: 1,
},
];
await createSpreadsheetDashboard({ serverData });
expect(".o-chart-container").toHaveCount(1);
});
test("scorecards are placed two per row", async () => {
const figure = {
tag: "chart",
height: 500,
width: 500,
offset: { x: 100, y: 100 },
col: 0,
row: 0,
};
const spreadsheetData = {
sheets: [
{
id: "sheet1",
figures: [
{ ...figure, id: "figure1", data: TEST_SCORECARD_CHART_DATA },
{ ...figure, id: "figure2", data: TEST_SCORECARD_CHART_DATA },
{ ...figure, id: "figure3", data: TEST_SCORECARD_CHART_DATA },
{ ...figure, id: "figure4", data: TEST_LINE_CHART_DATA },
],
},
],
};
const serverData = getDashboardServerData();
serverData.models["spreadsheet.dashboard.group"].records = [
{ published_dashboard_ids: [789], id: 1, name: "Chart" },
];
serverData.models["spreadsheet.dashboard"].records = [
{
id: 789,
name: "Spreadsheet with chart figure",
json_data: JSON.stringify(spreadsheetData),
spreadsheet_data: JSON.stringify(spreadsheetData),
dashboard_group_id: 1,
},
];
await createSpreadsheetDashboard({ serverData });
const figureRows = queryAll(".o_figure_row");
expect(figureRows).toHaveLength(3);
expect(figureRows[0].querySelectorAll(".o-scorecard")).toHaveLength(2);
expect(figureRows[1].querySelectorAll(".o-scorecard")).toHaveLength(1);
expect(figureRows[1].querySelectorAll(".o_empty_figure")).toHaveLength(1);
expect(figureRows[2].querySelectorAll(".o-figure-canvas")).toHaveLength(1);
});
test("double clicking on a figure doesn't open the side panel", async () => {
const figure = {
tag: "chart",
height: 500,
width: 500,
col: 0,
row: 0,
offset: {
x: 100,
y: 100,
},
data: TEST_LINE_CHART_DATA,
};
const spreadsheetData = {
sheets: [
{
id: "sheet1",
figures: [{ ...figure, id: "figure1" }],
},
],
};
const serverData = getDashboardServerData();
serverData.models["spreadsheet.dashboard.group"].records = [
{
published_dashboard_ids: [789],
id: 1,
name: "Chart",
},
];
serverData.models["spreadsheet.dashboard"].records = [
{
id: 789,
name: "Spreadsheet with chart figure",
json_data: JSON.stringify(spreadsheetData),
spreadsheet_data: JSON.stringify(spreadsheetData),
dashboard_group_id: 1,
},
];
await createSpreadsheetDashboard({ serverData });
await contains(".o-chart-container").focus();
await dblclick(".o-chart-container");
await animationFrame();
expect(".o-chart-container").toHaveCount(1);
expect(".o-sidePanel").toHaveCount(0);
});
test("can switch dashboard", async () => {
await createSpreadsheetDashboard();
expect(".o_search_panel_current_selection").toHaveText("Dashboard CRM 1");
await contains(".o_search_panel_current_selection").click();
const dashboardElements = queryAll("section header.list-group-item", { root: document.body });
expect(dashboardElements[0]).toHaveClass("active");
expect(queryAllTexts(dashboardElements)).toEqual([
"Dashboard CRM 1",
"Dashboard CRM 2",
"Dashboard Accounting 1",
]);
await contains(dashboardElements[1]).click();
expect(".o_search_panel_current_selection").toHaveText("Dashboard CRM 2");
});
test("can go back from dashboard selection", async () => {
await createSpreadsheetDashboard();
expect(".o_mobile_dashboard").toHaveCount(1);
expect(".o_search_panel_current_selection").toHaveText("Dashboard CRM 1");
await contains(".o_search_panel_current_selection").click();
await contains(document.querySelector(".o_mobile_search_button")).click();
expect(".o_search_panel_current_selection").toHaveText("Dashboard CRM 1");
});

View file

@ -1,115 +0,0 @@
/** @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

@ -1,25 +0,0 @@
/** @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

@ -1,57 +0,0 @@
/** @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: {},
};
}