Initial commit: Report packages

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

View file

@ -0,0 +1,217 @@
/** @odoo-module */
import spreadsheet from "@spreadsheet/o_spreadsheet/o_spreadsheet_extended";
import { getBasicData } from "@spreadsheet/../tests/utils/data";
import { createBasicChart } from "@spreadsheet/../tests/utils/commands";
import { createSpreadsheetWithChart } from "@spreadsheet/../tests/utils/chart";
import { makeTestEnv } from "@web/../tests/helpers/mock_env";
import { registry } from "@web/core/registry";
import { menuService } from "@web/webclient/menus/menu_service";
import { actionService } from "@web/webclient/actions/action_service";
import { ormService } from "@web/core/orm_service";
import { viewService } from "@web/views/view_service";
const { Model } = spreadsheet;
const chartId = "uuid1";
QUnit.module(
"spreadsheet > ir.ui.menu chart plugin",
{
beforeEach: function () {
this.serverData = {};
this.serverData.menus = {
root: {
id: "root",
children: [1, 2],
name: "root",
appID: "root",
},
1: {
id: 1,
children: [],
name: "test menu 1",
xmlid: "documents_spreadsheet.test.menu",
appID: 1,
actionID: "menuAction",
},
2: {
id: 2,
children: [],
name: "test menu 2",
xmlid: "documents_spreadsheet.test.menu2",
appID: 1,
actionID: "menuAction2",
},
};
this.serverData.actions = {
menuAction: {
id: 99,
xml_id: "ir.ui.menu",
name: "menuAction",
res_model: "ir.ui.menu",
type: "ir.actions.act_window",
views: [[false, "list"]],
},
menuAction2: {
id: 100,
xml_id: "ir.ui.menu",
name: "menuAction2",
res_model: "ir.ui.menu",
type: "ir.actions.act_window",
views: [[false, "list"]],
},
};
this.serverData.views = {};
this.serverData.views["ir.ui.menu,false,list"] = `<tree></tree>`;
this.serverData.views["ir.ui.menu,false,search"] = `<search></search>`;
this.serverData.models = {
...getBasicData(),
"ir.ui.menu": {
fields: {
name: { string: "Name", type: "char" },
action: { string: "Action", type: "char" },
groups_id: {
string: "Groups",
type: "many2many",
relation: "res.group",
},
},
records: [
{
id: 1,
name: "test menu 1",
action: "action1",
groups_id: [10],
},
{
id: 2,
name: "test menu 2",
action: "action2",
groups_id: [10],
},
],
},
"res.users": {
fields: {
name: { string: "Name", type: "char" },
groups_id: {
string: "Groups",
type: "many2many",
relation: "res.group",
},
},
records: [{ id: 1, name: "Raoul", groups_id: [10] }],
},
"ir.actions": {
fields: {
name: { string: "Name", type: "char" },
},
records: [{ id: 1 }],
},
"res.group": {
fields: { name: { string: "Name", type: "char" } },
records: [{ id: 10, name: "test group" }],
},
};
registry.category("services").add("menu", menuService).add("action", actionService);
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.test(
"Links between charts and ir.menus are correctly imported/exported",
async function (assert) {
const env = await makeTestEnv({ serverData: this.serverData });
const model = new Model({}, { evalContext: { env } });
createBasicChart(model, chartId);
model.dispatch("LINK_ODOO_MENU_TO_CHART", {
chartId,
odooMenuId: 1,
});
const exportedData = model.exportData();
assert.equal(
exportedData.chartOdooMenusReferences[chartId],
1,
"Link to odoo menu is exported"
);
const importedModel = new Model(exportedData, { evalContext: { env } });
const chartMenu = importedModel.getters.getChartOdooMenu(chartId);
assert.equal(chartMenu.id, 1, "Link to odoo menu is imported");
}
);
QUnit.test("Can undo-redo a LINK_ODOO_MENU_TO_CHART", async function (assert) {
const env = await makeTestEnv({ serverData: this.serverData });
const model = new Model({}, { evalContext: { env } });
createBasicChart(model, chartId);
model.dispatch("LINK_ODOO_MENU_TO_CHART", {
chartId,
odooMenuId: 1,
});
assert.equal(model.getters.getChartOdooMenu(chartId).id, 1);
model.dispatch("REQUEST_UNDO");
assert.equal(model.getters.getChartOdooMenu(chartId), undefined);
model.dispatch("REQUEST_REDO");
assert.equal(model.getters.getChartOdooMenu(chartId).id, 1);
});
QUnit.test("link is removed when figure is deleted", async function (assert) {
const env = await makeTestEnv({ serverData: this.serverData });
const model = new Model({}, { evalContext: { env } });
createBasicChart(model, chartId);
model.dispatch("LINK_ODOO_MENU_TO_CHART", {
chartId,
odooMenuId: 1,
});
assert.equal(model.getters.getChartOdooMenu(chartId).id, 1);
model.dispatch("DELETE_FIGURE", {
sheetId: model.getters.getActiveSheetId(),
id: chartId,
});
assert.equal(model.getters.getChartOdooMenu(chartId), undefined);
});
QUnit.test(
"Links of Odoo charts are duplicated when duplicating a sheet",
async function (assert) {
const { model } = await createSpreadsheetWithChart({
type: "odoo_pie",
serverData: this.serverData,
});
const sheetId = model.getters.getActiveSheetId();
const secondSheetId = "mySecondSheetId";
const chartId = model.getters.getChartIds(sheetId)[0];
model.dispatch("DUPLICATE_SHEET", { sheetId, sheetIdTo: secondSheetId });
const newChartId = model.getters.getChartIds(secondSheetId)[0];
assert.deepEqual(
model.getters.getChartOdooMenu(newChartId),
model.getters.getChartOdooMenu(chartId)
);
}
);
QUnit.test(
"Links of standard charts are duplicated when duplicating a sheet",
async function (assert) {
const env = await makeTestEnv({ serverData: this.serverData });
const model = new Model({}, { evalContext: { env } });
const sheetId = model.getters.getActiveSheetId();
const secondSheetId = "mySecondSheetId";
createBasicChart(model, chartId);
model.dispatch("LINK_ODOO_MENU_TO_CHART", {
chartId,
odooMenuId: 1,
});
model.dispatch("DUPLICATE_SHEET", { sheetId, sheetIdTo: secondSheetId });
const newChartId = model.getters.getChartIds(secondSheetId)[0];
assert.deepEqual(
model.getters.getChartOdooMenu(newChartId),
model.getters.getChartOdooMenu(chartId)
);
}
);
}
);

View file

@ -0,0 +1,481 @@
/** @odoo-module */
import { OdooBarChart } from "@spreadsheet/chart/odoo_chart/odoo_bar_chart";
import { OdooChart } from "@spreadsheet/chart/odoo_chart/odoo_chart";
import { OdooLineChart } from "@spreadsheet/chart/odoo_chart/odoo_line_chart";
import { nextTick } from "@web/../tests/helpers/utils";
import { createSpreadsheetWithChart, insertChartInSpreadsheet } from "../../utils/chart";
import { createModelWithDataSource, waitForDataSourcesLoaded } from "../../utils/model";
import spreadsheet from "@spreadsheet/o_spreadsheet/o_spreadsheet_extended";
import { RPCError } from "@web/core/network/rpc_service";
const { toZone } = spreadsheet.helpers;
QUnit.module("spreadsheet > odoo chart plugin", {}, () => {
QUnit.test("Can add an Odoo Bar chart", async (assert) => {
const { model } = await createSpreadsheetWithChart({ type: "odoo_bar" });
const sheetId = model.getters.getActiveSheetId();
assert.strictEqual(model.getters.getChartIds(sheetId).length, 1);
const chartId = model.getters.getChartIds(sheetId)[0];
const chart = model.getters.getChart(chartId);
assert.ok(chart instanceof OdooBarChart);
assert.strictEqual(chart.getDefinitionForExcel(), undefined);
assert.strictEqual(model.getters.getChartRuntime(chartId).chartJsConfig.type, "bar");
});
QUnit.test("Can add an Odoo Line chart", async (assert) => {
const { model } = await createSpreadsheetWithChart({ type: "odoo_line" });
const sheetId = model.getters.getActiveSheetId();
assert.strictEqual(model.getters.getChartIds(sheetId).length, 1);
const chartId = model.getters.getChartIds(sheetId)[0];
const chart = model.getters.getChart(chartId);
assert.ok(chart instanceof OdooLineChart);
assert.strictEqual(chart.getDefinitionForExcel(), undefined);
assert.strictEqual(model.getters.getChartRuntime(chartId).chartJsConfig.type, "line");
});
QUnit.test("Can add an Odoo Pie chart", async (assert) => {
const { model } = await createSpreadsheetWithChart({ type: "odoo_pie" });
const sheetId = model.getters.getActiveSheetId();
assert.strictEqual(model.getters.getChartIds(sheetId).length, 1);
const chartId = model.getters.getChartIds(sheetId)[0];
const chart = model.getters.getChart(chartId);
assert.ok(chart instanceof OdooChart);
assert.strictEqual(chart.getDefinitionForExcel(), undefined);
assert.strictEqual(model.getters.getChartRuntime(chartId).chartJsConfig.type, "pie");
});
QUnit.test("A data source is added after a chart creation", async (assert) => {
const { model } = await createSpreadsheetWithChart();
const sheetId = model.getters.getActiveSheetId();
const chartId = model.getters.getChartIds(sheetId)[0];
assert.ok(model.getters.getChartDataSource(chartId));
});
QUnit.test("Odoo bar chart runtime loads the data", async (assert) => {
const { model } = await createSpreadsheetWithChart({
type: "odoo_bar",
mockRPC: async function (route, args) {
if (args.method === "web_read_group") {
assert.step("web_read_group");
}
},
});
const sheetId = model.getters.getActiveSheetId();
const chartId = model.getters.getChartIds(sheetId)[0];
assert.verifySteps([], "it should not be loaded eagerly");
assert.deepEqual(model.getters.getChartRuntime(chartId).chartJsConfig.data, {
datasets: [],
labels: [],
});
await nextTick();
assert.deepEqual(model.getters.getChartRuntime(chartId).chartJsConfig.data, {
datasets: [
{
backgroundColor: "rgb(31,119,180)",
borderColor: "rgb(31,119,180)",
data: [1, 3],
label: "Count",
},
],
labels: ["false", "true"],
});
assert.verifySteps(["web_read_group"], "it should have loaded the data");
});
QUnit.test("Odoo pie chart runtime loads the data", async (assert) => {
const { model } = await createSpreadsheetWithChart({
type: "odoo_pie",
mockRPC: async function (route, args) {
if (args.method === "web_read_group") {
assert.step("web_read_group");
}
},
});
const sheetId = model.getters.getActiveSheetId();
const chartId = model.getters.getChartIds(sheetId)[0];
assert.verifySteps([], "it should not be loaded eagerly");
assert.deepEqual(model.getters.getChartRuntime(chartId).chartJsConfig.data, {
datasets: [],
labels: [],
});
await nextTick();
assert.deepEqual(model.getters.getChartRuntime(chartId).chartJsConfig.data, {
datasets: [
{
backgroundColor: ["rgb(31,119,180)", "rgb(255,127,14)", "rgb(174,199,232)"],
borderColor: "#FFFFFF",
data: [1, 3],
label: "",
},
],
labels: ["false", "true"],
});
assert.verifySteps(["web_read_group"], "it should have loaded the data");
});
QUnit.test("Odoo line chart runtime loads the data", async (assert) => {
const { model } = await createSpreadsheetWithChart({
type: "odoo_line",
mockRPC: async function (route, args) {
if (args.method === "web_read_group") {
assert.step("web_read_group");
}
},
});
const sheetId = model.getters.getActiveSheetId();
const chartId = model.getters.getChartIds(sheetId)[0];
assert.verifySteps([], "it should not be loaded eagerly");
assert.deepEqual(model.getters.getChartRuntime(chartId).chartJsConfig.data, {
datasets: [],
labels: [],
});
await nextTick();
assert.deepEqual(model.getters.getChartRuntime(chartId).chartJsConfig.data, {
datasets: [
{
backgroundColor: "#1F77B466",
borderColor: "rgb(31,119,180)",
data: [1, 3],
label: "Count",
lineTension: 0,
fill: "origin",
pointBackgroundColor: "rgb(31,119,180)",
},
],
labels: ["false", "true"],
});
assert.verifySteps(["web_read_group"], "it should have loaded the data");
});
QUnit.test("Changing the chart type does not reload the data", async (assert) => {
const { model } = await createSpreadsheetWithChart({
type: "odoo_line",
mockRPC: async function (route, args) {
if (args.method === "web_read_group") {
assert.step("web_read_group");
}
},
});
const sheetId = model.getters.getActiveSheetId();
const chartId = model.getters.getChartIds(sheetId)[0];
const definition = model.getters.getChartDefinition(chartId);
// force runtime computation
model.getters.getChartRuntime(chartId);
await nextTick();
assert.verifySteps(["web_read_group"], "it should have loaded the data");
model.dispatch("UPDATE_CHART", {
definition: {
...definition,
type: "odoo_bar",
},
id: chartId,
sheetId,
});
await nextTick();
// force runtime computation
model.getters.getChartRuntime(chartId);
assert.verifySteps([], "it should have not have loaded the data a second time");
});
QUnit.test("Can import/export an Odoo chart", async (assert) => {
const model = await createModelWithDataSource();
insertChartInSpreadsheet(model, "odoo_line");
const data = model.exportData();
const figures = data.sheets[0].figures;
assert.strictEqual(figures.length, 1);
const figure = figures[0];
assert.strictEqual(figure.tag, "chart");
assert.strictEqual(figure.data.type, "odoo_line");
const m1 = await createModelWithDataSource({ spreadsheetData: data });
const sheetId = m1.getters.getActiveSheetId();
assert.strictEqual(m1.getters.getChartIds(sheetId).length, 1);
const chartId = m1.getters.getChartIds(sheetId)[0];
assert.ok(m1.getters.getChartDataSource(chartId));
assert.strictEqual(m1.getters.getChartRuntime(chartId).chartJsConfig.type, "line");
});
QUnit.test("Can undo/redo an Odoo chart creation", async (assert) => {
const model = await createModelWithDataSource();
insertChartInSpreadsheet(model, "odoo_line");
const sheetId = model.getters.getActiveSheetId();
const chartId = model.getters.getChartIds(sheetId)[0];
assert.ok(model.getters.getChartDataSource(chartId));
model.dispatch("REQUEST_UNDO");
assert.strictEqual(model.getters.getChartIds(sheetId).length, 0);
model.dispatch("REQUEST_REDO");
assert.ok(model.getters.getChartDataSource(chartId));
assert.strictEqual(model.getters.getChartIds(sheetId).length, 1);
});
QUnit.test("charts with no legend", async (assert) => {
const { model } = await createSpreadsheetWithChart({ type: "odoo_pie" });
insertChartInSpreadsheet(model, "odoo_bar");
insertChartInSpreadsheet(model, "odoo_line");
const sheetId = model.getters.getActiveSheetId();
const [pieChartId, barChartId, lineChartId] = model.getters.getChartIds(sheetId);
const pie = model.getters.getChartDefinition(pieChartId);
const bar = model.getters.getChartDefinition(barChartId);
const line = model.getters.getChartDefinition(lineChartId);
assert.strictEqual(
model.getters.getChartRuntime(pieChartId).chartJsConfig.options.legend.display,
true
);
assert.strictEqual(
model.getters.getChartRuntime(barChartId).chartJsConfig.options.legend.display,
true
);
assert.strictEqual(
model.getters.getChartRuntime(lineChartId).chartJsConfig.options.legend.display,
true
);
model.dispatch("UPDATE_CHART", {
definition: {
...pie,
legendPosition: "none",
},
id: pieChartId,
sheetId,
});
model.dispatch("UPDATE_CHART", {
definition: {
...bar,
legendPosition: "none",
},
id: barChartId,
sheetId,
});
model.dispatch("UPDATE_CHART", {
definition: {
...line,
legendPosition: "none",
},
id: lineChartId,
sheetId,
});
assert.strictEqual(
model.getters.getChartRuntime(pieChartId).chartJsConfig.options.legend.display,
false
);
assert.strictEqual(
model.getters.getChartRuntime(barChartId).chartJsConfig.options.legend.display,
false
);
assert.strictEqual(
model.getters.getChartRuntime(lineChartId).chartJsConfig.options.legend.display,
false
);
});
QUnit.test("Bar chart with stacked attribute is supported", async (assert) => {
const { model } = await createSpreadsheetWithChart({ type: "odoo_bar" });
const sheetId = model.getters.getActiveSheetId();
const chartId = model.getters.getChartIds(sheetId)[0];
const definition = model.getters.getChartDefinition(chartId);
model.dispatch("UPDATE_CHART", {
definition: {
...definition,
stacked: true,
},
id: chartId,
sheetId,
});
assert.ok(
model.getters.getChartRuntime(chartId).chartJsConfig.options.scales.xAxes[0].stacked
);
assert.ok(
model.getters.getChartRuntime(chartId).chartJsConfig.options.scales.yAxes[0].stacked
);
model.dispatch("UPDATE_CHART", {
definition: {
...definition,
stacked: false,
},
id: chartId,
sheetId,
});
assert.notOk(
model.getters.getChartRuntime(chartId).chartJsConfig.options.scales.xAxes[0].stacked
);
assert.notOk(
model.getters.getChartRuntime(chartId).chartJsConfig.options.scales.yAxes[0].stacked
);
});
QUnit.test("Can copy/paste Odoo chart", async (assert) => {
const { model } = await createSpreadsheetWithChart({ type: "odoo_pie" });
const sheetId = model.getters.getActiveSheetId();
const chartId = model.getters.getChartIds(sheetId)[0];
model.dispatch("SELECT_FIGURE", { id: chartId });
model.dispatch("COPY");
model.dispatch("PASTE", { target: [toZone("A1")] });
const chartIds = model.getters.getChartIds(sheetId);
assert.strictEqual(chartIds.length, 2);
assert.ok(model.getters.getChart(chartIds[1]) instanceof OdooChart);
assert.strictEqual(
JSON.stringify(model.getters.getChartRuntime(chartIds[1])),
JSON.stringify(model.getters.getChartRuntime(chartId))
);
assert.notEqual(
model.getters.getChart(chartId).dataSource,
model.getters.getChart(chartIds[1]).dataSource,
"The datasource is also duplicated"
);
});
QUnit.test("Can cut/paste Odoo chart", async (assert) => {
const { model } = await createSpreadsheetWithChart({ type: "odoo_pie" });
const sheetId = model.getters.getActiveSheetId();
const chartId = model.getters.getChartIds(sheetId)[0];
const chartRuntime = model.getters.getChartRuntime(chartId);
model.dispatch("SELECT_FIGURE", { id: chartId });
model.dispatch("CUT");
model.dispatch("PASTE", { target: [toZone("A1")] });
const chartIds = model.getters.getChartIds(sheetId);
assert.strictEqual(chartIds.length, 1);
assert.notEqual(chartIds[0], chartId);
assert.ok(model.getters.getChart(chartIds[0]) instanceof OdooChart);
assert.strictEqual(
JSON.stringify(model.getters.getChartRuntime(chartIds[0])),
JSON.stringify(chartRuntime)
);
});
QUnit.test("Duplicating a sheet correctly duplicates Odoo chart", async (assert) => {
const { model } = await createSpreadsheetWithChart({ type: "odoo_bar" });
const sheetId = model.getters.getActiveSheetId();
const secondSheetId = "secondSheetId";
const chartId = model.getters.getChartIds(sheetId)[0];
model.dispatch("DUPLICATE_SHEET", { sheetId, sheetIdTo: secondSheetId });
const chartIds = model.getters.getChartIds(secondSheetId);
assert.strictEqual(chartIds.length, 1);
assert.ok(model.getters.getChart(chartIds[0]) instanceof OdooChart);
assert.strictEqual(
JSON.stringify(model.getters.getChartRuntime(chartIds[0])),
JSON.stringify(model.getters.getChartRuntime(chartId))
);
assert.notEqual(
model.getters.getChart(chartId).dataSource,
model.getters.getChart(chartIds[0]).dataSource,
"The datasource is also duplicated"
);
});
QUnit.test("Line chart with stacked attribute is supported", async (assert) => {
const { model } = await createSpreadsheetWithChart({ type: "odoo_line" });
const sheetId = model.getters.getActiveSheetId();
const chartId = model.getters.getChartIds(sheetId)[0];
const definition = model.getters.getChartDefinition(chartId);
model.dispatch("UPDATE_CHART", {
definition: {
...definition,
stacked: true,
},
id: chartId,
sheetId,
});
assert.notOk(
model.getters.getChartRuntime(chartId).chartJsConfig.options.scales.xAxes[0].stacked
);
assert.ok(
model.getters.getChartRuntime(chartId).chartJsConfig.options.scales.yAxes[0].stacked
);
model.dispatch("UPDATE_CHART", {
definition: {
...definition,
stacked: false,
},
id: chartId,
sheetId,
});
assert.notOk(
model.getters.getChartRuntime(chartId).chartJsConfig.options.scales.xAxes[0].stacked
);
assert.notOk(
model.getters.getChartRuntime(chartId).chartJsConfig.options.scales.yAxes[0].stacked
);
});
QUnit.test(
"Load odoo chart spreadsheet with models that cannot be accessed",
async function (assert) {
let hasAccessRights = true;
const { model } = await createSpreadsheetWithChart({
mockRPC: async function (route, args) {
if (
args.model === "partner" &&
args.method === "web_read_group" &&
!hasAccessRights
) {
const error = new RPCError();
error.data = { message: "ya done!" };
throw error;
}
},
});
const chartId = model.getters.getFigures(model.getters.getActiveSheetId())[0].id;
const chartDataSource = model.getters.getChartDataSource(chartId);
await waitForDataSourcesLoaded(model);
const data = chartDataSource.getData();
assert.equal(data.datasets.length, 1);
assert.equal(data.labels.length, 2);
hasAccessRights = false;
chartDataSource.load({ reload: true });
await waitForDataSourcesLoaded(model);
assert.deepEqual(chartDataSource.getData(), { datasets: [], labels: [] });
}
);
QUnit.test("Line chart to support cumulative data", async (assert) => {
const { model } = await createSpreadsheetWithChart({ type: "odoo_line" });
const sheetId = model.getters.getActiveSheetId();
const chartId = model.getters.getChartIds(sheetId)[0];
const definition = model.getters.getChartDefinition(chartId);
await waitForDataSourcesLoaded(model);
assert.deepEqual(
model.getters.getChartRuntime(chartId).chartJsConfig.data.datasets[0].data,
[1, 3]
);
model.dispatch("UPDATE_CHART", {
definition: {
...definition,
cumulative: true,
},
id: chartId,
sheetId,
});
assert.deepEqual(
model.getters.getChartRuntime(chartId).chartJsConfig.data.datasets[0].data,
[1, 4]
);
model.dispatch("UPDATE_CHART", {
definition: {
...definition,
cumulative: false,
},
id: chartId,
sheetId,
});
assert.deepEqual(
model.getters.getChartRuntime(chartId).chartJsConfig.data.datasets[0].data,
[1, 3]
);
});
QUnit.test("Remove odoo chart when sheet is deleted", async (assert) => {
const { model } = await createSpreadsheetWithChart({ type: "odoo_line" });
const sheetId = model.getters.getActiveSheetId();
model.dispatch("CREATE_SHEET", {
sheetId: model.uuidGenerator.uuidv4(),
position: model.getters.getSheetIds().length,
});
assert.strictEqual(model.getters.getOdooChartIds().length, 1);
model.dispatch("DELETE_SHEET", { sheetId });
assert.strictEqual(model.getters.getOdooChartIds().length, 0);
});
});

View file

@ -0,0 +1,336 @@
/** @odoo-module */
import { click, nextTick, patchWithCleanup } from "@web/../tests/helpers/utils";
import { session } from "@web/session";
import { getBasicData } from "@spreadsheet/../tests/utils/data";
import { createBasicChart } from "@spreadsheet/../tests/utils/commands";
import { registry } from "@web/core/registry";
import { menuService } from "@web/webclient/menus/menu_service";
import { actionService } from "@web/webclient/actions/action_service";
import { ormService } from "@web/core/orm_service";
import { viewService } from "@web/views/view_service";
import { mountSpreadsheet } from "@spreadsheet/../tests/utils/ui";
import { createModelWithDataSource } from "@spreadsheet/../tests/utils/model";
const chartId = "uuid1";
/**
* The chart menu is hidden by default, and visible on :hover, but this property
* can't be triggered programmatically, so we artificially make it visible to be
* able to interact with it.
*/
async function showChartMenu(fixture) {
const chartMenu = fixture.querySelector(".o-chart-menu");
chartMenu.style.display = "flex";
await nextTick();
}
/** Click on external link of the first chart found in the page*/
async function clickChartExternalLink(fixture) {
await showChartMenu(fixture);
const chartMenuItem = fixture.querySelector(".o-chart-menu-item.o-chart-external-link");
await click(chartMenuItem);
}
function mockActionService(assert, doActionStep) {
const serviceRegistry = registry.category("services");
serviceRegistry.add("actionMain", actionService);
const fakeActionService = {
dependencies: ["actionMain"],
start(env, { actionMain }) {
return {
...actionMain,
doAction: (actionRequest, options = {}) => {
if (actionRequest === "menuAction2") {
assert.step(doActionStep);
}
return actionMain.doAction(actionRequest, options);
},
};
},
};
serviceRegistry.add("action", fakeActionService, {
force: true,
});
}
QUnit.module(
"spreadsheet > ir.ui.menu chart figure",
{
beforeEach: function () {
this.serverData = {};
this.serverData.menus = {
root: {
id: "root",
children: [1, 2],
name: "root",
appID: "root",
},
1: {
id: 1,
children: [],
name: "test menu 1",
xmlid: "documents_spreadsheet.test.menu",
appID: 1,
actionID: "menuAction",
},
2: {
id: 2,
children: [],
name: "test menu 2",
xmlid: "documents_spreadsheet.test.menu2",
appID: 1,
actionID: "menuAction2",
},
};
this.serverData.actions = {
menuAction: {
id: 99,
xml_id: "ir.ui.menu",
name: "menuAction",
res_model: "ir.ui.menu",
type: "ir.actions.act_window",
views: [[false, "list"]],
},
menuAction2: {
id: 100,
xml_id: "ir.ui.menu",
name: "menuAction2",
res_model: "ir.ui.menu",
type: "ir.actions.act_window",
views: [[false, "list"]],
},
};
this.serverData.views = {};
this.serverData.views["ir.ui.menu,false,list"] = `<tree></tree>`;
this.serverData.views["ir.ui.menu,false,search"] = `<search></search>`;
this.serverData.models = {
...getBasicData(),
"ir.ui.menu": {
fields: {
name: { string: "Name", type: "char" },
action: { string: "Action", type: "char" },
groups_id: {
string: "Groups",
type: "many2many",
relation: "res.group",
},
},
records: [
{
id: 1,
name: "test menu 1",
action: "action1",
groups_id: [10],
},
{
id: 2,
name: "test menu 2",
action: "action2",
groups_id: [10],
},
],
},
"res.users": {
fields: {
name: { string: "Name", type: "char" },
groups_id: {
string: "Groups",
type: "many2many",
relation: "res.group",
},
},
records: [{ id: 1, name: "Raoul", groups_id: [10] }],
},
"ir.actions": {
fields: {
name: { string: "Name", type: "char" },
},
records: [{ id: 1 }],
},
"res.group": {
fields: { name: { string: "Name", type: "char" } },
records: [{ id: 10, name: "test group" }],
},
};
patchWithCleanup(session, { uid: 1 });
registry.category("services").add("menu", menuService).add("action", actionService);
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.test(
"icon external link isn't on the chart when its not linked to an odoo menu",
async function (assert) {
const model = await createModelWithDataSource({
serverData: this.serverData,
});
const fixture = await mountSpreadsheet(model);
createBasicChart(model, chartId);
await nextTick();
const odooMenu = model.getters.getChartOdooMenu(chartId);
assert.equal(odooMenu, undefined, "No menu linked with the chart");
const externalRefIcon = fixture.querySelector(".o-chart-external-link");
assert.equal(externalRefIcon, null);
}
);
QUnit.test(
"icon external link is on the chart when its linked to an odoo menu",
async function (assert) {
const model = await createModelWithDataSource({
serverData: this.serverData,
});
const fixture = await mountSpreadsheet(model);
createBasicChart(model, chartId);
model.dispatch("LINK_ODOO_MENU_TO_CHART", {
chartId,
odooMenuId: 1,
});
const chartMenu = model.getters.getChartOdooMenu(chartId);
assert.equal(chartMenu.id, 1, "Odoo menu is linked to chart");
await nextTick();
const externalRefIcon = fixture.querySelector(".o-chart-external-link");
assert.ok(externalRefIcon);
}
);
QUnit.test(
"icon external link is not on the chart when its linked to a wrong odoo menu",
async function (assert) {
const model = await createModelWithDataSource({
serverData: this.serverData,
});
const fixture = await mountSpreadsheet(model);
createBasicChart(model, chartId);
model.dispatch("LINK_ODOO_MENU_TO_CHART", {
chartId,
odooMenuId: "menu which does not exist",
});
const chartMenu = model.getters.getChartOdooMenu(chartId);
assert.equal(chartMenu, undefined, "cannot get a wrong menu");
await nextTick();
assert.containsNone(fixture, ".o-chart-external-link");
}
);
QUnit.test(
"icon external link isn't on the chart in dashboard mode",
async function (assert) {
const model = await createModelWithDataSource({
serverData: this.serverData,
});
const fixture = await mountSpreadsheet(model);
createBasicChart(model, chartId);
model.dispatch("LINK_ODOO_MENU_TO_CHART", {
chartId,
odooMenuId: 1,
});
const chartMenu = model.getters.getChartOdooMenu(chartId);
assert.equal(chartMenu.id, 1, "Odoo menu is linked to chart");
model.updateMode("dashboard");
await nextTick();
assert.containsNone(fixture, ".o-chart-external-link", "No link icon in dashboard");
}
);
QUnit.test(
"click on icon external link on chart redirect to the odoo menu",
async function (assert) {
const doActionStep = "doAction";
mockActionService(assert, doActionStep);
const model = await createModelWithDataSource({
serverData: this.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);
assert.equal(chartMenu.id, 2, "Odoo menu is linked to chart");
await nextTick();
await clickChartExternalLink(fixture);
assert.verifySteps([doActionStep]);
}
);
QUnit.test(
"Click on chart in dashboard mode redirect to the odoo menu",
async function (assert) {
const doActionStep = "doAction";
mockActionService(assert, doActionStep);
const model = await createModelWithDataSource({
serverData: this.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);
assert.equal(chartMenu.id, 2, "Odoo menu is linked to chart");
await nextTick();
await click(fixture, ".o-chart-container");
assert.verifySteps([], "Clicking on a chart while not dashboard mode do nothing");
model.updateMode("dashboard");
await nextTick();
await click(fixture, ".o-chart-container");
assert.verifySteps(
[doActionStep],
"Clicking on a chart while on dashboard mode redirect to the odoo menu"
);
}
);
QUnit.test("can use menus xmlIds instead of menu ids", async function (assert) {
const serviceRegistry = registry.category("services");
serviceRegistry.add("actionMain", actionService);
const fakeActionService = {
dependencies: ["actionMain"],
start(env, { actionMain }) {
return {
...actionMain,
doAction: (actionRequest, options = {}) => {
if (actionRequest === "menuAction2") {
assert.step("doAction");
}
return actionMain.doAction(actionRequest, options);
},
};
},
};
serviceRegistry.add("action", fakeActionService, {
force: true,
});
const model = await createModelWithDataSource({
serverData: this.serverData,
});
const fixture = await mountSpreadsheet(model);
createBasicChart(model, chartId);
model.dispatch("LINK_ODOO_MENU_TO_CHART", {
chartId,
odooMenuId: "documents_spreadsheet.test.menu2",
});
await nextTick();
await clickChartExternalLink(fixture);
assert.verifySteps(["doAction"]);
});
}
);

View file

@ -0,0 +1,120 @@
/** @odoo-module */
import { setCellContent } from "@spreadsheet/../tests/utils/commands";
import { getCell, getCellValue } from "@spreadsheet/../tests/utils/getters";
import {
createModelWithDataSource,
waitForDataSourcesLoaded,
} from "@spreadsheet/../tests/utils/model";
QUnit.module("spreadsheet > Currency");
QUnit.test("Basic exchange formula", async (assert) => {
const model = await createModelWithDataSource({
mockRPC: async function (route, args) {
if (args.method === "get_rates_for_spreadsheet") {
const info = args.args[0][0];
assert.equal(info.from, "EUR");
assert.equal(info.to, "USD");
assert.equal(info.date, undefined);
assert.step("rate fetched");
return [{ ...info, rate: 0.9 }];
}
},
});
setCellContent(model, "A1", `=ODOO.CURRENCY.RATE("EUR","USD")`);
assert.strictEqual(getCellValue(model, "A1"), "Loading...");
await waitForDataSourcesLoaded(model);
assert.strictEqual(getCellValue(model, "A1"), 0.9);
assert.verifySteps(["rate fetched"]);
});
QUnit.test("rate formula at a given date(time)", async (assert) => {
const model = await createModelWithDataSource({
mockRPC: async function (route, args) {
if (args.method === "get_rates_for_spreadsheet") {
const [A1, A2] = args.args[0];
assert.equal(A1.date, "2020-12-31");
assert.equal(A2.date, "2020-11-30");
assert.step("rate fetched");
return [
{ ...A1, rate: 0.9 },
{ ...A2, rate: 0.9 },
];
}
},
});
setCellContent(model, "A1", `=ODOO.CURRENCY.RATE("EUR","USD", "12-31-2020")`);
setCellContent(model, "A2", `=ODOO.CURRENCY.RATE("EUR","USD", "11-30-2020 00:00:00")`);
await waitForDataSourcesLoaded(model);
assert.verifySteps(["rate fetched"]);
});
QUnit.test("invalid date", async (assert) => {
const model = await createModelWithDataSource({
mockRPC: async function (route, args) {
if (args.method === "get_rates_for_spreadsheet") {
throw new Error("Should not be called");
}
},
});
setCellContent(model, "A1", `=ODOO.CURRENCY.RATE("EUR","USD", "hello")`);
await waitForDataSourcesLoaded(model);
assert.strictEqual(getCellValue(model, "A1"), "#ERROR");
assert.strictEqual(
getCell(model, "A1").evaluated.error.message,
"The function ODOO.CURRENCY.RATE expects a number value, but 'hello' is a string, and cannot be coerced to a number."
);
});
QUnit.test("Currency rate throw with unknown currency", async (assert) => {
const model = await createModelWithDataSource({
mockRPC: async function (route, args) {
if (args.method === "get_rates_for_spreadsheet") {
const info = args.args[0][0];
return [{ ...info, rate: false }];
}
},
});
setCellContent(model, "A1", `=ODOO.CURRENCY.RATE("INVALID","USD")`);
await waitForDataSourcesLoaded(model);
assert.strictEqual(getCell(model, "A1").evaluated.error.message, "Currency rate unavailable.");
});
QUnit.test("Currency rates are only loaded once", async (assert) => {
const model = await createModelWithDataSource({
mockRPC: async function (route, args) {
if (args.method === "get_rates_for_spreadsheet") {
assert.step("FETCH");
const info = args.args[0][0];
return [{ ...info, rate: 0.9 }];
}
},
});
setCellContent(model, "A1", `=ODOO.CURRENCY.RATE("EUR","USD")`);
await waitForDataSourcesLoaded(model);
assert.verifySteps(["FETCH"]);
setCellContent(model, "A2", `=ODOO.CURRENCY.RATE("EUR","USD")`);
await waitForDataSourcesLoaded(model);
assert.verifySteps([]);
});
QUnit.test("Currency rates are loaded once by clock", async (assert) => {
const model = await createModelWithDataSource({
mockRPC: async function (route, args) {
if (args.method === "get_rates_for_spreadsheet") {
assert.step("FETCH:" + args.args[0].length);
const info1 = args.args[0][0];
const info2 = args.args[0][1];
return [
{ ...info1, rate: 0.9 },
{ ...info2, rate: 1 },
];
}
},
});
setCellContent(model, "A1", `=ODOO.CURRENCY.RATE("EUR","USD")`);
setCellContent(model, "A2", `=ODOO.CURRENCY.RATE("EUR","SEK")`);
await waitForDataSourcesLoaded(model);
assert.verifySteps(["FETCH:2"]);
});

View file

@ -0,0 +1,82 @@
/** @odoo-module */
import { nextTick } from "@web/../tests/helpers/utils";
import { LoadableDataSource } from "@spreadsheet/data_sources/data_source";
import { Deferred } from "@web/core/utils/concurrency";
import { RPCError } from "@web/core/network/rpc_service";
QUnit.module("spreadsheet data source", {}, () => {
QUnit.test(
"data source is ready after all concurrent requests are resolved",
async (assert) => {
const def1 = new Deferred();
const def2 = new Deferred();
let req = 0;
class TestDataSource extends LoadableDataSource {
constructor() {
super(...arguments);
this.data = null;
}
async _load() {
this.data = null;
switch (++req) {
case 1:
await def1;
break;
case 2:
await def2;
break;
}
this.data = "something";
}
}
const dataSource = new TestDataSource({
notify: () => assert.step("notify"),
notifyWhenPromiseResolves: () => assert.step("notify-from-promise"),
cancelPromise: () => assert.step("cancel-promise"),
});
dataSource.load();
assert.verifySteps(["notify-from-promise"]);
dataSource.load({ reload: true });
assert.strictEqual(dataSource.isReady(), false);
def1.resolve();
await nextTick();
assert.verifySteps(["cancel-promise", "notify-from-promise"]);
assert.strictEqual(dataSource.isReady(), false);
def2.resolve();
await nextTick();
assert.strictEqual(dataSource.isReady(), true);
assert.verifySteps([]);
}
);
QUnit.test("Datasources handle errors thrown at _load", async (assert) => {
class TestDataSource extends LoadableDataSource {
constructor() {
super(...arguments);
this.data = null;
}
async _load() {
this.data = await this._orm.call();
}
}
const dataSource = new TestDataSource({
notify: () => assert.step("notify"),
notifyWhenPromiseResolves: () => assert.step("notify-from-promise"),
cancelPromise: () => assert.step("cancel-promise"),
orm: {
call: () => {
const error = new RPCError();
error.data = { message: "Ya done!" };
throw error;
},
},
});
await dataSource.load();
assert.verifySteps(["notify-from-promise"]);
assert.ok(dataSource._isFullyLoaded);
assert.notOk(dataSource._isValid);
assert.equal(dataSource._loadErrorMessage, "Ya done!");
});
});

View file

@ -0,0 +1,183 @@
/** @odoo-module */
import { nextTick } from "@web/../tests/helpers/utils";
import { MetadataRepository } from "@spreadsheet/data_sources/metadata_repository";
QUnit.module("spreadsheet > Metadata Repository", {}, () => {
QUnit.test("Fields_get are only loaded once", async function (assert) {
assert.expect(6);
const orm = {
call: async (model, method) => {
assert.step(`${method}-${model}`);
return model;
},
};
const metadataRepository = new MetadataRepository(orm);
const first = await metadataRepository.fieldsGet("A");
const second = await metadataRepository.fieldsGet("A");
const third = await metadataRepository.fieldsGet("B");
assert.strictEqual(first, "A");
assert.strictEqual(second, "A");
assert.strictEqual(third, "B");
assert.verifySteps(["fields_get-A", "fields_get-B"]);
});
QUnit.test("display_name_for on ir.model are only loaded once", async function (assert) {
assert.expect(6);
const orm = {
call: async (model, method, args) => {
if (method === "display_name_for" && model === "ir.model") {
const [modelName] = args[0];
assert.step(`${modelName}`);
return [{ display_name: modelName, model: modelName }];
}
},
};
const metadataRepository = new MetadataRepository(orm);
const first = await metadataRepository.modelDisplayName("A");
const second = await metadataRepository.modelDisplayName("A");
const third = await metadataRepository.modelDisplayName("B");
assert.strictEqual(first, "A");
assert.strictEqual(second, "A");
assert.strictEqual(third, "B");
assert.verifySteps(["A", "B"]);
});
QUnit.test("Register label correctly memorize labels", function (assert) {
assert.expect(2);
const metadataRepository = new MetadataRepository({});
assert.strictEqual(metadataRepository.getLabel("model", "field", "value"), undefined);
const label = "label";
metadataRepository.registerLabel("model", "field", "value", label);
assert.strictEqual(metadataRepository.getLabel("model", "field", "value"), label);
});
QUnit.test("Name_get are collected and executed once by clock", async function (assert) {
const orm = {
call: async (model, method, args) => {
const ids = args[0];
assert.step(`${method}-${model}-[${ids.join(",")}]`);
return ids.map((id) => [id, id.toString()]);
},
};
const metadataRepository = new MetadataRepository(orm);
metadataRepository.addEventListener("labels-fetched", () => {
assert.step("labels-fetched");
});
assert.throws(() => metadataRepository.getRecordDisplayName("A", 1), /Data is loading/);
assert.throws(() => metadataRepository.getRecordDisplayName("A", 1), /Data is loading/);
assert.throws(() => metadataRepository.getRecordDisplayName("A", 2), /Data is loading/);
assert.throws(() => metadataRepository.getRecordDisplayName("B", 1), /Data is loading/);
assert.verifySteps([]);
await nextTick();
assert.verifySteps([
"name_get-A-[1,2]",
"name_get-B-[1]",
"labels-fetched",
"labels-fetched",
]);
assert.strictEqual(metadataRepository.getRecordDisplayName("A", 1), "1");
assert.strictEqual(metadataRepository.getRecordDisplayName("A", 2), "2");
assert.strictEqual(metadataRepository.getRecordDisplayName("B", 1), "1");
});
QUnit.test("Name_get to fetch are cleared after being fetched", async function (assert) {
const orm = {
call: async (model, method, args) => {
const ids = args[0];
assert.step(`${method}-${model}-[${ids.join(",")}]`);
return ids.map((id) => [id, id.toString()]);
},
};
const metadataRepository = new MetadataRepository(orm);
assert.throws(() => metadataRepository.getRecordDisplayName("A", 1));
assert.verifySteps([]);
await nextTick();
assert.verifySteps(["name_get-A-[1]"]);
assert.throws(() => metadataRepository.getRecordDisplayName("A", 2));
await nextTick();
assert.verifySteps(["name_get-A-[2]"]);
});
QUnit.test(
"Assigning a result after triggering the request should not crash",
async function (assert) {
const orm = {
call: async (model, method, args) => {
const ids = args[0];
assert.step(`${method}-${model}-[${ids.join(",")}]`);
return ids.map((id) => [id, id.toString()]);
},
};
const metadataRepository = new MetadataRepository(orm);
assert.throws(() => metadataRepository.getRecordDisplayName("A", 1));
assert.verifySteps([]);
metadataRepository.setDisplayName("A", 1, "test");
assert.strictEqual(metadataRepository.getRecordDisplayName("A", 1), "test");
await nextTick();
assert.verifySteps(["name_get-A-[1]"]);
assert.strictEqual(metadataRepository.getRecordDisplayName("A", 1), "1");
}
);
QUnit.test(
"Name_get will retry with one id by request in case of failure",
async function (assert) {
const orm = {
call: async (model, method, args) => {
const ids = args[0];
assert.step(`${method}-${model}-[${ids.join(",")}]`);
if (model === "B" && ids.includes(1)) {
throw new Error("Missing");
}
return ids.map((id) => [id, id.toString()]);
},
};
const metadataRepository = new MetadataRepository(orm);
assert.throws(() => metadataRepository.getRecordDisplayName("A", 1), /Data is loading/);
assert.throws(() => metadataRepository.getRecordDisplayName("B", 1), /Data is loading/);
assert.throws(() => metadataRepository.getRecordDisplayName("B", 2), /Data is loading/);
assert.verifySteps([]);
await nextTick();
assert.verifySteps([
"name_get-A-[1]",
"name_get-B-[1,2]",
"name_get-B-[1]",
"name_get-B-[2]",
]);
assert.strictEqual(metadataRepository.getRecordDisplayName("A", 1), "1");
assert.throws(
() => metadataRepository.getRecordDisplayName("B", 1),
/Unable to fetch the label of 1 of model B/
);
assert.strictEqual(metadataRepository.getRecordDisplayName("B", 2), "2");
}
);
});

View file

@ -0,0 +1,305 @@
/** @odoo-module */
import { nextTick } from "@web/../tests/helpers/utils";
import { LoadingDataError } from "@spreadsheet/o_spreadsheet/errors";
import BatchEndpoint, { Request, ServerData } from "@spreadsheet/data_sources/server_data";
QUnit.module("spreadsheet server data", {}, () => {
QUnit.test("simple synchronous get", async (assert) => {
const orm = {
call: async (model, method, args) => {
assert.step(`${model}/${method}`);
return args[0];
},
};
const serverData = new ServerData(orm, {
whenDataIsFetched: () => assert.step("data-fetched-notification"),
});
assert.throws(
() => serverData.get("partner", "get_something", [5]),
LoadingDataError,
"it should throw when it's not loaded"
);
await nextTick();
assert.verifySteps(["partner/get_something", "data-fetched-notification"]);
assert.deepEqual(serverData.get("partner", "get_something", [5]), 5);
assert.verifySteps([]);
});
QUnit.test("synchronous get which returns an error", async (assert) => {
const orm = {
call: async (model, method, args) => {
assert.step(`${model}/${method}`);
throw new Error("error while fetching data");
},
};
const serverData = new ServerData(orm, {
whenDataIsFetched: () => assert.step("data-fetched-notification"),
});
assert.throws(
() => serverData.get("partner", "get_something", [5]),
LoadingDataError,
"it should throw when it's not loaded"
);
await nextTick();
assert.verifySteps(["partner/get_something", "data-fetched-notification"]);
assert.throws(() => serverData.get("partner", "get_something", [5]), Error);
assert.verifySteps([]);
});
QUnit.test("simple async fetch", async (assert) => {
const orm = {
call: async (model, method, args) => {
assert.step(`${model}/${method}`);
return args[0];
},
};
const serverData = new ServerData(orm, {
whenDataIsFetched: () => assert.step("data-fetched-notification"),
});
const result = await serverData.fetch("partner", "get_something", [5]);
assert.deepEqual(result, 5);
assert.verifySteps(["partner/get_something"]);
assert.deepEqual(await serverData.fetch("partner", "get_something", [5]), 5);
assert.verifySteps([]);
});
QUnit.test("async fetch which throws an error", async (assert) => {
const orm = {
call: async (model, method, args) => {
assert.step(`${model}/${method}`);
throw new Error("error while fetching data");
},
};
const serverData = new ServerData(orm, {
whenDataIsFetched: () => assert.step("data-fetched-notification"),
});
assert.rejects(serverData.fetch("partner", "get_something", [5]));
assert.verifySteps(["partner/get_something"]);
assert.rejects(serverData.fetch("partner", "get_something", [5]));
assert.verifySteps([]);
});
QUnit.test("two identical concurrent async fetch", async (assert) => {
const orm = {
call: async (model, method, args) => {
assert.step(`${model}/${method}`);
return args[0];
},
};
const serverData = new ServerData(orm, {
whenDataIsFetched: () => assert.step("data-fetched-notification"),
});
const [result1, result2] = await Promise.all([
serverData.fetch("partner", "get_something", [5]),
serverData.fetch("partner", "get_something", [5]),
]);
assert.verifySteps(["partner/get_something"], "it should have fetch the data once");
assert.deepEqual(result1, 5);
assert.deepEqual(result2, 5);
assert.verifySteps([]);
});
QUnit.test("batch get with a single item", async (assert) => {
const orm = {
call: async (model, method, args) => {
assert.step(`${model}/${method}`);
return args[0];
},
};
const serverData = new ServerData(orm, {
whenDataIsFetched: () => assert.step("data-fetched-notification"),
});
assert.throws(
() => serverData.batch.get("partner", "get_something_in_batch", 5),
LoadingDataError,
"it should throw when it's not loaded"
);
await nextTick();
assert.verifySteps(["partner/get_something_in_batch", "data-fetched-notification"]);
assert.deepEqual(serverData.batch.get("partner", "get_something_in_batch", 5), 5);
assert.verifySteps([]);
});
QUnit.test("batch get with multiple items", async (assert) => {
const orm = {
call: async (model, method, args) => {
assert.step(`${model}/${method}`);
return args[0];
},
};
const serverData = new ServerData(orm, {
whenDataIsFetched: () => assert.step("data-fetched-notification"),
});
assert.throws(
() => serverData.batch.get("partner", "get_something_in_batch", 5),
LoadingDataError,
"it should throw when it's not loaded"
);
assert.throws(
() => serverData.batch.get("partner", "get_something_in_batch", 6),
LoadingDataError,
"it should throw when it's not loaded"
);
await nextTick();
assert.verifySteps(["partner/get_something_in_batch", "data-fetched-notification"]);
assert.deepEqual(serverData.batch.get("partner", "get_something_in_batch", 5), 5);
assert.deepEqual(serverData.batch.get("partner", "get_something_in_batch", 6), 6);
assert.verifySteps([]);
});
QUnit.test("batch get with one error", async (assert) => {
const orm = {
call: async (model, method, args) => {
assert.step(`${model}/${method}`);
if (args[0].includes(5)) {
throw new Error("error while fetching data");
}
return args[0];
},
};
const serverData = new ServerData(orm, {
whenDataIsFetched: () => assert.step("data-fetched-notification"),
});
assert.throws(
() => serverData.batch.get("partner", "get_something_in_batch", 4),
LoadingDataError,
"it should throw when it's not loaded"
);
assert.throws(
() => serverData.batch.get("partner", "get_something_in_batch", 5),
LoadingDataError,
"it should throw when it's not loaded"
);
assert.throws(
() => serverData.batch.get("partner", "get_something_in_batch", 6),
LoadingDataError,
"it should throw when it's not loaded"
);
await nextTick();
assert.verifySteps([
// one call for the batch
"partner/get_something_in_batch",
// retries one by one
"partner/get_something_in_batch",
"partner/get_something_in_batch",
"partner/get_something_in_batch",
"data-fetched-notification",
]);
assert.deepEqual(serverData.batch.get("partner", "get_something_in_batch", 4), 4);
assert.throws(() => serverData.batch.get("partner", "get_something_in_batch", 5), Error);
assert.deepEqual(serverData.batch.get("partner", "get_something_in_batch", 6), 6);
assert.verifySteps([]);
});
QUnit.test("concurrently fetch then get the same request", async (assert) => {
const orm = {
call: async (model, method, args) => {
assert.step(`${model}/${method}`);
return args[0];
},
};
const serverData = new ServerData(orm, {
whenDataIsFetched: () => assert.step("data-fetched-notification"),
});
const promise = serverData.fetch("partner", "get_something", [5]);
assert.throws(() => serverData.get("partner", "get_something", [5]), LoadingDataError);
const result = await promise;
await nextTick();
assert.verifySteps(
["partner/get_something", "partner/get_something", "data-fetched-notification"],
"it loads the data independently"
);
assert.deepEqual(result, 5);
assert.deepEqual(serverData.get("partner", "get_something", [5]), 5);
assert.verifySteps([]);
});
QUnit.test("concurrently get then fetch the same request", async (assert) => {
const orm = {
call: async (model, method, args) => {
assert.step(`${model}/${method}`);
return args[0];
},
};
const serverData = new ServerData(orm, {
whenDataIsFetched: () => assert.step("data-fetched-notification"),
});
assert.throws(() => serverData.get("partner", "get_something", [5]), LoadingDataError);
const result = await serverData.fetch("partner", "get_something", [5]);
assert.verifySteps(
["partner/get_something", "partner/get_something", "data-fetched-notification"],
"it should have fetch the data once"
);
assert.deepEqual(result, 5);
assert.deepEqual(serverData.get("partner", "get_something", [5]), 5);
assert.verifySteps([]);
});
QUnit.test("concurrently batch get then fetch the same request", async (assert) => {
const orm = {
call: async (model, method, args) => {
assert.step(`${model}/${method}`);
return args[0];
},
};
const serverData = new ServerData(orm, {
whenDataIsFetched: () => assert.step("data-fetched-notification"),
});
assert.throws(() => serverData.batch.get("partner", "get_something", 5), LoadingDataError);
const result = await serverData.fetch("partner", "get_something", [5]);
await nextTick();
assert.verifySteps(
["partner/get_something", "partner/get_something", "data-fetched-notification"],
"it should have fetch the data once"
);
assert.deepEqual(result, 5);
assert.deepEqual(serverData.batch.get("partner", "get_something", 5), 5);
assert.verifySteps([]);
});
QUnit.test("concurrently get and batch get the same request", async (assert) => {
const orm = {
call: async (model, method, args) => {
assert.step(`${model}/${method}`);
return args[0];
},
};
const serverData = new ServerData(orm, {
whenDataIsFetched: () => assert.step("data-fetched-notification"),
});
assert.throws(() => serverData.batch.get("partner", "get_something", 5), LoadingDataError);
assert.throws(() => serverData.get("partner", "get_something", [5]), LoadingDataError);
await nextTick();
assert.verifySteps(
["partner/get_something", "data-fetched-notification"],
"it should have fetch the data once"
);
assert.deepEqual(serverData.get("partner", "get_something", [5]), 5);
assert.deepEqual(serverData.batch.get("partner", "get_something", 5), 5);
assert.verifySteps([]);
});
QUnit.test("Call the correct callback after a batch result", async (assert) => {
const orm = {
call: async (model, method, args) => {
if (args[0].includes(5)) {
throw new Error("error while fetching data");
}
return args[0];
},
};
const batchEndpoint = new BatchEndpoint(orm, "partner", "get_something", {
whenDataIsFetched: () => {},
successCallback: () => assert.step("success-callback"),
failureCallback: () => assert.step("failure-callback"),
});
const request = new Request("partner", "get_something", [4]);
const request2 = new Request("partner", "get_something", [5]);
batchEndpoint.call(request);
batchEndpoint.call(request2);
assert.verifySteps([]);
await nextTick();
assert.verifySteps(["success-callback", "failure-callback"]);
});
});

View file

@ -0,0 +1,160 @@
/** @odoo-module */
import { getRelativeDateDomain } from "@spreadsheet/global_filters/helpers";
import {
getDateDomainDurationInDays,
assertDateDomainEqual,
} from "@spreadsheet/../tests/utils/date_domain";
const { DateTime } = luxon;
QUnit.module("spreadsheet > Global filters helpers", {}, () => {
QUnit.test("getRelativeDateDomain > last_week (last 7 days)", async function (assert) {
const now = DateTime.fromISO("2022-05-16");
const domain = getRelativeDateDomain(now, 0, "last_week", "field", "date");
assert.equal(getDateDomainDurationInDays(domain), 7);
assertDateDomainEqual(assert, "field", "2022-05-09", "2022-05-15", domain);
});
QUnit.test("getRelativeDateDomain > last_month (last 30 days)", async function (assert) {
const now = DateTime.fromISO("2022-05-16");
const domain = getRelativeDateDomain(now, 0, "last_month", "field", "date");
assert.equal(getDateDomainDurationInDays(domain), 30);
assertDateDomainEqual(assert, "field", "2022-04-16", "2022-05-15", domain);
});
QUnit.test("getRelativeDateDomain > last_three_months (last 90 days)", async function (assert) {
const now = DateTime.fromISO("2022-05-16");
const domain = getRelativeDateDomain(now, 0, "last_three_months", "field", "date");
assert.equal(getDateDomainDurationInDays(domain), 90);
assertDateDomainEqual(assert, "field", "2022-02-15", "2022-05-15", domain);
});
QUnit.test("getRelativeDateDomain > last_six_months (last 180 days)", async function (assert) {
const now = DateTime.fromISO("2022-05-16");
const domain = getRelativeDateDomain(now, 0, "last_six_months", "field", "date");
assert.equal(getDateDomainDurationInDays(domain), 180);
assertDateDomainEqual(assert, "field", "2021-11-17", "2022-05-15", domain);
});
QUnit.test("getRelativeDateDomain > last_year (last 365 days)", async function (assert) {
const now = DateTime.fromISO("2022-05-16");
const domain = getRelativeDateDomain(now, 0, "last_year", "field", "date");
assert.equal(getDateDomainDurationInDays(domain), 365);
assertDateDomainEqual(assert, "field", "2021-05-16", "2022-05-15", domain);
});
QUnit.test(
"getRelativeDateDomain > last_three_years (last 3 * 365 days)",
async function (assert) {
const now = DateTime.fromISO("2022-05-16");
const domain = getRelativeDateDomain(now, 0, "last_three_years", "field", "date");
assert.equal(getDateDomainDurationInDays(domain), 3 * 365);
assertDateDomainEqual(assert, "field", "2019-05-17", "2022-05-15", domain);
}
);
QUnit.test("getRelativeDateDomain > simple date time", async function (assert) {
const now = DateTime.fromISO("2022-05-16T00:00:00+00:00", { zone: "utc" });
const domain = getRelativeDateDomain(now, 0, "last_week", "field", "datetime");
assert.equal(getDateDomainDurationInDays(domain), 7);
assertDateDomainEqual(
assert,
"field",
"2022-05-09 00:00:00",
"2022-05-15 23:59:59",
domain
);
});
QUnit.test("getRelativeDateDomain > date time from middle of day", async function (assert) {
const now = DateTime.fromISO("2022-05-16T13:59:00+00:00", { zone: "utc" });
const domain = getRelativeDateDomain(now, 0, "last_week", "field", "datetime");
assert.equal(getDateDomainDurationInDays(domain), 7);
assertDateDomainEqual(
assert,
"field",
"2022-05-09 00:00:00",
"2022-05-15 23:59:59",
domain
);
});
QUnit.test("getRelativeDateDomain > date time with timezone", async function (assert) {
const now = DateTime.fromISO("2022-05-16T12:00:00+02:00", { zone: "UTC+2" });
const domain = getRelativeDateDomain(now, 0, "last_week", "field", "datetime");
assert.equal(getDateDomainDurationInDays(domain), 7);
assertDateDomainEqual(
assert,
"field",
"2022-05-08 22:00:00",
"2022-05-15 21:59:59",
domain
);
});
QUnit.test(
"getRelativeDateDomain > date time with timezone on different day than UTC",
async function (assert) {
const now = DateTime.fromISO("2022-05-16T01:00:00+02:00", { zone: "UTC+2" });
const domain = getRelativeDateDomain(now, 0, "last_week", "field", "datetime");
assert.equal(getDateDomainDurationInDays(domain), 7);
assertDateDomainEqual(
assert,
"field",
"2022-05-08 22:00:00",
"2022-05-15 21:59:59",
domain
);
}
);
QUnit.test(
"getRelativeDateDomain > with offset > last_week (last 7 days)",
async function (assert) {
const now = DateTime.fromISO("2022-05-16");
const domain = getRelativeDateDomain(now, -1, "last_week", "field", "date");
assert.equal(getDateDomainDurationInDays(domain), 7);
assertDateDomainEqual(assert, "field", "2022-05-02", "2022-05-08", domain);
}
);
QUnit.test("getRelativeDateDomain > with offset (last 30 days)", async function (assert) {
const now = DateTime.fromISO("2022-05-16");
const domain = getRelativeDateDomain(now, -2, "last_month", "field", "date");
assert.equal(getDateDomainDurationInDays(domain), 30);
assertDateDomainEqual(assert, "field", "2022-02-15", "2022-03-16", domain);
});
QUnit.test(
"getRelativeDateDomain > with offset > last_year (last 365 days)",
async function (assert) {
const now = DateTime.fromISO("2022-05-16");
const domain = getRelativeDateDomain(now, 1, "last_year", "field", "date");
assert.equal(getDateDomainDurationInDays(domain), 365);
assertDateDomainEqual(assert, "field", "2022-05-16", "2023-05-15", domain);
}
);
QUnit.test(
"getRelativeDateDomain > with offset > last_three_years (last 3 * 365 days)",
async function (assert) {
const now = DateTime.fromISO("2022-05-16");
const domain = getRelativeDateDomain(now, -1, "last_three_years", "field", "date");
assert.equal(getDateDomainDurationInDays(domain), 3 * 365);
assertDateDomainEqual(assert, "field", "2016-05-17", "2019-05-16", domain);
}
);
QUnit.test("getRelativeDateDomain > with offset > simple date time", async function (assert) {
const now = DateTime.fromISO("2022-05-16T00:00:00+00:00", { zone: "utc" });
const domain = getRelativeDateDomain(now, -1, "last_week", "field", "datetime");
assert.equal(getDateDomainDurationInDays(domain), 7);
assertDateDomainEqual(
assert,
"field",
"2022-05-02 00:00:00",
"2022-05-08 23:59:59",
domain
);
});
});

View file

@ -0,0 +1,137 @@
/** @odoo-module */
import { globalFiltersFieldMatchers } from "../../src/global_filters/plugins/global_filters_core_plugin";
import { createSpreadsheetWithChart } from "../utils/chart";
import { addGlobalFilter, setGlobalFilterValue } from "../utils/commands";
import { patchDate } from "@web/../tests/helpers/utils";
async function addChartGlobalFilter(model) {
const chartId = model.getters.getChartIds(model.getters.getActiveSheetId())[0];
const filter = {
id: "42",
type: "date",
label: "Last Year",
rangeType: "year",
defaultValue: { yearOffset: -1 },
};
await addGlobalFilter(
model,
{ filter },
{ chart: { [chartId]: { chain: "date", type: "date" } } }
);
}
QUnit.module("spreadsheet > Global filters chart", {}, () => {
QUnit.test("Can add a chart global filter", async function (assert) {
const { model } = await createSpreadsheetWithChart();
assert.equal(model.getters.getGlobalFilters().length, 0);
await addChartGlobalFilter(model);
assert.equal(model.getters.getGlobalFilters().length, 1);
const chartId = model.getters.getChartIds(model.getters.getActiveSheetId())[0];
const computedDomain = model.getters.getChartDataSource(chartId).getComputedDomain();
assert.equal(computedDomain.length, 3);
assert.equal(computedDomain[0], "&");
});
QUnit.test("Chart is loaded with computed domain", async function (assert) {
const { model } = await createSpreadsheetWithChart({
mockRPC: function (route, { model, method, kwargs }) {
if (model === "partner" && method === "web_read_group") {
assert.strictEqual(kwargs.domain.length, 3);
assert.strictEqual(kwargs.domain[0], "&");
assert.strictEqual(kwargs.domain[1][0], "date");
}
},
});
await addChartGlobalFilter(model);
});
QUnit.test("Chart is impacted by global filter in dashboard mode", async function (assert) {
const { model } = await createSpreadsheetWithChart();
assert.equal(model.getters.getGlobalFilters().length, 0);
const chartId = model.getters.getChartIds(model.getters.getActiveSheetId())[0];
const filter = {
id: "42",
type: "date",
label: "Last Year",
rangeType: "year",
};
await addGlobalFilter(
model,
{ filter },
{ chart: { [chartId]: { chain: "date", type: "date" } } }
);
model.updateMode("dashboard");
let computedDomain = model.getters.getChartDataSource(chartId).getComputedDomain();
assert.deepEqual(computedDomain, []);
await setGlobalFilterValue(model, {
id: "42",
value: { yearOffset: -1 },
});
computedDomain = model.getters.getChartDataSource(chartId).getComputedDomain();
assert.equal(computedDomain.length, 3);
assert.equal(computedDomain[0], "&");
});
QUnit.test("field matching is removed when chart is deleted", async function (assert) {
const { model } = await createSpreadsheetWithChart();
await addChartGlobalFilter(model);
const [filter] = model.getters.getGlobalFilters();
const [chartId] = model.getters.getChartIds(model.getters.getActiveSheetId());
const matching = {
chain: "date",
type: "date",
};
assert.deepEqual(model.getters.getChartFieldMatch(chartId)[filter.id], matching);
model.dispatch("DELETE_FIGURE", {
sheetId: model.getters.getActiveSheetId(),
id: chartId,
});
assert.deepEqual(
globalFiltersFieldMatchers["chart"].geIds(),
[],
"it should have removed the chart and its fieldMatching and datasource altogether"
);
model.dispatch("REQUEST_UNDO");
assert.deepEqual(model.getters.getChartFieldMatch(chartId)[filter.id], matching);
model.dispatch("REQUEST_REDO");
assert.deepEqual(globalFiltersFieldMatchers["chart"].geIds(), []);
});
QUnit.test("field matching is removed when filter is deleted", async function (assert) {
patchDate(2022, 6, 10, 0, 0, 0);
const { model } = await createSpreadsheetWithChart();
await addChartGlobalFilter(model);
const [filter] = model.getters.getGlobalFilters();
const [chartId] = model.getters.getChartIds(model.getters.getActiveSheetId());
const matching = {
chain: "date",
type: "date",
};
assert.deepEqual(model.getters.getChartFieldMatch(chartId)[filter.id], matching);
assert.deepEqual(model.getters.getChartDataSource(chartId).getComputedDomain(), [
"&",
["date", ">=", "2021-01-01"],
["date", "<=", "2021-12-31"],
]);
model.dispatch("REMOVE_GLOBAL_FILTER", {
id: filter.id,
});
assert.deepEqual(
model.getters.getChartFieldMatch(chartId)[filter.id],
undefined,
"it should have removed the chart and its fieldMatching and datasource altogether"
);
assert.deepEqual(model.getters.getChartDataSource(chartId).getComputedDomain(), []);
model.dispatch("REQUEST_UNDO");
assert.deepEqual(model.getters.getChartFieldMatch(chartId)[filter.id], matching);
assert.deepEqual(model.getters.getChartDataSource(chartId).getComputedDomain(), [
"&",
["date", ">=", "2021-01-01"],
["date", "<=", "2021-12-31"],
]);
model.dispatch("REQUEST_REDO");
assert.deepEqual(model.getters.getChartFieldMatch(chartId)[filter.id], undefined);
assert.deepEqual(model.getters.getChartDataSource(chartId).getComputedDomain(), []);
});
});

View file

@ -0,0 +1,62 @@
/** @odoo-module */
import { getBasicData } from "@spreadsheet/../tests/utils/data";
export function getMenuServerData() {
const serverData = {};
serverData.menus = {
root: { id: "root", children: [1, 2], name: "root", appID: "root" },
1: {
id: 1,
children: [],
name: "menu with xmlid",
appID: 1,
xmlid: "test_menu",
actionID: "action1",
},
2: { id: 2, children: [], name: "menu without xmlid", appID: 2 },
};
serverData.actions = {
action1: {
id: 99,
xml_id: "action1",
name: "action1",
res_model: "ir.ui.menu",
type: "ir.actions.act_window",
views: [[false, "list"]],
},
};
serverData.views = {};
serverData.views["ir.ui.menu,false,list"] = `<tree></tree>`;
serverData.views["ir.ui.menu,false,search"] = `<search></search>`;
serverData.models = {
...getBasicData(),
"ir.ui.menu": {
fields: {
name: { string: "Name", type: "char" },
action: { string: "Action", type: "char" },
groups_id: { string: "Groups", type: "many2many", relation: "res.group" },
},
records: [
{ id: 1, name: "menu with xmlid", action: "action1", groups_id: [10] },
{ id: 2, name: "menu without xmlid", action: "action2", groups_id: [10] },
],
},
"res.users": {
fields: {
name: { string: "Name", type: "char" },
groups_id: { string: "Groups", type: "many2many", relation: "res.group" },
},
records: [
{ id: 1, name: "Raoul", groups_id: [10] },
{ id: 2, name: "Joseph", groups_id: [] },
],
},
"res.group": {
fields: { name: { string: "Name", type: "char" } },
records: [{ id: 10, name: "test group" }],
},
};
return serverData;
}

View file

@ -0,0 +1,115 @@
/** @odoo-module */
import { spreadsheetLinkMenuCellService } from "@spreadsheet/ir_ui_menu/index";
import spreadsheet from "@spreadsheet/o_spreadsheet/o_spreadsheet_extended";
import { registry } from "@web/core/registry";
import { actionService } from "@web/webclient/actions/action_service";
import { ormService } from "@web/core/orm_service";
import { viewService } from "@web/views/view_service";
import { menuService } from "@web/webclient/menus/menu_service";
import { makeTestEnv } from "@web/../tests/helpers/mock_env";
import { setCellContent } from "@spreadsheet/../tests/utils/commands";
import { getCell } from "@spreadsheet/../tests/utils/getters";
import { getMenuServerData } from "../menu_data_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 > menu link cells", { beforeEach }, () => {
QUnit.test("ir.menu linked based on xml id", async function (assert) {
const env = await makeTestEnv({ serverData: getMenuServerData() });
const model = new Model({}, { evalContext: { env } });
setCellContent(model, "A1", "[label](odoo://ir_menu_xml_id/test_menu)");
const cell = getCell(model, "A1");
assert.equal(cell.evaluated.value, "label", "The value should be the menu name");
assert.equal(
cell.content,
"[label](odoo://ir_menu_xml_id/test_menu)",
"The content should be the complete markdown link"
);
assert.equal(cell.link.label, "label", "The link label should be the menu name");
assert.equal(
cell.link.url,
"odoo://ir_menu_xml_id/test_menu",
"The link url should reference the correct menu"
);
});
QUnit.test("ir.menu linked based on record id", async function (assert) {
const env = await makeTestEnv({ serverData: getMenuServerData() });
const model = new Model({}, { evalContext: { env } });
setCellContent(model, "A1", "[label](odoo://ir_menu_id/2)");
const cell = getCell(model, "A1");
assert.equal(cell.evaluated.value, "label", "The value should be the menu name");
assert.equal(
cell.content,
"[label](odoo://ir_menu_id/2)",
"The content should be the complete markdown link"
);
assert.equal(cell.link.label, "label", "The link label should be the menu name");
assert.equal(
cell.link.url,
"odoo://ir_menu_id/2",
"The link url should reference the correct menu"
);
});
QUnit.test("ir.menu linked based on xml id which does not exists", async function (assert) {
const env = await makeTestEnv({ serverData: getMenuServerData() });
const model = new Model({}, { evalContext: { env } });
setCellContent(model, "A1", "[label](odoo://ir_menu_xml_id/does_not_exists)");
const cell = getCell(model, "A1");
assert.equal(cell.content, "[label](odoo://ir_menu_xml_id/does_not_exists)");
assert.equal(cell.evaluated.value, "#BAD_EXPR");
});
QUnit.test("ir.menu linked based on record id which does not exists", async function (assert) {
const env = await makeTestEnv({ serverData: getMenuServerData() });
const model = new Model({}, { evalContext: { env } });
setCellContent(model, "A1", "[label](odoo://ir_menu_id/9999)");
const cell = getCell(model, "A1");
assert.equal(cell.content, "[label](odoo://ir_menu_id/9999)");
assert.equal(cell.evaluated.value, "#BAD_EXPR");
});
QUnit.test("Odoo link cells can be imported/exported", async function (assert) {
const env = await makeTestEnv({ serverData: getMenuServerData() });
const model = new Model({}, { evalContext: { env } });
setCellContent(model, "A1", "[label](odoo://ir_menu_id/2)");
let cell = getCell(model, "A1");
assert.equal(cell.evaluated.value, "label", "The value should be the menu name");
assert.equal(
cell.content,
"[label](odoo://ir_menu_id/2)",
"The content should be the complete markdown link"
);
assert.equal(cell.link.label, "label", "The link label should be the menu name");
assert.equal(
cell.link.url,
"odoo://ir_menu_id/2",
"The link url should reference the correct menu"
);
const model2 = new Model(model.exportData(), { evalContext: { env } });
cell = getCell(model2, "A1");
assert.equal(cell.evaluated.value, "label", "The value should be the menu name");
assert.equal(
cell.content,
"[label](odoo://ir_menu_id/2)",
"The content should be the complete markdown link"
);
assert.equal(cell.link.label, "label", "The link label should be the menu name");
assert.equal(
cell.link.url,
"odoo://ir_menu_id/2",
"The link url should reference the correct menu"
);
});
});

View file

@ -0,0 +1,609 @@
/** @odoo-module */
import { session } from "@web/session";
import { nextTick, patchWithCleanup } from "@web/../tests/helpers/utils";
import CommandResult from "@spreadsheet/o_spreadsheet/cancelled_reason";
import { createModelWithDataSource, waitForDataSourcesLoaded } from "../utils/model";
import { addGlobalFilter, selectCell, setCellContent } from "../utils/commands";
import { getCell, getCellContent, getCellFormula, getCells, getCellValue } from "../utils/getters";
import { createSpreadsheetWithList } from "../utils/list";
import { registry } from "@web/core/registry";
import { RPCError } from "@web/core/network/rpc_service";
import { getBasicServerData } from "../utils/data";
QUnit.module("spreadsheet > list plugin", {}, () => {
QUnit.test("List export", async (assert) => {
const { model } = await createSpreadsheetWithList();
const total = 4 + 10 * 4; // 4 Headers + 10 lines
assert.strictEqual(Object.values(getCells(model)).length, total);
assert.strictEqual(getCellFormula(model, "A1"), `=ODOO.LIST.HEADER(1,"foo")`);
assert.strictEqual(getCellFormula(model, "B1"), `=ODOO.LIST.HEADER(1,"bar")`);
assert.strictEqual(getCellFormula(model, "C1"), `=ODOO.LIST.HEADER(1,"date")`);
assert.strictEqual(getCellFormula(model, "D1"), `=ODOO.LIST.HEADER(1,"product_id")`);
assert.strictEqual(getCellFormula(model, "A2"), `=ODOO.LIST(1,1,"foo")`);
assert.strictEqual(getCellFormula(model, "B2"), `=ODOO.LIST(1,1,"bar")`);
assert.strictEqual(getCellFormula(model, "C2"), `=ODOO.LIST(1,1,"date")`);
assert.strictEqual(getCellFormula(model, "D2"), `=ODOO.LIST(1,1,"product_id")`);
assert.strictEqual(getCellFormula(model, "A3"), `=ODOO.LIST(1,2,"foo")`);
assert.strictEqual(getCellFormula(model, "A11"), `=ODOO.LIST(1,10,"foo")`);
assert.strictEqual(getCellFormula(model, "A12"), "");
});
QUnit.test("Return display name of selection field", async (assert) => {
const { model } = await createSpreadsheetWithList({
model: "documents.document",
columns: ["handler"],
});
assert.strictEqual(getCellValue(model, "A2", "Spreadsheet"));
});
QUnit.test("Return name_get of many2one field", async (assert) => {
const { model } = await createSpreadsheetWithList({ columns: ["product_id"] });
assert.strictEqual(getCellValue(model, "A2"), "xphone");
});
QUnit.test("Boolean fields are correctly formatted", async (assert) => {
const { model } = await createSpreadsheetWithList({ columns: ["bar"] });
assert.strictEqual(getCellValue(model, "A2"), "TRUE");
assert.strictEqual(getCellValue(model, "A5"), "FALSE");
});
QUnit.test("properties field displays property display names", async (assert) => {
const serverData = getBasicServerData();
serverData.models.partner.records = [
{
id: 45,
partner_properties: [
{ name: "dbfc66e0afaa6a8d", type: "date", string: "prop 1", default: false },
{ name: "f80b6fb58d0d4c72", type: "integer", string: "prop 2", default: 0 },
],
},
];
const { model } = await createSpreadsheetWithList({
serverData,
columns: ["partner_properties"],
});
assert.strictEqual(getCellValue(model, "A2"), "prop 1, prop 2");
});
QUnit.test("Can display a field which is not in the columns", async function (assert) {
const { model } = await createSpreadsheetWithList();
setCellContent(model, "A1", `=ODOO.LIST(1,1,"active")`);
assert.strictEqual(getCellValue(model, "A1"), "Loading...");
await waitForDataSourcesLoaded(model); // Await for batching collection of missing fields
assert.strictEqual(getCellValue(model, "A1"), true);
});
QUnit.test("Can remove a list with undo after editing a cell", async function (assert) {
const { model } = await createSpreadsheetWithList();
assert.ok(getCellContent(model, "B1").startsWith("=ODOO.LIST.HEADER"));
setCellContent(model, "G10", "should be undoable");
model.dispatch("REQUEST_UNDO");
assert.equal(getCellContent(model, "G10"), "");
model.dispatch("REQUEST_UNDO");
assert.equal(getCellContent(model, "B1"), "");
assert.equal(model.getters.getListIds().length, 0);
});
QUnit.test("List formulas are correctly formatted at evaluation", async function (assert) {
const { model } = await createSpreadsheetWithList({
columns: ["foo", "probability", "bar", "date", "create_date", "product_id", "pognon"],
linesNumber: 2,
});
await waitForDataSourcesLoaded(model);
assert.strictEqual(getCell(model, "A2").format, undefined);
assert.strictEqual(getCell(model, "B2").format, undefined);
assert.strictEqual(getCell(model, "C2").format, undefined);
assert.strictEqual(getCell(model, "D2").format, undefined);
assert.strictEqual(getCell(model, "E2").format, undefined);
assert.strictEqual(getCell(model, "F2").format, undefined);
assert.strictEqual(getCell(model, "G2").format, undefined);
assert.strictEqual(getCell(model, "G3").format, undefined);
assert.strictEqual(getCell(model, "A2").evaluated.format, "0");
assert.strictEqual(getCell(model, "B2").evaluated.format, "#,##0.00");
assert.strictEqual(getCell(model, "C2").evaluated.format, undefined);
assert.strictEqual(getCell(model, "D2").evaluated.format, "m/d/yyyy");
assert.strictEqual(getCell(model, "E2").evaluated.format, "m/d/yyyy hh:mm:ss");
assert.strictEqual(getCell(model, "F2").evaluated.format, undefined);
assert.strictEqual(getCell(model, "G2").evaluated.format, "#,##0.00[$€]");
assert.strictEqual(getCell(model, "G3").evaluated.format, "[$$]#,##0.00");
});
QUnit.test("Json fields are not supported in list formulas", async function (assert) {
const { model } = await createSpreadsheetWithList({
columns: ["foo", "jsonField"],
linesNumber: 2,
});
setCellContent(model, "A1", `=ODOO.LIST(1,1,"foo")`);
setCellContent(model, "A2", `=ODOO.LIST(1,1,"jsonField")`);
await waitForDataSourcesLoaded(model);
assert.strictEqual(getCell(model, "A1").evaluated.value, 12);
assert.strictEqual(getCell(model, "A2").evaluated.value, "#ERROR");
assert.strictEqual(
getCell(model, "A2").evaluated.error.message,
`Fields of type "json" are not supported`
);
});
QUnit.test("can select a List from cell formula", async function (assert) {
const { model } = await createSpreadsheetWithList();
const sheetId = model.getters.getActiveSheetId();
const listId = model.getters.getListIdFromPosition(sheetId, 0, 0);
model.dispatch("SELECT_ODOO_LIST", { listId });
const selectedListId = model.getters.getSelectedListId();
assert.strictEqual(selectedListId, "1");
});
QUnit.test(
"can select a List from cell formula with '-' before the formula",
async function (assert) {
const { model } = await createSpreadsheetWithList();
setCellContent(model, "A1", `=-ODOO.LIST("1","1","foo")`);
const sheetId = model.getters.getActiveSheetId();
const listId = model.getters.getListIdFromPosition(sheetId, 0, 0);
model.dispatch("SELECT_ODOO_LIST", { listId });
const selectedListId = model.getters.getSelectedListId();
assert.strictEqual(selectedListId, "1");
}
);
QUnit.test(
"can select a List from cell formula with other numerical values",
async function (assert) {
const { model } = await createSpreadsheetWithList();
setCellContent(model, "A1", `=3*ODOO.LIST("1","1","foo")`);
const sheetId = model.getters.getActiveSheetId();
const listId = model.getters.getListIdFromPosition(sheetId, 0, 0);
model.dispatch("SELECT_ODOO_LIST", { listId });
const selectedListId = model.getters.getSelectedListId();
assert.strictEqual(selectedListId, "1");
}
);
QUnit.test("List datasource is loaded with correct linesNumber", async function (assert) {
const { model } = await createSpreadsheetWithList({ linesNumber: 2 });
const [listId] = model.getters.getListIds();
const dataSource = model.getters.getListDataSource(listId);
assert.strictEqual(dataSource.maxPosition, 2);
});
QUnit.test("can select a List from cell formula within a formula", async function (assert) {
const { model } = await createSpreadsheetWithList();
setCellContent(model, "A1", `=SUM(ODOO.LIST("1","1","foo"),1)`);
const sheetId = model.getters.getActiveSheetId();
const listId = model.getters.getListIdFromPosition(sheetId, 0, 0);
model.dispatch("SELECT_ODOO_LIST", { listId });
const selectedListId = model.getters.getSelectedListId();
assert.strictEqual(selectedListId, "1");
});
QUnit.test(
"can select a List from cell formula where the id is a reference",
async function (assert) {
const { model } = await createSpreadsheetWithList();
setCellContent(model, "A1", `=ODOO.LIST(G10,"1","foo")`);
setCellContent(model, "G10", "1");
const sheetId = model.getters.getActiveSheetId();
const listId = model.getters.getListIdFromPosition(sheetId, 0, 0);
model.dispatch("SELECT_ODOO_LIST", { listId });
const selectedListId = model.getters.getSelectedListId();
assert.strictEqual(selectedListId, "1");
}
);
QUnit.test("Referencing non-existing fields does not crash", async function (assert) {
assert.expect(4);
const forbiddenFieldName = "product_id";
let spreadsheetLoaded = false;
const { model } = await createSpreadsheetWithList({
columns: ["bar", "product_id"],
mockRPC: async function (route, args, performRPC) {
if (
spreadsheetLoaded &&
args.method === "search_read" &&
args.model === "partner" &&
args.kwargs.fields &&
args.kwargs.fields.includes(forbiddenFieldName)
) {
// We should not go through this condition if the forbidden fields is properly filtered
assert.ok(false, `${forbiddenFieldName} should have been ignored`);
}
if (this) {
// @ts-ignore
return this._super.apply(this, arguments);
}
},
});
const listId = model.getters.getListIds()[0];
// remove forbidden field from the fields of the list.
delete model.getters.getListDataSource(listId).getFields()[forbiddenFieldName];
spreadsheetLoaded = true;
model.dispatch("REFRESH_ALL_DATA_SOURCES");
await nextTick();
setCellContent(model, "A1", `=ODOO.LIST.HEADER("1", "${forbiddenFieldName}")`);
setCellContent(model, "A2", `=ODOO.LIST("1","1","${forbiddenFieldName}")`);
assert.equal(
model.getters.getListDataSource(listId).getFields()[forbiddenFieldName],
undefined
);
assert.strictEqual(getCellValue(model, "A1"), forbiddenFieldName);
const A2 = getCell(model, "A2");
assert.equal(A2.evaluated.type, "error");
assert.equal(
A2.evaluated.error.message,
`The field ${forbiddenFieldName} does not exist or you do not have access to that field`
);
});
QUnit.test("don't fetch list data if no formula use it", async function (assert) {
const spreadsheetData = {
sheets: [
{
id: "sheet1",
},
{
id: "sheet2",
cells: {
A1: { content: `=ODOO.LIST("1", "1", "foo")` },
},
},
],
lists: {
1: {
id: 1,
columns: ["foo", "contact_name"],
domain: [],
model: "partner",
orderBy: [],
context: {},
},
},
};
const model = await createModelWithDataSource({
spreadsheetData,
mockRPC: function (_, { model, method }) {
if (!["partner", "ir.model"].includes(model)) {
return;
}
assert.step(`${model}/${method}`);
},
});
assert.verifySteps([]);
model.dispatch("ACTIVATE_SHEET", { sheetIdFrom: "sheet1", sheetIdTo: "sheet2" });
/*
* Ask a first time the value => It will trigger a loading of the data source.
*/
assert.equal(getCellValue(model, "A1"), "Loading...");
await nextTick();
assert.equal(getCellValue(model, "A1"), 12);
assert.verifySteps(["partner/fields_get", "partner/search_read"]);
});
QUnit.test("user context is combined with list context to fetch data", async function (assert) {
const context = {
allowed_company_ids: [15],
tz: "bx",
lang: "FR",
uid: 4,
};
const testSession = {
uid: 4,
user_companies: {
allowed_companies: {
15: { id: 15, name: "Hermit" },
16: { id: 16, name: "Craft" },
},
current_company: 15,
},
user_context: context,
};
const spreadsheetData = {
sheets: [
{
id: "sheet1",
cells: {
A1: { content: `=ODOO.LIST("1", "1", "name")` },
},
},
],
lists: {
1: {
id: 1,
columns: ["name", "contact_name"],
domain: [],
model: "partner",
orderBy: [],
context: {
allowed_company_ids: [16],
default_stage_id: 9,
search_default_stage_id: 90,
tz: "nz",
lang: "EN",
uid: 40,
},
},
},
};
const expectedFetchContext = {
allowed_company_ids: [15],
default_stage_id: 9,
search_default_stage_id: 90,
tz: "bx",
lang: "FR",
uid: 4,
};
patchWithCleanup(session, testSession);
const model = await createModelWithDataSource({
spreadsheetData,
mockRPC: function (route, { model, method, kwargs }) {
if (model !== "partner") {
return;
}
switch (method) {
case "search_read":
assert.step("search_read");
assert.deepEqual(
kwargs.context,
expectedFetchContext,
"search_read context"
);
break;
}
},
});
await waitForDataSourcesLoaded(model);
assert.verifySteps(["search_read"]);
});
QUnit.test("rename list with empty name is refused", async (assert) => {
const { model } = await createSpreadsheetWithList();
const result = model.dispatch("RENAME_ODOO_LIST", {
listId: "1",
name: "",
});
assert.deepEqual(result.reasons, [CommandResult.EmptyName]);
});
QUnit.test("rename list with incorrect id is refused", async (assert) => {
const { model } = await createSpreadsheetWithList();
const result = model.dispatch("RENAME_ODOO_LIST", {
listId: "invalid",
name: "name",
});
assert.deepEqual(result.reasons, [CommandResult.ListIdNotFound]);
});
QUnit.test("Undo/Redo for RENAME_ODOO_LIST", async function (assert) {
assert.expect(4);
const { model } = await createSpreadsheetWithList();
assert.equal(model.getters.getListName("1"), "List");
model.dispatch("RENAME_ODOO_LIST", { listId: "1", name: "test" });
assert.equal(model.getters.getListName("1"), "test");
model.dispatch("REQUEST_UNDO");
assert.equal(model.getters.getListName("1"), "List");
model.dispatch("REQUEST_REDO");
assert.equal(model.getters.getListName("1"), "test");
});
QUnit.test("Can delete list", async function (assert) {
const { model } = await createSpreadsheetWithList();
model.dispatch("REMOVE_ODOO_LIST", { listId: "1" });
assert.strictEqual(model.getters.getListIds().length, 0);
const B4 = getCell(model, "B4");
assert.equal(B4.evaluated.error.message, `There is no list with id "1"`);
assert.equal(B4.evaluated.value, `#ERROR`);
});
QUnit.test("Can undo/redo a delete list", async function (assert) {
const { model } = await createSpreadsheetWithList();
const value = getCell(model, "B4").evaluated.value;
model.dispatch("REMOVE_ODOO_LIST", { listId: "1" });
model.dispatch("REQUEST_UNDO");
assert.strictEqual(model.getters.getListIds().length, 1);
let B4 = getCell(model, "B4");
assert.equal(B4.evaluated.error, undefined);
assert.equal(B4.evaluated.value, value);
model.dispatch("REQUEST_REDO");
assert.strictEqual(model.getters.getListIds().length, 0);
B4 = getCell(model, "B4");
assert.equal(B4.evaluated.error.message, `There is no list with id "1"`);
assert.equal(B4.evaluated.value, `#ERROR`);
});
QUnit.test("can edit list domain", async (assert) => {
const { model } = await createSpreadsheetWithList();
const [listId] = model.getters.getListIds();
assert.deepEqual(model.getters.getListDefinition(listId).domain, []);
assert.strictEqual(getCellValue(model, "B2"), "TRUE");
model.dispatch("UPDATE_ODOO_LIST_DOMAIN", {
listId,
domain: [["foo", "in", [55]]],
});
assert.deepEqual(model.getters.getListDefinition(listId).domain, [["foo", "in", [55]]]);
await waitForDataSourcesLoaded(model);
assert.strictEqual(getCellValue(model, "B2"), "");
model.dispatch("REQUEST_UNDO");
await waitForDataSourcesLoaded(model);
assert.deepEqual(model.getters.getListDefinition(listId).domain, []);
await waitForDataSourcesLoaded(model);
assert.strictEqual(getCellValue(model, "B2"), "TRUE");
model.dispatch("REQUEST_REDO");
assert.deepEqual(model.getters.getListDefinition(listId).domain, [["foo", "in", [55]]]);
await waitForDataSourcesLoaded(model);
assert.strictEqual(getCellValue(model, "B2"), "");
const result = model.dispatch("UPDATE_ODOO_LIST_DOMAIN", {
listId: "invalid",
domain: [],
});
assert.deepEqual(result.reasons, [CommandResult.ListIdNotFound]);
});
QUnit.test("edited domain is exported", async (assert) => {
const { model } = await createSpreadsheetWithList();
const [listId] = model.getters.getListIds();
model.dispatch("UPDATE_ODOO_LIST_DOMAIN", {
listId,
domain: [["foo", "in", [55]]],
});
assert.deepEqual(model.exportData().lists["1"].domain, [["foo", "in", [55]]]);
});
QUnit.test(
"Cannot see record of a list in dashboard mode if wrong list formula",
async function (assert) {
const fakeActionService = {
dependencies: [],
start: (env) => ({
doAction: (params) => {
assert.step(params.res_model);
assert.step(params.res_id.toString());
},
}),
};
registry.category("services").add("action", fakeActionService);
const { model } = await createSpreadsheetWithList();
const sheetId = model.getters.getActiveSheetId();
model.dispatch("UPDATE_CELL", {
col: 0,
row: 1,
sheetId,
content: "=ODOO.LIST()",
});
model.updateMode("dashboard");
selectCell(model, "A2");
assert.verifySteps([]);
}
);
QUnit.test("field matching is removed when filter is deleted", async function (assert) {
const { model } = await createSpreadsheetWithList();
await addGlobalFilter(
model,
{
filter: {
id: "42",
type: "relation",
label: "test",
defaultValue: [41],
modelName: undefined,
rangeType: undefined,
},
},
{
list: { 1: { chain: "product_id", type: "many2one" } },
}
);
const [filter] = model.getters.getGlobalFilters();
const matching = {
chain: "product_id",
type: "many2one",
};
assert.deepEqual(model.getters.getListFieldMatching("1", filter.id), matching);
assert.deepEqual(model.getters.getListDataSource("1").getComputedDomain(), [
["product_id", "in", [41]],
]);
model.dispatch("REMOVE_GLOBAL_FILTER", {
id: filter.id,
});
assert.deepEqual(
model.getters.getListFieldMatching("1", filter.id),
undefined,
"it should have removed the pivot and its fieldMatching and datasource altogether"
);
assert.deepEqual(model.getters.getListDataSource("1").getComputedDomain(), []);
model.dispatch("REQUEST_UNDO");
assert.deepEqual(model.getters.getListFieldMatching("1", filter.id), matching);
assert.deepEqual(model.getters.getListDataSource("1").getComputedDomain(), [
["product_id", "in", [41]],
]);
model.dispatch("REQUEST_REDO");
assert.deepEqual(model.getters.getListFieldMatching("1", filter.id), undefined);
assert.deepEqual(model.getters.getListDataSource("1").getComputedDomain(), []);
});
QUnit.test("Preload currency of monetary field", async function (assert) {
assert.expect(3);
await createSpreadsheetWithList({
columns: ["pognon"],
mockRPC: async function (route, args, performRPC) {
if (args.method === "search_read" && args.model === "partner") {
assert.strictEqual(args.kwargs.fields.length, 2);
assert.strictEqual(args.kwargs.fields[0], "pognon");
assert.strictEqual(args.kwargs.fields[1], "currency_id");
}
},
});
});
QUnit.test(
"List record limit is computed during the import and UPDATE_CELL",
async function (assert) {
const spreadsheetData = {
sheets: [
{
id: "sheet1",
cells: {
A1: { content: `=ODOO.LIST("1", "1", "foo")` },
},
},
],
lists: {
1: {
id: 1,
columns: ["foo", "contact_name"],
domain: [],
model: "partner",
orderBy: [],
context: {},
},
},
};
const model = await createModelWithDataSource({ spreadsheetData });
const ds = model.getters.getListDataSource("1");
assert.strictEqual(ds.maxPosition, 1);
assert.strictEqual(ds.maxPositionFetched, 0);
setCellContent(model, "A1", `=ODOO.LIST("1", "42", "foo", 2)`);
assert.strictEqual(ds.maxPosition, 42);
assert.strictEqual(ds.maxPositionFetched, 0);
await waitForDataSourcesLoaded(model);
assert.strictEqual(ds.maxPosition, 42);
assert.strictEqual(ds.maxPositionFetched, 42);
}
);
QUnit.test(
"Load list spreadsheet with models that cannot be accessed",
async function (assert) {
let hasAccessRights = true;
const { model } = await createSpreadsheetWithList({
mockRPC: async function (route, args) {
if (
args.model === "partner" &&
args.method === "search_read" &&
!hasAccessRights
) {
const error = new RPCError();
error.data = { message: "ya done!" };
throw error;
}
},
});
const headerCell = getCell(model, "A3");
const cell = getCell(model, "C3");
await waitForDataSourcesLoaded(model);
assert.equal(headerCell.evaluated.value, 1);
assert.equal(cell.evaluated.value, 42669);
hasAccessRights = false;
model.dispatch("REFRESH_ODOO_LIST", { listId: "1" });
await waitForDataSourcesLoaded(model);
assert.equal(headerCell.evaluated.value, "#ERROR");
assert.equal(headerCell.evaluated.error.message, "ya done!");
assert.equal(cell.evaluated.value, "#ERROR");
assert.equal(cell.evaluated.error.message, "ya done!");
}
);
});

View file

@ -0,0 +1,298 @@
/** @odoo-module */
import { migrate, ODOO_VERSION } from "@spreadsheet/o_spreadsheet/migration";
import spreadsheet from "@spreadsheet/o_spreadsheet/o_spreadsheet_extended";
const { Model } = spreadsheet;
QUnit.module("spreadsheet > migrations");
QUnit.test("Odoo formulas are migrated", (assert) => {
const data = {
sheets: [
{
cells: {
A1: { content: `=PIVOT("1")` },
A2: { content: `=PIVOT.HEADER("1")` },
A3: { content: `=FILTER.VALUE("1")` },
A4: { content: `=LIST("1")` },
A5: { content: `=LIST.HEADER("1")` },
A6: { content: `=PIVOT.POSITION("1")` },
A7: { content: `=pivot("1")` },
},
},
],
};
const migratedData = migrate(data);
assert.strictEqual(migratedData.sheets[0].cells.A1.content, `=ODOO.PIVOT("1")`);
assert.strictEqual(migratedData.sheets[0].cells.A2.content, `=ODOO.PIVOT.HEADER("1")`);
assert.strictEqual(migratedData.sheets[0].cells.A3.content, `=ODOO.FILTER.VALUE("1")`);
assert.strictEqual(migratedData.sheets[0].cells.A4.content, `=ODOO.LIST("1")`);
assert.strictEqual(migratedData.sheets[0].cells.A5.content, `=ODOO.LIST.HEADER("1")`);
assert.strictEqual(migratedData.sheets[0].cells.A6.content, `=ODOO.PIVOT.POSITION("1")`);
assert.strictEqual(migratedData.sheets[0].cells.A7.content, `=ODOO.PIVOT("1")`);
});
QUnit.test("Pivot 'day' arguments are migrated", (assert) => {
const data = {
odooVersion: 1,
sheets: [
{
cells: {
A1: { content: `=ODOO.PIVOT("1","21/07/2022")` },
A2: { content: `=ODOO.PIVOT.HEADER("1","11/12/2022")` },
A3: { content: `=odoo.pivot("1","21/07/2021")` },
A4: { content: `=ODOO.PIVOT("1","test")` },
A5: { content: `=odoo.pivot("1","21/07/2021")+"21/07/2021"` },
A6: { content: `=BAD_FORMULA(` },
},
},
],
};
const migratedData = migrate(data);
assert.strictEqual(migratedData.sheets[0].cells.A1.content, `=ODOO.PIVOT("1","07/21/2022")`);
assert.strictEqual(
migratedData.sheets[0].cells.A2.content,
`=ODOO.PIVOT.HEADER("1","12/11/2022")`
);
assert.strictEqual(migratedData.sheets[0].cells.A3.content, `=odoo.pivot("1","07/21/2021")`);
assert.strictEqual(migratedData.sheets[0].cells.A4.content, `=ODOO.PIVOT("1","test")`);
assert.strictEqual(
migratedData.sheets[0].cells.A5.content,
`=odoo.pivot("1","07/21/2021")+"21/07/2021"`
);
assert.strictEqual(migratedData.sheets[0].cells.A6.content, `=BAD_FORMULA(`);
});
QUnit.test("Global filters: pivot fields is correctly added", (assert) => {
const data = {
globalFilters: [
{
id: "Filter1",
type: "relation",
label: "Relation Filter",
fields: {
1: {
field: "foo",
type: "char",
},
},
},
],
pivots: {
1: {
name: "test",
},
},
};
const migratedData = migrate(data);
const filter = migratedData.globalFilters[0];
const pivot = migratedData.pivots["1"];
assert.deepEqual(pivot.fieldMatching, {
Filter1: {
chain: "foo",
type: "char",
},
});
assert.strictEqual(filter.fields, undefined);
});
QUnit.test("Global filters: date is correctly migrated", (assert) => {
const data = {
globalFilters: [
{
id: "1",
type: "date",
rangeType: "year",
defaultValue: { year: "last_year" },
},
{
id: "2",
type: "date",
rangeType: "year",
defaultValue: { year: "antepenultimate_year" },
},
{
id: "3",
type: "date",
rangeType: "year",
defaultValue: { year: "this_year" },
},
],
};
const migratedData = migrate(data);
const [f1, f2, f3] = migratedData.globalFilters;
assert.deepEqual(f1.defaultValue, { yearOffset: -1 });
assert.deepEqual(f2.defaultValue, { yearOffset: -2 });
assert.deepEqual(f3.defaultValue, { yearOffset: 0 });
});
QUnit.test("List name default is model name", (assert) => {
const data = {
lists: {
1: {
name: "Name",
model: "Model",
},
2: {
model: "Model",
},
},
};
const migratedData = migrate(data);
assert.strictEqual(Object.values(migratedData.lists).length, 2);
assert.strictEqual(migratedData.lists["1"].name, "Name");
assert.strictEqual(migratedData.lists["2"].name, "Model");
});
QUnit.test("Pivot name default is model name", (assert) => {
const data = {
pivots: {
1: {
name: "Name",
model: "Model",
},
2: {
model: "Model",
},
},
};
const migratedData = migrate(data);
assert.strictEqual(Object.values(migratedData.pivots).length, 2);
assert.strictEqual(migratedData.pivots["1"].name, "Name");
assert.strictEqual(migratedData.pivots["2"].name, "Model");
});
QUnit.test("fieldMatchings are moved from filters to their respective datasources", (assert) => {
const data = {
globalFilters: [
{
id: "Filter",
label: "MyFilter1",
type: "relation",
listFields: {
1: {
field: "parent_id",
type: "many2one",
},
},
pivotFields: {
1: {
field: "parent_id",
type: "many2one",
},
},
graphFields: {
fig1: {
field: "parent_id",
type: "many2one",
},
},
},
],
pivots: {
1: {
name: "Name",
},
},
lists: {
1: {
name: "Name",
},
},
sheets: [
{
figures: [
{
id: "fig1",
tag: "chart",
data: {
type: "odoo_bar",
},
},
],
},
],
};
const migratedData = migrate(data);
assert.deepEqual(migratedData.pivots["1"].fieldMatching, {
Filter: { chain: "parent_id", type: "many2one" },
});
assert.deepEqual(migratedData.lists["1"].fieldMatching, {
Filter: { chain: "parent_id", type: "many2one" },
});
assert.deepEqual(migratedData.sheets[0].figures[0].data.fieldMatching, {
Filter: { chain: "parent_id", type: "many2one" },
});
});
QUnit.test("fieldMatchings offsets are correctly preserved after migration", (assert) => {
const data = {
globalFilters: [
{
id: "Filter",
label: "MyFilter1",
type: "relation",
listFields: {
1: {
field: "parent_id",
type: "date",
offset: "-1",
},
},
pivotFields: {
1: {
field: "parent_id",
type: "date",
offset: "-1",
},
},
graphFields: {
fig1: {
field: "parent_id",
type: "date",
offset: "-1",
},
},
},
],
pivots: {
1: {
name: "Name",
},
},
lists: {
1: {
name: "Name",
},
},
sheets: [
{
figures: [
{
id: "fig1",
tag: "chart",
data: {
type: "odoo_bar",
},
},
],
},
],
};
const migratedData = migrate(data);
assert.deepEqual(migratedData.pivots["1"].fieldMatching, {
Filter: { chain: "parent_id", type: "date", offset: "-1" },
});
assert.deepEqual(migratedData.lists["1"].fieldMatching, {
Filter: { chain: "parent_id", type: "date", offset: "-1" },
});
assert.deepEqual(migratedData.sheets[0].figures[0].data.fieldMatching, {
Filter: { chain: "parent_id", type: "date", offset: "-1" },
});
});
QUnit.test("Odoo version is exported", (assert) => {
const model = new Model();
assert.strictEqual(model.exportData().odooVersion, ODOO_VERSION);
});

View file

@ -0,0 +1,962 @@
/** @odoo-module */
import {
getCell,
getCellContent,
getCellFormula,
getCellFormattedValue,
getCellValue,
} from "@spreadsheet/../tests/utils/getters";
import { createSpreadsheetWithPivot } from "@spreadsheet/../tests/utils/pivot";
import CommandResult from "@spreadsheet/o_spreadsheet/cancelled_reason";
import { addGlobalFilter, setCellContent } from "@spreadsheet/../tests/utils/commands";
import {
createModelWithDataSource,
waitForDataSourcesLoaded,
} from "@spreadsheet/../tests/utils/model";
import { makeDeferred, nextTick, patchWithCleanup } from "@web/../tests/helpers/utils";
import { session } from "@web/session";
import { RPCError } from "@web/core/network/rpc_service";
import { getBasicServerData } from "../../utils/data";
QUnit.module("spreadsheet > pivot plugin", {}, () => {
QUnit.test("can select a Pivot from cell formula", async function (assert) {
const { model } = await createSpreadsheetWithPivot({
arch: /* xml */ `
<pivot>
<field name="product_id" type="col"/>
<field name="foo" type="row"/>
<field name="probability" type="measure"/>
</pivot>`,
});
const sheetId = model.getters.getActiveSheetId();
const pivotId = model.getters.getPivotIdFromPosition(sheetId, 2, 2);
model.dispatch("SELECT_PIVOT", { pivotId });
const selectedPivotId = model.getters.getSelectedPivotId();
assert.strictEqual(selectedPivotId, "1");
});
QUnit.test(
"can select a Pivot from cell formula with '-' before the formula",
async function (assert) {
const { model } = await createSpreadsheetWithPivot({
arch: /* xml */ `
<pivot>
<field name="product_id" type="col"/>
<field name="foo" type="row"/>
<field name="probability" type="measure"/>
</pivot>`,
});
model.dispatch("SET_VALUE", {
xc: "C3",
text: `=-PIVOT("1","probability","bar","false","foo","2")`,
});
const sheetId = model.getters.getActiveSheetId();
const pivotId = model.getters.getPivotIdFromPosition(sheetId, 2, 2);
model.dispatch("SELECT_PIVOT", { pivotId });
const selectedPivotId = model.getters.getSelectedPivotId();
assert.strictEqual(selectedPivotId, "1");
}
);
QUnit.test(
"can select a Pivot from cell formula with other numerical values",
async function (assert) {
const { model } = await createSpreadsheetWithPivot({
arch: /* xml */ `
<pivot>
<field name="product_id" type="col"/>
<field name="foo" type="row"/>
<field name="probability" type="measure"/>
</pivot>`,
});
model.dispatch("SET_VALUE", {
xc: "C3",
text: `=3*PIVOT("1","probability","bar","false","foo","2")+2`,
});
const sheetId = model.getters.getActiveSheetId();
const pivotId = model.getters.getPivotIdFromPosition(sheetId, 2, 2);
model.dispatch("SELECT_PIVOT", { pivotId });
const selectedPivotId = model.getters.getSelectedPivotId();
assert.strictEqual(selectedPivotId, "1");
}
);
QUnit.test(
"can select a Pivot from cell formula where pivot is in a function call",
async function (assert) {
const { model } = await createSpreadsheetWithPivot({
arch: /* xml */ `
<pivot>
<field name="product_id" type="col"/>
<field name="foo" type="row"/>
<field name="probability" type="measure"/>
</pivot>`,
});
model.dispatch("SET_VALUE", {
xc: "C3",
text: `=SUM(PIVOT("1","probability","bar","false","foo","2"),PIVOT("1","probability","bar","false","foo","2"))`,
});
const sheetId = model.getters.getActiveSheetId();
const pivotId = model.getters.getPivotIdFromPosition(sheetId, 2, 2);
model.dispatch("SELECT_PIVOT", { pivotId });
const selectedPivotId = model.getters.getSelectedPivotId();
assert.strictEqual(selectedPivotId, "1");
}
);
QUnit.test(
"can select a Pivot from cell formula where the id is a reference",
async function (assert) {
const { model } = await createSpreadsheetWithPivot();
setCellContent(model, "C3", `=ODOO.PIVOT(G10,"probability","bar","false","foo","2")+2`);
setCellContent(model, "G10", "1");
const sheetId = model.getters.getActiveSheetId();
const pivotId = model.getters.getPivotIdFromPosition(sheetId, 2, 2);
model.dispatch("SELECT_PIVOT", { pivotId });
const selectedPivotId = model.getters.getSelectedPivotId();
assert.strictEqual(selectedPivotId, "1");
}
);
QUnit.test(
"can select a Pivot from cell formula (Mix of test scenarios above)",
async function (assert) {
const { model } = await createSpreadsheetWithPivot({
arch: /*xml*/ `
<pivot>
<field name="product_id" type="col"/>
<field name="foo" type="row"/>
<field name="probability" type="measure"/>
</pivot>`,
});
model.dispatch("SET_VALUE", {
xc: "C3",
text: `=3*SUM(PIVOT("1","probability","bar","false","foo","2"),PIVOT("1","probability","bar","false","foo","2"))+2*PIVOT("1","probability","bar","false","foo","2")`,
});
const sheetId = model.getters.getActiveSheetId();
const pivotId = model.getters.getPivotIdFromPosition(sheetId, 2, 2);
model.dispatch("SELECT_PIVOT", { pivotId });
const selectedPivotId = model.getters.getSelectedPivotId();
assert.strictEqual(selectedPivotId, "1");
}
);
QUnit.test("Can remove a pivot with undo after editing a cell", async function (assert) {
const { model } = await createSpreadsheetWithPivot();
assert.ok(getCellContent(model, "B1").startsWith("=ODOO.PIVOT.HEADER"));
setCellContent(model, "G10", "should be undoable");
model.dispatch("REQUEST_UNDO");
assert.equal(getCellContent(model, "G10"), "");
// 2 REQUEST_UNDO because of the AUTORESIZE feature
model.dispatch("REQUEST_UNDO");
model.dispatch("REQUEST_UNDO");
assert.equal(getCellContent(model, "B1"), "");
assert.equal(model.getters.getPivotIds().length, 0);
});
QUnit.test("rename pivot with empty name is refused", async (assert) => {
const { model } = await createSpreadsheetWithPivot();
const result = model.dispatch("RENAME_ODOO_PIVOT", {
pivotId: "1",
name: "",
});
assert.deepEqual(result.reasons, [CommandResult.EmptyName]);
});
QUnit.test("rename pivot with incorrect id is refused", async (assert) => {
const { model } = await createSpreadsheetWithPivot();
const result = model.dispatch("RENAME_ODOO_PIVOT", {
pivotId: "invalid",
name: "name",
});
assert.deepEqual(result.reasons, [CommandResult.PivotIdNotFound]);
});
QUnit.test("Undo/Redo for RENAME_ODOO_PIVOT", async function (assert) {
const { model } = await createSpreadsheetWithPivot();
assert.equal(model.getters.getPivotName("1"), "Partner Pivot");
model.dispatch("RENAME_ODOO_PIVOT", { pivotId: "1", name: "test" });
assert.equal(model.getters.getPivotName("1"), "test");
model.dispatch("REQUEST_UNDO");
assert.equal(model.getters.getPivotName("1"), "Partner Pivot");
model.dispatch("REQUEST_REDO");
assert.equal(model.getters.getPivotName("1"), "test");
});
QUnit.test("Can delete pivot", async function (assert) {
const { model } = await createSpreadsheetWithPivot();
model.dispatch("REMOVE_PIVOT", { pivotId: "1" });
assert.strictEqual(model.getters.getPivotIds().length, 0);
const B4 = getCell(model, "B4");
assert.equal(B4.evaluated.error.message, `There is no pivot with id "1"`);
assert.equal(B4.evaluated.value, `#ERROR`);
});
QUnit.test("Can undo/redo a delete pivot", async function (assert) {
const { model } = await createSpreadsheetWithPivot();
const value = getCell(model, "B4").evaluated.value;
model.dispatch("REMOVE_PIVOT", { pivotId: "1" });
model.dispatch("REQUEST_UNDO");
assert.strictEqual(model.getters.getPivotIds().length, 1);
let B4 = getCell(model, "B4");
assert.equal(B4.evaluated.error, undefined);
assert.equal(B4.evaluated.value, value);
model.dispatch("REQUEST_REDO");
assert.strictEqual(model.getters.getPivotIds().length, 0);
B4 = getCell(model, "B4");
assert.equal(B4.evaluated.error.message, `There is no pivot with id "1"`);
assert.equal(B4.evaluated.value, `#ERROR`);
});
QUnit.test("Format header displays an error for non-existing field", async function (assert) {
const { model } = await createSpreadsheetWithPivot();
setCellContent(model, "G10", `=ODOO.PIVOT.HEADER("1", "measure", "non-existing")`);
setCellContent(model, "G11", `=ODOO.PIVOT.HEADER("1", "non-existing", "bla")`);
await nextTick();
assert.equal(getCellValue(model, "G10"), "#ERROR");
assert.equal(getCellValue(model, "G11"), "#ERROR");
assert.equal(
getCell(model, "G10").evaluated.error.message,
"Field non-existing does not exist"
);
assert.equal(
getCell(model, "G11").evaluated.error.message,
"Field non-existing does not exist"
);
});
QUnit.test(
"user context is combined with pivot context to fetch data",
async function (assert) {
const context = {
allowed_company_ids: [15],
tz: "bx",
lang: "FR",
uid: 4,
};
const testSession = {
uid: 4,
user_companies: {
allowed_companies: {
15: { id: 15, name: "Hermit" },
16: { id: 16, name: "Craft" },
},
current_company: 15,
},
user_context: context,
};
const spreadsheetData = {
sheets: [
{
id: "sheet1",
cells: {
A1: { content: `=ODOO.PIVOT(1, "probability")` },
},
},
],
pivots: {
1: {
id: 1,
colGroupBys: ["foo"],
domain: [],
measures: [{ field: "probability", operator: "avg" }],
model: "partner",
rowGroupBys: ["bar"],
context: {
allowed_company_ids: [16],
default_stage_id: 9,
search_default_stage_id: 90,
tz: "nz",
lang: "EN",
uid: 40,
},
},
},
};
const expectedFetchContext = {
allowed_company_ids: [15],
default_stage_id: 9,
search_default_stage_id: 90,
tz: "bx",
lang: "FR",
uid: 4,
};
patchWithCleanup(session, testSession);
const model = await createModelWithDataSource({
spreadsheetData,
mockRPC: function (route, { model, method, kwargs }) {
if (model !== "partner") {
return;
}
switch (method) {
case "read_group":
assert.step("read_group");
assert.deepEqual(kwargs.context, expectedFetchContext, "read_group");
break;
}
},
});
await waitForDataSourcesLoaded(model);
assert.verifySteps(["read_group", "read_group", "read_group", "read_group"]);
}
);
QUnit.test("Context is purged from PivotView related keys", async function (assert) {
const spreadsheetData = {
sheets: [
{
id: "sheet1",
cells: {
A1: { content: `=ODOO.PIVOT(1, "probability")` },
},
},
],
pivots: {
1: {
id: 1,
colGroupBys: ["foo"],
rowGroupBys: ["bar"],
domain: [],
measures: [{ field: "probability", operator: "avg" }],
model: "partner",
context: {
pivot_measures: ["__count"],
// inverse row and col group bys
pivot_row_groupby: ["test"],
pivot_column_groupby: ["check"],
dummyKey: "true",
},
},
},
};
const model = await createModelWithDataSource({
spreadsheetData,
mockRPC: function (route, { model, method, kwargs }) {
if (model === "partner" && method === "read_group") {
assert.step(`pop`);
assert.notOk(
["pivot_measures", "pivot_row_groupby", "pivot_column_groupby"].some(
(val) => val in (kwargs.context || {})
),
"The context should not contain pivot related keys"
);
}
},
});
await waitForDataSourcesLoaded(model);
assert.verifySteps(["pop", "pop", "pop", "pop"]);
});
QUnit.test("fetch metadata only once per model", async function (assert) {
const spreadsheetData = {
sheets: [
{
id: "sheet1",
cells: {
A1: { content: `=ODOO.PIVOT(1, "probability")` },
A2: { content: `=ODOO.PIVOT(2, "probability")` },
},
},
],
pivots: {
1: {
id: 1,
colGroupBys: ["foo"],
domain: [],
measures: [{ field: "probability", operator: "avg" }],
model: "partner",
rowGroupBys: ["bar"],
context: {},
},
2: {
id: 2,
colGroupBys: ["bar"],
domain: [],
measures: [{ field: "probability", operator: "max" }],
model: "partner",
rowGroupBys: ["foo"],
context: {},
},
},
};
const model = await createModelWithDataSource({
spreadsheetData,
mockRPC: function (route, { model, method, kwargs }) {
if (model === "partner" && method === "fields_get") {
assert.step(`${model}/${method}`);
} else if (model === "ir.model" && method === "search_read") {
assert.step(`${model}/${method}`);
}
},
});
await waitForDataSourcesLoaded(model);
assert.verifySteps(["partner/fields_get"]);
});
QUnit.test("don't fetch pivot data if no formula use it", async function (assert) {
const spreadsheetData = {
sheets: [
{
id: "sheet1",
},
{
id: "sheet2",
cells: {
A1: { content: `=ODOO.PIVOT("1", "probability")` },
},
},
],
pivots: {
1: {
id: 1,
colGroupBys: ["foo"],
domain: [],
measures: [{ field: "probability", operator: "avg" }],
model: "partner",
rowGroupBys: ["bar"],
},
},
};
const model = await createModelWithDataSource({
spreadsheetData,
mockRPC: function (route, { model, method, kwargs }) {
if (!["partner", "ir.model"].includes(model)) {
return;
}
assert.step(`${model}/${method}`);
},
});
assert.verifySteps([]);
model.dispatch("ACTIVATE_SHEET", { sheetIdFrom: "sheet1", sheetIdTo: "sheet2" });
assert.equal(getCellValue(model, "A1"), "Loading...");
await nextTick();
assert.verifySteps([
"partner/fields_get",
"partner/read_group",
"partner/read_group",
"partner/read_group",
"partner/read_group",
]);
assert.equal(getCellValue(model, "A1"), 131);
});
QUnit.test("evaluates only once when two pivots are loading", async function (assert) {
const spreadsheetData = {
sheets: [{ id: "sheet1" }],
pivots: {
1: {
id: 1,
colGroupBys: ["foo"],
domain: [],
measures: [{ field: "probability", operator: "avg" }],
model: "partner",
rowGroupBys: ["bar"],
},
2: {
id: 2,
colGroupBys: ["foo"],
domain: [],
measures: [{ field: "probability", operator: "avg" }],
model: "partner",
rowGroupBys: ["bar"],
},
},
};
const model = await createModelWithDataSource({
spreadsheetData,
});
model.config.dataSources.addEventListener("data-source-updated", () =>
assert.step("data-source-notified")
);
setCellContent(model, "A1", '=ODOO.PIVOT("1", "probability")');
setCellContent(model, "A2", '=ODOO.PIVOT("2", "probability")');
assert.equal(getCellValue(model, "A1"), "Loading...");
assert.equal(getCellValue(model, "A2"), "Loading...");
await nextTick();
assert.equal(getCellValue(model, "A1"), 131);
assert.equal(getCellValue(model, "A2"), 131);
assert.verifySteps(["data-source-notified"], "evaluation after both pivots are loaded");
});
QUnit.test("concurrently load the same pivot twice", async function (assert) {
const spreadsheetData = {
sheets: [{ id: "sheet1" }],
pivots: {
1: {
id: 1,
colGroupBys: ["foo"],
domain: [],
measures: [{ field: "probability", operator: "avg" }],
model: "partner",
rowGroupBys: ["bar"],
},
},
};
const model = await createModelWithDataSource({
spreadsheetData,
});
// the data loads first here, when we insert the first pivot function
setCellContent(model, "A1", '=ODOO.PIVOT("1", "probability")');
assert.equal(getCellValue(model, "A1"), "Loading...");
// concurrently reload the same pivot
model.dispatch("REFRESH_PIVOT", { id: 1 });
await nextTick();
assert.equal(getCellValue(model, "A1"), 131);
});
QUnit.test("display loading while data is not fully available", async function (assert) {
const metadataPromise = makeDeferred();
const dataPromise = makeDeferred();
const spreadsheetData = {
sheets: [
{
id: "sheet1",
cells: {
A1: { content: `=ODOO.PIVOT.HEADER(1, "measure", "probability")` },
A2: { content: `=ODOO.PIVOT.HEADER(1, "product_id", 37)` },
A3: { content: `=ODOO.PIVOT(1, "probability")` },
},
},
],
pivots: {
1: {
id: 1,
colGroupBys: ["product_id"],
domain: [],
measures: [{ field: "probability", operator: "avg" }],
model: "partner",
rowGroupBys: [],
},
},
};
const model = await createModelWithDataSource({
spreadsheetData,
mockRPC: async function (route, args, performRPC) {
const { model, method, kwargs } = args;
const result = await performRPC(route, args);
if (model === "partner" && method === "fields_get") {
assert.step(`${model}/${method}`);
await metadataPromise;
}
if (
model === "partner" &&
method === "read_group" &&
kwargs.groupby[0] === "product_id"
) {
assert.step(`${model}/${method}`);
await dataPromise;
}
if (model === "product" && method === "name_get") {
assert.ok(false, "should not be called because data is put in cache");
}
return result;
},
});
assert.strictEqual(getCellValue(model, "A1"), "Loading...");
assert.strictEqual(getCellValue(model, "A2"), "Loading...");
assert.strictEqual(getCellValue(model, "A3"), "Loading...");
metadataPromise.resolve();
await nextTick();
setCellContent(model, "A10", "1"); // trigger a new evaluation (might also be caused by other async formulas resolving)
assert.strictEqual(getCellValue(model, "A1"), "Loading...");
assert.strictEqual(getCellValue(model, "A2"), "Loading...");
assert.strictEqual(getCellValue(model, "A3"), "Loading...");
dataPromise.resolve();
await nextTick();
setCellContent(model, "A10", "2");
assert.strictEqual(getCellValue(model, "A1"), "Probability");
assert.strictEqual(getCellValue(model, "A2"), "xphone");
assert.strictEqual(getCellValue(model, "A3"), 131);
assert.verifySteps(["partner/fields_get", "partner/read_group"]);
});
QUnit.test("pivot grouped by char field which represents numbers", async function (assert) {
const serverData = getBasicServerData();
serverData.models.partner.records = [
{ id: 1, name: "111", probability: 11 },
{ id: 2, name: "000111", probability: 15 },
];
const { model } = await createSpreadsheetWithPivot({
serverData,
arch: /*xml*/ `
<pivot>
<field name="name" type="row"/>
<field name="probability" type="measure"/>
</pivot>`,
});
const A3 = getCell(model, "A3");
const A4 = getCell(model, "A4");
assert.strictEqual(A3.content, '=ODOO.PIVOT.HEADER(1,"name","000111")');
assert.strictEqual(A4.content, '=ODOO.PIVOT.HEADER(1,"name",111)');
assert.strictEqual(A3.evaluated.value, "000111");
assert.strictEqual(A4.evaluated.value, "111");
const B3 = getCell(model, "B3");
const B4 = getCell(model, "B4");
assert.strictEqual(B3.content, '=ODOO.PIVOT(1,"probability","name","000111")');
assert.strictEqual(B4.content, '=ODOO.PIVOT(1,"probability","name",111)');
assert.strictEqual(B3.evaluated.value, 15);
assert.strictEqual(B4.evaluated.value, 11);
});
QUnit.test("relational PIVOT.HEADER with missing id", async function (assert) {
assert.expect(1);
const { model } = await createSpreadsheetWithPivot({
arch: /*xml*/ `
<pivot>
<field name="product_id" type="col"/>
<field name="bar" type="row"/>
<field name="probability" type="measure"/>
</pivot>`,
});
const sheetId = model.getters.getActiveSheetId();
model.dispatch("UPDATE_CELL", {
col: 4,
row: 9,
content: `=ODOO.PIVOT.HEADER("1", "product_id", "1111111")`,
sheetId,
});
await waitForDataSourcesLoaded(model);
assert.equal(
getCell(model, "E10").evaluated.error.message,
"Unable to fetch the label of 1111111 of model product"
);
});
QUnit.test("relational PIVOT.HEADER with undefined id", async function (assert) {
assert.expect(2);
const { model } = await createSpreadsheetWithPivot({
arch: /*xml*/ `
<pivot>
<field name="foo" type="col"/>
<field name="product_id" type="row"/>
<field name="probability" type="measure"/>
</pivot>`,
});
setCellContent(model, "F10", `=ODOO.PIVOT.HEADER("1", "product_id", A25)`);
assert.equal(getCell(model, "A25"), null, "the cell should be empty");
await waitForDataSourcesLoaded(model);
assert.equal(getCellValue(model, "F10"), "None");
});
QUnit.test("Verify pivot measures are correctly computed :)", async function (assert) {
assert.expect(4);
const { model } = await createSpreadsheetWithPivot();
assert.equal(getCellValue(model, "B4"), 11);
assert.equal(getCellValue(model, "C3"), 15);
assert.equal(getCellValue(model, "D4"), 10);
assert.equal(getCellValue(model, "E4"), 95);
});
QUnit.test("can import/export sorted pivot", async (assert) => {
const spreadsheetData = {
pivots: {
1: {
id: "1",
colGroupBys: ["foo"],
domain: [],
measures: [{ field: "probability" }],
model: "partner",
rowGroupBys: ["bar"],
sortedColumn: {
measure: "probability",
order: "asc",
groupId: [[], [1]],
},
name: "A pivot",
context: {},
fieldMatching: {},
},
},
};
const model = await createModelWithDataSource({ spreadsheetData });
assert.deepEqual(model.getters.getPivotDefinition(1).sortedColumn, {
measure: "probability",
order: "asc",
groupId: [[], [1]],
});
assert.deepEqual(model.exportData().pivots, spreadsheetData.pivots);
});
QUnit.test("Can group by many2many field ", async (assert) => {
const { model } = await createSpreadsheetWithPivot({
arch: /* xml */ `
<pivot>
<field name="foo" type="col"/>
<field name="tag_ids" type="row"/>
<field name="probability" type="measure"/>
</pivot>`,
});
assert.equal(getCellFormula(model, "A3"), '=ODOO.PIVOT.HEADER(1,"tag_ids","false")');
assert.equal(getCellFormula(model, "A4"), '=ODOO.PIVOT.HEADER(1,"tag_ids",42)');
assert.equal(getCellFormula(model, "A5"), '=ODOO.PIVOT.HEADER(1,"tag_ids",67)');
assert.equal(
getCellFormula(model, "B3"),
'=ODOO.PIVOT(1,"probability","tag_ids","false","foo",1)'
);
assert.equal(
getCellFormula(model, "B4"),
'=ODOO.PIVOT(1,"probability","tag_ids",42,"foo",1)'
);
assert.equal(
getCellFormula(model, "B5"),
'=ODOO.PIVOT(1,"probability","tag_ids",67,"foo",1)'
);
assert.equal(
getCellFormula(model, "C3"),
'=ODOO.PIVOT(1,"probability","tag_ids","false","foo",2)'
);
assert.equal(
getCellFormula(model, "C4"),
'=ODOO.PIVOT(1,"probability","tag_ids",42,"foo",2)'
);
assert.equal(
getCellFormula(model, "C5"),
'=ODOO.PIVOT(1,"probability","tag_ids",67,"foo",2)'
);
assert.equal(getCellValue(model, "A3"), "None");
assert.equal(getCellValue(model, "A4"), "isCool");
assert.equal(getCellValue(model, "A5"), "Growing");
assert.equal(getCellValue(model, "B3"), "");
assert.equal(getCellValue(model, "B4"), "11");
assert.equal(getCellValue(model, "B5"), "11");
assert.equal(getCellValue(model, "C3"), "");
assert.equal(getCellValue(model, "C4"), "15");
assert.equal(getCellValue(model, "C5"), "");
});
QUnit.test("PIVOT formulas are correctly formatted at evaluation", async function (assert) {
const { model } = await createSpreadsheetWithPivot({
arch: /* xml */ `
<pivot>
<field name="product_id" type="col"/>
<field name="name" type="row"/>
<field name="foo" type="measure"/>
<field name="probability" type="measure"/>
</pivot>`,
});
assert.strictEqual(getCell(model, "B3").evaluated.format, "0");
assert.strictEqual(getCell(model, "C3").evaluated.format, "#,##0.00");
});
QUnit.test(
"PIVOT formulas with monetary measure are correctly formatted at evaluation",
async function (assert) {
const { model } = await createSpreadsheetWithPivot({
arch: /* xml */ `
<pivot>
<field name="product_id" type="col"/>
<field name="name" type="row"/>
<field name="pognon" type="measure"/>
</pivot>`,
});
assert.strictEqual(getCell(model, "B3").evaluated.format, "#,##0.00[$€]");
}
);
QUnit.test(
"PIVOT.HEADER formulas are correctly formatted at evaluation",
async function (assert) {
const { model } = await createSpreadsheetWithPivot({
arch: /* xml */ `
<pivot>
<field name="date" interval="day" type="col"/>
<field name="probability" type="row"/>
<field name="foo" type="measure"/>
</pivot>`,
});
assert.strictEqual(getCell(model, "A3").evaluated.format, "#,##0.00");
assert.strictEqual(getCell(model, "B1").evaluated.format, "mm/dd/yyyy");
assert.strictEqual(getCell(model, "B2").evaluated.format, undefined);
}
);
QUnit.test("can edit pivot domain", async (assert) => {
const { model } = await createSpreadsheetWithPivot();
const [pivotId] = model.getters.getPivotIds();
assert.deepEqual(model.getters.getPivotDefinition(pivotId).domain, []);
assert.strictEqual(getCellValue(model, "B4"), 11);
model.dispatch("UPDATE_ODOO_PIVOT_DOMAIN", {
pivotId,
domain: [["foo", "in", [55]]],
});
assert.deepEqual(model.getters.getPivotDefinition(pivotId).domain, [["foo", "in", [55]]]);
await waitForDataSourcesLoaded(model);
assert.strictEqual(getCellValue(model, "B4"), "");
model.dispatch("REQUEST_UNDO");
await waitForDataSourcesLoaded(model);
assert.deepEqual(model.getters.getPivotDefinition(pivotId).domain, []);
await waitForDataSourcesLoaded(model);
assert.strictEqual(getCellValue(model, "B4"), 11);
model.dispatch("REQUEST_REDO");
assert.deepEqual(model.getters.getPivotDefinition(pivotId).domain, [["foo", "in", [55]]]);
await waitForDataSourcesLoaded(model);
assert.strictEqual(getCellValue(model, "B4"), "");
});
QUnit.test("edited domain is exported", async (assert) => {
const { model } = await createSpreadsheetWithPivot();
const [pivotId] = model.getters.getPivotIds();
model.dispatch("UPDATE_ODOO_PIVOT_DOMAIN", {
pivotId,
domain: [["foo", "in", [55]]],
});
assert.deepEqual(model.exportData().pivots["1"].domain, [["foo", "in", [55]]]);
});
QUnit.test("field matching is removed when filter is deleted", async function (assert) {
const { model } = await createSpreadsheetWithPivot();
await addGlobalFilter(
model,
{
filter: {
id: "42",
type: "relation",
label: "test",
defaultValue: [41],
modelName: undefined,
rangeType: undefined,
},
},
{
pivot: { 1: { chain: "product_id", type: "many2one" } },
}
);
const [filter] = model.getters.getGlobalFilters();
const matching = {
chain: "product_id",
type: "many2one",
};
assert.deepEqual(model.getters.getPivotFieldMatching("1", filter.id), matching);
assert.deepEqual(model.getters.getPivotDataSource("1").getComputedDomain(), [
["product_id", "in", [41]],
]);
model.dispatch("REMOVE_GLOBAL_FILTER", {
id: filter.id,
});
assert.deepEqual(
model.getters.getPivotFieldMatching("1", filter.id),
undefined,
"it should have removed the pivot and its fieldMatching and datasource altogether"
);
assert.deepEqual(model.getters.getPivotDataSource("1").getComputedDomain(), []);
model.dispatch("REQUEST_UNDO");
assert.deepEqual(model.getters.getPivotFieldMatching("1", filter.id), matching);
assert.deepEqual(model.getters.getPivotDataSource("1").getComputedDomain(), [
["product_id", "in", [41]],
]);
model.dispatch("REQUEST_REDO");
assert.deepEqual(model.getters.getPivotFieldMatching("1", filter.id), undefined);
assert.deepEqual(model.getters.getPivotDataSource("1").getComputedDomain(), []);
});
QUnit.test(
"Load pivot spreadsheet with models that cannot be accessed",
async function (assert) {
let hasAccessRights = true;
const { model } = await createSpreadsheetWithPivot({
mockRPC: async function (route, args) {
if (
args.model === "partner" &&
args.method === "read_group" &&
!hasAccessRights
) {
const error = new RPCError();
error.data = { message: "ya done!" };
throw error;
}
},
});
const headerCell = getCell(model, "A3");
const cell = getCell(model, "C3");
await waitForDataSourcesLoaded(model);
assert.equal(headerCell.evaluated.value, "No");
assert.equal(cell.evaluated.value, 15);
hasAccessRights = false;
model.dispatch("REFRESH_PIVOT", { id: "1" });
await waitForDataSourcesLoaded(model);
assert.equal(headerCell.evaluated.value, "#ERROR");
assert.equal(headerCell.evaluated.error.message, "ya done!");
assert.equal(cell.evaluated.value, "#ERROR");
assert.equal(cell.evaluated.error.message, "ya done!");
}
);
QUnit.test("date are between two years are correctly grouped by weeks", async (assert) => {
const serverData = getBasicServerData();
serverData.models.partner.records= [
{ active: true, id: 5, foo: 11, bar: true, product_id: 37, date: "2024-01-03" },
{ active: true, id: 6, foo: 12, bar: true, product_id: 41, date: "2024-12-30" },
{ active: true, id: 7, foo: 13, bar: true, product_id: 37, date: "2024-12-31" },
{ active: true, id: 8, foo: 14, bar: true, product_id: 37, date: "2025-01-01" }
];
const { model } = await createSpreadsheetWithPivot({
serverData,
arch: /*xml*/ `
<pivot string="Partners">
<field name="date:year" type="col"/>
<field name="date:week" type="col"/>
<field name="foo" type="measure"/>
</pivot>`,
});
assert.equal(getCellFormattedValue(model,"B1"),"2024");
assert.equal(getCellFormattedValue(model,"B2"),"W1 2024");
assert.equal(getCellFormattedValue(model,"B4"),"11");
assert.equal(getCellFormattedValue(model,"C2"),"W1 2025");
assert.equal(getCellFormattedValue(model,"C4"),"25");
assert.equal(getCellFormattedValue(model,"D1"),"2025");
assert.equal(getCellFormattedValue(model,"D2"),"W1 2025");
assert.equal(getCellFormattedValue(model,"D4"),"14");
});
QUnit.test("date are between two years are correctly grouped by weeks and days", async (assert) => {
const serverData = getBasicServerData();
serverData.models.partner.records= [
{ active: true, id: 5, foo: 11, bar: true, product_id: 37, date: "2024-01-03" },
{ active: true, id: 6, foo: 12, bar: true, product_id: 41, date: "2024-12-30" },
{ active: true, id: 7, foo: 13, bar: true, product_id: 37, date: "2024-12-31" },
{ active: true, id: 8, foo: 14, bar: true, product_id: 37, date: "2025-01-01" }
];
const { model } = await createSpreadsheetWithPivot({
serverData,
arch: /*xml*/ `
<pivot string="Partners">
<field name="date:year" type="col"/>
<field name="date:week" type="col"/>
<field name="date:day" type="col"/>
<field name="foo" type="measure"/>
</pivot>`,
});
assert.equal(getCellFormattedValue(model,"B1"),"2024");
assert.equal(getCellFormattedValue(model,"B2"),"W1 2024");
assert.equal(getCellFormattedValue(model,"B3"),"01/03/2024");
assert.equal(getCellFormattedValue(model,"B5"),"11");
assert.equal(getCellFormattedValue(model,"C2"),"W1 2025");
assert.equal(getCellFormattedValue(model,"C3"),"12/30/2024");
assert.equal(getCellFormattedValue(model,"C5"),"12");
assert.equal(getCellFormattedValue(model,"D3"),"12/31/2024");
assert.equal(getCellFormattedValue(model,"D5"),"13");
assert.equal(getCellFormattedValue(model,"E1"),"2025");
assert.equal(getCellFormattedValue(model,"E2"),"W1 2025");
assert.equal(getCellFormattedValue(model,"E3"),"01/01/2025");
assert.equal(getCellFormattedValue(model,"E5"),"14");
});
});

View file

@ -0,0 +1,343 @@
/** @odoo-module */
import { setCellContent } from "@spreadsheet/../tests/utils/commands";
import { getCell, getCellValue } from "@spreadsheet/../tests/utils/getters";
import { createSpreadsheetWithPivot } from "@spreadsheet/../tests/utils/pivot";
import {
createModelWithDataSource,
waitForDataSourcesLoaded,
} from "@spreadsheet/../tests/utils/model";
QUnit.module("spreadsheet > positional pivot formula", {}, () => {
QUnit.test("Can have positional args in pivot formula", async function (assert) {
const { model } = await createSpreadsheetWithPivot();
// Columns
setCellContent(model, "H1", `=ODOO.PIVOT(1,"probability","#foo", 1)`);
setCellContent(model, "H2", `=ODOO.PIVOT(1,"probability","#foo", 2)`);
setCellContent(model, "H3", `=ODOO.PIVOT(1,"probability","#foo", 3)`);
setCellContent(model, "H4", `=ODOO.PIVOT(1,"probability","#foo", 4)`);
setCellContent(model, "H5", `=ODOO.PIVOT(1,"probability","#foo", 5)`);
assert.strictEqual(getCellValue(model, "H1"), 11);
assert.strictEqual(getCellValue(model, "H2"), 15);
assert.strictEqual(getCellValue(model, "H3"), 10);
assert.strictEqual(getCellValue(model, "H4"), 95);
assert.strictEqual(getCellValue(model, "H5"), "");
// Rows
setCellContent(model, "I1", `=ODOO.PIVOT(1,"probability","#bar", 1)`);
setCellContent(model, "I2", `=ODOO.PIVOT(1,"probability","#bar", 2)`);
setCellContent(model, "I3", `=ODOO.PIVOT(1,"probability","#bar", 3)`);
assert.strictEqual(getCellValue(model, "I1"), 15);
assert.strictEqual(getCellValue(model, "I2"), 116);
assert.strictEqual(getCellValue(model, "I3"), "");
});
QUnit.test("Can have positional args in pivot headers formula", async function (assert) {
const { model } = await createSpreadsheetWithPivot();
// Columns
setCellContent(model, "H1", `=ODOO.PIVOT.HEADER(1,"#foo",1)`);
setCellContent(model, "H2", `=ODOO.PIVOT.HEADER(1,"#foo",2)`);
setCellContent(model, "H3", `=ODOO.PIVOT.HEADER(1,"#foo",3)`);
setCellContent(model, "H4", `=ODOO.PIVOT.HEADER(1,"#foo",4)`);
setCellContent(model, "H5", `=ODOO.PIVOT.HEADER(1,"#foo",5)`);
setCellContent(model, "H6", `=ODOO.PIVOT.HEADER(1,"#foo",5, "measure", "probability")`);
assert.strictEqual(getCellValue(model, "H1"), 1);
assert.strictEqual(getCellValue(model, "H2"), 2);
assert.strictEqual(getCellValue(model, "H3"), 12);
assert.strictEqual(getCellValue(model, "H4"), 17);
assert.strictEqual(getCellValue(model, "H5"), "");
assert.strictEqual(getCellValue(model, "H6"), "Probability");
// Rows
setCellContent(model, "I1", `=ODOO.PIVOT.HEADER(1,"#bar",1)`);
setCellContent(model, "I2", `=ODOO.PIVOT.HEADER(1,"#bar",2)`);
setCellContent(model, "I3", `=ODOO.PIVOT.HEADER(1,"#bar",3)`);
setCellContent(model, "I4", `=ODOO.PIVOT.HEADER(1,"#bar",3, "measure", "probability")`);
assert.strictEqual(getCellValue(model, "I1"), "No");
assert.strictEqual(getCellValue(model, "I2"), "Yes");
assert.strictEqual(getCellValue(model, "I3"), "");
assert.strictEqual(getCellValue(model, "I4"), "Probability");
});
QUnit.test("pivot positional with two levels of group bys in rows", async (assert) => {
const { model } = await createSpreadsheetWithPivot({
arch: /*xml*/ `
<pivot>
<field name="bar" type="row"/>
<field name="product_id" type="row"/>
<field name="foo" type="col"/>
<field name="probability" type="measure"/>
</pivot>`,
});
// Rows Headers
setCellContent(model, "H1", `=ODOO.PIVOT.HEADER(1,"bar","false","#product_id",1)`);
setCellContent(model, "H2", `=ODOO.PIVOT.HEADER(1,"bar","true","#product_id",1)`);
setCellContent(model, "H3", `=ODOO.PIVOT.HEADER(1,"#bar",1,"#product_id",1)`);
setCellContent(model, "H4", `=ODOO.PIVOT.HEADER(1,"#bar",2,"#product_id",1)`);
setCellContent(model, "H5", `=ODOO.PIVOT.HEADER(1,"#bar",3,"#product_id",1)`);
assert.strictEqual(getCellValue(model, "H1"), "xpad");
assert.strictEqual(getCellValue(model, "H2"), "xphone");
assert.strictEqual(getCellValue(model, "H3"), "xpad");
assert.strictEqual(getCellValue(model, "H4"), "xphone");
assert.strictEqual(getCellValue(model, "H5"), "");
// Cells
setCellContent(
model,
"H1",
`=ODOO.PIVOT(1,"probability","#bar",1,"#product_id",1,"#foo",2)`
);
setCellContent(
model,
"H2",
`=ODOO.PIVOT(1,"probability","#bar",1,"#product_id",2,"#foo",2)`
);
assert.strictEqual(getCellValue(model, "H1"), 15);
assert.strictEqual(getCellValue(model, "H2"), "");
});
QUnit.test("Positional argument without a number should crash", async (assert) => {
const { model } = await createSpreadsheetWithPivot();
setCellContent(model, "A10", `=ODOO.PIVOT.HEADER(1,"#bar","this is not a number")`);
assert.strictEqual(getCellValue(model, "A10"), "#ERROR");
assert.strictEqual(
getCell(model, "A10").evaluated.error.message,
"The function ODOO.PIVOT.HEADER expects a number value, but 'this is not a number' is a string, and cannot be coerced to a number."
);
});
QUnit.test("sort first pivot column (ascending)", async (assert) => {
const spreadsheetData = {
pivots: {
1: {
colGroupBys: ["foo"],
rowGroupBys: ["bar"],
domain: [],
id: "1",
measures: [{ field: "probability" }],
model: "partner",
sortedColumn: {
groupId: [[], [1]],
measure: "probability",
order: "asc",
},
},
},
};
const model = await createModelWithDataSource({ spreadsheetData });
setCellContent(model, "A1", `=ODOO.PIVOT.HEADER(1,"#bar",1)`);
setCellContent(model, "A2", `=ODOO.PIVOT.HEADER(1,"#bar",2)`);
setCellContent(model, "B1", `=ODOO.PIVOT(1,"probability","#bar",1,"#foo",1)`);
setCellContent(model, "B2", `=ODOO.PIVOT(1,"probability","#bar",2,"#foo",1)`);
setCellContent(model, "C1", `=ODOO.PIVOT(1,"probability","#bar",1,"#foo",2)`);
setCellContent(model, "C2", `=ODOO.PIVOT(1,"probability","#bar",2,"#foo",2)`);
setCellContent(model, "D1", `=ODOO.PIVOT(1,"probability","#bar",1)`);
setCellContent(model, "D2", `=ODOO.PIVOT(1,"probability","#bar",2)`);
await waitForDataSourcesLoaded(model);
assert.strictEqual(getCellValue(model, "A1"), "No");
assert.strictEqual(getCellValue(model, "A2"), "Yes");
assert.strictEqual(getCellValue(model, "B1"), "");
assert.strictEqual(getCellValue(model, "B2"), 11);
assert.strictEqual(getCellValue(model, "C1"), 15);
assert.strictEqual(getCellValue(model, "C2"), "");
assert.strictEqual(getCellValue(model, "D1"), 15);
assert.strictEqual(getCellValue(model, "D2"), 116);
});
QUnit.test("sort first pivot column (descending)", async (assert) => {
const spreadsheetData = {
pivots: {
1: {
colGroupBys: ["foo"],
rowGroupBys: ["bar"],
domain: [],
id: "1",
measures: [{ field: "probability" }],
model: "partner",
sortedColumn: {
groupId: [[], [1]],
measure: "probability",
order: "desc",
},
},
},
};
const model = await createModelWithDataSource({ spreadsheetData });
setCellContent(model, "A1", `=ODOO.PIVOT.HEADER(1,"#bar",1)`);
setCellContent(model, "A2", `=ODOO.PIVOT.HEADER(1,"#bar",2)`);
setCellContent(model, "B1", `=ODOO.PIVOT(1,"probability","#bar",1,"#foo",1)`);
setCellContent(model, "B2", `=ODOO.PIVOT(1,"probability","#bar",2,"#foo",1)`);
setCellContent(model, "C1", `=ODOO.PIVOT(1,"probability","#bar",1,"#foo",2)`);
setCellContent(model, "C2", `=ODOO.PIVOT(1,"probability","#bar",2,"#foo",2)`);
setCellContent(model, "D1", `=ODOO.PIVOT(1,"probability","#bar",1)`);
setCellContent(model, "D2", `=ODOO.PIVOT(1,"probability","#bar",2)`);
await waitForDataSourcesLoaded(model);
assert.strictEqual(getCellValue(model, "A1"), "Yes");
assert.strictEqual(getCellValue(model, "A2"), "No");
assert.strictEqual(getCellValue(model, "B1"), 11);
assert.strictEqual(getCellValue(model, "B2"), "");
assert.strictEqual(getCellValue(model, "C1"), "");
assert.strictEqual(getCellValue(model, "C2"), 15);
assert.strictEqual(getCellValue(model, "D1"), 116);
assert.strictEqual(getCellValue(model, "D2"), 15);
});
QUnit.test("sort second pivot column (ascending)", async (assert) => {
const spreadsheetData = {
pivots: {
1: {
colGroupBys: ["foo"],
domain: [],
id: "1",
measures: [{ field: "probability" }],
model: "partner",
rowGroupBys: ["bar"],
name: "Partners by Foo",
sortedColumn: {
groupId: [[], [2]],
measure: "probability",
order: "asc",
},
},
},
};
const model = await createModelWithDataSource({ spreadsheetData });
setCellContent(model, "A1", `=ODOO.PIVOT.HEADER(1,"#bar",1)`);
setCellContent(model, "A2", `=ODOO.PIVOT.HEADER(1,"#bar",2)`);
setCellContent(model, "B1", `=ODOO.PIVOT(1,"probability","#bar",1,"#foo",1)`);
setCellContent(model, "B2", `=ODOO.PIVOT(1,"probability","#bar",2,"#foo",1)`);
setCellContent(model, "C1", `=ODOO.PIVOT(1,"probability","#bar",1,"#foo",2)`);
setCellContent(model, "C2", `=ODOO.PIVOT(1,"probability","#bar",2,"#foo",2)`);
setCellContent(model, "D1", `=ODOO.PIVOT(1,"probability","#bar",1)`);
setCellContent(model, "D2", `=ODOO.PIVOT(1,"probability","#bar",2)`);
await waitForDataSourcesLoaded(model);
assert.strictEqual(getCellValue(model, "A1"), "Yes");
assert.strictEqual(getCellValue(model, "A2"), "No");
assert.strictEqual(getCellValue(model, "B1"), 11);
assert.strictEqual(getCellValue(model, "B2"), "");
assert.strictEqual(getCellValue(model, "C1"), "");
assert.strictEqual(getCellValue(model, "C2"), 15);
assert.strictEqual(getCellValue(model, "D1"), 116);
assert.strictEqual(getCellValue(model, "D2"), 15);
});
QUnit.test("sort second pivot column (descending)", async (assert) => {
const spreadsheetData = {
pivots: {
1: {
colGroupBys: ["foo"],
domain: [],
id: "1",
measures: [{ field: "probability" }],
model: "partner",
rowGroupBys: ["bar"],
name: "Partners by Foo",
sortedColumn: {
groupId: [[], [2]],
measure: "probability",
order: "desc",
},
},
},
};
const model = await createModelWithDataSource({ spreadsheetData });
setCellContent(model, "A1", `=ODOO.PIVOT.HEADER(1,"#bar",1)`);
setCellContent(model, "A2", `=ODOO.PIVOT.HEADER(1,"#bar",2)`);
setCellContent(model, "B1", `=ODOO.PIVOT(1,"probability","#bar",1,"#foo",1)`);
setCellContent(model, "B2", `=ODOO.PIVOT(1,"probability","#bar",2,"#foo",1)`);
setCellContent(model, "C1", `=ODOO.PIVOT(1,"probability","#bar",1,"#foo",2)`);
setCellContent(model, "C2", `=ODOO.PIVOT(1,"probability","#bar",2,"#foo",2)`);
setCellContent(model, "D1", `=ODOO.PIVOT(1,"probability","#bar",1)`);
setCellContent(model, "D2", `=ODOO.PIVOT(1,"probability","#bar",2)`);
await waitForDataSourcesLoaded(model);
assert.strictEqual(getCellValue(model, "A1"), "No");
assert.strictEqual(getCellValue(model, "A2"), "Yes");
assert.strictEqual(getCellValue(model, "B1"), "");
assert.strictEqual(getCellValue(model, "B2"), 11);
assert.strictEqual(getCellValue(model, "C1"), 15);
assert.strictEqual(getCellValue(model, "C2"), "");
assert.strictEqual(getCellValue(model, "D1"), 15);
assert.strictEqual(getCellValue(model, "D2"), 116);
});
QUnit.test("sort second pivot measure (ascending)", async (assert) => {
const spreadsheetData = {
pivots: {
1: {
rowGroupBys: ["product_id"],
colGroupBys: [],
domain: [],
id: "1",
measures: [{ field: "probability" }, { field: "foo" }],
model: "partner",
sortedColumn: {
groupId: [[], []],
measure: "foo",
order: "asc",
},
},
},
};
const model = await createModelWithDataSource({ spreadsheetData });
setCellContent(model, "A10", `=ODOO.PIVOT.HEADER(1,"#product_id",1)`);
setCellContent(model, "A11", `=ODOO.PIVOT.HEADER(1,"#product_id",2)`);
setCellContent(model, "B10", `=ODOO.PIVOT(1,"probability","#product_id",1)`);
setCellContent(model, "B11", `=ODOO.PIVOT(1,"probability","#product_id",2)`);
setCellContent(model, "C10", `=ODOO.PIVOT(1,"foo","#product_id",1)`);
setCellContent(model, "C11", `=ODOO.PIVOT(1,"foo","#product_id",2)`);
await waitForDataSourcesLoaded(model);
assert.strictEqual(getCellValue(model, "A10"), "xphone");
assert.strictEqual(getCellValue(model, "A11"), "xpad");
assert.strictEqual(getCellValue(model, "B10"), 10);
assert.strictEqual(getCellValue(model, "B11"), 121);
assert.strictEqual(getCellValue(model, "C10"), 12);
assert.strictEqual(getCellValue(model, "C11"), 20);
});
QUnit.test("sort second pivot measure (descending)", async (assert) => {
const spreadsheetData = {
pivots: {
1: {
colGroupBys: [],
domain: [],
id: "1",
measures: [{ field: "probability" }, { field: "foo" }],
model: "partner",
rowGroupBys: ["product_id"],
sortedColumn: {
groupId: [[], []],
measure: "foo",
order: "desc",
},
},
},
};
const model = await createModelWithDataSource({ spreadsheetData });
setCellContent(model, "A10", `=ODOO.PIVOT.HEADER(1,"#product_id",1)`);
setCellContent(model, "A11", `=ODOO.PIVOT.HEADER(1,"#product_id",2)`);
setCellContent(model, "B10", `=ODOO.PIVOT(1,"probability","#product_id",1)`);
setCellContent(model, "B11", `=ODOO.PIVOT(1,"probability","#product_id",2)`);
setCellContent(model, "C10", `=ODOO.PIVOT(1,"foo","#product_id",1)`);
setCellContent(model, "C11", `=ODOO.PIVOT(1,"foo","#product_id",2)`);
await waitForDataSourcesLoaded(model);
assert.strictEqual(getCellValue(model, "A10"), "xpad");
assert.strictEqual(getCellValue(model, "A11"), "xphone");
assert.strictEqual(getCellValue(model, "B10"), 121);
assert.strictEqual(getCellValue(model, "B11"), 10);
assert.strictEqual(getCellValue(model, "C10"), 20);
assert.strictEqual(getCellValue(model, "C11"), 12);
});
QUnit.test("Formatting a pivot positional preserves the interval", async (assert) => {
const { model } = await createSpreadsheetWithPivot({
arch: /*xml*/ `
<pivot>
<field name="date:day" type="row"/>
<field name="probability" type="measure"/>
</pivot>`,
});
setCellContent(model, "A1", `=ODOO.PIVOT.HEADER(1,"#date:day",1)`);
assert.strictEqual(getCell(model, "A1").formattedValue, "04/14/2016");
});
});

View file

@ -0,0 +1,154 @@
/** @odoo-module */
import { getFirstPivotFunction, getNumberOfPivotFormulas } from "@spreadsheet/pivot/pivot_helpers";
import { getFirstListFunction, getNumberOfListFormulas } from "@spreadsheet/list/list_helpers";
import { parsePivotFormulaFieldValue } from "@spreadsheet/pivot/pivot_model";
function stringArg(value) {
return { type: "STRING", value: `${value}` };
}
QUnit.module("spreadsheet > pivot_helpers", {}, () => {
QUnit.test("Basic formula extractor", async function (assert) {
const formula = `=ODOO.PIVOT("1", "test") + ODOO.LIST("2", "hello", "bla")`;
let functionName;
let args;
({ functionName, args } = getFirstPivotFunction(formula));
assert.strictEqual(functionName, "ODOO.PIVOT");
assert.strictEqual(args.length, 2);
assert.deepEqual(args[0], stringArg("1"));
assert.deepEqual(args[1], stringArg("test"));
({ functionName, args } = getFirstListFunction(formula));
assert.strictEqual(functionName, "ODOO.LIST");
assert.strictEqual(args.length, 3);
assert.deepEqual(args[0], stringArg("2"));
assert.deepEqual(args[1], stringArg("hello"));
assert.deepEqual(args[2], stringArg("bla"));
});
QUnit.test("Extraction with two PIVOT formulas", async function (assert) {
const formula = `=ODOO.PIVOT("1", "test") + ODOO.PIVOT("2", "hello", "bla")`;
let functionName;
let args;
({ functionName, args } = getFirstPivotFunction(formula));
assert.strictEqual(functionName, "ODOO.PIVOT");
assert.strictEqual(args.length, 2);
assert.deepEqual(args[0], stringArg("1"));
assert.deepEqual(args[1], stringArg("test"));
assert.strictEqual(getFirstListFunction(formula), undefined);
});
QUnit.test("Number of formulas", async function (assert) {
const formula = `=ODOO.PIVOT("1", "test") + ODOO.PIVOT("2", "hello", "bla") + ODOO.LIST("1", "bla")`;
assert.strictEqual(getNumberOfPivotFormulas(formula), 2);
assert.strictEqual(getNumberOfListFormulas(formula), 1);
assert.strictEqual(getNumberOfPivotFormulas("=1+1"), 0);
assert.strictEqual(getNumberOfListFormulas("=1+1"), 0);
assert.strictEqual(getNumberOfPivotFormulas("=bla"), 0);
assert.strictEqual(getNumberOfListFormulas("=bla"), 0);
});
QUnit.test("getFirstPivotFunction does not crash when given crap", async function (assert) {
assert.strictEqual(getFirstListFunction("=SUM(A1)"), undefined);
assert.strictEqual(getFirstPivotFunction("=SUM(A1)"), undefined);
assert.strictEqual(getFirstListFunction("=1+1"), undefined);
assert.strictEqual(getFirstPivotFunction("=1+1"), undefined);
assert.strictEqual(getFirstListFunction("=bla"), undefined);
assert.strictEqual(getFirstPivotFunction("=bla"), undefined);
assert.strictEqual(getFirstListFunction("bla"), undefined);
assert.strictEqual(getFirstPivotFunction("bla"), undefined);
});
});
QUnit.module("spreadsheet > parsePivotFormulaFieldValue", {}, () => {
QUnit.test("parse values of a selection, char or text field", (assert) => {
for (const fieldType of ["selection", "text", "char"]) {
const field = {
type: fieldType,
string: "A field",
};
assert.strictEqual(parsePivotFormulaFieldValue(field, "won"), "won");
assert.strictEqual(parsePivotFormulaFieldValue(field, "1"), "1");
assert.strictEqual(parsePivotFormulaFieldValue(field, 1), "1");
assert.strictEqual(parsePivotFormulaFieldValue(field, "11/2020"), "11/2020");
assert.strictEqual(parsePivotFormulaFieldValue(field, "2020"), "2020");
assert.strictEqual(parsePivotFormulaFieldValue(field, "01/11/2020"), "01/11/2020");
assert.strictEqual(parsePivotFormulaFieldValue(field, "false"), false);
assert.strictEqual(parsePivotFormulaFieldValue(field, false), false);
assert.strictEqual(parsePivotFormulaFieldValue(field, "true"), "true");
}
});
QUnit.test("parse values of time fields", (assert) => {
for (const fieldType of ["date", "datetime"]) {
const field = {
type: fieldType,
string: "A field",
};
assert.strictEqual(parsePivotFormulaFieldValue(field, "11/2020"), "11/2020");
assert.strictEqual(parsePivotFormulaFieldValue(field, "2020"), "2020");
assert.strictEqual(parsePivotFormulaFieldValue(field, "01/11/2020"), "01/11/2020");
assert.strictEqual(parsePivotFormulaFieldValue(field, "1"), "1");
assert.strictEqual(parsePivotFormulaFieldValue(field, 1), "1");
assert.strictEqual(parsePivotFormulaFieldValue(field, "false"), false);
assert.strictEqual(parsePivotFormulaFieldValue(field, false), false);
assert.strictEqual(parsePivotFormulaFieldValue(field, "true"), "true"); // this should throw because it's not a valid date
assert.strictEqual(parsePivotFormulaFieldValue(field, true), "true"); // this should throw because it's not a valid date
assert.strictEqual(parsePivotFormulaFieldValue(field, "won"), "won"); // this should throw because it's not a valid date
}
});
QUnit.test("parse values of boolean field", (assert) => {
const field = {
type: "boolean",
string: "A field",
};
assert.strictEqual(parsePivotFormulaFieldValue(field, "false"), false);
assert.strictEqual(parsePivotFormulaFieldValue(field, false), false);
assert.strictEqual(parsePivotFormulaFieldValue(field, "true"), true);
assert.strictEqual(parsePivotFormulaFieldValue(field, true), true);
assert.throws(() => parsePivotFormulaFieldValue(field, "11/2020"));
assert.throws(() => parsePivotFormulaFieldValue(field, "2020"));
assert.throws(() => parsePivotFormulaFieldValue(field, "01/11/2020"));
assert.throws(() => parsePivotFormulaFieldValue(field, "1"));
assert.throws(() => parsePivotFormulaFieldValue(field, 1));
assert.throws(() => parsePivotFormulaFieldValue(field, "won"));
});
QUnit.test("parse values of numeric fields", (assert) => {
for (const fieldType of ["float", "integer", "monetary", "many2one", "many2many"]) {
const field = {
type: fieldType,
string: "A field",
};
assert.strictEqual(parsePivotFormulaFieldValue(field, "2020"), 2020);
assert.strictEqual(parsePivotFormulaFieldValue(field, "01/11/2020"), 43841); // a date is actually a number in a spreadsheet
assert.strictEqual(parsePivotFormulaFieldValue(field, "1"), 1);
assert.strictEqual(parsePivotFormulaFieldValue(field, 1), 1);
assert.strictEqual(parsePivotFormulaFieldValue(field, "false"), false);
assert.strictEqual(parsePivotFormulaFieldValue(field, false), false);
assert.throws(() => parsePivotFormulaFieldValue(field, "true"));
assert.throws(() => parsePivotFormulaFieldValue(field, true));
assert.throws(() => parsePivotFormulaFieldValue(field, "won"));
assert.throws(() => parsePivotFormulaFieldValue(field, "11/2020"));
}
});
QUnit.test("parse values of unsupported fields", (assert) => {
for (const fieldType of ["one2many", "binary", "html"]) {
const field = {
type: fieldType,
string: "A field",
};
assert.throws(() => parsePivotFormulaFieldValue(field, "false"));
assert.throws(() => parsePivotFormulaFieldValue(field, false));
assert.throws(() => parsePivotFormulaFieldValue(field, "true"));
assert.throws(() => parsePivotFormulaFieldValue(field, true));
assert.throws(() => parsePivotFormulaFieldValue(field, "11/2020"));
assert.throws(() => parsePivotFormulaFieldValue(field, "2020"));
assert.throws(() => parsePivotFormulaFieldValue(field, "01/11/2020"));
assert.throws(() => parsePivotFormulaFieldValue(field, "1"));
assert.throws(() => parsePivotFormulaFieldValue(field, 1));
assert.throws(() => parsePivotFormulaFieldValue(field, "won"));
}
});
});

View file

@ -0,0 +1,155 @@
/** @odoo-module */
import { makeDeferred, nextTick } from "@web/../tests/helpers/utils";
import { selectCell } from "@spreadsheet/../tests/utils/commands";
import { createSpreadsheetWithPivot } from "@spreadsheet/../tests/utils/pivot";
import spreadsheet from "@spreadsheet/o_spreadsheet/o_spreadsheet_extended";
import { registry } from "@web/core/registry";
import { setCellContent } from "../utils/commands";
import { getCell } from "../utils/getters";
const { cellMenuRegistry } = spreadsheet.registries;
QUnit.module("spreadsheet > see pivot records");
const basicListAction = {
type: "ir.actions.act_window",
name: "Partner",
res_model: "partner",
view_mode: "list",
views: [
[false, "list"],
[false, "form"],
],
target: "current",
domain: [],
};
QUnit.test("Can open see records on headers col", async function (assert) {
const fakeActionService = {
dependencies: [],
start: (env) => ({
doAction: (actionRequest, options = {}) => {
assert.step("doAction");
assert.deepEqual(actionRequest, {
...basicListAction,
domain: [["foo", "=", 1]],
});
},
}),
};
registry.category("services").add("action", fakeActionService);
const { env, model } = await createSpreadsheetWithPivot();
selectCell(model, "B1");
await nextTick();
const root = cellMenuRegistry.getAll().find((item) => item.id === "pivot_see_records");
await root.action(env);
assert.verifySteps(["doAction"]);
});
QUnit.test("Can open see records on headers row", async function (assert) {
const fakeActionService = {
dependencies: [],
start: (env) => ({
doAction: (actionRequest, options = {}) => {
assert.step("doAction");
assert.deepEqual(actionRequest, {
...basicListAction,
domain: [["bar", "=", false]],
});
},
}),
};
registry.category("services").add("action", fakeActionService);
const { env, model } = await createSpreadsheetWithPivot();
selectCell(model, "A3");
await nextTick();
const root = cellMenuRegistry.getAll().find((item) => item.id === "pivot_see_records");
await root.action(env);
assert.verifySteps(["doAction"]);
});
QUnit.test("Can open see records on measure headers", async function (assert) {
const fakeActionService = {
dependencies: [],
start: (env) => ({
doAction: (actionRequest, options = {}) => {
assert.step("doAction");
assert.deepEqual(actionRequest, {
...basicListAction,
domain: [["foo", "=", 1]],
});
},
}),
};
registry.category("services").add("action", fakeActionService);
const { env, model } = await createSpreadsheetWithPivot();
selectCell(model, "B2");
await nextTick();
const root = cellMenuRegistry.getAll().find((item) => item.id === "pivot_see_records");
await root.action(env);
assert.verifySteps(["doAction"]);
});
QUnit.test(
"See records is not visible if the pivot is not loaded, even if the cell has a value",
async function (assert) {
let deferred = undefined;
const { env, model } = await createSpreadsheetWithPivot({
arch: /*xml*/ `
<pivot>
<field name="probability" type="measure"/>
</pivot>
`,
mockRPC: async function (route, args) {
if (deferred && args.method === "read_group" && args.model === "partner") {
await deferred;
}
},
});
setCellContent(model, "A1", '=IFERROR(ODOO.PIVOT("1","probability"), 42)');
deferred = makeDeferred();
model.dispatch("REFRESH_ALL_DATA_SOURCES");
const action = cellMenuRegistry.getAll().find((item) => item.id === "pivot_see_records");
assert.strictEqual(action.isVisible(env), false);
deferred.resolve();
await nextTick();
assert.strictEqual(action.isVisible(env), true);
}
);
QUnit.test("See records is not visible if the formula has an weird IF", async function (assert) {
let deferred = undefined;
const { env, model } = await createSpreadsheetWithPivot({
arch: /*xml*/ `
<pivot>
<field name="probability" type="measure"/>
</pivot>
`,
mockRPC: async function (route, args) {
if (deferred && args.method === "read_group" && args.model === "partner") {
await deferred;
}
},
});
setCellContent(
model,
"A1",
'=if(false, ODOO.PIVOT("1","probability","user_id",2,"partner_id", "#Error"), "test")'
);
deferred = makeDeferred();
model.dispatch("REFRESH_ALL_DATA_SOURCES");
const action = cellMenuRegistry.getAll().find((item) => item.id === "pivot_see_records");
assert.strictEqual(action.isVisible(env), false);
deferred.resolve();
await nextTick();
assert.strictEqual(action.isVisible(env), false);
});
QUnit.test("See records is not visible on an empty cell", async function (assert) {
const { env, model } = await createSpreadsheetWithPivot();
assert.strictEqual(getCell(model, "A21"), undefined);
selectCell(model, "A21");
const action = cellMenuRegistry.getAll().find((item) => item.id === "pivot_see_records");
assert.strictEqual(action.isVisible(env), false);
});

View file

@ -0,0 +1,73 @@
/** @odoo-module */
import { nextTick } from "@web/../tests/helpers/utils";
import spreadsheet from "@spreadsheet/o_spreadsheet/o_spreadsheet_extended";
import { createModelWithDataSource } from "./model";
const uuidGenerator = new spreadsheet.helpers.UuidGenerator();
/** @typedef {import("@spreadsheet/o_spreadsheet/o_spreadsheet").Model} Model */
/**
*
* @param {Model} model
*/
export function insertChartInSpreadsheet(model, type = "odoo_bar") {
const definition = getChartDefinition(type);
model.dispatch("CREATE_CHART", {
sheetId: model.getters.getActiveSheetId(),
id: definition.id,
position: {
x: 10,
y: 10,
},
definition,
});
}
/**
*
* @param {Object} params
* @param {function} [params.mockRPC]
* @param {object} [params.serverData]
* @param {string} [params.type]
*
* @returns { Promise<{ model: Model, env: Object }>}
*/
export async function createSpreadsheetWithChart(params = {}) {
const model = await createModelWithDataSource({
mockRPC: params.mockRPC,
serverData: params.serverData,
});
insertChartInSpreadsheet(model, params.type);
const env = model.config.evalContext.env;
env.model = model;
await nextTick();
return { model, env };
}
function getChartDefinition(type) {
return {
metaData: {
groupBy: ["foo", "bar"],
measure: "__count",
order: null,
resModel: "partner",
},
searchParams: {
comparison: null,
context: {},
domain: [],
groupBy: [],
orderBy: [],
},
stacked: true,
title: "Partners",
background: "#FFFFFF",
legendPosition: "top",
verticalAxisPosition: "left",
dataSourceId: uuidGenerator.uuidv4(),
id: uuidGenerator.uuidv4(),
type,
};
}

View file

@ -0,0 +1,163 @@
/** @odoo-module */
import spreadsheet from "@spreadsheet/o_spreadsheet/o_spreadsheet_extended";
import { waitForDataSourcesLoaded } from "@spreadsheet/../tests/utils/model";
const { toCartesian, toZone } = spreadsheet.helpers;
/**
* @typedef {import("@spreadsheet/global_filters/plugins/global_filters_core_plugin").GlobalFilter} GlobalFilter
*/
/**
* Select a cell
*/
export function selectCell(model, xc) {
const { col, row } = toCartesian(xc);
return model.selection.selectCell(col, row);
}
/**
* Add a global filter and ensure the data sources are completely reloaded
* @param {Model} model
* @param {{filter: GlobalFilter}} filter
*/
export async function addGlobalFilter(model, filter, fieldMatchings = {}) {
const result = model.dispatch("ADD_GLOBAL_FILTER", { ...filter, ...fieldMatchings });
await waitForDataSourcesLoaded(model);
return result;
}
/**
* Remove a global filter and ensure the data sources are completely reloaded
*/
export async function removeGlobalFilter(model, id) {
const result = model.dispatch("REMOVE_GLOBAL_FILTER", { id });
await waitForDataSourcesLoaded(model);
return result;
}
/**
* Edit a global filter and ensure the data sources are completely reloaded
*/
export async function editGlobalFilter(model, filter) {
const result = model.dispatch("EDIT_GLOBAL_FILTER", filter);
await waitForDataSourcesLoaded(model);
return result;
}
/**
* Set the value of a global filter and ensure the data sources are completely
* reloaded
*/
export async function setGlobalFilterValue(model, payload) {
const result = model.dispatch("SET_GLOBAL_FILTER_VALUE", payload);
await waitForDataSourcesLoaded(model);
return result;
}
/**
* Set the selection
*/
export function setSelection(model, xc) {
const zone = toZone(xc);
model.selection.selectZone({ cell: { col: zone.left, row: zone.top }, zone });
}
/**
* Autofill from a zone to a cell
*/
export function autofill(model, from, to) {
setSelection(model, from);
model.dispatch("AUTOFILL_SELECT", toCartesian(to));
model.dispatch("AUTOFILL");
}
/**
* Set the content of a cell
*/
export function setCellContent(model, xc, content, sheetId = model.getters.getActiveSheetId()) {
model.dispatch("UPDATE_CELL", { ...toCartesian(xc), sheetId, content });
}
/**
* Set the format of a cell
*/
export function setCellFormat(model, xc, format, sheetId = model.getters.getActiveSheetId()) {
model.dispatch("UPDATE_CELL", { ...toCartesian(xc), sheetId, format });
}
/**
* Set the style of a cell
*/
export function setCellStyle(model, xc, style, sheetId = model.getters.getActiveSheetId()) {
model.dispatch("UPDATE_CELL", { ...toCartesian(xc), sheetId, style });
}
/** Create a test chart in the active sheet*/
export function createBasicChart(model, chartId, sheetId = model.getters.getActiveSheetId()) {
model.dispatch("CREATE_CHART", {
id: chartId,
position: { x: 0, y: 0 },
sheetId: sheetId,
definition: {
title: "test",
dataSets: ["A1"],
type: "bar",
background: "#fff",
verticalAxisPosition: "left",
legendPosition: "top",
stackedBar: false,
},
});
}
/** Create a test scorecard chart in the active sheet*/
export function createScorecardChart(model, chartId, sheetId = model.getters.getActiveSheetId()) {
model.dispatch("CREATE_CHART", {
id: chartId,
position: { x: 0, y: 0 },
sheetId: sheetId,
definition: {
title: "test",
keyValue: "A1",
type: "scorecard",
background: "#fff",
baselineColorDown: "#DC6965",
baselineColorUp: "#00A04A",
baselineMode: "absolute",
},
});
}
/** Create a test scorecard chart in the active sheet*/
export function createGaugeChart(model, chartId, sheetId = model.getters.getActiveSheetId()) {
model.dispatch("CREATE_CHART", {
id: chartId,
position: { x: 0, y: 0 },
sheetId: sheetId,
definition: {
title: "test",
type: "gauge",
background: "#fff",
dataRange: "A1",
sectionRule: {
rangeMin: "0",
rangeMax: "100",
colors: {
lowerColor: "#112233",
middleColor: "#445566",
upperColor: "#778899",
},
lowerInflectionPoint: {
type: "number",
value: "25",
},
upperInflectionPoint: {
type: "number",
value: "85",
},
},
},
});
}

View file

@ -0,0 +1,445 @@
/** @odoo-module */
/**
* @typedef {object} ServerData
* @property {object} models
* @property {object} views
*/
/**
* Get a basic arch for a pivot, which is compatible with the data given by
* getBasicData().
*
* Here is the pivot created:
* A B C D E F
* 1 1 2 12 17 Total
* 2 Proba Proba Proba Proba Proba
* 3 false 15 15
* 4 true 11 10 95 116
* 5 Total 11 15 10 95 131
*/
export function getBasicPivotArch() {
return /* xml */ `
<pivot string="Partners">
<field name="foo" type="col"/>
<field name="bar" type="row"/>
<field name="probability" type="measure"/>
</pivot>`;
}
/**
* Get a basic arch for a list, which is compatible with the data given by
* getBasicData().
*
* Here is the list created:
* A B C D
* 1 Foo bar Date Product
* 2 12 True 2016-04-14 xphone
* 3 1 True 2016-10-26 xpad
* 4 17 True 2016-12-15 xpad
* 5 2 False 2016-12-11 xpad
*/
export function getBasicListArch() {
return /* xml */ `
<tree string="Partners">
<field name="foo"/>
<field name="bar"/>
<field name="date"/>
<field name="product_id"/>
</tree>
`;
}
export function getBasicGraphArch() {
return /* xml */ `
<graph>
<field name="bar" />
</graph>
`;
}
/**
* @returns {ServerData}
*/
export function getBasicServerData() {
return {
models: getBasicData(),
views: {
"partner,false,list": getBasicListArch(),
"partner,false,pivot": getBasicPivotArch(),
"partner,false,graph": getBasicGraphArch(),
"partner,false,form": /* xml */ `<Form/>`,
"partner,false,search": /* xml */ `<search/>`,
},
};
}
/**
*
* @param {string} model
* @param {Array<string>} columns
* @param {Object} data
*
* @returns { {definition: Object, columns: Array<Object>}}
*/
export function generateListDefinition(model, columns, data = getBasicData()) {
const cols = [];
for (const name of columns) {
cols.push({
name,
type: data[model].fields[name].type,
});
}
return {
definition: {
metaData: {
resModel: model,
columns,
},
searchParams: {
domain: [],
context: {},
orderBy: [],
},
name: "List",
},
columns: cols,
};
}
export function getBasicListArchs() {
return {
"partner,false,list": getBasicListArch(),
"partner,false,search": /* xml */ `<search/>`,
"partner,false,form": /* xml */ `<form/>`,
};
}
export function getBasicData() {
return {
"documents.document": {
fields: {
name: { string: "Name", type: "char" },
raw: { string: "Data", type: "text" },
thumbnail: { string: "Thumbnail", type: "text" },
display_thumbnail: { string: "Thumbnail", type: "text" },
favorited_ids: { string: "Name", type: "many2many" },
is_favorited: { string: "Name", type: "boolean" },
mimetype: { string: "Mimetype", type: "char" },
partner_id: { string: "Related partner", type: "many2one", relation: "partner" },
owner_id: { string: "Owner", type: "many2one", relation: "partner" },
handler: {
string: "Handler",
type: "selection",
selection: [["spreadsheet", "Spreadsheet"]],
},
previous_attachment_ids: {
string: "History",
type: "many2many",
relation: "ir.attachment",
},
tag_ids: { string: "Tags", type: "many2many", relation: "documents.tag" },
folder_id: { string: "Workspaces", type: "many2one", relation: "documents.folder" },
res_model: { string: "Model (technical)", type: "char" },
available_rule_ids: {
string: "Rules",
type: "many2many",
relation: "documents.workflow.rule",
},
},
records: [
{
id: 1,
name: "My spreadsheet",
raw: "{}",
is_favorited: false,
folder_id: 1,
handler: "spreadsheet",
},
{
id: 2,
name: "",
raw: "{}",
is_favorited: true,
folder_id: 1,
handler: "spreadsheet",
},
],
},
"ir.model": {
fields: {
name: { string: "Model Name", type: "char" },
model: { string: "Model", type: "char" },
},
records: [
{
id: 37,
name: "Product",
model: "product",
},
{
id: 40,
name: "Partner",
model: "partner",
},
],
},
"documents.folder": {
fields: {
name: { string: "Name", type: "char" },
parent_folder_id: {
string: "Parent Workspace",
type: "many2one",
relation: "documents.folder",
},
description: { string: "Description", type: "text" },
},
records: [
{
id: 1,
name: "Workspace1",
description: "Workspace",
parent_folder_id: false,
},
],
},
"documents.tag": {
fields: {},
records: [],
get_tags: () => [],
},
"documents.workflow.rule": {
fields: {},
records: [],
},
"documents.share": {
fields: {},
records: [],
},
"spreadsheet.template": {
fields: {
name: { string: "Name", type: "char" },
data: { string: "Data", type: "binary" },
thumbnail: { string: "Thumbnail", type: "binary" },
display_thumbnail: { string: "Thumbnail", type: "text" },
},
records: [
{ id: 1, name: "Template 1", data: btoa("{}") },
{ id: 2, name: "Template 2", data: btoa("{}") },
],
},
"res.currency": {
fields: {
name: { string: "Code", type: "char" },
symbol: { string: "Symbol", type: "char" },
position: {
string: "Position",
type: "selection",
selection: [
["after", "A"],
["before", "B"],
],
},
decimal_places: { string: "decimal", type: "integer" },
},
records: [
{
id: 1,
name: "EUR",
symbol: "€",
position: "after",
decimal_places: 2,
},
{
id: 2,
name: "USD",
symbol: "$",
position: "before",
decimal_places: 2,
},
],
},
partner: {
fields: {
foo: {
string: "Foo",
type: "integer",
store: true,
searchable: true,
group_operator: "sum",
},
bar: {
string: "Bar",
type: "boolean",
store: true,
sortable: true,
searchable: true,
},
name: {
string: "name",
type: "char",
store: true,
sortable: true,
searchable: true,
},
date: {
string: "Date",
type: "date",
store: true,
sortable: true,
searchable: true,
},
create_date: {
string: "Creation Date",
type: "datetime",
store: true,
sortable: true,
},
active: { string: "Active", type: "bool", default: true, searchable: true },
product_id: {
string: "Product",
type: "many2one",
relation: "product",
store: true,
sortable: true,
searchable: true,
},
tag_ids: {
string: "Tags",
type: "many2many",
relation: "tag",
store: true,
sortable: true,
searchable: true,
},
probability: {
string: "Probability",
type: "float",
searchable: true,
store: true,
group_operator: "avg",
},
field_with_array_agg: {
string: "field_with_array_agg",
type: "integer",
searchable: true,
group_operator: "array_agg",
},
currency_id: {
string: "Currency",
type: "many2one",
relation: "res.currency",
store: true,
sortable: true,
searchable: true,
},
pognon: {
string: "Money!",
type: "monetary",
currency_field: "currency_id",
store: true,
sortable: true,
group_operator: "avg",
searchable: true,
},
partner_properties: {
string: "Properties",
type: "properties",
store: true,
sortable: true,
searchable: true,
},
jsonField: {
string: "Json Field",
type: "json",
store: true,
},
},
records: [
{
id: 1,
foo: 12,
bar: true,
date: "2016-04-14",
create_date: "2016-04-03 00:00:00",
product_id: 37,
probability: 10,
field_with_array_agg: 1,
tag_ids: [42, 67],
currency_id: 1,
pognon: 74.4,
},
{
id: 2,
foo: 1,
bar: true,
date: "2016-10-26",
create_date: "2014-04-03 00:05:32",
product_id: 41,
probability: 11,
field_with_array_agg: 2,
tag_ids: [42, 67],
currency_id: 2,
pognon: 74.8,
},
{
id: 3,
foo: 17,
bar: true,
date: "2016-12-15",
create_date: "2006-01-03 11:30:50",
product_id: 41,
probability: 95,
field_with_array_agg: 3,
tag_ids: [],
currency_id: 1,
pognon: 4,
},
{
id: 4,
foo: 2,
bar: false,
date: "2016-12-11",
create_date: "2016-12-10 21:59:59",
product_id: 41,
probability: 15,
field_with_array_agg: 4,
tag_ids: [42],
currency_id: 2,
pognon: 1000,
},
],
},
product: {
fields: {
name: { string: "Product Name", type: "char" },
active: { string: "Active", type: "bool", default: true },
},
records: [
{
id: 37,
display_name: "xphone",
},
{
id: 41,
display_name: "xpad",
},
],
},
tag: {
fields: {
name: { string: "Tag Name", type: "char" },
},
records: [
{
id: 42,
display_name: "isCool",
},
{
id: 67,
display_name: "Growing",
},
],
},
};
}

View file

@ -0,0 +1,52 @@
/** @odoo-module */
const { DateTime } = luxon;
import { Domain } from "@web/core/domain";
function getDateDomainBounds(domain) {
const startDateStr = domain[1][2];
const endDateStr = domain[2][2];
const isDateTime = startDateStr.includes(":");
if (isDateTime) {
const dateTimeFormat = "yyyy-MM-dd HH:mm:ss";
const start = DateTime.fromFormat(startDateStr, dateTimeFormat);
const end = DateTime.fromFormat(endDateStr, dateTimeFormat);
return { start, end };
}
const start = DateTime.fromISO(startDateStr);
const end = DateTime.fromISO(endDateStr);
const startIsIncluded = domain[1][1] === ">=";
const endIsIncluded = domain[2][1] === "<=";
return {
start: startIsIncluded ? start.startOf("day") : start.endOf("day"),
end: endIsIncluded ? end.endOf("day") : end.startOf("day"),
};
}
/**
* @param {object} assert
* @param {string} field
* @param {string} start
* @param {string} end
* @param {import("@web/core/domain").DomainRepr} domain
*/
export function assertDateDomainEqual(assert, field, start, end, domain) {
domain = new Domain(domain).toList();
assert.deepEqual(domain[0], "&");
assert.deepEqual(domain[1], [field, ">=", start]);
assert.deepEqual(domain[2], [field, "<=", end]);
}
/**
* @param {import("@web/core/domain").DomainRepr} domain
* @returns {number}
*/
export function getDateDomainDurationInDays(domain) {
domain = new Domain(domain).toList();
const bounds = getDateDomainBounds(domain);
const diff = bounds.end.diff(bounds.start, ["days"]);
return Math.round(diff.days);
}

View file

@ -0,0 +1,63 @@
/** @odoo-module */
import spreadsheet from "@spreadsheet/o_spreadsheet/o_spreadsheet_extended";
const { toCartesian } = spreadsheet.helpers;
/**
* Get the value of the given cell
*/
export function getCellValue(model, xc, sheetId = model.getters.getActiveSheetId()) {
const { col, row } = toCartesian(xc);
const cell = model.getters.getCell(sheetId, col, row);
if (!cell) {
return undefined;
}
return cell.evaluated.value;
}
/**
* Get the cell of the given xc
*/
export function getCell(model, xc, sheetId = model.getters.getActiveSheetId()) {
const { col, row } = toCartesian(xc);
return model.getters.getCell(sheetId, col, row);
}
/**
* Get the cells of the given sheet (or active sheet if not provided)
*/
export function getCells(model, sheetId = model.getters.getActiveSheetId()) {
return model.getters.getCells(sheetId);
}
/**
* Get the formula of the given xc
*/
export function getCellFormula(model, xc, sheetId = model.getters.getActiveSheetId()) {
const cell = getCell(model, xc, sheetId);
return cell && cell.isFormula() ? model.getters.getFormulaCellContent(sheetId, cell) : "";
}
/**
* Get the content of the given xc
*/
export function getCellContent(model, xc, sheetId = model.getters.getActiveSheetId()) {
const cell = getCell(model, xc, sheetId);
return cell ? model.getters.getCellText(cell, true) : "";
}
/**
* Get the list of the merges (["A1:A2"]) of the sheet
*/
export function getMerges(model, sheetId = model.getters.getActiveSheetId()) {
return model.exportData().sheets.find((sheet) => sheet.id === sheetId).merges;
}
/**
* Get the formatted value of the given xc
*/
export function getCellFormattedValue(model, xc, sheetId = model.getters.getActiveSheetId()) {
const cell = getCell(model, xc, sheetId);
return cell ? model.getters.getCellText(cell, false) : "";
}

View file

@ -0,0 +1,69 @@
/** @odoo-module */
import spreadsheet from "@spreadsheet/o_spreadsheet/o_spreadsheet_extended";
import { generateListDefinition } from "./data";
import { createModelWithDataSource, waitForDataSourcesLoaded } from "./model";
const uuidGenerator = new spreadsheet.helpers.UuidGenerator();
/** @typedef {import("@spreadsheet/o_spreadsheet/o_spreadsheet").Model} Model */
/**
* Insert a list in a spreadsheet model.
*
* @param {Model} model
* @param {Object} params
* @param {string} params.model
* @param {Array<string>} params.columns
* @param {number} [params.linesNumber]
* @param {[number, number]} [params.position]
* @param {string} [params.sheetId]
*/
export function insertListInSpreadsheet(model, params) {
const { definition, columns } = generateListDefinition(params.model, params.columns);
const [col, row] = params.position || [0, 0];
model.dispatch("INSERT_ODOO_LIST", {
sheetId: params.sheetId || model.getters.getActiveSheetId(),
definition,
linesNumber: params.linesNumber || 10,
columns,
id: model.getters.getNextListId(),
col,
row,
dataSourceId: uuidGenerator.uuidv4(),
});
}
/**
*
* @param {Object} params
* @param {string} [params.model]
* @param {Array<string>} [params.columns]
* @param {Object} [params.serverData]
* @param {function} [params.mockRPC]
* @param {number} [params.linesNumber]
* @param {[number, number]} [params.position]
* @param {string} [params.sheetId]
*
* @returns { Promise<{ model: Model, env: Object }>}
*/
export async function createSpreadsheetWithList(params = {}) {
const model = await createModelWithDataSource({
mockRPC: params.mockRPC,
serverData: params.serverData,
});
insertListInSpreadsheet(model, {
columns: params.columns || ["foo", "bar", "date", "product_id"],
model: params.model || "partner",
linesNumber: params.linesNumber,
position: params.position,
sheetId: params.sheetId,
});
const env = model.config.evalContext.env;
env.model = model;
await waitForDataSourcesLoaded(model);
return { model, env };
}

View file

@ -0,0 +1,31 @@
/** @odoo-module */
import { registry } from "@web/core/registry";
registry
.category("mock_server")
.add("res.currency/get_currencies_for_spreadsheet", function (route, args) {
const currencyNames = args.args[0];
const result = [];
for (let currencyName of currencyNames) {
const curr = this.models["res.currency"].records.find(
(curr) => curr.name === currencyName
);
result.push({
code: curr.name,
symbol: curr.symbol,
decimalPlaces: curr.decimal_places || 2,
position: curr.position || "after",
});
}
return result;
})
.add("res.currency/get_company_currency_for_spreadsheet", function (route, args) {
return {
code: "EUR",
symbol: "€",
position: "after",
decimalPlaces: 2,
};
});

View file

@ -0,0 +1,74 @@
/** @odoo-module */
import { ormService } from "@web/core/orm_service";
import { registry } from "@web/core/registry";
import { makeFakeLocalizationService } from "@web/../tests/helpers/mock_services";
import { makeTestEnv } from "@web/../tests/helpers/mock_env";
import { nextTick } from "@web/../tests/helpers/utils";
import spreadsheet from "@spreadsheet/o_spreadsheet/o_spreadsheet_extended";
import { DataSources } from "@spreadsheet/data_sources/data_sources";
import { getBasicServerData } from "./data";
const { Model } = spreadsheet;
/**
* @typedef {import("@spreadsheet/../tests/utils/data").ServerData} ServerData
*/
export function setupDataSourceEvaluation(model) {
model.config.dataSources.addEventListener("data-source-updated", () => {
const sheetId = model.getters.getActiveSheetId();
model.dispatch("EVALUATE_CELLS", { sheetId });
});
}
/**
* Create a spreadsheet model with a mocked server environnement
*
* @param {object} params
* @param {object} [params.spreadsheetData] Spreadsheet data to import
* @param {ServerData} [params.serverData] Data to be injected in the mock server
* @param {function} [params.mockRPC] Mock rpc function
*/
export async function createModelWithDataSource(params = {}) {
registry.category("services").add("orm", ormService, { force: true });
registry.category("services").add("localization", makeFakeLocalizationService(), { force: true });
const env = await makeTestEnv({
serverData: params.serverData || getBasicServerData(),
mockRPC: params.mockRPC,
});
const model = new Model(params.spreadsheetData, {
evalContext: { env },
//@ts-ignore
dataSources: new DataSources(env.services.orm),
});
setupDataSourceEvaluation(model);
await nextTick(); // initial async formulas loading
return model;
}
/**
* @param {Model} model
*/
export async function waitForDataSourcesLoaded(model) {
function readAllCellsValue() {
for (const sheetId of model.getters.getSheetIds()) {
const cells = model.getters.getCells(sheetId);
for (const cellId in cells) {
cells[cellId].evaluated.value;
}
}
}
// Read a first time in order to trigger the RPC
readAllCellsValue();
//@ts-ignore
await model.config.dataSources.waitForAllLoaded();
await nextTick();
// Read a second time to trigger the compute format (which could trigger a RPC for currency, in list)
readAllCellsValue();
await nextTick();
// Read a third time to trigger the RPC to get the correct currency
readAllCellsValue();
await nextTick();
}

View file

@ -0,0 +1,75 @@
/** @odoo-module */
import { PivotArchParser } from "@web/views/pivot/pivot_arch_parser";
import { nextTick } from "@web/../tests/helpers/utils";
import PivotDataSource from "@spreadsheet/pivot/pivot_data_source";
import { getBasicServerData } from "./data";
import { createModelWithDataSource, waitForDataSourcesLoaded } from "./model";
/** @typedef {import("@spreadsheet/o_spreadsheet/o_spreadsheet").Model} Model */
/**
* @param {Model} model
* @param {object} params
* @param {string} params.arch
* @param {[number, number]} [params.anchor]
*/
export async function insertPivotInSpreadsheet(model, params) {
const archInfo = new PivotArchParser().parse(params.arch);
const definition = {
metaData: {
colGroupBys: archInfo.colGroupBys,
rowGroupBys: archInfo.rowGroupBys,
activeMeasures: archInfo.activeMeasures,
resModel: params.resModel || "partner",
},
searchParams: {
domain: [],
context: {},
groupBy: [],
orderBy: [],
},
name: "Partner Pivot",
};
const dataSource = model.config.dataSources.create(PivotDataSource, definition);
await dataSource.load();
const { cols, rows, measures } = dataSource.getTableStructure().export();
const table = {
cols,
rows,
measures,
};
const [col, row] = params.anchor || [0, 0];
model.dispatch("INSERT_PIVOT", {
id: model.getters.getNextPivotId(),
sheetId: model.getters.getActiveSheetId(),
col,
row,
table,
dataSourceId: "pivotData1",
definition,
});
await nextTick();
}
/**
* @param {object} params
* @param {string} [params.arch]
* @param {object} [params.serverData]
* @param {function} [params.mockRPC]
* @returns {Promise<{ model: Model, env: object}>}
*/
export async function createSpreadsheetWithPivot(params = {}) {
const serverData = params.serverData || getBasicServerData();
const model = await createModelWithDataSource({
mockRPC: params.mockRPC,
serverData: params.serverData,
});
const arch = params.arch || serverData.views["partner,false,pivot"];
await insertPivotInSpreadsheet(model, { arch });
const env = model.config.evalContext.env;
env.model = model;
await waitForDataSourcesLoaded(model);
return { model, env };
}

View file

@ -0,0 +1,15 @@
/** @odoo-module */
import { nextTick } from "@web/../tests/helpers/utils";
import { createSpreadsheetWithPivot } from "./pivot";
import { insertListInSpreadsheet } from "./list";
export async function createSpreadsheetWithPivotAndList() {
const { model, env } = await createSpreadsheetWithPivot();
insertListInSpreadsheet(model, {
model: "partner",
columns: ["foo", "bar", "date", "product_id"],
});
await nextTick();
return { env, model };
}

View file

@ -0,0 +1,44 @@
/** @odoo-module */
import spreadsheet from "@spreadsheet/o_spreadsheet/o_spreadsheet_extended";
import { registerCleanup } from "@web/../tests/helpers/cleanup";
import { getFixture } from "@web/../tests/helpers/utils";
import { loadJS, templates } from "@web/core/assets";
const { App } = owl;
const { Spreadsheet } = spreadsheet;
const { getMenuChildren } = spreadsheet.helpers;
/** @typedef {import("@spreadsheet/o_spreadsheet/o_spreadsheet").Model} Model */
/**
* Mount o-spreadsheet component with the given spreadsheet model
* @param {Model} model
* @returns {Promise<HTMLElement>}
*/
export async function mountSpreadsheet(model) {
await loadJS("/web/static/lib/Chart/Chart.js");
const app = new App(Spreadsheet, {
props: { model },
templates: templates,
env: model.config.evalContext.env,
test: true,
});
registerCleanup(() => app.destroy());
const fixture = getFixture();
await app.mount(fixture);
return fixture;
}
export async function doMenuAction(registry, path, env) {
const root = path[0];
let node = registry.get(root);
for (const p of path.slice(1)) {
const children = getMenuChildren(node, env);
node = children.find((child) => child.id === p);
}
if (!node) {
throw new Error(`Cannot find menu with path "${path.join("/")}"`);
}
await node.action(env);
}