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 @@
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 38 38"><defs><style>.cls-1{fill:#429646;}.cls-1,.cls-2{fill-rule:evenodd;}.cls-2{fill:#fff;fill-opacity:0.93;}.cls-3,.cls-4{fill:none;stroke:#429646;}.cls-3{stroke-width:2px;}.cls-4{stroke-miterlimit:10;stroke-width:3px;}</style></defs><title>E1</title><polygon class="cls-1" points="34 2 34 34 4 34 34 2"/><path id="pdf-a" class="cls-2" d="M6,1H32a3,3,0,0,1,3,3V33a3,3,0,0,1-3,3H6a3,3,0,0,1-3-3V4A3,3,0,0,1,6,1Z"/><path class="cls-3" d="M7,2H31a3,3,0,0,1,3,3V32a3,3,0,0,1-3,3H7a3,3,0,0,1-3-3V5A3,3,0,0,1,7,2Z"/><line class="cls-4" x1="9.26" y1="26.6" x2="14.22" y2="26.6"/><line class="cls-4" x1="9.26" y1="21.28" x2="14.22" y2="21.28"/><line class="cls-4" x1="9.26" y1="15.95" x2="14.22" y2="15.95"/><line class="cls-4" x1="9.26" y1="10.62" x2="14.22" y2="10.62"/><line class="cls-4" x1="16.52" y1="26.6" x2="21.48" y2="26.6"/><line class="cls-4" x1="16.52" y1="21.28" x2="21.48" y2="21.28"/><line class="cls-4" x1="16.52" y1="15.95" x2="21.48" y2="15.95"/><line class="cls-4" x1="16.52" y1="10.62" x2="21.48" y2="10.62"/><line class="cls-4" x1="23.77" y1="26.6" x2="28.73" y2="26.6"/><line class="cls-4" x1="23.77" y1="21.28" x2="28.73" y2="21.28"/><line class="cls-4" x1="23.77" y1="15.95" x2="28.73" y2="15.95"/><line class="cls-4" x1="23.77" y1="10.62" x2="28.73" y2="10.62"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View file

@ -0,0 +1,363 @@
/*!
* chartjs-gauge.js v0.3.0
* https://github.com/haiiaaa/chartjs-gauge/
* (c) 2021 chartjs-gauge.js Contributors
* Released under the MIT License
*/
(function (global, factory) {
typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory(require('chart.js')) :
typeof define === 'function' && define.amd ? define(['chart.js'], factory) :
(global = global || self, global.Gauge = factory(global.Chart));
}(this, (function (Chart) { 'use strict';
Chart = Chart && Object.prototype.hasOwnProperty.call(Chart, 'default') ? Chart['default'] : Chart;
function _defineProperty(obj, key, value) {
if (key in obj) {
Object.defineProperty(obj, key, {
value: value,
enumerable: true,
configurable: true,
writable: true
});
} else {
obj[key] = value;
}
return obj;
}
function ownKeys(object, enumerableOnly) {
var keys = Object.keys(object);
if (Object.getOwnPropertySymbols) {
var symbols = Object.getOwnPropertySymbols(object);
if (enumerableOnly) symbols = symbols.filter(function (sym) {
return Object.getOwnPropertyDescriptor(object, sym).enumerable;
});
keys.push.apply(keys, symbols);
}
return keys;
}
function _objectSpread2(target) {
for (var i = 1; i < arguments.length; i++) {
var source = arguments[i] != null ? arguments[i] : {};
if (i % 2) {
ownKeys(Object(source), true).forEach(function (key) {
_defineProperty(target, key, source[key]);
});
} else if (Object.getOwnPropertyDescriptors) {
Object.defineProperties(target, Object.getOwnPropertyDescriptors(source));
} else {
ownKeys(Object(source)).forEach(function (key) {
Object.defineProperty(target, key, Object.getOwnPropertyDescriptor(source, key));
});
}
}
return target;
}
Chart.defaults._set('gauge', {
needle: {
// Needle circle radius as the percentage of the chart area width
radiusPercentage: 2,
// Needle width as the percentage of the chart area width
widthPercentage: 3.2,
// Needle length as the percentage of the interval between inner radius (0%) and outer radius (100%) of the arc
lengthPercentage: 80,
// The color of the needle
color: 'rgba(0, 0, 0, 1)'
},
valueLabel: {
// fontSize: undefined
display: true,
formatter: null,
color: 'rgba(255, 255, 255, 1)',
backgroundColor: 'rgba(0, 0, 0, 1)',
borderRadius: 5,
padding: {
top: 5,
right: 5,
bottom: 5,
left: 5
},
bottomMarginPercentage: 5
},
animation: {
duration: 1000,
animateRotate: true,
animateScale: false
},
// The percentage of the chart that we cut out of the middle.
cutoutPercentage: 50,
// The rotation of the chart, where the first data arc begins.
rotation: -Math.PI,
// The total circumference of the chart.
circumference: Math.PI,
legend: {
display: false
},
tooltips: {
enabled: false
}
});
var GaugeController = Chart.controllers.doughnut.extend({
getValuePercent: function getValuePercent(_ref, value) {
var minValue = _ref.minValue,
data = _ref.data;
var min = minValue || 0;
var max = [undefined, null].includes(data[data.length - 1]) ? 1 : data[data.length - 1];
var length = max - min;
var percent = (value - min) / length;
return percent;
},
getWidth: function getWidth(chart) {
return chart.chartArea.right - chart.chartArea.left;
},
getTranslation: function getTranslation(chart) {
var chartArea = chart.chartArea,
offsetX = chart.offsetX,
offsetY = chart.offsetY;
var centerX = (chartArea.left + chartArea.right) / 2;
var centerY = (chartArea.top + chartArea.bottom) / 2;
var dx = centerX + offsetX;
var dy = centerY + offsetY;
return {
dx: dx,
dy: dy
};
},
getAngle: function getAngle(_ref2) {
var chart = _ref2.chart,
valuePercent = _ref2.valuePercent;
var _chart$options = chart.options,
rotation = _chart$options.rotation,
circumference = _chart$options.circumference;
return rotation + circumference * valuePercent;
},
/* TODO set min padding, not applied until chart.update() (also chartArea must have been set)
setBottomPadding(chart) {
const needleRadius = this.getNeedleRadius(chart);
const padding = this.chart.config.options.layout.padding;
if (needleRadius > padding.bottom) {
padding.bottom = needleRadius;
return true;
}
return false;
},
*/
drawNeedle: function drawNeedle(ease) {
if (!this.chart.animating) {
// triggered when hovering
ease = 1;
}
var _this$chart = this.chart,
ctx = _this$chart.ctx,
config = _this$chart.config,
innerRadius = _this$chart.innerRadius,
outerRadius = _this$chart.outerRadius;
var dataset = config.data.datasets[this.index];
var _this$getMeta = this.getMeta(),
previous = _this$getMeta.previous;
var _config$options$needl = config.options.needle,
radiusPercentage = _config$options$needl.radiusPercentage,
widthPercentage = _config$options$needl.widthPercentage,
lengthPercentage = _config$options$needl.lengthPercentage,
color = _config$options$needl.color;
var width = this.getWidth(this.chart);
var needleRadius = radiusPercentage / 100 * width;
var needleWidth = widthPercentage / 100 * width;
var needleLength = lengthPercentage / 100 * (outerRadius - innerRadius) + innerRadius; // center
var _this$getTranslation = this.getTranslation(this.chart),
dx = _this$getTranslation.dx,
dy = _this$getTranslation.dy; // interpolate
var origin = this.getAngle({
chart: this.chart,
valuePercent: previous.valuePercent
}); // TODO valuePercent is in current.valuePercent also
var target = this.getAngle({
chart: this.chart,
valuePercent: this.getValuePercent(dataset, dataset.value)
});
var angle = origin + (target - origin) * ease; // draw
ctx.save();
ctx.translate(dx, dy);
ctx.rotate(angle);
ctx.fillStyle = color; // draw circle
ctx.beginPath();
ctx.ellipse(0, 0, needleRadius, needleRadius, 0, 0, 2 * Math.PI);
ctx.fill(); // draw needle
ctx.beginPath();
ctx.moveTo(0, needleWidth / 2);
ctx.lineTo(needleLength, 0);
ctx.lineTo(0, -needleWidth / 2);
ctx.fill();
ctx.restore();
},
drawValueLabel: function drawValueLabel(ease) {
// eslint-disable-line no-unused-vars
if (!this.chart.config.options.valueLabel.display) {
return;
}
var _this$chart2 = this.chart,
ctx = _this$chart2.ctx,
config = _this$chart2.config;
var defaultFontFamily = config.options.defaultFontFamily;
var dataset = config.data.datasets[this.index];
var _config$options$value = config.options.valueLabel,
formatter = _config$options$value.formatter,
fontSize = _config$options$value.fontSize,
color = _config$options$value.color,
backgroundColor = _config$options$value.backgroundColor,
borderRadius = _config$options$value.borderRadius,
padding = _config$options$value.padding,
bottomMarginPercentage = _config$options$value.bottomMarginPercentage;
var width = this.getWidth(this.chart);
var bottomMargin = bottomMarginPercentage / 100 * width;
var fmt = formatter || function (value) {
return value;
};
var valueText = fmt(dataset.value).toString();
ctx.textBaseline = 'middle';
ctx.textAlign = 'center';
if (fontSize) {
ctx.font = "".concat(fontSize, "px ").concat(defaultFontFamily);
} // const { width: textWidth, actualBoundingBoxAscent, actualBoundingBoxDescent } = ctx.measureText(valueText);
// const textHeight = actualBoundingBoxAscent + actualBoundingBoxDescent;
var _ctx$measureText = ctx.measureText(valueText),
textWidth = _ctx$measureText.width; // approximate height until browsers support advanced TextMetrics
var textHeight = Math.max(ctx.measureText('m').width, ctx.measureText("\uFF37").width);
var x = -(padding.left + textWidth / 2);
var y = -(padding.top + textHeight / 2);
var w = padding.left + textWidth + padding.right;
var h = padding.top + textHeight + padding.bottom; // center
var _this$getTranslation2 = this.getTranslation(this.chart),
dx = _this$getTranslation2.dx,
dy = _this$getTranslation2.dy; // add rotation
var rotation = this.chart.options.rotation % (Math.PI * 2.0);
dx += bottomMargin * Math.cos(rotation + Math.PI / 2);
dy += bottomMargin * Math.sin(rotation + Math.PI / 2); // draw
ctx.save();
ctx.translate(dx, dy); // draw background
ctx.beginPath();
Chart.helpers.canvas.roundedRect(ctx, x, y, w, h, borderRadius);
ctx.fillStyle = backgroundColor;
ctx.fill(); // draw value text
ctx.fillStyle = color || config.options.defaultFontColor;
var magicNumber = 0.075; // manual testing
ctx.fillText(valueText, 0, textHeight * magicNumber);
ctx.restore();
},
// overrides
update: function update(reset) {
var dataset = this.chart.config.data.datasets[this.index];
dataset.minValue = dataset.minValue || 0;
var meta = this.getMeta();
var initialValue = {
valuePercent: 0
}; // animations on will call update(reset) before update()
if (reset) {
meta.previous = null;
meta.current = initialValue;
} else {
dataset.data.sort(function (a, b) {
return a - b;
});
meta.previous = meta.current || initialValue;
meta.current = {
valuePercent: this.getValuePercent(dataset, dataset.value)
};
}
Chart.controllers.doughnut.prototype.update.call(this, reset);
},
updateElement: function updateElement(arc, index, reset) {
// TODO handle reset and options.animation
Chart.controllers.doughnut.prototype.updateElement.call(this, arc, index, reset);
var dataset = this.getDataset();
var data = dataset.data; // const { options } = this.chart.config;
// scale data
var previousValue = index === 0 ? dataset.minValue : data[index - 1];
var value = data[index];
var startAngle = this.getAngle({
chart: this.chart,
valuePercent: this.getValuePercent(dataset, previousValue)
});
var endAngle = this.getAngle({
chart: this.chart,
valuePercent: this.getValuePercent(dataset, value)
});
var circumference = endAngle - startAngle;
arc._model = _objectSpread2({}, arc._model, {
startAngle: startAngle,
endAngle: endAngle,
circumference: circumference
});
},
draw: function draw(ease) {
Chart.controllers.doughnut.prototype.draw.call(this, ease);
this.drawNeedle(ease);
this.drawValueLabel(ease);
}
});
/* eslint-disable max-len, func-names */
var polyfill = function polyfill() {
if (CanvasRenderingContext2D.prototype.ellipse === undefined) {
CanvasRenderingContext2D.prototype.ellipse = function (x, y, radiusX, radiusY, rotation, startAngle, endAngle, antiClockwise) {
this.save();
this.translate(x, y);
this.rotate(rotation);
this.scale(radiusX, radiusY);
this.arc(0, 0, 1, startAngle, endAngle, antiClockwise);
this.restore();
};
}
};
polyfill();
Chart.controllers.gauge = GaugeController;
Chart.Gauge = function (context, config) {
config.type = 'gauge';
return new Chart(context, config);
};
var index = Chart.Gauge;
return index;
})));

View file

@ -0,0 +1,66 @@
/** @odoo-module */
import { DataSources } from "@spreadsheet/data_sources/data_sources";
import { migrate } from "@spreadsheet/o_spreadsheet/migration";
import { download } from "@web/core/network/download";
import { registry } from "@web/core/registry";
import spreadsheet from "../o_spreadsheet/o_spreadsheet_extended";
import { _t } from "@web/core/l10n/translation";
const { Model } = spreadsheet;
async function downloadSpreadsheet(env, action) {
let { orm, name, data, stateUpdateMessages, xlsxData } = action.params;
if (!xlsxData) {
const dataSources = new DataSources(orm);
const model = new Model(migrate(data), { dataSources }, stateUpdateMessages);
await waitForDataLoaded(model);
xlsxData = model.exportXLSX();
}
await download({
url: "/spreadsheet/xlsx",
data: {
zip_name: `${name}.xlsx`,
files: new Blob([JSON.stringify(xlsxData.files)], { type: "application/json" }),
},
});
}
/**
* Ensure that the spreadsheet does not contains cells that are in loading state
* @param {Model} model
* @returns {Promise<void>}
*/
export async function waitForDataLoaded(model) {
const dataSources = model.config.dataSources;
return new Promise((resolve, reject) => {
function check() {
model.dispatch("EVALUATE_CELLS");
if (isLoaded(model)) {
dataSources.removeEventListener("data-source-updated", check);
resolve();
}
}
dataSources.addEventListener("data-source-updated", check);
check();
});
}
function isLoaded(model) {
for (const sheetId of model.getters.getSheetIds()) {
for (const cell of Object.values(model.getters.getCells(sheetId))) {
if (
cell.evaluated &&
cell.evaluated.type === "error" &&
cell.evaluated.error.message === _t("Data is loading")
) {
return false;
}
}
}
return true;
}
registry
.category("actions")
.add("action_download_spreadsheet", downloadSpreadsheet, { force: true });

View file

@ -0,0 +1,25 @@
/** @odoo-module */
import { _lt } from "@web/core/l10n/translation";
export const FILTER_DATE_OPTION = {
quarter: ["first_quarter", "second_quarter", "third_quarter", "fourth_quarter"],
year: ["this_year", "last_year", "antepenultimate_year"],
};
// TODO Remove this mapping, We should only need number > description to avoid multiple conversions
// This would require a migration though
export const monthsOptions = [
{ id: "january", description: _lt("January") },
{ id: "february", description: _lt("February") },
{ id: "march", description: _lt("March") },
{ id: "april", description: _lt("April") },
{ id: "may", description: _lt("May") },
{ id: "june", description: _lt("June") },
{ id: "july", description: _lt("July") },
{ id: "august", description: _lt("August") },
{ id: "september", description: _lt("September") },
{ id: "october", description: _lt("October") },
{ id: "november", description: _lt("November") },
{ id: "december", description: _lt("December") },
];

View file

@ -0,0 +1,48 @@
/** @odoo-module **/
import { registry } from "@web/core/registry";
import { getBundle, loadBundle } from "@web/core/assets";
import { sprintf } from "@web/core/utils/strings";
const actionRegistry = registry.category("actions");
/**
*
* @param {object} env
* @param {string} actionName
* @param {function} actionLazyLoader
*/
export async function loadSpreadsheetAction(env, actionName, actionLazyLoader) {
const desc = await getBundle("spreadsheet.o_spreadsheet");
await loadBundle(desc);
if (actionRegistry.get(actionName) === actionLazyLoader) {
// At this point, the real spreadsheet client action should be loaded and have
// replaced this function in the action registry. If it's not the case,
// it probably means that there was a crash in the bundle (e.g. syntax
// error). In this case, this action will remain in the registry, which
// will lead to an infinite loop. To prevent that, we push another action
// in the registry.
actionRegistry.add(
actionName,
() => {
const msg = sprintf(env._t("%s couldn't be loaded"), actionName);
env.services.notification.add(msg, { type: "danger" });
},
{ force: true }
);
}
}
const loadSpreadsheetDownloadAction = async (env, context) => {
await loadSpreadsheetAction(env, "action_download_spreadsheet", loadSpreadsheetDownloadAction);
return {
...context,
target: "current",
tag: "action_download_spreadsheet",
type: "ir.actions.client",
};
};
actionRegistry.add("action_download_spreadsheet", loadSpreadsheetDownloadAction);

View file

@ -0,0 +1,47 @@
/** @odoo-module */
import { OdooViewsDataSource } from "@spreadsheet/data_sources/odoo_views_data_source";
import { _t } from "@web/core/l10n/translation";
import { GraphModel as ChartModel} from "@web/views/graph/graph_model";
export default class ChartDataSource extends OdooViewsDataSource {
/**
* @override
* @param {Object} services Services (see DataSource)
*/
constructor(services, params) {
super(services, params);
}
/**
* @protected
*/
async _load() {
await super._load();
const metaData = {
fieldAttrs: {},
...this._metaData,
};
this._model = new ChartModel(
{
_t,
},
metaData,
{
orm: this._orm,
}
);
await this._model.load(this._searchParams);
}
getData() {
if (!this.isReady()) {
this.load();
return { datasets: [], labels: [] };
}
if (!this._isValid) {
return { datasets: [], labels: [] };
}
return this._model.data;
}
}

View file

@ -0,0 +1,16 @@
/** @odoo-module */
import spreadsheet from "@spreadsheet/o_spreadsheet/o_spreadsheet_extended";
const { chartComponentRegistry } = spreadsheet.registries;
const { ChartJsComponent } = spreadsheet.components;
chartComponentRegistry.add("odoo_bar", ChartJsComponent);
chartComponentRegistry.add("odoo_line", ChartJsComponent);
chartComponentRegistry.add("odoo_pie", ChartJsComponent);
import OdooChartCorePlugin from "./plugins/odoo_chart_core_plugin";
import ChartOdooMenuPlugin from "./plugins/chart_odoo_menu_plugin";
import OdooChartUIPlugin from "./plugins/odoo_chart_ui_plugin";
export { OdooChartCorePlugin, ChartOdooMenuPlugin, OdooChartUIPlugin };

View file

@ -0,0 +1,100 @@
/** @odoo-module */
import spreadsheet from "@spreadsheet/o_spreadsheet/o_spreadsheet_extended";
import { _t } from "@web/core/l10n/translation";
import { OdooChart } from "./odoo_chart";
const { chartRegistry } = spreadsheet.registries;
const { getDefaultChartJsRuntime, chartFontColor, ChartColors } = spreadsheet.helpers;
export class OdooBarChart extends OdooChart {
constructor(definition, sheetId, getters) {
super(definition, sheetId, getters);
this.verticalAxisPosition = definition.verticalAxisPosition;
this.stacked = definition.stacked;
}
getDefinition() {
return {
...super.getDefinition(),
verticalAxisPosition: this.verticalAxisPosition,
stacked: this.stacked,
};
}
}
chartRegistry.add("odoo_bar", {
match: (type) => type === "odoo_bar",
createChart: (definition, sheetId, getters) => new OdooBarChart(definition, sheetId, getters),
getChartRuntime: createOdooChartRuntime,
validateChartDefinition: (validator, definition) =>
OdooBarChart.validateChartDefinition(validator, definition),
transformDefinition: (definition) => OdooBarChart.transformDefinition(definition),
getChartDefinitionFromContextCreation: () => OdooBarChart.getDefinitionFromContextCreation(),
name: _t("Bar"),
});
function createOdooChartRuntime(chart, getters) {
const background = chart.background || "#FFFFFF";
const { datasets, labels } = chart.dataSource.getData();
const chartJsConfig = getBarConfiguration(chart, labels);
const colors = new ChartColors();
for (const { label, data } of datasets) {
const color = colors.next();
const dataset = {
label,
data,
borderColor: color,
backgroundColor: color,
};
chartJsConfig.data.datasets.push(dataset);
}
return { background, chartJsConfig };
}
function getBarConfiguration(chart, labels) {
const fontColor = chartFontColor(chart.background);
const config = getDefaultChartJsRuntime(chart, labels, fontColor);
config.type = chart.type.replace("odoo_", "");
const legend = {
...config.options.legend,
display: chart.legendPosition !== "none",
labels: { fontColor },
};
legend.position = chart.legendPosition;
config.options.legend = legend;
config.options.layout = {
padding: { left: 20, right: 20, top: chart.title ? 10 : 25, bottom: 10 },
};
config.options.scales = {
xAxes: [
{
ticks: {
// x axis configuration
maxRotation: 60,
minRotation: 15,
padding: 5,
labelOffset: 2,
fontColor,
},
},
],
yAxes: [
{
position: chart.verticalAxisPosition,
ticks: {
fontColor,
// y axis configuration
beginAtZero: true, // the origin of the y axis is always zero
},
},
],
};
if (chart.stacked) {
config.options.scales.xAxes[0].stacked = true;
config.options.scales.yAxes[0].stacked = true;
}
return config;
}

View file

@ -0,0 +1,133 @@
/** @odoo-module */
import spreadsheet from "@spreadsheet/o_spreadsheet/o_spreadsheet_extended";
import ChartDataSource from "../data_source/chart_data_source";
const { AbstractChart, CommandResult } = spreadsheet;
/**
* @typedef {import("@web/search/search_model").SearchParams} SearchParams
*
* @typedef MetaData
* @property {Array<Object>} domains
* @property {Array<string>} groupBy
* @property {string} measure
* @property {string} mode
* @property {string} [order]
* @property {string} resModel
* @property {boolean} stacked
*
* @typedef OdooChartDefinition
* @property {string} type
* @property {MetaData} metaData
* @property {SearchParams} searchParams
* @property {string} title
* @property {string} background
* @property {string} legendPosition
*
* @typedef OdooChartDefinitionDataSource
* @property {MetaData} metaData
* @property {SearchParams} searchParams
*
*/
export class OdooChart extends AbstractChart {
/**
* @param {OdooChartDefinition} definition
* @param {string} sheetId
* @param {Object} getters
*/
constructor(definition, sheetId, getters) {
super(definition, sheetId, getters);
this.type = definition.type;
this.metaData = definition.metaData;
this.searchParams = definition.searchParams;
this.legendPosition = definition.legendPosition;
this.background = definition.background;
this.dataSource = undefined;
}
static transformDefinition(definition) {
return definition;
}
static validateChartDefinition(validator, definition) {
return CommandResult.Success;
}
static getDefinitionFromContextCreation() {
throw new Error("It's not possible to convert an Odoo chart to a native chart");
}
/**
* @returns {OdooChartDefinitionDataSource}
*/
getDefinitionForDataSource() {
return {
metaData: {
...this.metaData,
mode: this.type.replace("odoo_", ""),
},
searchParams: this.searchParams,
};
}
/**
* @returns {OdooChartDefinition}
*/
getDefinition() {
return {
//@ts-ignore Defined in the parent class
title: this.title,
background: this.background,
legendPosition: this.legendPosition,
metaData: this.metaData,
searchParams: this.searchParams,
type: this.type,
};
}
getDefinitionForExcel() {
// Export not supported
return undefined;
}
/**
* @returns {OdooChart}
*/
updateRanges() {
// No range on this graph
return this;
}
/**
* @returns {OdooChart}
*/
copyForSheetId() {
return this;
}
/**
* @returns {OdooChart}
*/
copyInSheetId() {
return this;
}
getContextCreation() {
return {};
}
getSheetIdsUsedInChartRanges() {
return [];
}
setDataSource(dataSource) {
if (dataSource instanceof ChartDataSource) {
this.dataSource = dataSource;
}
else {
throw new Error("Only ChartDataSources can be added.");
}
}
}

View file

@ -0,0 +1,135 @@
/** @odoo-module */
import spreadsheet from "@spreadsheet/o_spreadsheet/o_spreadsheet_extended";
import { _t } from "@web/core/l10n/translation";
import { OdooChart } from "./odoo_chart";
import { LINE_FILL_TRANSPARENCY } from "@web/views/graph/graph_renderer";
const { chartRegistry } = spreadsheet.registries;
const {
getDefaultChartJsRuntime,
chartFontColor,
ChartColors,
getFillingMode,
colorToRGBA,
rgbaToHex,
} = spreadsheet.helpers;
export class OdooLineChart extends OdooChart {
constructor(definition, sheetId, getters) {
super(definition, sheetId, getters);
this.verticalAxisPosition = definition.verticalAxisPosition;
this.stacked = definition.stacked;
this.cumulative = definition.cumulative;
}
getDefinition() {
return {
...super.getDefinition(),
verticalAxisPosition: this.verticalAxisPosition,
stacked: this.stacked,
cumulative: this.cumulative,
};
}
}
chartRegistry.add("odoo_line", {
match: (type) => type === "odoo_line",
createChart: (definition, sheetId, getters) => new OdooLineChart(definition, sheetId, getters),
getChartRuntime: createOdooChartRuntime,
validateChartDefinition: (validator, definition) =>
OdooLineChart.validateChartDefinition(validator, definition),
transformDefinition: (definition) => OdooLineChart.transformDefinition(definition),
getChartDefinitionFromContextCreation: () => OdooLineChart.getDefinitionFromContextCreation(),
name: _t("Line"),
});
function createOdooChartRuntime(chart, getters) {
const background = chart.background || "#FFFFFF";
const { datasets, labels } = chart.dataSource.getData();
const chartJsConfig = getLineConfiguration(chart, labels);
const colors = new ChartColors();
for (let [index, { label, data }] of datasets.entries()) {
const color = colors.next();
const backgroundRGBA = colorToRGBA(color);
if (chart.stacked) {
// use the transparency of Odoo to keep consistency
backgroundRGBA.a = LINE_FILL_TRANSPARENCY;
}
if (chart.cumulative) {
let accumulator = 0;
data = data.map((value) => {
accumulator += value;
return accumulator;
});
}
const backgroundColor = rgbaToHex(backgroundRGBA);
const dataset = {
label,
data,
lineTension: 0,
borderColor: color,
backgroundColor,
pointBackgroundColor: color,
fill: chart.stacked ? getFillingMode(index) : false,
};
chartJsConfig.data.datasets.push(dataset);
}
return { background, chartJsConfig };
}
function getLineConfiguration(chart, labels) {
const fontColor = chartFontColor(chart.background);
const config = getDefaultChartJsRuntime(chart, labels, fontColor);
config.type = chart.type.replace("odoo_", "");
const legend = {
...config.options.legend,
display: chart.legendPosition !== "none",
labels: {
fontColor,
generateLabels(chart) {
const { data } = chart;
const labels = window.Chart.defaults.global.legend.labels.generateLabels(chart);
for (const [index, label] of labels.entries()) {
label.fillStyle = data.datasets[index].borderColor;
}
return labels;
},
},
};
legend.position = chart.legendPosition;
config.options.legend = legend;
config.options.layout = {
padding: { left: 20, right: 20, top: chart.title ? 10 : 25, bottom: 10 },
};
config.options.scales = {
xAxes: [
{
ticks: {
// x axis configuration
maxRotation: 60,
minRotation: 15,
padding: 5,
labelOffset: 2,
fontColor,
},
},
],
yAxes: [
{
position: chart.verticalAxisPosition,
ticks: {
fontColor,
// y axis configuration
beginAtZero: true, // the origin of the y axis is always zero
},
},
],
};
if (chart.stacked) {
config.options.scales.yAxes[0].stacked = true;
}
return config;
}

View file

@ -0,0 +1,72 @@
/** @odoo-module */
import spreadsheet from "@spreadsheet/o_spreadsheet/o_spreadsheet_extended";
import { _t } from "@web/core/l10n/translation";
import { OdooChart } from "./odoo_chart";
const { chartRegistry } = spreadsheet.registries;
const { getDefaultChartJsRuntime, chartFontColor, ChartColors } = spreadsheet.helpers;
chartRegistry.add("odoo_pie", {
match: (type) => type === "odoo_pie",
createChart: (definition, sheetId, getters) => new OdooChart(definition, sheetId, getters),
getChartRuntime: createOdooChartRuntime,
validateChartDefinition: (validator, definition) =>
OdooChart.validateChartDefinition(validator, definition),
transformDefinition: (definition) => OdooChart.transformDefinition(definition),
getChartDefinitionFromContextCreation: () => OdooChart.getDefinitionFromContextCreation(),
name: _t("Pie"),
});
function createOdooChartRuntime(chart, getters) {
const background = chart.background || "#FFFFFF";
const { datasets, labels } = chart.dataSource.getData();
const chartJsConfig = getPieConfiguration(chart, labels);
const colors = new ChartColors();
for (const { label, data } of datasets) {
const backgroundColor = getPieColors(colors, datasets);
const dataset = {
label,
data,
borderColor: "#FFFFFF",
backgroundColor,
};
chartJsConfig.data.datasets.push(dataset);
}
return { background, chartJsConfig };
}
function getPieConfiguration(chart, labels) {
const fontColor = chartFontColor(chart.background);
const config = getDefaultChartJsRuntime(chart, labels, fontColor);
config.type = chart.type.replace("odoo_", "");
const legend = {
...config.options.legend,
display: chart.legendPosition !== "none",
labels: { fontColor },
};
legend.position = chart.legendPosition;
config.options.legend = legend;
config.options.layout = {
padding: { left: 20, right: 20, top: chart.title ? 10 : 25, bottom: 10 },
};
config.options.tooltips = {
callbacks: {
title: function (tooltipItems, data) {
return data.datasets[tooltipItems[0].datasetIndex].label;
},
},
};
return config;
}
function getPieColors(colors, dataSetsValues) {
const pieColors = [];
const maxLength = Math.max(...dataSetsValues.map((ds) => ds.data.length));
for (let i = 0; i <= maxLength; i++) {
pieColors.push(colors.next());
}
return pieColors;
}

View file

@ -0,0 +1,28 @@
/** @odoo-module **/
import { patch } from "@web/core/utils/patch";
import spreadsheet from "@spreadsheet/o_spreadsheet/o_spreadsheet_extended";
import { useService } from "@web/core/utils/hooks";
patch(spreadsheet.components.ChartFigure.prototype, "spreadsheet.ChartFigure", {
setup() {
this._super();
this.menuService = useService("menu");
this.actionService = useService("action");
},
async navigateToOdooMenu() {
const menu = this.env.model.getters.getChartOdooMenu(this.props.figure.id);
if (!menu) {
throw new Error(`Cannot find any menu associated with the chart`);
}
await this.actionService.doAction(menu.actionID);
},
get hasOdooMenu() {
return this.env.model.getters.getChartOdooMenu(this.props.figure.id) !== undefined;
},
async onClick() {
if (this.env.isDashboard() && this.hasOdooMenu) {
this.navigateToOdooMenu();
}
},
});

View file

@ -0,0 +1,9 @@
.o-chart-menu {
.o-chart-menu-item {
padding-left: 7px;
}
.o-chart-external-link {
font-size: 15px;
}
}

View file

@ -0,0 +1,16 @@
<odoo>
<div t-name="spreadsheet.ChartFigure" t-inherit="o-spreadsheet-ChartFigure" t-inherit-mode="extension" owl="1">
<xpath expr="//div[hasclass('o-chart-menu-item')]" position="before">
<div
t-if="hasOdooMenu and !env.isDashboard()"
class="o-chart-menu-item o-chart-external-link"
t-on-click="navigateToOdooMenu">
<span class="fa fa-external-link" />
</div>
</xpath>
<xpath expr="//div[hasclass('o-chart-container')]" position="attributes">
<attribute name="t-on-click">() => this.onClick()</attribute>
<attribute name="t-att-role">env.isDashboard() and hasOdooMenu ? "button" : ""</attribute>
</xpath>
</div>
</odoo>

View file

@ -0,0 +1,83 @@
/** @odoo-module */
import spreadsheet from "@spreadsheet/o_spreadsheet/o_spreadsheet_extended";
import { omit } from "@web/core/utils/objects";
const { coreTypes, helpers } = spreadsheet;
const { deepEquals } = helpers;
/** Plugin that link charts with Odoo menus. It can contain either the Id of the odoo menu, or its xml id. */
export default class ChartOdooMenuPlugin extends spreadsheet.CorePlugin {
constructor() {
super(...arguments);
this.odooMenuReference = {};
}
/**
* Handle a spreadsheet command
* @param {Object} cmd Command
*/
handle(cmd) {
switch (cmd.type) {
case "LINK_ODOO_MENU_TO_CHART":
this.history.update("odooMenuReference", cmd.chartId, cmd.odooMenuId);
break;
case "DELETE_FIGURE":
this.history.update("odooMenuReference", cmd.id, undefined);
break;
case "DUPLICATE_SHEET":
this.updateOnDuplicateSheet(cmd.sheetId, cmd.sheetIdTo);
break;
}
}
updateOnDuplicateSheet(sheetIdFrom, sheetIdTo) {
for (const oldChartId of this.getters.getChartIds(sheetIdFrom)) {
if (!this.odooMenuReference[oldChartId]) {
continue;
}
const oldChartDefinition = this.getters.getChartDefinition(oldChartId);
const oldFigure = this.getters.getFigure(sheetIdFrom, oldChartId);
const newChartId = this.getters.getChartIds(sheetIdTo).find((newChartId) => {
const newChartDefinition = this.getters.getChartDefinition(newChartId);
const newFigure = this.getters.getFigure(sheetIdTo, newChartId);
return (
deepEquals(oldChartDefinition, newChartDefinition) &&
deepEquals(omit(newFigure, "id"), omit(oldFigure, "id")) // compare size and position
);
});
if (newChartId) {
this.history.update(
"odooMenuReference",
newChartId,
this.odooMenuReference[oldChartId]
);
}
}
}
/**
* Get odoo menu linked to the chart
*
* @param {string} chartId
* @returns {object | undefined}
*/
getChartOdooMenu(chartId) {
const menuId = this.odooMenuReference[chartId];
return menuId ? this.getters.getIrMenu(menuId) : undefined;
}
import(data) {
if (data.chartOdooMenusReferences) {
this.odooMenuReference = data.chartOdooMenusReferences;
}
}
export(data) {
data.chartOdooMenusReferences = this.odooMenuReference;
}
}
ChartOdooMenuPlugin.modes = ["normal", "headless"];
ChartOdooMenuPlugin.getters = ["getChartOdooMenu"];
coreTypes.add("LINK_ODOO_MENU_TO_CHART");

View file

@ -0,0 +1,258 @@
/** @odoo-module */
import spreadsheet from "../../o_spreadsheet/o_spreadsheet_extended";
import ChartDataSource from "../data_source/chart_data_source";
import { globalFiltersFieldMatchers } from "@spreadsheet/global_filters/plugins/global_filters_core_plugin";
import { sprintf } from "@web/core/utils/strings";
import { _t } from "@web/core/l10n/translation";
import { checkFilterFieldMatching } from "@spreadsheet/global_filters/helpers";
import CommandResult from "../../o_spreadsheet/cancelled_reason";
const { CorePlugin } = spreadsheet;
/**
* @typedef {Object} Chart
* @property {string} dataSourceId
* @property {Object} fieldMatching
*
* @typedef {import("@spreadsheet/global_filters/plugins/global_filters_core_plugin").FieldMatching} FieldMatching
*/
export default class OdooChartCorePlugin extends CorePlugin {
constructor(getters, history, range, dispatch, config, uuidGenerator) {
super(getters, history, range, dispatch, config, uuidGenerator);
this.dataSources = config.dataSources;
/** @type {Object.<string, Chart>} */
this.charts = {};
globalFiltersFieldMatchers["chart"] = {
geIds: () => this.getters.getOdooChartIds(),
getDisplayName: (chartId) => this.getters.getOdooChartDisplayName(chartId),
getTag: async (chartId) => {
const model = await this.getChartDataSource(chartId).getModelLabel();
return sprintf(_t("Chart - %s"), model);
},
getFieldMatching: (chartId, filterId) =>
this.getOdooChartFieldMatching(chartId, filterId),
waitForReady: () => this.getOdooChartsWaitForReady(),
getModel: (chartId) =>
this.getters.getChart(chartId).getDefinitionForDataSource().metaData.resModel,
getFields: (chartId) => this.getChartDataSource(chartId).getFields(),
};
}
allowDispatch(cmd) {
switch (cmd.type) {
case "ADD_GLOBAL_FILTER":
case "EDIT_GLOBAL_FILTER":
if (cmd.chart) {
return checkFilterFieldMatching(cmd.chart);
}
}
return CommandResult.Success;
}
/**
* Handle a spreadsheet command
*
* @param {Object} cmd Command
*/
handle(cmd) {
switch (cmd.type) {
case "CREATE_CHART": {
switch (cmd.definition.type) {
case "odoo_pie":
case "odoo_bar":
case "odoo_line":
this._addOdooChart(cmd.id);
break;
}
break;
}
case "UPDATE_CHART": {
switch (cmd.definition.type) {
case "odoo_pie":
case "odoo_bar":
case "odoo_line":
this._setChartDataSource(cmd.id);
break;
}
break;
}
case "DELETE_FIGURE": {
const charts = { ...this.charts };
delete charts[cmd.id];
this.history.update("charts", charts);
break;
}
case "REMOVE_GLOBAL_FILTER":
this._onFilterDeletion(cmd.id);
break;
case "ADD_GLOBAL_FILTER":
case "EDIT_GLOBAL_FILTER":
if (cmd.chart) {
this._setOdooChartFieldMatching(cmd.filter.id, cmd.chart);
}
break;
}
}
// -------------------------------------------------------------------------
// Getters
// -------------------------------------------------------------------------
/**
* Get all the odoo chart ids
* @returns {Array<string>}
*/
getOdooChartIds() {
const ids = [];
for (const sheetId of this.getters.getSheetIds()) {
ids.push(
...this.getters
.getChartIds(sheetId)
.filter((id) => this.getters.getChartType(id).startsWith("odoo_"))
);
}
return ids;
}
/**
* @param {string} chartId
* @returns {string}
*/
getChartFieldMatch(chartId) {
return this.charts[chartId].fieldMatching;
}
/**
* @param {string} id
* @returns {ChartDataSource|undefined}
*/
getChartDataSource(id) {
const dataSourceId = this.charts[id].dataSourceId;
return this.dataSources.get(dataSourceId);
}
/**
*
* @param {string} chartId
* @returns {string}
*/
getOdooChartDisplayName(chartId) {
return this.getters.getChart(chartId).title;
}
/**
* Import the pivots
*
* @param {Object} data
*/
import(data) {
for (const sheet of data.sheets) {
if (sheet.figures) {
for (const figure of sheet.figures) {
if (figure.tag === "chart" && figure.data.type.startsWith("odoo_")) {
this._addOdooChart(figure.id, figure.data.fieldMatching);
}
}
}
}
}
/**
* Export the pivots
*
* @param {Object} data
*/
export(data) {
for (const sheet of data.sheets) {
if (sheet.figures) {
for (const figure of sheet.figures) {
if (figure.tag === "chart" && figure.data.type.startsWith("odoo_")) {
figure.data.fieldMatching = this.getChartFieldMatch(figure.id);
}
}
}
}
}
// -------------------------------------------------------------------------
// Private
// -------------------------------------------------------------------------
/**
*
* @return {Promise[]}
*/
getOdooChartsWaitForReady() {
return this.getOdooChartIds().map((chartId) =>
this.getChartDataSource(chartId).loadMetadata()
);
}
/**
* Get the current pivotFieldMatching of a chart
*
* @param {string} chartId
* @param {string} filterId
*/
getOdooChartFieldMatching(chartId, filterId) {
return this.charts[chartId].fieldMatching[filterId];
}
/**
* Sets the current pivotFieldMatching of a chart
*
* @param {string} filterId
* @param {Record<string,FieldMatching>} chartFieldMatches
*/
_setOdooChartFieldMatching(filterId, chartFieldMatches) {
const charts = { ...this.charts };
for (const [chartId, fieldMatch] of Object.entries(chartFieldMatches)) {
charts[chartId].fieldMatching[filterId] = fieldMatch;
}
this.history.update("charts", charts);
}
_onFilterDeletion(filterId) {
const charts = { ...this.charts };
for (const chartId in charts) {
this.history.update("charts", chartId, "fieldMatching", filterId, undefined);
}
}
/**
* @param {string} chartId
* @param {string} dataSourceId
*/
_addOdooChart(chartId, fieldMatching = {}) {
const dataSourceId = this.uuidGenerator.uuidv4();
const charts = { ...this.charts };
charts[chartId] = {
dataSourceId,
fieldMatching,
};
const definition = this.getters.getChart(chartId).getDefinitionForDataSource();
if (!this.dataSources.contains(dataSourceId)) {
this.dataSources.add(dataSourceId, ChartDataSource, definition);
}
this.history.update("charts", charts);
this._setChartDataSource(chartId);
}
/**
* Sets the catasource on the corresponding chart
* @param {string} chartId
*/
_setChartDataSource(chartId) {
const chart = this.getters.getChart(chartId);
chart.setDataSource(this.getters.getChartDataSource(chartId));
}
}
OdooChartCorePlugin.getters = [
"getChartDataSource",
"getOdooChartIds",
"getChartFieldMatch",
"getOdooChartDisplayName",
"getOdooChartFieldMatching",
];

View file

@ -0,0 +1,85 @@
/** @odoo-module */
import spreadsheet from "../../o_spreadsheet/o_spreadsheet_extended";
import { Domain } from "@web/core/domain";
const { UIPlugin } = spreadsheet;
export default class OdooChartUIPlugin extends UIPlugin {
beforeHandle(cmd) {
switch (cmd.type) {
case "START":
// make sure the domains are correctly set before
// any evaluation
this._addDomains();
break;
}
}
/**
* Handle a spreadsheet command
*
* @param {Object} cmd Command
*/
handle(cmd) {
switch (cmd.type) {
case "ADD_GLOBAL_FILTER":
case "EDIT_GLOBAL_FILTER":
case "REMOVE_GLOBAL_FILTER":
case "SET_GLOBAL_FILTER_VALUE":
case "CLEAR_GLOBAL_FILTER_VALUE":
this._addDomains();
break;
case "UNDO":
case "REDO":
if (
cmd.commands.find((command) =>
[
"ADD_GLOBAL_FILTER",
"EDIT_GLOBAL_FILTER",
"REMOVE_GLOBAL_FILTER",
].includes(command.type)
)
) {
this._addDomains();
}
break;
}
}
// -------------------------------------------------------------------------
// Private
// -------------------------------------------------------------------------
/**
* Add an additional domain to a chart
*
* @private
*
* @param {string} chartId chart id
*/
_addDomain(chartId) {
const domainList = [];
for (const [filterId, fieldMatch] of Object.entries(
this.getters.getChartFieldMatch(chartId)
)) {
domainList.push(this.getters.getGlobalFilterDomain(filterId, fieldMatch));
}
const domain = Domain.combine(domainList, "AND").toString();
this.getters.getChartDataSource(chartId).addDomain(domain);
}
/**
* Add an additional domain to all chart
*
* @private
*
*/
_addDomains() {
for (const chartId of this.getters.getOdooChartIds()) {
this._addDomain(chartId);
}
}
}
OdooChartUIPlugin.getters = [];

View file

@ -0,0 +1,21 @@
/** @odoo-module */
import spreadsheet from "@spreadsheet/o_spreadsheet/o_spreadsheet_extended";
const { inverseCommandRegistry, otRegistry } = spreadsheet.registries;
function identity(cmd) {
return [cmd];
}
otRegistry.addTransformation(
"DELETE_FIGURE",
["LINK_ODOO_MENU_TO_CHART"],
(toTransform, executed) => {
if (executed.id === toTransform.chartId) {
return undefined;
}
return toTransform;
}
);
inverseCommandRegistry.add("LINK_ODOO_MENU_TO_CHART", identity);

View file

@ -0,0 +1,69 @@
/** @odoo-module */
import { _t } from "@web/core/l10n/translation";
import { ServerData } from "../data_sources/server_data";
import { toServerDateString } from "../helpers/helpers";
/**
* @typedef Currency
* @property {string} name
* @property {string} code
* @property {string} symbol
* @property {number} decimalPlaces
* @property {"before" | "after"} position
*/
export class CurrencyDataSource {
constructor(services) {
this.serverData = new ServerData(services.orm, {
whenDataIsFetched: () => services.notify(),
});
}
/**
* Get the currency rate between the two given currencies
* @param {string} from Currency from
* @param {string} to Currency to
* @param {string|undefined} date
* @returns {number|undefined}
*/
getCurrencyRate(from, to, date) {
const data = this.serverData.batch.get("res.currency.rate", "get_rates_for_spreadsheet", {
from,
to,
date: date ? toServerDateString(date) : undefined,
});
const rate = data !== undefined ? data.rate : undefined;
if (rate === false) {
throw new Error(_t("Currency rate unavailable."));
}
return rate;
}
/**
*
* @param {number|undefined} companyId
* @returns {Currency}
*/
getCompanyCurrencyFormat(companyId) {
const result = this.serverData.get("res.currency", "get_company_currency_for_spreadsheet", [
companyId,
]);
if (result === false) {
throw new Error(_t("Currency not available for this company."));
}
return result;
}
/**
* Get all currencies from the server
* @param {string} currencyName
* @returns {Currency}
*/
getCurrency(currencyName) {
return this.serverData.batch.get(
"res.currency",
"get_currencies_for_spreadsheet",
currencyName
);
}
}

View file

@ -0,0 +1,24 @@
/** @odoo-module **/
import { _t } from "@web/core/l10n/translation";
import spreadsheet from "../o_spreadsheet/o_spreadsheet_extended";
const { args, toString, toJsDate } = spreadsheet.helpers;
const { functionRegistry } = spreadsheet.registries;
functionRegistry.add("ODOO.CURRENCY.RATE", {
description: _t(
"This function takes in two currency codes as arguments, and returns the exchange rate from the first currency to the second as float."
),
compute: function (currencyFrom, currencyTo, date) {
const from = toString(currencyFrom);
const to = toString(currencyTo);
const _date = date ? toJsDate(date) : undefined;
return this.getters.getCurrencyRate(from, to, _date);
},
args: args(`
currency_from (string) ${_t("First currency code.")}
currency_to (string) ${_t("Second currency code.")}
date (date, optional) ${_t("Date of the rate.")}
`),
returns: ["NUMBER"],
});

View file

@ -0,0 +1,89 @@
/** @odoo-module */
import spreadsheet from "../../o_spreadsheet/o_spreadsheet_extended";
import { CurrencyDataSource } from "../currency_data_source";
const { uiPluginRegistry } = spreadsheet.registries;
const DATA_SOURCE_ID = "CURRENCIES";
/**
* @typedef {import("../currency_data_source").Currency} Currency
*/
class CurrencyPlugin extends spreadsheet.UIPlugin {
constructor(getters, history, dispatch, config) {
super(getters, history, dispatch, config);
this.dataSources = config.dataSources;
if (this.dataSources) {
this.dataSources.add(DATA_SOURCE_ID, CurrencyDataSource);
}
}
// -------------------------------------------------------------------------
// Getters
// -------------------------------------------------------------------------
/**
* Get the currency rate between the two given currencies
* @param {string} from Currency from
* @param {string} to Currency to
* @param {string} date
* @returns {number|string}
*/
getCurrencyRate(from, to, date) {
return (
this.dataSources && this.dataSources.get(DATA_SOURCE_ID).getCurrencyRate(from, to, date)
);
}
/**
*
* @param {Currency | undefined} currency
* @private
*
* @returns {string | undefined}
*/
computeFormatFromCurrency(currency) {
if (!currency) {
return undefined;
}
const decimalFormatPart = currency.decimalPlaces
? "." + "0".repeat(currency.decimalPlaces)
: "";
const numberFormat = "#,##0" + decimalFormatPart;
const symbolFormatPart = "[$" + currency.symbol + "]";
return currency.position === "after"
? numberFormat + symbolFormatPart
: symbolFormatPart + numberFormat;
}
/**
* Returns the default display format of a given currency
* @param {string} currencyName
* @returns {string | undefined}
*/
getCurrencyFormat(currencyName) {
const currency =
currencyName &&
this.dataSources &&
this.dataSources.get(DATA_SOURCE_ID).getCurrency(currencyName);
return this.computeFormatFromCurrency(currency);
}
/**
* Returns the default display format of a the company currency
* @param {number|undefined} companyId
* @returns {string | undefined}
*/
getCompanyCurrencyFormat(companyId) {
const currency =
this.dataSources &&
this.dataSources.get(DATA_SOURCE_ID).getCompanyCurrencyFormat(companyId);
return this.computeFormatFromCurrency(currency);
}
}
CurrencyPlugin.modes = ["normal", "headless"];
CurrencyPlugin.getters = ["getCurrencyRate", "getCurrencyFormat", "getCompanyCurrencyFormat"];
uiPluginRegistry.add("odooCurrency", CurrencyPlugin);

View file

@ -0,0 +1,103 @@
/** @odoo-module */
import { LoadingDataError } from "@spreadsheet/o_spreadsheet/errors";
import { RPCError } from "@web/core/network/rpc_service";
import { KeepLast } from "@web/core/utils/concurrency";
/**
* DataSource is an abstract class that contains the logic of fetching and
* maintaining access to data that have to be loaded.
*
* A class which extends this class have to implement the `_load` method
* * which should load the data it needs
*
* Subclass can implement concrete methods to have access to a
* particular data.
*/
export class LoadableDataSource {
constructor(params) {
this._orm = params.orm;
this._metadataRepository = params.metadataRepository;
this._notifyWhenPromiseResolves = params.notifyWhenPromiseResolves;
this._cancelPromise = params.cancelPromise;
/**
* Last time that this dataSource has been updated
*/
this._lastUpdate = undefined;
this._concurrency = new KeepLast();
/**
* Promise to control the loading of data
*/
this._loadPromise = undefined;
this._isFullyLoaded = false;
this._isValid = true;
this._loadErrorMessage = "";
}
/**
* Load data in the model
* @param {object} [params] Params for fetching data
* @param {boolean} [params.reload=false] Force the reload of the data
*
* @returns {Promise} Resolved when data are fetched.
*/
async load(params) {
if (params && params.reload) {
this._cancelPromise(this._loadPromise);
this._loadPromise = undefined;
}
if (!this._loadPromise) {
this._isFullyLoaded = false;
this._isValid = true;
this._loadErrorMessage = "";
this._loadPromise = this._concurrency
.add(this._load())
.catch((e) => {
this._isValid = false;
this._loadErrorMessage = e instanceof RPCError ? e.data.message : e.message;
})
.finally(() => {
this._lastUpdate = Date.now();
this._isFullyLoaded = true;
});
await this._notifyWhenPromiseResolves(this._loadPromise);
}
return this._loadPromise;
}
get lastUpdate() {
return this._lastUpdate;
}
/**
* @returns {boolean}
*/
isReady() {
return this._isFullyLoaded;
}
/**
* @protected
*/
_assertDataIsLoaded() {
if (!this._isFullyLoaded) {
this.load();
throw LOADING_ERROR;
}
if (!this._isValid) {
throw new Error(this._loadErrorMessage);
}
}
/**
* Load the data in the model
*
* @abstract
* @protected
*/
async _load() {}
}
const LOADING_ERROR = new LoadingDataError();

View file

@ -0,0 +1,137 @@
/** @odoo-module */
import { LoadableDataSource } from "./data_source";
import { MetadataRepository } from "./metadata_repository";
const { EventBus } = owl;
/** *
* @typedef {object} DataSourceServices
* @property {MetadataRepository} metadataRepository
* @property {import("@web/core/orm_service")} orm
* @property {() => void} notify
*
* @typedef {new (services: DataSourceServices, params: object) => any} DataSourceConstructor
*/
export class DataSources extends EventBus {
constructor(orm) {
super();
this._orm = orm.silent;
this._metadataRepository = new MetadataRepository(orm);
this._metadataRepository.addEventListener("labels-fetched", () => this.notify());
/** @type {Object.<string, any>} */
this._dataSources = {};
this.pendingPromises = new Set();
}
/**
* Create a new data source but do not register it.
*
* @param {DataSourceConstructor} cls Class to instantiate
* @param {object} params Params to give to data source
*
* @returns {any}
*/
create(cls, params) {
return new cls(
{
orm: this._orm,
metadataRepository: this._metadataRepository,
notify: () => this.notify(),
notifyWhenPromiseResolves: this.notifyWhenPromiseResolves.bind(this),
cancelPromise: (promise) => this.pendingPromises.delete(promise),
},
params
);
}
/**
* Create a new data source and register it with the following id.
*
* @param {string} id
* @param {DataSourceConstructor} cls Class to instantiate
* @param {object} params Params to give to data source
*
* @returns {any}
*/
add(id, cls, params) {
this._dataSources[id] = this.create(cls, params);
return this._dataSources[id];
}
async load(id, reload = false) {
const dataSource = this.get(id);
if (dataSource instanceof LoadableDataSource) {
await dataSource.load({ reload });
}
}
/**
* Retrieve the data source with the following id.
*
* @param {string} id
*
* @returns {any}
*/
get(id) {
return this._dataSources[id];
}
/**
* Check if the following is correspond to a data source.
*
* @param {string} id
*
* @returns {boolean}
*/
contains(id) {
return id in this._dataSources;
}
/**
* @private
* @param {Promise<unknown>} promise
*/
async notifyWhenPromiseResolves(promise) {
this.pendingPromises.add(promise);
await promise
.then(() => {
this.pendingPromises.delete(promise);
this.notify();
})
.catch(() => {
this.pendingPromises.delete(promise);
this.notify();
});
}
/**
* Notify that a data source has been updated. Could be useful to
* request a re-evaluation.
*/
notify() {
if (this.pendingPromises.size) {
if (!this.nextTriggerTimeOutId) {
// evaluates at least every 10 seconds, even if there are pending promises
// to avoid blocking everything if there is a really long request
this.nextTriggerTimeOutId = setTimeout(() => {
this.nextTriggerTimeOutId = undefined;
if (this.pendingPromises.size) {
this.trigger("data-source-updated");
}
}, 10000);
}
return;
}
this.trigger("data-source-updated");
}
async waitForAllLoaded() {
await Promise.all(
Object.values(this._dataSources).map(
(ds) => ds instanceof LoadableDataSource && ds.load()
)
);
}
}

View file

@ -0,0 +1,202 @@
/** @odoo-module */
import { Deferred } from "@web/core/utils/concurrency";
import { LoadingDataError } from "../o_spreadsheet/errors";
import BatchEndpoint, { Request } from "./server_data";
/**
* @typedef PendingDisplayName
* @property {"PENDING"} state
* @property {Deferred<string>} deferred
*
* @typedef ErrorDisplayName
* @property {"ERROR"} state
* @property {Deferred<string>} deferred
* @property {Error} error
*
* @typedef CompletedDisplayName
* @property {"COMPLETED"} state
* @property {Deferred<string>} deferred
* @property {string|undefined} value
*
* @typedef {PendingDisplayName | ErrorDisplayName | CompletedDisplayName} DisplayNameResult
*
* @typedef {[number, string]} BatchedNameGetRPCResult
*/
/**
* This class is responsible for fetching the display names of records. It
* caches the display names of records that have already been fetched.
* It also provides a way to wait for the display name of a record to be
* fetched.
*/
export class DisplayNameRepository {
/**
*
* @param {import("@web/core/orm_service").ORM} orm
* @param {Object} params
* @param {function} params.whenDataIsFetched Callback to call when the
* display name of a record is fetched.
*/
constructor(orm, { whenDataIsFetched }) {
this.dataFetchedCallback = whenDataIsFetched;
/**
* Contains the display names of records. It's organized in the following way:
* {
* "res.country": {
* 1: {
* "value": "Belgium",
* "deferred": Deferred<"Belgium">,
* },
* }
*/
/** @type {Object.<string, Object.<number, DisplayNameResult>>}*/
this._displayNames = {};
this._orm = orm;
this._endpoints = {};
}
/**
* Get the display name of the given record.
*
* @param {string} model
* @param {number} id
* @returns {Promise<string>}
*/
async getDisplayNameAsync(model, id) {
const displayNameResult = this._displayNames[model] && this._displayNames[model][id];
if (!displayNameResult) {
return this._fetchDisplayName(model, id);
}
return displayNameResult.deferred;
}
/**
* Set the display name of the given record. This will prevent the display name
* from being fetched in the background.
*
* @param {string} model
* @param {number} id
* @param {string} displayName
*/
setDisplayName(model, id, displayName) {
if (!this._displayNames[model]) {
this._displayNames[model] = {};
}
const deferred = new Deferred();
deferred.resolve(displayName);
this._displayNames[model][id] = {
state: "COMPLETED",
deferred,
value: displayName,
};
}
/**
* Get the display name of the given record. If the record does not exist,
* it will throw a LoadingDataError and fetch the display name in the background.
*
* @param {string} model
* @param {number} id
* @returns {string}
*/
getDisplayName(model, id) {
const displayNameResult = this._displayNames[model] && this._displayNames[model][id];
if (!displayNameResult) {
// Catch the error to prevent the error from being thrown in the
// background.
this._fetchDisplayName(model, id).catch(() => {});
throw new LoadingDataError();
}
switch (displayNameResult.state) {
case "ERROR":
throw displayNameResult.error;
case "COMPLETED":
return displayNameResult.value;
default:
throw new LoadingDataError();
}
}
/**
* Get the batch endpoint for the given model. If it does not exist, it will
* be created.
*
* @param {string} model
* @returns {BatchEndpoint}
*/
_getEndpoint(model) {
if (!this._endpoints[model]) {
this._endpoints[model] = new BatchEndpoint(this._orm, model, "name_get", {
whenDataIsFetched: () => this.dataFetchedCallback(),
successCallback: this._assignResult.bind(this),
failureCallback: this._assignError.bind(this),
});
}
return this._endpoints[model];
}
/**
* This method is called when the display name of a record is successfully
* fetched. It updates the cache and resolves the deferred of the record.
*
* @param {Request} request
* @param {BatchedNameGetRPCResult} result
*
* @private
*/
_assignResult(request, result) {
const deferred = this._displayNames[request.resModel][request.args[0]].deferred;
deferred.resolve(result && result[1]);
this._displayNames[request.resModel][request.args[0]] = {
state: "COMPLETED",
deferred,
value: result && result[1],
};
}
/**
* This method is called when the display name of a record could not be
* fetched. It updates the cache and rejects the deferred of the record.
*
* @param {Request} request
* @param {Error} error
*
* @private
*/
_assignError(request, error) {
const deferred = this._displayNames[request.resModel][request.args[0]].deferred;
deferred.reject(error);
this._displayNames[request.resModel][request.args[0]] = {
state: "ERROR",
deferred,
error,
};
}
/**
* This method is called when the display name of a record is not in the
* cache. It creates a deferred and fetches the display name in the
* background.
*
* @param {string} model
* @param {number} id
*
* @private
* @returns {Deferred<string>}
*/
async _fetchDisplayName(model, id) {
const deferred = new Deferred();
if (!this._displayNames[model]) {
this._displayNames[model] = {};
}
this._displayNames[model][id] = {
state: "PENDING",
deferred,
};
const endpoint = this._getEndpoint(model);
const request = new Request(model, "name_get", [id]);
endpoint.call(request);
return deferred;
}
}

View file

@ -0,0 +1,54 @@
/** @odoo-module */
/**
* This class is responsible for keeping track of the labels of records. It
* caches the labels of records that have already been fetched.
* This class will not fetch the labels of records, it is the responsibility of
* the caller to fetch the labels and insert them in this repository.
*/
export class LabelsRepository {
constructor() {
/**
* Contains the labels of records. It's organized in the following way:
* {
* "crm.lead": {
* "city": {
* "bruxelles": "Bruxelles",
* }
* },
* }
*/
this._labels = {};
}
/**
* Get the label of a record.
* @param {string} model technical name of the model
* @param {string} field name of the field
* @param {any} value value of the field
*
* @returns {string|undefined} label of the record
*/
getLabel(model, field, value) {
return (
this._labels[model] && this._labels[model][field] && this._labels[model][field][value]
);
}
/**
* Set the label of a record.
* @param {string} model
* @param {string} field
* @param {string|number} value
* @param {string|undefined} label
*/
setLabel(model, field, value, label) {
if (!this._labels[model]) {
this._labels[model] = {};
}
if (!this._labels[model][field]) {
this._labels[model][field] = {};
}
this._labels[model][field][value] = label;
}
}

View file

@ -0,0 +1,126 @@
/** @odoo-module */
import { _t } from "@web/core/l10n/translation";
import { sprintf } from "@web/core/utils/strings";
import { ServerData } from "../data_sources/server_data";
import { LoadingDataError } from "../o_spreadsheet/errors";
import { DisplayNameRepository } from "./display_name_repository";
import { LabelsRepository } from "./labels_repository";
const { EventBus } = owl;
/**
* @typedef {object} Field
* @property {string} name technical name
* @property {string} type field type
* @property {string} string display name
* @property {string} [relation] related model technical name (only for relational fields)
* @property {boolean} [searchable] true if a field can be searched in database
*/
/**
* This class is used to provide facilities to fetch some common data. It's
* used in the data sources to obtain the fields (fields_get) and the display
* name of the models (display_name_for on ir.model).
*
* It also manages the labels of all the spreadsheet models (labels of basic
* fields or display name of relational fields).
*
* All the results are cached in order to avoid useless rpc calls, basically
* for different entities that are defined on the same model.
*
* Implementation note:
* For the labels, when someone is asking for a display name which is not loaded yet,
* the proxy returns directly (undefined) and a request for a name_get will
* be triggered. All the requests created are batched and send, with only one
* request per model, after a clock cycle.
* At the end of this process, an event is triggered (labels-fetched)
*/
export class MetadataRepository extends EventBus {
constructor(orm) {
super();
this.orm = orm;
this.serverData = new ServerData(this.orm, {
whenDataIsFetched: () => this.trigger("labels-fetched"),
});
this.labelsRepository = new LabelsRepository();
this.displayNameRepository = new DisplayNameRepository(this.orm, {
whenDataIsFetched: () => this.trigger("labels-fetched"),
});
}
/**
* Get the display name of the given model
*
* @param {string} model Technical name
* @returns {Promise<string>} Display name of the model
*/
async modelDisplayName(model) {
const result = await this.serverData.fetch("ir.model", "display_name_for", [[model]]);
return (result[0] && result[0].display_name) || "";
}
/**
* Get the list of fields for the given model
*
* @param {string} model Technical name
* @returns {Promise<Record<string, Field>>} List of fields (result of fields_get)
*/
async fieldsGet(model) {
return this.serverData.fetch(model, "fields_get");
}
/**
* Add a label to the cache
*
* @param {string} model
* @param {string} field
* @param {any} value
* @param {string} label
*/
registerLabel(model, field, value, label) {
this.labelsRepository.setLabel(model, field, value, label);
}
/**
* Get the label associated with the given arguments
*
* @param {string} model
* @param {string} field
* @param {any} value
* @returns {string}
*/
getLabel(model, field, value) {
return this.labelsRepository.getLabel(model, field, value);
}
/**
* Save the result of a name_get request in the cache
*/
setDisplayName(model, id, result) {
this.displayNameRepository.setDisplayName(model, id, result);
}
/**
* Get the display name associated to the given model-id
* If the name is not yet loaded, a rpc will be triggered in the next clock
* cycle.
*
* @param {string} model
* @param {number} id
* @returns {string}
*/
getRecordDisplayName(model, id) {
try {
return this.displayNameRepository.getDisplayName(model, id);
} catch (e) {
if (e instanceof LoadingDataError) {
throw e;
}
throw new Error(sprintf(_t("Unable to fetch the label of %s of model %s"), id, model));
}
}
}

View file

@ -0,0 +1,115 @@
/** @odoo-module */
import { LoadableDataSource } from "./data_source";
import { Domain } from "@web/core/domain";
import { LoadingDataError } from "@spreadsheet/o_spreadsheet/errors";
import { omit } from "@web/core/utils/objects";
/**
* @typedef {import("@spreadsheet/data_sources/metadata_repository").Field} Field
*/
/**
* @typedef {Object} OdooModelMetaData
* @property {string} resModel
* @property {Array<Object>|undefined} fields
*/
export class OdooViewsDataSource extends LoadableDataSource {
/**
* @override
* @param {Object} services
* @param {Object} params
* @param {OdooModelMetaData} params.metaData
* @param {Object} params.searchParams
*/
constructor(services, params) {
super(services);
this._metaData = JSON.parse(JSON.stringify(params.metaData));
/** @protected */
this._initialSearchParams = JSON.parse(JSON.stringify(params.searchParams));
this._initialSearchParams.context = omit(
this._initialSearchParams.context || {},
...Object.keys(this._orm.user.context)
);
/** @private */
this._customDomain = this._initialSearchParams.domain;
}
/**
* @protected
*/
get _searchParams() {
return {
...this._initialSearchParams,
domain: this._customDomain,
};
}
async loadMetadata() {
if (!this._metaData.fields) {
this._metaData.fields = await this._metadataRepository.fieldsGet(
this._metaData.resModel
);
}
}
/**
* @returns {Record<string, Field>} List of fields
*/
getFields() {
if (this._metaData.fields === undefined) {
this.loadMetadata();
throw new LoadingDataError();
}
return this._metaData.fields;
}
/**
* @param {string} field Field name
* @returns {Field | undefined} Field
*/
getField(field) {
return this._metaData.fields[field];
}
/**
* @protected
*/
async _load() {
await this.loadMetadata();
}
isMetaDataLoaded() {
return this._metaData.fields !== undefined;
}
/**
* Get the computed domain of this source
* @returns {Array}
*/
getComputedDomain() {
return this._customDomain;
}
addDomain(domain) {
const newDomain = Domain.and([this._initialSearchParams.domain, domain]);
if (newDomain.toString() === new Domain(this._customDomain).toString()) {
return;
}
this._customDomain = newDomain.toList();
if (this._loadPromise === undefined) {
// if the data source has never been loaded, there's no point
// at reloading it now.
return;
}
this.load({ reload: true });
}
/**
* @returns {Promise<string>} Display name of the model
*/
getModelLabel() {
return this._metadataRepository.modelDisplayName(this._metaData.resModel);
}
}

View file

@ -0,0 +1,312 @@
/** @odoo-module */
import { LoadingDataError } from "../o_spreadsheet/errors";
/**
* @param {T[]} array
* @returns {T[]}
* @template T
*/
function removeDuplicates(array) {
return [...new Set(array.map((el) => JSON.stringify(el)))].map((el) => JSON.parse(el));
}
export class Request {
/**
* @param {string} resModel
* @param {string} method
* @param {unknown[]} args
*/
constructor(resModel, method, args) {
this.resModel = resModel;
this.method = method;
this.args = args;
this.key = `${resModel}/${method}(${JSON.stringify(args)})`;
}
}
/**
* A batch request consists of multiple requests which are combined into a single RPC.
*
* The batch responsibility is to combine individual requests into a single RPC payload
* and to split the response back for individual requests.
*
* The server method must have the following API:
* - The input is a list of arguments. Each list item being the arguments of a single request.
* - The output is a list of results, ordered according to the input list
*
* ```
* [result1, result2] = self.env['my.model'].my_batched_method([request_1_args, request_2_args])
* ```
*/
class ListRequestBatch {
/**
* @param {string} resModel
* @param {string} method
* @param {Request[]} requests
*/
constructor(resModel, method, requests = []) {
this.resModel = resModel;
this.method = method;
this.requests = requests;
}
get payload() {
const payload = removeDuplicates(this.requests.map((request) => request.args).flat());
return [payload];
}
/**
* @param {Request} request
*/
add(request) {
if (request.resModel !== this.resModel || request.method !== this.method) {
throw new Error(
`Request ${request.resModel}/${request.method} cannot be added to the batch ${this.resModel}/${this.method}`
);
}
this.requests.push(request);
}
/**
* Split the batched RPC response into single request results
*
* @param {T[]} results
* @returns {Map<Request, T>}
* @template T
*/
splitResponse(results) {
const split = new Map();
for (let i = 0; i < this.requests.length; i++) {
split.set(this.requests[i], results[i]);
}
return split;
}
}
export class ServerData {
/**
* @param {any} orm
* @param {object} params
* @param {function} params.whenDataIsFetched
*/
constructor(orm, { whenDataIsFetched }) {
this.orm = orm;
this.dataFetchedCallback = whenDataIsFetched;
/** @type {Record<string, unknown>}*/
this.cache = {};
/** @type {Record<string, Promise<unknown>>}*/
this.asyncCache = {};
this.batchEndpoints = {};
}
/**
* @returns {{get: (resModel:string, method: string, args: unknown) => any}}
*/
get batch() {
return { get: (resModel, method, args) => this._getBatchItem(resModel, method, args) };
}
/**
* @private
* @param {string} resModel
* @param {string} method
* @param {unknown} args
* @returns {any}
*/
_getBatchItem(resModel, method, args) {
const request = new Request(resModel, method, [args]);
if (!(request.key in this.cache)) {
const error = new LoadingDataError();
this.cache[request.key] = error;
this._batch(request);
throw error;
}
return this._getOrThrowCachedResponse(request);
}
/**
* @param {string} resModel
* @param {string} method
* @param {unknown[]} args
* @returns {any}}
*/
get(resModel, method, args) {
const request = new Request(resModel, method, args);
if (!(request.key in this.cache)) {
const error = new LoadingDataError();
this.cache[request.key] = error;
this.orm
.call(resModel, method, args)
.then((result) => (this.cache[request.key] = result))
.catch((error) => (this.cache[request.key] = error))
.finally(() => this.dataFetchedCallback());
throw error;
}
return this._getOrThrowCachedResponse(request);
}
/**
* Returns the request result if cached or the associated promise
* @param {string} resModel
* @param {string} method
* @param {unknown[]} [args]
* @returns {Promise<any>}
*/
async fetch(resModel, method, args) {
const request = new Request(resModel, method, args);
if (!(request.key in this.asyncCache)) {
this.asyncCache[request.key] = this.orm.call(resModel, method, args);
}
return this.asyncCache[request.key];
}
/**
* @private
* @param {Request} request
* @returns {void}
*/
_batch(request) {
const endpoint = this._getBatchEndPoint(request.resModel, request.method);
endpoint.call(request);
}
/**
* @private
* @param {Request} request
* @return {unknown}
*/
_getOrThrowCachedResponse(request) {
const data = this.cache[request.key];
if (data instanceof Error) {
throw data;
}
return data;
}
/**
* @private
* @param {string} resModel
* @param {string} method
*/
_getBatchEndPoint(resModel, method) {
if (!this.batchEndpoints[resModel] || !this.batchEndpoints[resModel][method]) {
this.batchEndpoints[resModel] = {
...this.batchEndpoints[resModel],
[method]: this._createBatchEndpoint(resModel, method),
};
}
return this.batchEndpoints[resModel][method];
}
/**
* @private
* @param {string} resModel
* @param {string} method
*/
_createBatchEndpoint(resModel, method) {
return new BatchEndpoint(this.orm, resModel, method, {
whenDataIsFetched: () => this.dataFetchedCallback(),
successCallback: (request, result) => (this.cache[request.key] = result),
failureCallback: (request, error) => (this.cache[request.key] = error),
});
}
}
/**
* Collect multiple requests into a single batch.
*/
export default class BatchEndpoint {
/**
* @param {object} orm
* @param {string} resModel
* @param {string} method
* @param {object} callbacks
* @param {function} callbacks.successCallback
* @param {function} callbacks.failureCallback
* @param {function} callbacks.whenDataIsFetched
*/
constructor(orm, resModel, method, { successCallback, failureCallback, whenDataIsFetched }) {
this.orm = orm;
this.resModel = resModel;
this.method = method;
this.successCallback = successCallback;
this.failureCallback = failureCallback;
this.batchedFetchedCallback = whenDataIsFetched;
this._isScheduled = false;
this._pendingBatch = new ListRequestBatch(resModel, method);
}
/**
* @param {Request} request
*/
call(request) {
this._pendingBatch.add(request);
this._scheduleNextBatch();
}
/**
* @param {Map} batchResult
* @private
*/
_notifyResults(batchResult) {
for (const [request, result] of batchResult) {
if (result instanceof Error) {
this.failureCallback(request, result);
} else {
this.successCallback(request, result);
}
}
}
/**
* @private
*/
_scheduleNextBatch() {
if (this._isScheduled || this._pendingBatch.requests.length === 0) {
return;
}
this._isScheduled = true;
queueMicrotask(async () => {
try {
this._isScheduled = false;
const batch = this._pendingBatch;
const { resModel, method } = batch;
this._pendingBatch = new ListRequestBatch(resModel, method);
await this.orm
.call(resModel, method, batch.payload)
.then((result) => batch.splitResponse(result))
.catch(() => this._retryOneByOne(batch))
.then((batchResults) => this._notifyResults(batchResults));
} finally {
this.batchedFetchedCallback();
}
});
}
/**
* @private
* @param {ListRequestBatch} batch
* @returns {Promise<Map<Request, unknown>>}
*/
async _retryOneByOne(batch) {
const mergedResults = new Map();
const { resModel, method } = batch;
const singleRequestBatches = batch.requests.map(
(request) => new ListRequestBatch(resModel, method, [request])
);
const proms = [];
for (const batch of singleRequestBatches) {
const request = batch.requests[0];
const prom = this.orm
.call(resModel, method, batch.payload)
.then((result) =>
mergedResults.set(request, batch.splitResponse(result).get(request))
)
.catch((error) => mergedResults.set(request, error));
proms.push(prom);
}
await Promise.allSettled(proms);
return mergedResults;
}
}

View file

@ -0,0 +1,69 @@
/** @odoo-module */
import { YearPicker } from "../year_picker";
import { dateOptions } from "@spreadsheet/global_filters/helpers";
const { DateTime } = luxon;
const { Component, onWillUpdateProps } = owl;
export class DateFilterValue extends Component {
setup() {
this._setStateFromProps(this.props);
onWillUpdateProps(this._setStateFromProps);
}
_setStateFromProps(props) {
this.period = props.period;
/** @type {number|undefined} */
this.yearOffset = props.yearOffset;
// date should be undefined if we don't have the yearOffset
/** @type {DateTime|undefined} */
this.date =
this.yearOffset !== undefined
? DateTime.local().plus({ year: this.yearOffset })
: undefined;
}
dateOptions(type) {
return type ? dateOptions(type) : [];
}
isYear() {
return this.props.type === "year";
}
isSelected(periodId) {
return this.period === periodId;
}
onPeriodChanged(ev) {
this.period = ev.target.value;
this._updateFilter();
}
onYearChanged(date) {
if (!date) {
date = undefined;
}
this.date = date;
this.yearOffset = date && date.year - DateTime.now().year;
this._updateFilter();
}
_updateFilter() {
this.props.onTimeRangeChanged({
yearOffset: this.yearOffset || 0,
period: this.period,
});
}
}
DateFilterValue.template = "spreadsheet_edition.DateFilterValue";
DateFilterValue.components = { YearPicker };
DateFilterValue.props = {
// See @spreadsheet_edition/bundle/global_filters/filters_plugin.RangeType
type: { validate: (t) => ["year", "month", "quarter"].includes(t) },
onTimeRangeChanged: Function,
yearOffset: { type: Number, optional: true },
period: { type: String, optional: true },
};

View file

@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<templates>
<div t-name="spreadsheet_edition.DateFilterValue" class="date_filter_values" owl="1">
<select t-if="!isYear()" class="o_input text-truncate" t-on-change="onPeriodChanged">
<option value="empty">Select period...</option>
<t t-set="type" t-value="props.type"/>
<t t-foreach="dateOptions(type)" t-as="periodOption" t-key="periodOption.id">
<option t-if="isSelected(periodOption.id)" selected="1" t-att-value="periodOption.id">
<t t-esc="periodOption.description"/>
</option>
<option t-else="" t-att-value="periodOption.id">
<t t-esc="periodOption.description"/>
</option>
</t>
</select>
<YearPicker
date="date"
onDateTimeChanged.bind="onYearChanged"
placeholder="env._t('Select year...')"
/>
</div>
</templates>

View file

@ -0,0 +1,50 @@
/** @odoo-module */
import { RecordsSelector } from "../records_selector/records_selector";
import { RELATIVE_DATE_RANGE_TYPES } from "@spreadsheet/helpers/constants";
import { DateFilterValue } from "../filter_date_value/filter_date_value";
import { useService } from "@web/core/utils/hooks";
const { Component } = owl;
export class FilterValue extends Component {
setup() {
this.getters = this.props.model.getters;
this.relativeDateRangesTypes = RELATIVE_DATE_RANGE_TYPES;
this.orm = useService("orm");
}
onDateInput(id, value) {
this.props.model.dispatch("SET_GLOBAL_FILTER_VALUE", { id, value });
}
onTextInput(id, value) {
this.props.model.dispatch("SET_GLOBAL_FILTER_VALUE", { id, value });
}
async onTagSelected(id, values) {
let records = values;
if (values.some((record) => record.display_name === undefined)) {
({ records } = await this.orm.webSearchRead(
this.props.filter.modelName,
[["id", "in", values.map((record) => record.id)]],
["display_name"]
));
}
this.props.model.dispatch("SET_GLOBAL_FILTER_VALUE", {
id,
value: records.map((record) => record.id),
displayNames: records.map((record) => record.display_name),
});
}
onClear(id) {
this.props.model.dispatch("CLEAR_GLOBAL_FILTER_VALUE", { id });
}
}
FilterValue.template = "spreadsheet_edition.FilterValue";
FilterValue.components = { RecordsSelector, DateFilterValue };
FilterValue.props = {
filter: Object,
model: Object,
showTitle: { type: Boolean, optional: true },
};

View file

@ -0,0 +1,24 @@
.o-filter-value {
.o-text-filter-input {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.o_datepicker {
width: 100%;
}
.o_datepicker_input {
color: $o-main-text-color;
}
select:has(option[value="empty"]:checked),
select:has(option[value=""]:checked) {
color: $input-placeholder-color;
}
select option {
color: $o-gray-700;
}
}

View file

@ -0,0 +1,49 @@
<?xml version="1.0" encoding="utf-8"?>
<templates>
<t t-name="spreadsheet_edition.FilterValue"
owl="1">
<t t-set="filter" t-value="props.filter"/>
<div class="o-filter-value d-flex align-items-start w-100" t-att-title="props.showTitle and filter.label">
<t t-set="filterValue"
t-value="getters.getGlobalFilterValue(filter.id)"/>
<div t-if="filter.type === 'text'" class="w-100">
<input type="text"
class="o_input o-text-filter-input"
t-att-placeholder="env._t(filter.label)"
t-att-value="filterValue"
t-on-change="(e) => this.onTextInput(filter.id, e.target.value)"/>
</div>
<span t-if="filter.type === 'relation'" class="w-100">
<RecordsSelector placeholder="' ' + env._t(filter.label)"
resModel="filter.modelName"
resIds="filterValue"
onValueChanged="(value) => this.onTagSelected(filter.id, value)" />
</span>
<div t-if="filter.type === 'date'" class="w-100">
<DateFilterValue t-if="filter.rangeType !== 'relative'"
period="filterValue &amp;&amp; filterValue.period"
yearOffset="filterValue &amp;&amp; filterValue.yearOffset"
type="filter.rangeType"
onTimeRangeChanged="(value) => this.onDateInput(filter.id, value)" />
<select t-if="filter.rangeType === 'relative'"
t-on-change="(e) => this.onDateInput(filter.id, e.target.value || undefined)"
class="date_filter_values o_input me-3 text-truncate"
required="true">
<option value="">Select period...</option>
<t t-foreach="relativeDateRangesTypes"
t-as="range"
t-key="range.type">
<option t-att-selected="range.type === filterValue"
t-att-value="range.type">
<t t-esc="range.description"/>
</option>
</t>
</select>
</div>
<i t-if="getters.isGlobalFilterActive(filter.id)"
class="fa fa-times btn btn-link text-muted o_side_panel_filter_icon ms-1 mt-1"
title="Clear"
t-on-click="() => this.onClear(filter.id)"/>
</div>
</t>
</templates>

View file

@ -0,0 +1,99 @@
/** @odoo-module **/
import { Domain } from "@web/core/domain";
import { useService } from "@web/core/utils/hooks";
import { TagsList } from "@web/views/fields/many2many_tags/tags_list";
import { Many2XAutocomplete } from "@web/views/fields/relational_utils";
const { Component, onWillStart, onWillUpdateProps } = owl;
export class RecordsSelector extends Component {
setup() {
/** @type {Record<number, string>} */
this.displayNames = {};
/** @type {import("@web/core/orm_service").ORM}*/
this.orm = useService("orm");
onWillStart(() => this.fetchMissingDisplayNames(this.props.resModel, this.props.resIds));
onWillUpdateProps((nextProps) =>
this.fetchMissingDisplayNames(nextProps.resModel, nextProps.resIds)
);
}
get tags() {
return this.props.resIds.map((id) => ({
text: this.displayNames[id],
onDelete: () => this.removeRecord(id),
displayBadge: true,
}));
}
searchDomain() {
return Domain.not([["id", "in", this.props.resIds]]).toList();
}
/**
* @param {number} recordId
*/
removeRecord(recordId) {
delete this.displayNames[recordId];
this.notifyChange(this.props.resIds.filter((id) => id !== recordId));
}
/**
* @param {{ id: number; name?: string}[]} records
*/
update(records) {
for (const record of records.filter((record) => record.name)) {
this.displayNames[record.id] = record.name;
}
this.notifyChange(this.props.resIds.concat(records.map(({ id }) => id)));
}
/**
* @param {number[]} selectedIds
*/
notifyChange(selectedIds) {
this.props.onValueChanged(
selectedIds.map((id) => ({ id, display_name: this.displayNames[id] }))
);
}
/**
* @param {string} resModel
* @param {number[]} recordIds
*/
async fetchMissingDisplayNames(resModel, recordIds) {
const missingNameIds = recordIds.filter((id) => !(id in this.displayNames));
if (missingNameIds.length === 0) {
return;
}
const results = await this.orm.read(resModel, missingNameIds, ["display_name"]);
for (const { id, display_name } of results) {
this.displayNames[id] = display_name;
}
}
}
RecordsSelector.components = { TagsList, Many2XAutocomplete };
RecordsSelector.template = "spreadsheet.RecordsSelector";
RecordsSelector.props = {
/**
* Callback called when a record is selected or removed.
* (selectedRecords: Array<{ id: number; display_name: string }>) => void
**/
onValueChanged: Function,
resModel: String,
/**
* Array of selected record ids
*/
resIds: {
optional: true,
type: Array,
},
placeholder: {
optional: true,
type: String,
},
};
RecordsSelector.defaultProps = {
resIds: [],
};

View file

@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<templates>
<div t-name="spreadsheet.RecordsSelector" class="o_field_widget o_field_many2many_tags" owl="1">
<div class="o_field_tags d-inline-flex flex-wrap mw-100 o_tags_input o_input">
<TagsList tags="tags"/>
<div class="o_field_many2many_selection d-inline-flex w-100">
<Many2XAutocomplete
placeholder="props.placeholder"
resModel="props.resModel"
fieldString="props.placeholder"
activeActions="{}"
update.bind="update"
getDomain.bind="searchDomain"
isToMany="true"
/>
</div>
</div>
</div>
</templates>

View file

@ -0,0 +1,47 @@
/** @odoo-module */
import { DatePicker } from "@web/core/datepicker/datepicker";
const { DateTime } = luxon;
export class YearPicker extends DatePicker {
/**
* @override
*/
initFormat() {
super.initFormat();
// moment.js format
this.defaultFormat = "yyyy";
}
/**
* @override
*/
bootstrapDateTimePicker(commandOrParams, ...commandArgs) {
if (typeof commandOrParams === "object") {
const widgetParent = window.$(this.rootRef.el);
commandOrParams = { ...commandOrParams, widgetParent };
}
super.bootstrapDateTimePicker(commandOrParams, ...commandArgs);
}
/**
* @override
*/
setDateAndFormat({ date, locale, format }) {
super.setDateAndFormat({ date, locale, format });
this.staticFormat = "yyyy";
}
}
const props = {
...DatePicker.props,
date: { type: DateTime, optional: true },
};
delete props["format"];
YearPicker.props = props;
YearPicker.defaultProps = {
...DatePicker.defaultProps,
};

View file

@ -0,0 +1,148 @@
/** @odoo-module */
import { serializeDate, serializeDateTime } from "@web/core/l10n/dates";
import { Domain } from "@web/core/domain";
import CommandResult from "@spreadsheet/o_spreadsheet/cancelled_reason";
import { FILTER_DATE_OPTION, monthsOptions } from "@spreadsheet/assets_backend/constants";
import { getPeriodOptions } from "@web/search/utils/dates";
import { RELATIVE_DATE_RANGE_TYPES } from "@spreadsheet/helpers/constants";
const { DateTime } = luxon;
/**
* @typedef {import("@spreadsheet/global_filters/plugins/global_filters_core_plugin").FieldMatching} FieldMatching
*/
export function checkFiltersTypeValueCombination(type, value) {
if (value !== undefined) {
switch (type) {
case "text":
if (typeof value !== "string") {
return CommandResult.InvalidValueTypeCombination;
}
break;
case "date":
if (typeof value === "string") {
const expectedValues = RELATIVE_DATE_RANGE_TYPES.map((val) => val.type);
if (value && !expectedValues.includes(value)) {
return CommandResult.InvalidValueTypeCombination;
}
} else if (typeof value !== "object" || Array.isArray(value)) {
// not a date
return CommandResult.InvalidValueTypeCombination;
}
break;
case "relation":
if (!Array.isArray(value)) {
return CommandResult.InvalidValueTypeCombination;
}
break;
}
}
return CommandResult.Success;
}
/**
*
* @param {Record<string, FieldMatching>} fieldMatchings
*/
export function checkFilterFieldMatching(fieldMatchings) {
for (const fieldMatch of Object.values(fieldMatchings)) {
if (fieldMatch.offset && (!fieldMatch.chain || !fieldMatch.type)) {
return CommandResult.InvalidFieldMatch;
}
}
return CommandResult.Success;
}
/**
* Get a date domain relative to the current date.
* The domain will span the amount of time specified in rangeType and end the day before the current day.
*
*
* @param {Object} now current time, as luxon time
* @param {number} offset offset to add to the date
* @param {"last_month" | "last_week" | "last_year" | "last_three_years"} rangeType
* @param {string} fieldName
* @param {"date" | "datetime"} fieldType
*
* @returns {Domain|undefined}
*/
export function getRelativeDateDomain(now, offset, rangeType, fieldName, fieldType) {
let endDate = now.minus({ day: 1 }).endOf("day");
let startDate = endDate;
switch (rangeType) {
case "last_week": {
const offsetParam = { day: 7 * offset };
endDate = endDate.plus(offsetParam);
startDate = now.minus({ day: 7 }).plus(offsetParam);
break;
}
case "last_month": {
const offsetParam = { day: 30 * offset };
endDate = endDate.plus(offsetParam);
startDate = now.minus({ day: 30 }).plus(offsetParam);
break;
}
case "last_three_months": {
const offsetParam = { day: 90 * offset };
endDate = endDate.plus(offsetParam);
startDate = now.minus({ day: 90 }).plus(offsetParam);
break;
}
case "last_six_months": {
const offsetParam = { day: 180 * offset };
endDate = endDate.plus(offsetParam);
startDate = now.minus({ day: 180 }).plus(offsetParam);
break;
}
case "last_year": {
const offsetParam = { day: 365 * offset };
endDate = endDate.plus(offsetParam);
startDate = now.minus({ day: 365 }).plus(offsetParam);
break;
}
case "last_three_years": {
const offsetParam = { day: 3 * 365 * offset };
endDate = endDate.plus(offsetParam);
startDate = now.minus({ day: 3 * 365 }).plus(offsetParam);
break;
}
default:
return undefined;
}
startDate = startDate.startOf("day");
let leftBound, rightBound;
if (fieldType === "date") {
leftBound = serializeDate(startDate);
rightBound = serializeDate(endDate);
} else {
leftBound = serializeDateTime(startDate);
rightBound = serializeDateTime(endDate);
}
return new Domain(["&", [fieldName, ">=", leftBound], [fieldName, "<=", rightBound]]);
}
/**
* Returns a list of time options to choose from according to the requested
* type. Each option contains its (translated) description.
* see getPeriodOptions
*
*
* @param {string} type "month" | "quarter" | "year"
*
* @returns {Array<Object>}
*/
export function dateOptions(type) {
if (type === "month") {
return monthsOptions;
} else {
return getPeriodOptions(DateTime.local()).filter(({ id }) =>
FILTER_DATE_OPTION[type].includes(id)
);
}
}

View file

@ -0,0 +1,50 @@
/** @odoo-module */
import spreadsheet from "@spreadsheet/o_spreadsheet/o_spreadsheet_extended";
import GlobalFiltersUIPlugin from "./plugins/global_filters_ui_plugin";
import { GlobalFiltersCorePlugin } from "./plugins/global_filters_core_plugin";
const { inverseCommandRegistry } = spreadsheet.registries;
function identity(cmd) {
return [cmd];
}
const { coreTypes, invalidateEvaluationCommands, readonlyAllowedCommands } = spreadsheet;
coreTypes.add("ADD_GLOBAL_FILTER");
coreTypes.add("EDIT_GLOBAL_FILTER");
coreTypes.add("REMOVE_GLOBAL_FILTER");
invalidateEvaluationCommands.add("ADD_GLOBAL_FILTER");
invalidateEvaluationCommands.add("EDIT_GLOBAL_FILTER");
invalidateEvaluationCommands.add("REMOVE_GLOBAL_FILTER");
invalidateEvaluationCommands.add("SET_GLOBAL_FILTER_VALUE");
invalidateEvaluationCommands.add("CLEAR_GLOBAL_FILTER_VALUE");
readonlyAllowedCommands.add("SET_GLOBAL_FILTER_VALUE");
readonlyAllowedCommands.add("SET_MANY_GLOBAL_FILTER_VALUE");
readonlyAllowedCommands.add("CLEAR_GLOBAL_FILTER_VALUE");
readonlyAllowedCommands.add("UPDATE_OBJECT_DOMAINS");
inverseCommandRegistry
.add("EDIT_GLOBAL_FILTER", identity)
.add("ADD_GLOBAL_FILTER", (cmd) => {
return [
{
type: "REMOVE_GLOBAL_FILTER",
id: cmd.id,
},
];
})
.add("REMOVE_GLOBAL_FILTER", (cmd) => {
return [
{
type: "ADD_GLOBAL_FILTER",
id: cmd.id,
filter: {},
},
];
});
export { GlobalFiltersCorePlugin, GlobalFiltersUIPlugin };

View file

@ -0,0 +1,252 @@
/** @odoo-module */
/**
* @typedef {"year"|"month"|"quarter"|"relative"} RangeType
*
/**
* @typedef {Object} FieldMatching
* @property {string} chain name of the field
* @property {string} type type of the field
* @property {number} [offset] offset to apply to the field (for date filters)
*
* @typedef {Object} GlobalFilter
* @property {string} id
* @property {string} label
* @property {string} type "text" | "date" | "relation"
* @property {RangeType} [rangeType]
* @property {boolean} [defaultsToCurrentPeriod]
* @property {string|Array<string>|Object} defaultValue Default Value
* @property {number} [modelID] ID of the related model
* @property {string} [modelName] Name of the related model
*/
export const globalFiltersFieldMatchers = {};
import spreadsheet from "@spreadsheet/o_spreadsheet/o_spreadsheet_extended";
import CommandResult from "@spreadsheet/o_spreadsheet/cancelled_reason";
import { checkFiltersTypeValueCombination } from "@spreadsheet/global_filters/helpers";
import { _t } from "@web/core/l10n/translation";
export class GlobalFiltersCorePlugin extends spreadsheet.CorePlugin {
constructor() {
super(...arguments);
/** @type {Object.<string, GlobalFilter>} */
this.globalFilters = {};
}
/**
* Check if the given command can be dispatched
*
* @param {Object} cmd Command
*/
allowDispatch(cmd) {
switch (cmd.type) {
case "EDIT_GLOBAL_FILTER":
if (!this.getGlobalFilter(cmd.id)) {
return CommandResult.FilterNotFound;
} else if (this._isDuplicatedLabel(cmd.id, cmd.filter.label)) {
return CommandResult.DuplicatedFilterLabel;
}
return checkFiltersTypeValueCombination(cmd.filter.type, cmd.filter.defaultValue);
case "REMOVE_GLOBAL_FILTER":
if (!this.getGlobalFilter(cmd.id)) {
return CommandResult.FilterNotFound;
}
break;
case "ADD_GLOBAL_FILTER":
if (this._isDuplicatedLabel(cmd.id, cmd.filter.label)) {
return CommandResult.DuplicatedFilterLabel;
}
return checkFiltersTypeValueCombination(cmd.filter.type, cmd.filter.defaultValue);
}
return CommandResult.Success;
}
/**
* Handle a spreadsheet command
*
* @param {Object} cmd Command
*/
handle(cmd) {
switch (cmd.type) {
case "ADD_GLOBAL_FILTER":
this._addGlobalFilter(cmd.filter);
break;
case "EDIT_GLOBAL_FILTER":
this._editGlobalFilter(cmd.id, cmd.filter);
break;
case "REMOVE_GLOBAL_FILTER":
this._removeGlobalFilter(cmd.id);
break;
}
}
// ---------------------------------------------------------------------
// Getters
// ---------------------------------------------------------------------
/**
* Retrieve the global filter with the given id
*
* @param {string} id
* @returns {GlobalFilter|undefined} Global filter
*/
getGlobalFilter(id) {
return this.globalFilters[id];
}
/**
* Get the global filter with the given name
*
* @param {string} label Label
*
* @returns {GlobalFilter|undefined}
*/
getGlobalFilterLabel(label) {
return this.getGlobalFilters().find((filter) => _t(filter.label) === _t(label));
}
/**
* Retrieve all the global filters
*
* @returns {Array<GlobalFilter>} Array of Global filters
*/
getGlobalFilters() {
return Object.values(this.globalFilters);
}
/**
* Get the default value of a global filter
*
* @param {string} id Id of the filter
*
* @returns {string|Array<string>|Object}
*/
getGlobalFilterDefaultValue(id) {
return this.getGlobalFilter(id).defaultValue;
}
// ---------------------------------------------------------------------
// Handlers
// ---------------------------------------------------------------------
/**
* Add a global filter
*
* @param {GlobalFilter} filter
*/
_addGlobalFilter(filter) {
const globalFilters = { ...this.globalFilters };
globalFilters[filter.id] = filter;
this.history.update("globalFilters", globalFilters);
}
/**
* Remove a global filter
*
* @param {number} id Id of the filter to remove
*/
_removeGlobalFilter(id) {
const globalFilters = { ...this.globalFilters };
delete globalFilters[id];
this.history.update("globalFilters", globalFilters);
}
/**
* Edit a global filter
*
* @param {number} id Id of the filter to update
* @param {GlobalFilter} newFilter
*/
_editGlobalFilter(id, newFilter) {
const currentLabel = this.getGlobalFilter(id).label;
const globalFilters = { ...this.globalFilters };
newFilter.id = id;
globalFilters[id] = newFilter;
this.history.update("globalFilters", globalFilters);
const newLabel = this.getGlobalFilter(id).label;
if (currentLabel !== newLabel) {
this._updateFilterLabelInFormulas(currentLabel, newLabel);
}
}
// ---------------------------------------------------------------------
// Import/Export
// ---------------------------------------------------------------------
/**
* Import the filters
*
* @param {Object} data
*/
import(data) {
for (const globalFilter of data.globalFilters || []) {
this.globalFilters[globalFilter.id] = globalFilter;
}
}
/**
* Export the filters
*
* @param {Object} data
*/
export(data) {
data.globalFilters = this.getGlobalFilters().map((filter) => ({
...filter,
}));
}
// ---------------------------------------------------------------------
// Global filters
// ---------------------------------------------------------------------
/**
* Update all ODOO.FILTER.VALUE formulas to reference a filter
* by its new label.
*
* @param {string} currentLabel
* @param {string} newLabel
*/
_updateFilterLabelInFormulas(currentLabel, newLabel) {
const sheetIds = this.getters.getSheetIds();
currentLabel = currentLabel.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
for (const sheetId of sheetIds) {
for (const cell of Object.values(this.getters.getCells(sheetId))) {
if (cell.isFormula()) {
const newContent = cell.content.replace(
new RegExp(`FILTER\\.VALUE\\(\\s*"${currentLabel}"\\s*\\)`, "g"),
`FILTER.VALUE("${newLabel}")`
);
if (newContent !== cell.content) {
const { col, row } = this.getters.getCellPosition(cell.id);
this.dispatch("UPDATE_CELL", {
sheetId,
content: newContent,
col,
row,
});
}
}
}
}
}
/**
* Return true if the label is duplicated
*
* @param {string | undefined} filterId
* @param {string} label
* @returns {boolean}
*/
_isDuplicatedLabel(filterId, label) {
return (
this.getGlobalFilters().findIndex(
(filter) => (!filterId || filter.id !== filterId) && filter.label === label
) > -1
);
}
}
GlobalFiltersCorePlugin.getters = [
"getGlobalFilter",
"getGlobalFilters",
"getGlobalFilterDefaultValue",
"getGlobalFilterLabel",
];

View file

@ -0,0 +1,460 @@
/** @odoo-module */
/**
* @typedef {import("@spreadsheet/data_sources/metadata_repository").Field} Field
* @typedef {import("./global_filters_core_plugin").GlobalFilter} GlobalFilter
* @typedef {import("./global_filters_core_plugin").FieldMatching} FieldMatching
*/
import { _t } from "@web/core/l10n/translation";
import { sprintf } from "@web/core/utils/strings";
import { Domain } from "@web/core/domain";
import { constructDateRange, getPeriodOptions, QUARTER_OPTIONS } from "@web/search/utils/dates";
import spreadsheet from "@spreadsheet/o_spreadsheet/o_spreadsheet_extended";
import CommandResult from "@spreadsheet/o_spreadsheet/cancelled_reason";
import { isEmpty } from "@spreadsheet/helpers/helpers";
import { FILTER_DATE_OPTION } from "@spreadsheet/assets_backend/constants";
import {
checkFiltersTypeValueCombination,
getRelativeDateDomain,
} from "@spreadsheet/global_filters/helpers";
import { RELATIVE_DATE_RANGE_TYPES } from "@spreadsheet/helpers/constants";
const { DateTime } = luxon;
const MONTHS = {
january: { value: 1, granularity: "month" },
february: { value: 2, granularity: "month" },
march: { value: 3, granularity: "month" },
april: { value: 4, granularity: "month" },
may: { value: 5, granularity: "month" },
june: { value: 6, granularity: "month" },
july: { value: 7, granularity: "month" },
august: { value: 8, granularity: "month" },
september: { value: 9, granularity: "month" },
october: { value: 10, granularity: "month" },
november: { value: 11, granularity: "month" },
december: { value: 12, granularity: "month" },
};
const { UuidGenerator, createEmptyExcelSheet } = spreadsheet.helpers;
const uuidGenerator = new UuidGenerator();
export default class GlobalFiltersUIPlugin extends spreadsheet.UIPlugin {
constructor(getters, history, dispatch, config) {
super(getters, history, dispatch, config);
this.orm = config.evalContext.env ? config.evalContext.env.services.orm : undefined;
/**
* Cache record display names for relation filters.
* For each filter, contains a promise resolving to
* the list of display names.
*/
this.recordsDisplayName = {};
/** @type {Object.<string, string|Array<string>|Object>} */
this.values = {};
}
/**
* Check if the given command can be dispatched
*
* @param {Object} cmd Command
*/
allowDispatch(cmd) {
switch (cmd.type) {
case "SET_GLOBAL_FILTER_VALUE": {
const filter = this.getters.getGlobalFilter(cmd.id);
if (!filter) {
return CommandResult.FilterNotFound;
}
return checkFiltersTypeValueCombination(filter.type, cmd.value);
}
}
return CommandResult.Success;
}
/**
* Handle a spreadsheet command
*
* @param {Object} cmd Command
*/
handle(cmd) {
switch (cmd.type) {
case "ADD_GLOBAL_FILTER":
this.recordsDisplayName[cmd.filter.id] = cmd.filter.defaultValueDisplayNames;
break;
case "EDIT_GLOBAL_FILTER":
if (this.values[cmd.id] && this.values[cmd.id].rangeType !== cmd.filter.rangeType) {
delete this.values[cmd.id];
}
this.recordsDisplayName[cmd.filter.id] = cmd.filter.defaultValueDisplayNames;
break;
case "SET_GLOBAL_FILTER_VALUE":
this.recordsDisplayName[cmd.id] = cmd.displayNames;
this._setGlobalFilterValue(cmd.id, cmd.value);
break;
case "SET_MANY_GLOBAL_FILTER_VALUE":
for (const filter of cmd.filters) {
if (filter.value !== undefined) {
this.dispatch("SET_GLOBAL_FILTER_VALUE", {
id: filter.filterId,
value: filter.value,
});
} else {
this.dispatch("CLEAR_GLOBAL_FILTER_VALUE", { id: filter.filterId });
}
}
break;
case "REMOVE_GLOBAL_FILTER":
delete this.recordsDisplayName[cmd.id];
delete this.values[cmd.id];
break;
case "CLEAR_GLOBAL_FILTER_VALUE":
this.recordsDisplayName[cmd.id] = [];
this._clearGlobalFilterValue(cmd.id);
break;
}
}
// -------------------------------------------------------------------------
// Getters
// -------------------------------------------------------------------------
/**
* @param {string} filterId
* @param {FieldMatching} fieldMatching
*
* @return {Domain}
*/
getGlobalFilterDomain(filterId, fieldMatching) {
/** @type {GlobalFilter} */
const filter = this.getters.getGlobalFilter(filterId);
if (!filter) {
return new Domain();
}
switch (filter.type) {
case "text":
return this._getTextDomain(filter, fieldMatching);
case "date":
return this._getDateDomain(filter, fieldMatching);
case "relation":
return this._getRelationDomain(filter, fieldMatching);
}
}
/**
* Get the current value of a global filter
*
* @param {string} filterId Id of the filter
*
* @returns {string|Array<string>|Object} value Current value to set
*/
getGlobalFilterValue(filterId) {
const filter = this.getters.getGlobalFilter(filterId);
const value = filterId in this.values ? this.values[filterId].value : filter.defaultValue;
const preventAutomaticValue =
this.values[filterId] &&
this.values[filterId].value &&
this.values[filterId].value.preventAutomaticValue;
const defaultsToCurrentPeriod = !preventAutomaticValue && filter.defaultsToCurrentPeriod;
if (filter.type === "date" && isEmpty(value) && defaultsToCurrentPeriod) {
return this._getValueOfCurrentPeriod(filterId);
}
return value;
}
/**
* @param {string} id Id of the filter
*
* @returns { boolean } true if the given filter is active
*/
isGlobalFilterActive(id) {
const { type } = this.getters.getGlobalFilter(id);
const value = this.getGlobalFilterValue(id);
switch (type) {
case "text":
return value;
case "date":
return (
value &&
(typeof value === "string" || value.yearOffset !== undefined || value.period)
);
case "relation":
return value && value.length;
}
}
/**
* Get the number of active global filters
*
* @returns {number}
*/
getActiveFilterCount() {
return this.getters
.getGlobalFilters()
.filter((filter) => this.isGlobalFilterActive(filter.id)).length;
}
getFilterDisplayValue(filterName) {
const filter = this.getters.getGlobalFilterLabel(filterName);
if (!filter) {
throw new Error(sprintf(_t(`Filter "%s" not found`), filterName));
}
const value = this.getGlobalFilterValue(filter.id);
switch (filter.type) {
case "text":
return value || "";
case "date": {
if (value && typeof value === "string") {
const type = RELATIVE_DATE_RANGE_TYPES.find((type) => type.type === value);
if (!type) {
return "";
}
return type.description.toString();
}
if (!value || value.yearOffset === undefined) {
return "";
}
const periodOptions = getPeriodOptions(DateTime.local());
const year = String(DateTime.local().year + value.yearOffset);
const period = periodOptions.find(({ id }) => value.period === id);
let periodStr = period && period.description;
// Named months aren't in getPeriodOptions
if (!period) {
periodStr =
MONTHS[value.period] && String(MONTHS[value.period].value).padStart(2, "0");
}
return periodStr ? periodStr + "/" + year : year;
}
case "relation":
if (!value || !this.orm) {
return "";
}
if (!this.recordsDisplayName[filter.id]) {
this.orm.call(filter.modelName, "name_get", [value]).then((result) => {
const names = result.map(([, name]) => name);
this.recordsDisplayName[filter.id] = names;
this.dispatch("EVALUATE_CELLS", {
sheetId: this.getters.getActiveSheetId(),
});
});
return "";
}
return this.recordsDisplayName[filter.id].join(", ");
}
}
// -------------------------------------------------------------------------
// Handlers
// -------------------------------------------------------------------------
/**
* Set the current value of a global filter
*
* @param {string} id Id of the filter
* @param {string|Array<string>|Object} value Current value to set
*/
_setGlobalFilterValue(id, value) {
this.values[id] = { value: value, rangeType: this.getters.getGlobalFilter(id).rangeType };
}
/**
* Get the filter value corresponding to the current period, depending of the type of range of the filter.
* For example if rangeType === "month", the value will be the current month of the current year.
*
* @param {string} filterId a global filter
* @return {Object} filter value
*/
_getValueOfCurrentPeriod(filterId) {
const filter = this.getters.getGlobalFilter(filterId);
const rangeType = filter.rangeType;
switch (rangeType) {
case "year":
return { yearOffset: 0 };
case "month": {
const month = new Date().getMonth() + 1;
const period = Object.entries(MONTHS).find((item) => item[1].value === month)[0];
return { yearOffset: 0, period };
}
case "quarter": {
const quarter = Math.floor(new Date().getMonth() / 3);
const period = FILTER_DATE_OPTION.quarter[quarter];
return { yearOffset: 0, period };
}
}
return {};
}
/**
* Set the current value to empty values which functionally deactivate the filter
*
* @param {string} id Id of the filter
*/
_clearGlobalFilterValue(id) {
const { type, rangeType } = this.getters.getGlobalFilter(id);
let value;
switch (type) {
case "text":
value = "";
break;
case "date":
value = { yearOffset: undefined, preventAutomaticValue: true };
break;
case "relation":
value = [];
break;
}
this.values[id] = { value, rangeType };
}
// -------------------------------------------------------------------------
// Private
// -------------------------------------------------------------------------
/**
* Get the domain relative to a date field
*
* @private
*
* @param {GlobalFilter} filter
* @param {FieldMatching} fieldMatching
*
* @returns {Domain}
*/
_getDateDomain(filter, fieldMatching) {
let granularity;
const value = this.getGlobalFilterValue(filter.id);
if (!value || !fieldMatching.chain) {
return new Domain();
}
const field = fieldMatching.chain;
const type = fieldMatching.type;
const offset = fieldMatching.offset || 0;
const now = DateTime.local();
if (filter.rangeType === "relative") {
return getRelativeDateDomain(now, offset, value, field, type);
}
if (value.yearOffset === undefined) {
return new Domain();
}
const setParam = { year: now.year };
const yearOffset = value.yearOffset || 0;
const plusParam = {
years: filter.rangeType === "year" ? yearOffset + offset : yearOffset,
};
if (!value.period || value.period === "empty") {
granularity = "year";
} else {
switch (filter.rangeType) {
case "month":
granularity = "month";
setParam.month = MONTHS[value.period].value;
plusParam.month = offset;
break;
case "quarter":
granularity = "quarter";
setParam.quarter = QUARTER_OPTIONS[value.period].setParam.quarter;
plusParam.quarter = offset;
break;
}
}
return constructDateRange({
referenceMoment: now,
fieldName: field,
fieldType: type,
granularity,
setParam,
plusParam,
}).domain;
}
/**
* Get the domain relative to a text field
*
* @private
*
* @param {GlobalFilter} filter
* @param {FieldMatching} fieldMatching
*
* @returns {Domain}
*/
_getTextDomain(filter, fieldMatching) {
const value = this.getGlobalFilterValue(filter.id);
if (!value || !fieldMatching.chain) {
return new Domain();
}
const field = fieldMatching.chain;
return new Domain([[field, "ilike", value]]);
}
/**
* Get the domain relative to a relation field
*
* @private
*
* @param {GlobalFilter} filter
* @param {FieldMatching} fieldMatching
*
* @returns {Domain}
*/
_getRelationDomain(filter, fieldMatching) {
const values = this.getGlobalFilterValue(filter.id);
if (!values || values.length === 0 || !fieldMatching.chain) {
return new Domain();
}
const field = fieldMatching.chain;
return new Domain([[field, "in", values]]);
}
/**
* Adds all active filters (and their values) at the time of export in a dedicated sheet
*
* @param {Object} data
*/
exportForExcel(data) {
if (this.getters.getGlobalFilters().length === 0) {
return;
}
const styles = Object.entries(data.styles);
let titleStyleId =
styles.findIndex((el) => JSON.stringify(el[1]) === JSON.stringify({ bold: true })) + 1;
if (titleStyleId <= 0) {
titleStyleId = styles.length + 1;
data.styles[styles.length + 1] = { bold: true };
}
const cells = {};
cells["A1"] = { content: "Filter", style: titleStyleId };
cells["B1"] = { content: "Value", style: titleStyleId };
let row = 2;
for (const filter of this.getters.getGlobalFilters()) {
const content = this.getFilterDisplayValue(filter.label);
cells[`A${row}`] = { content: filter.label };
cells[`B${row}`] = { content };
row++;
}
data.sheets.push({
...createEmptyExcelSheet(uuidGenerator.uuidv4(), _t("Active Filters")),
cells,
colNumber: 2,
rowNumber: this.getters.getGlobalFilters().length + 1,
});
}
}
GlobalFiltersUIPlugin.getters = [
"getFilterDisplayValue",
"getGlobalFilterDomain",
"getGlobalFilterValue",
"getActiveFilterCount",
"isGlobalFilterActive",
];

View file

@ -0,0 +1,28 @@
/** @odoo-module */
import { _lt } from "@web/core/l10n/translation";
export const DEFAULT_LINES_NUMBER = 20;
export const FORMATS = {
day: { out: "MM/DD/YYYY", display: "DD MMM YYYY", interval: "d" },
week: { out: "WW/YYYY", display: "[W]W YYYY", interval: "w" },
month: { out: "MM/YYYY", display: "MMMM YYYY", interval: "M" },
quarter: { out: "Q/YYYY", display: "[Q]Q YYYY", interval: "Q" },
year: { out: "YYYY", display: "YYYY", interval: "y" },
};
export const HEADER_STYLE = { fillColor: "#f2f2f2" };
export const TOP_LEVEL_STYLE = { bold: true, fillColor: "#f2f2f2" };
export const MEASURE_STYLE = { fillColor: "#f2f2f2", textColor: "#756f6f" };
export const UNTITLED_SPREADSHEET_NAME = _lt("Untitled spreadsheet");
export const RELATIVE_DATE_RANGE_TYPES = [
{ type: "last_week", description: _lt("Last 7 Days") },
{ type: "last_month", description: _lt("Last 30 Days") },
{ type: "last_three_months", description: _lt("Last 90 Days") },
{ type: "last_six_months", description: _lt("Last 180 Days") },
{ type: "last_year", description: _lt("Last 365 Days") },
{ type: "last_three_years", description: _lt("Last 3 Years") },
];

View file

@ -0,0 +1,108 @@
/** @odoo-module */
import { loadJS } from "@web/core/assets";
/**
* Get the intersection of two arrays
*
* @param {Array} a
* @param {Array} b
*
* @private
* @returns {Array} intersection between a and b
*/
export function intersect(a, b) {
return a.filter((x) => b.includes(x));
}
/**
* Given an object of form {"1": {...}, "2": {...}, ...} get the maximum ID used
* in this object
* If the object has no keys, return 0
*
* @param {Object} o an object for which the keys are an ID
*
* @returns {number}
*/
export function getMaxObjectId(o) {
const keys = Object.keys(o);
if (!keys.length) {
return 0;
}
const nums = keys.map((id) => parseInt(id, 10));
const max = Math.max(...nums);
return max;
}
/** converts and orderBy Object to a string equivalent that can be processed by orm.call */
export function orderByToString(orderBy) {
return orderBy.map((o) => `${o.name} ${o.asc ? "ASC" : "DESC"}`).join(", ");
}
/**
* Convert a spreadsheet date representation to an odoo
* server formatted date
*
* @param {Date} value
* @returns {string}
*/
export function toServerDateString(value) {
return `${value.getFullYear()}-${value.getMonth() + 1}-${value.getDate()}`;
}
/**
* @param {number[]} array
* @returns {number}
*/
export function sum(array) {
return array.reduce((acc, n) => acc + n, 0);
}
function camelToSnakeKey(word) {
const result = word.replace(/(.){1}([A-Z])/g, "$1 $2");
return result.split(" ").join("_").toLowerCase();
}
/**
* Recursively convert camel case object keys to snake case keys
* @param {object} obj
* @returns {object}
*/
export function camelToSnakeObject(obj) {
const result = {};
for (const [key, value] of Object.entries(obj)) {
const isPojo = typeof value === "object" && value !== null && value.constructor === Object;
result[camelToSnakeKey(key)] = isPojo ? camelToSnakeObject(value) : value;
}
return result;
}
/**
* Check if the argument is falsy or is an empty object/array
*
* TODO : remove this and replace it by the one in o_spreadsheet xlsx import when its merged
*/
export function isEmpty(item) {
if (!item) {
return true;
}
if (typeof item === "object") {
if (
Object.values(item).length === 0 ||
Object.values(item).every((val) => val === undefined)
) {
return true;
}
}
return false;
}
/**
* Load external libraries required for o-spreadsheet
* @returns {Promise<void>}
*/
export async function loadSpreadsheetDependencies() {
await loadJS("/web/static/lib/Chart/Chart.js");
// chartjs-gauge should only be loaded when Chart.js is fully loaded !
await loadJS("/spreadsheet/static/lib/chartjs-gauge/chartjs-gauge.js");
}

View file

@ -0,0 +1,70 @@
/** @odoo-module **/
import spreadsheet from "../o_spreadsheet/o_spreadsheet_extended";
const { parse } = spreadsheet;
/**
* @typedef {Object} OdooFunctionDescription
* @property {string} functionName Name of the function
* @property {Array<string>} args Arguments of the function
* @property {boolean} isMatched True if the function is matched by the matcher function
*/
/**
* This function is used to search for the functions which match the given matcher
* from the given formula
*
* @param {string} formula
* @param {string[]} functionNames e.g. ["ODOO.LIST", "ODOO.LIST.HEADER"]
* @private
* @returns {Array<OdooFunctionDescription>}
*/
export function getOdooFunctions(formula, functionNames) {
const formulaUpperCased = formula.toUpperCase();
// Parsing is an expensive operation, so we first check if the
// formula contains one of the function names
if (!functionNames.some((fn) => formulaUpperCased.includes(fn.toUpperCase()))) {
return [];
}
let ast;
try {
ast = parse(formula);
} catch (_) {
return [];
}
return _getOdooFunctionsFromAST(ast, functionNames);
}
/**
* This function is used to search for the functions which match the given matcher
* from the given AST
*
* @param {Object} ast (see o-spreadsheet)
* @param {string[]} functionNames e.g. ["ODOO.LIST", "ODOO.LIST.HEADER"]
*
* @private
* @returns {Array<OdooFunctionDescription>}
*/
function _getOdooFunctionsFromAST(ast, functionNames) {
switch (ast.type) {
case "UNARY_OPERATION":
return _getOdooFunctionsFromAST(ast.operand, functionNames);
case "BIN_OPERATION": {
return _getOdooFunctionsFromAST(ast.left, functionNames).concat(
_getOdooFunctionsFromAST(ast.right, functionNames)
);
}
case "FUNCALL": {
const functionName = ast.value.toUpperCase();
if (functionNames.includes(functionName)) {
return [{ functionName, args: ast.args, isMatched: true }];
} else {
return ast.args.map((arg) => _getOdooFunctionsFromAST(arg, functionNames)).flat();
}
}
default:
return [];
}
}

View file

@ -0,0 +1,40 @@
/** @odoo-module */
/**
* This file is meant to load the different subparts of the module
* to guarantee their plugins are loaded in the right order
*
* dependency:
* other plugins
* |
* ...
* |
* filters
* /\ \
* / \ \
* pivot list Odoo chart
*/
/** TODO: Introduce a position parameter to the plugin registry in order to load them in a specific order */
import spreadsheet from "@spreadsheet/o_spreadsheet/o_spreadsheet_extended";
const { corePluginRegistry, uiPluginRegistry } = spreadsheet.registries;
import { GlobalFiltersCorePlugin, GlobalFiltersUIPlugin } from "@spreadsheet/global_filters/index";
import { PivotCorePlugin, PivotUIPlugin } from "@spreadsheet/pivot/index"; // list depends on filter for its getters
import { ListCorePlugin, ListUIPlugin } from "@spreadsheet/list/index"; // pivot depends on filter for its getters
import {
ChartOdooMenuPlugin,
OdooChartCorePlugin,
OdooChartUIPlugin,
} from "@spreadsheet/chart/index"; // Odoochart depends on filter for its getters
corePluginRegistry.add("OdooGlobalFiltersCorePlugin", GlobalFiltersCorePlugin);
corePluginRegistry.add("OdooPivotCorePlugin", PivotCorePlugin);
corePluginRegistry.add("OdooListCorePlugin", ListCorePlugin);
corePluginRegistry.add("odooChartCorePlugin", OdooChartCorePlugin);
corePluginRegistry.add("chartOdooMenuPlugin", ChartOdooMenuPlugin);
uiPluginRegistry.add("OdooGlobalFiltersUIPlugin", GlobalFiltersUIPlugin);
uiPluginRegistry.add("OdooPivotUIPlugin", PivotUIPlugin);
uiPluginRegistry.add("OdooListUIPlugin", ListUIPlugin);
uiPluginRegistry.add("odooChartUIPlugin", OdooChartUIPlugin);

View file

@ -0,0 +1,73 @@
/** @odoo-module */
import { registry } from "@web/core/registry";
import spreadsheet from "@spreadsheet/o_spreadsheet/o_spreadsheet_extended";
import IrMenuPlugin from "./ir_ui_menu_plugin";
import {
isMarkdownIrMenuIdLink,
isMarkdownIrMenuXmlLink,
isMarkdownViewLink,
parseIrMenuXmlLink,
OdooViewLinkCell,
OdooMenuLinkCell,
parseViewLink,
parseIrMenuIdLink,
} from "./odoo_menu_link_cell";
const { cellRegistry, corePluginRegistry } = spreadsheet.registries;
const { parseMarkdownLink } = spreadsheet.helpers;
corePluginRegistry.add("ir_ui_menu_plugin", IrMenuPlugin);
export const spreadsheetLinkMenuCellService = {
dependencies: ["menu"],
start(env) {
function _getIrMenuByXmlId(xmlId) {
const menu = env.services.menu.getAll().find((menu) => menu.xmlid === xmlId);
if (!menu) {
throw new Error(
`Menu ${xmlId} not found. You may not have the required access rights.`
);
}
return menu;
}
cellRegistry
.add("OdooMenuIdLink", {
sequence: 65,
match: isMarkdownIrMenuIdLink,
createCell: (id, content, properties, sheetId, getters) => {
const { url } = parseMarkdownLink(content);
const menuId = parseIrMenuIdLink(url);
const menuName = env.services.menu.getMenu(menuId).name;
return new OdooMenuLinkCell(id, content, menuId, menuName, properties);
},
})
.add("OdooMenuXmlLink", {
sequence: 66,
match: isMarkdownIrMenuXmlLink,
createCell: (id, content, properties, sheetId, getters) => {
const { url } = parseMarkdownLink(content);
const xmlId = parseIrMenuXmlLink(url);
const menuId = _getIrMenuByXmlId(xmlId).id;
const menuName = _getIrMenuByXmlId(xmlId).name;
return new OdooMenuLinkCell(id, content, menuId, menuName, properties);
},
})
.add("OdooIrFilterLink", {
sequence: 67,
match: isMarkdownViewLink,
createCell: (id, content, properties, sheetId, getters) => {
const { url } = parseMarkdownLink(content);
const actionDescription = parseViewLink(url);
return new OdooViewLinkCell(id, content, actionDescription, properties);
},
});
return true;
},
};
registry.category("services").add("spreadsheetLinkMenuCell", spreadsheetLinkMenuCellService);

View file

@ -0,0 +1,24 @@
/** @odoo-module */
import spreadsheet from "@spreadsheet/o_spreadsheet/o_spreadsheet_extended";
const { CorePlugin } = spreadsheet;
export default class IrMenuPlugin extends CorePlugin {
constructor(getters, history, range, dispatch, config, uuidGenerator) {
super(getters, history, range, dispatch, config, uuidGenerator);
this.env = config.evalContext.env;
}
/**
* Get an ir menu from an id or an xml id
* @param {number | string} menuId
* @returns {object | undefined}
*/
getIrMenu(menuId) {
let menu = this.env.services.menu.getMenu(menuId);
if (!menu) {
menu = this.env.services.menu.getAll().find((menu) => menu.xmlid === menuId);
}
return menu;
}
}
IrMenuPlugin.getters = ["getIrMenu"];

View file

@ -0,0 +1,171 @@
/** @odoo-module */
import spreadsheet from "@spreadsheet/o_spreadsheet/o_spreadsheet_extended";
const { LinkCell } = spreadsheet.cellTypes;
const { isMarkdownLink, parseMarkdownLink } = spreadsheet.helpers;
const VIEW_PREFIX = "odoo://view/";
const IR_MENU_ID_PREFIX = "odoo://ir_menu_id/";
const IR_MENU_XML_ID_PREFIX = "odoo://ir_menu_xml_id/";
/**
* @typedef Action
* @property {Array} domain
* @property {Object} context
* @property {string} modelName
* @property {string} orderBy
* @property {Array<[boolean, string]} views
*
* @typedef ViewLinkDescription
* @property {string} name Action name
* @property {Action} action
* @property {string} viewType Type of view (list, pivot, ...)
*/
/**
*
* @param {string} str
* @returns {boolean}
*/
export function isMarkdownViewLink(str) {
if (!isMarkdownLink(str)) {
return false;
}
const { url } = parseMarkdownLink(str);
return url.startsWith(VIEW_PREFIX);
}
/**
*
* @param {string} viewLink
* @returns {ViewLinkDescription}
*/
export function parseViewLink(viewLink) {
if (viewLink.startsWith(VIEW_PREFIX)) {
return JSON.parse(viewLink.substr(VIEW_PREFIX.length));
}
throw new Error(`${viewLink} is not a valid view link`);
}
/**
* @param {ViewLinkDescription} viewDescription Id of the ir.filter
* @returns {string}
*/
export function buildViewLink(viewDescription) {
return `${VIEW_PREFIX}${JSON.stringify(viewDescription)}`;
}
/**
*
* @param {string} str
* @returns
*/
export function isMarkdownIrMenuIdLink(str) {
if (!isMarkdownLink(str)) {
return false;
}
const { url } = parseMarkdownLink(str);
return url.startsWith(IR_MENU_ID_PREFIX);
}
/**
*
* @param {string} irMenuLink
* @returns ir.ui.menu record id
*/
export function parseIrMenuIdLink(irMenuLink) {
if (irMenuLink.startsWith(IR_MENU_ID_PREFIX)) {
return parseInt(irMenuLink.substr(IR_MENU_ID_PREFIX.length), 10);
}
throw new Error(`${irMenuLink} is not a valid menu id link`);
}
/**
* @param {number} menuId
* @returns
*/
export function buildIrMenuIdLink(menuId) {
return `${IR_MENU_ID_PREFIX}${menuId}`;
}
/**
*
* @param {string} str
* @returns
*/
export function isMarkdownIrMenuXmlLink(str) {
if (!isMarkdownLink(str)) {
return false;
}
const { url } = parseMarkdownLink(str);
return url.startsWith(IR_MENU_XML_ID_PREFIX);
}
/**
*
* @param {string} irMenuLink
* @returns ir.ui.menu record id
*/
export function parseIrMenuXmlLink(irMenuLink) {
if (irMenuLink.startsWith(IR_MENU_XML_ID_PREFIX)) {
return irMenuLink.substr(IR_MENU_XML_ID_PREFIX.length);
}
throw new Error(`${irMenuLink} is not a valid menu xml link`);
}
/**
* @param {number} menuXmlId
* @returns
*/
export function buildIrMenuXmlLink(menuXmlId) {
return `${IR_MENU_XML_ID_PREFIX}${menuXmlId}`;
}
export class OdooMenuLinkCell extends LinkCell {
constructor(id, content, menuId, menuName, properties = {}) {
super(id, content, properties);
this.urlRepresentation = menuName;
this.isUrlEditable = false;
this._irMenuId = menuId;
}
action(env) {
const menu = env.services.menu.getMenu(this._irMenuId);
env.services.action.doAction(menu.actionID);
}
}
export class OdooViewLinkCell extends LinkCell {
/**
*
* @param {string} id
* @param {string} content
* @param {ViewLinkDescription} actionDescription
* @param {Object} properties
*/
constructor(id, content, actionDescription, properties = {}) {
super(id, content, properties);
this.urlRepresentation = actionDescription.name;
this.isUrlEditable = false;
this._viewType = actionDescription.viewType;
/** @type {Action} */
this._action = actionDescription.action;
}
action(env) {
env.services.action.doAction(
{
type: "ir.actions.act_window",
name: this.urlRepresentation,
res_model: this._action.modelName,
views: this._action.views,
target: "current",
domain: this._action.domain,
context: this._action.context,
},
{
viewType: this._viewType,
}
);
}
}

View file

@ -0,0 +1,52 @@
/** @odoo-module */
import { _lt } from "@web/core/l10n/translation";
import spreadsheet from "@spreadsheet/o_spreadsheet/o_spreadsheet_extended";
import "./list_functions";
import ListCorePlugin from "@spreadsheet/list/plugins/list_core_plugin";
import ListUIPlugin from "@spreadsheet/list/plugins/list_ui_plugin";
import { SEE_RECORD_LIST, SEE_RECORD_LIST_VISIBLE } from "./list_actions";
const { inverseCommandRegistry } = spreadsheet.registries;
function identity(cmd) {
return [cmd];
}
const { coreTypes, invalidateEvaluationCommands } = spreadsheet;
const { cellMenuRegistry } = spreadsheet.registries;
coreTypes.add("INSERT_ODOO_LIST");
coreTypes.add("RENAME_ODOO_LIST");
coreTypes.add("REMOVE_ODOO_LIST");
coreTypes.add("RE_INSERT_ODOO_LIST");
coreTypes.add("UPDATE_ODOO_LIST_DOMAIN");
coreTypes.add("ADD_LIST_DOMAIN");
invalidateEvaluationCommands.add("UPDATE_ODOO_LIST_DOMAIN");
invalidateEvaluationCommands.add("INSERT_ODOO_LIST");
invalidateEvaluationCommands.add("REMOVE_ODOO_LIST");
cellMenuRegistry.add("list_see_record", {
name: _lt("See record"),
sequence: 200,
action: async (env) => {
const cell = env.model.getters.getActiveCell();
await SEE_RECORD_LIST(cell, env);
},
isVisible: (env) => {
const cell = env.model.getters.getActiveCell();
return SEE_RECORD_LIST_VISIBLE(cell);
},
});
inverseCommandRegistry
.add("INSERT_ODOO_LIST", identity)
.add("UPDATE_ODOO_LIST_DOMAIN", identity)
.add("RE_INSERT_ODOO_LIST", identity)
.add("RENAME_ODOO_LIST", identity)
.add("REMOVE_ODOO_LIST", identity);
export { ListCorePlugin, ListUIPlugin };

View file

@ -0,0 +1,41 @@
/** @odoo-module */
import spreadsheet from "@spreadsheet/o_spreadsheet/o_spreadsheet_extended";
import { getFirstListFunction, getNumberOfListFormulas } from "./list_helpers";
const { astToFormula } = spreadsheet;
export const SEE_RECORD_LIST = async (cell, env) => {
const { col, row, sheetId } = env.model.getters.getCellPosition(cell.id);
if (!cell) {
return;
}
const { args } = getFirstListFunction(cell.content);
const evaluatedArgs = args
.map(astToFormula)
.map((arg) => env.model.getters.evaluateFormula(arg));
const listId = env.model.getters.getListIdFromPosition(sheetId, col, row);
const { model } = env.model.getters.getListDefinition(listId);
const dataSource = await env.model.getters.getAsyncListDataSource(listId);
const recordId = dataSource.getIdFromPosition(evaluatedArgs[1] - 1);
if (!recordId) {
return;
}
await env.services.action.doAction({
type: "ir.actions.act_window",
res_model: model,
res_id: recordId,
views: [[false, "form"]],
view_mode: "form",
});
};
export const SEE_RECORD_LIST_VISIBLE = (cell) => {
return (
cell &&
cell.evaluated.value !== "" &&
!cell.evaluated.error &&
getNumberOfListFormulas(cell.content) === 1 &&
getFirstListFunction(cell.content).functionName === "ODOO.LIST"
);
};

View file

@ -0,0 +1,212 @@
/** @odoo-module */
import { OdooViewsDataSource } from "@spreadsheet/data_sources/odoo_views_data_source";
import { orderByToString } from "@spreadsheet/helpers/helpers";
import { LoadingDataError } from "@spreadsheet/o_spreadsheet/errors";
import { _t } from "@web/core/l10n/translation";
import { sprintf } from "@web/core/utils/strings";
import {
formatDateTime,
deserializeDateTime,
formatDate,
deserializeDate,
} from "@web/core/l10n/dates";
import spreadsheet from "../o_spreadsheet/o_spreadsheet_extended";
const { toNumber } = spreadsheet.helpers;
/**
* @typedef {import("@spreadsheet/data_sources/metadata_repository").Field} Field
*
* @typedef {Object} ListMetaData
* @property {Array<string>} columns
* @property {string} resModel
* @property {Record<string, Field>} fields
*
* @typedef {Object} ListSearchParams
* @property {Array<string>} orderBy
* @property {Object} domain
* @property {Object} context
*/
export default class ListDataSource extends OdooViewsDataSource {
/**
* @override
* @param {Object} services Services (see DataSource)
* @param {Object} params
* @param {ListMetaData} params.metaData
* @param {ListSearchParams} params.searchParams
* @param {number} params.limit
*/
constructor(services, params) {
super(services, params);
this.maxPosition = params.limit;
this.maxPositionFetched = 0;
this.data = [];
}
/**
* Increase the max position of the list
* @param {number} position
*/
increaseMaxPosition(position) {
this.maxPosition = Math.max(this.maxPosition, position);
}
async _load() {
await super._load();
if (this.maxPosition === 0) {
this.data = [];
return;
}
const { domain, orderBy, context } = this._searchParams;
this.data = await this._orm.searchRead(
this._metaData.resModel,
domain,
this._getFieldsToFetch(),
{
order: orderByToString(orderBy),
limit: this.maxPosition,
context,
}
);
this.maxPositionFetched = this.maxPosition;
}
/**
* Get the fields to fetch from the server.
* Automatically add the currency field if the field is a monetary field.
*/
_getFieldsToFetch() {
const fields = this._metaData.columns.filter((f) => this.getField(f));
for (const field of fields) {
if (this.getField(field).type === "monetary") {
fields.push(this.getField(field).currency_field);
}
}
return fields;
}
/**
* @param {number} position
* @returns {number}
*/
getIdFromPosition(position) {
this._assertDataIsLoaded();
const record = this.data[position];
return record ? record.id : undefined;
}
/**
* @param {string} fieldName
* @returns {string}
*/
getListHeaderValue(fieldName) {
this._assertDataIsLoaded();
const field = this.getField(fieldName);
return field ? field.string : fieldName;
}
/**
* @param {number} position
* @param {string} fieldName
* @returns {string|number|undefined}
*/
getListCellValue(position, fieldName) {
this._assertDataIsLoaded();
if (position >= this.maxPositionFetched) {
this.increaseMaxPosition(position + 1);
// A reload is needed because the asked position is not already loaded.
this._triggerFetching();
throw new LoadingDataError();
}
const record = this.data[position];
if (!record) {
return "";
}
const field = this.getField(fieldName);
if (!field) {
throw new Error(
sprintf(
_t("The field %s does not exist or you do not have access to that field"),
fieldName
)
);
}
if (!(fieldName in record)) {
this._metaData.columns.push(fieldName);
this._metaData.columns = [...new Set(this._metaData.columns)]; //Remove duplicates
this._triggerFetching();
throw new LoadingDataError();
}
switch (field.type) {
case "many2one":
return record[fieldName].length === 2 ? record[fieldName][1] : "";
case "one2many":
case "many2many": {
const labels = record[fieldName]
.map((id) => this._metadataRepository.getRecordDisplayName(field.relation, id))
.filter((value) => value !== undefined);
return labels.join(", ");
}
case "selection": {
const key = record[fieldName];
const value = field.selection.find((array) => array[0] === key);
return value ? value[1] : "";
}
case "boolean":
return record[fieldName] ? "TRUE" : "FALSE";
case "date":
return record[fieldName] ? toNumber(this._formatDate(record[fieldName])) : "";
case "datetime":
return record[fieldName] ? toNumber(this._formatDateTime(record[fieldName])) : "";
case "properties": {
const properties = record[fieldName] || [];
return properties.map((property) => property.string).join(", ");
}
case "json":
throw new Error(sprintf(_t('Fields of type "%s" are not supported'), "json"));
default:
return record[fieldName] || "";
}
}
//--------------------------------------------------------------------------
// Private
//--------------------------------------------------------------------------
_formatDateTime(dateValue) {
const date = deserializeDateTime(dateValue);
return formatDateTime(date, {
format: "yyyy-MM-dd HH:mm:ss",
numberingSystem: "latn",
});
}
_formatDate(dateValue) {
const date = deserializeDate(dateValue);
return formatDate(date, {
format: "yyyy-MM-dd",
numberingSystem: "latn",
});
}
/**
* Ask the parent data source to force a reload of this data source in the
* next clock cycle. It's necessary when this.limit was updated and new
* records have to be fetched.
*/
_triggerFetching() {
if (this._fetchingPromise) {
return;
}
this._fetchingPromise = Promise.resolve().then(() => {
new Promise((resolve) => {
this.load({ reload: true });
this._fetchingPromise = undefined;
resolve();
});
});
}
}

View file

@ -0,0 +1,74 @@
/** @odoo-module **/
import { _t } from "web.core";
import spreadsheet from "@spreadsheet/o_spreadsheet/o_spreadsheet_extended";
const { args, toString, toNumber } = spreadsheet.helpers;
const { functionRegistry } = spreadsheet.registries;
//--------------------------------------------------------------------------
// Spreadsheet functions
//--------------------------------------------------------------------------
function assertListsExists(listId, getters) {
if (!getters.isExistingList(listId)) {
throw new Error(_.str.sprintf(_t('There is no list with id "%s"'), listId));
}
}
functionRegistry.add("ODOO.LIST", {
description: _t("Get the value from a list."),
args: args(`
list_id (string) ${_t("ID of the list.")}
index (string) ${_t("Position of the record in the list.")}
field_name (string) ${_t("Name of the field.")}
`),
compute: function (listId, index, fieldName) {
const id = toString(listId);
const position = toNumber(index) - 1;
const field = toString(fieldName);
assertListsExists(id, this.getters);
return this.getters.getListCellValue(id, position, field);
},
computeFormat: function (listId, index, fieldName) {
const id = toString(listId.value);
const position = toNumber(index.value) - 1;
const field = this.getters.getListDataSource(id).getField(toString(fieldName.value));
switch (field.type) {
case "integer":
return "0";
case "float":
return "#,##0.00";
case "monetary": {
const currencyName = this.getters.getListCellValue(
id,
position,
field.currency_field
);
return this.getters.getCurrencyFormat(currencyName);
}
case "date":
return "m/d/yyyy";
case "datetime":
return "m/d/yyyy hh:mm:ss";
default:
return undefined;
}
},
returns: ["NUMBER", "STRING"],
});
functionRegistry.add("ODOO.LIST.HEADER", {
description: _t("Get the header of a list."),
args: args(`
list_id (string) ${_t("ID of the list.")}
field_name (string) ${_t("Name of the field.")}
`),
compute: function (listId, fieldName) {
const id = toString(listId);
const field = toString(fieldName);
assertListsExists(id, this.getters);
return this.getters.getListHeaderValue(id, field);
},
returns: ["NUMBER", "STRING"],
});

View file

@ -0,0 +1,27 @@
/** @odoo-module */
import { getOdooFunctions } from "../helpers/odoo_functions_helpers";
/**
* Parse a spreadsheet formula and detect the number of LIST functions that are
* present in the given formula.
*
* @param {string} formula
*
* @returns {number}
*/
export function getNumberOfListFormulas(formula) {
return getOdooFunctions(formula, ["ODOO.LIST", "ODOO.LIST.HEADER"]).filter((fn) => fn.isMatched)
.length;
}
/**
* Get the first List function description of the given formula.
*
* @param {string} formula
*
* @returns {import("../helpers/odoo_functions_helpers").OdooFunctionDescription|undefined}
*/
export function getFirstListFunction(formula) {
return getOdooFunctions(formula, ["ODOO.LIST", "ODOO.LIST.HEADER"]).find((fn) => fn.isMatched);
}

View file

@ -0,0 +1,505 @@
/** @odoo-module */
import spreadsheet from "../../o_spreadsheet/o_spreadsheet_extended";
import CommandResult from "../../o_spreadsheet/cancelled_reason";
import { getMaxObjectId } from "../../helpers/helpers";
import ListDataSource from "../list_data_source";
import { TOP_LEVEL_STYLE } from "../../helpers/constants";
import { _t } from "@web/core/l10n/translation";
import { globalFiltersFieldMatchers } from "@spreadsheet/global_filters/plugins/global_filters_core_plugin";
import { sprintf } from "@web/core/utils/strings";
import { checkFilterFieldMatching } from "@spreadsheet/global_filters/helpers";
import { getFirstListFunction, getNumberOfListFormulas } from "../list_helpers";
/**
* @typedef {Object} ListDefinition
* @property {Array<string>} columns
* @property {Object} context
* @property {Array<Array<string>>} domain
* @property {string} id The id of the list
* @property {string} model The technical name of the model we are listing
* @property {string} name Name of the list
* @property {Array<string>} orderBy
*
* @typedef {Object} List
* @property {string} id
* @property {string} dataSourceId
* @property {ListDefinition} definition
* @property {Object} fieldMatching
*
* @typedef {import("@spreadsheet/global_filters/plugins/global_filters_core_plugin").FieldMatching} FieldMatching
*/
const { CorePlugin } = spreadsheet;
export default class ListCorePlugin extends CorePlugin {
constructor(getters, history, range, dispatch, config, uuidGenerator) {
super(getters, history, range, dispatch, config, uuidGenerator);
this.dataSources = config.dataSources;
this.nextId = 1;
/** @type {Object.<string, List>} */
this.lists = {};
globalFiltersFieldMatchers["list"] = {
geIds: () => this.getters.getListIds(),
getDisplayName: (listId) => this.getters.getListName(listId),
getTag: (listId) => sprintf(_t("List #%s"), listId),
getFieldMatching: (listId, filterId) => this.getListFieldMatching(listId, filterId),
waitForReady: () => this.getListsWaitForReady(),
getModel: (listId) => this.getListDefinition(listId).model,
getFields: (listId) => this.getListDataSource(listId).getFields(),
};
}
allowDispatch(cmd) {
switch (cmd.type) {
case "INSERT_ODOO_LIST":
if (cmd.id !== this.nextId.toString()) {
return CommandResult.InvalidNextId;
}
if (this.lists[cmd.id]) {
return CommandResult.ListIdDuplicated;
}
break;
case "RENAME_ODOO_LIST":
if (!(cmd.listId in this.lists)) {
return CommandResult.ListIdNotFound;
}
if (cmd.name === "") {
return CommandResult.EmptyName;
}
break;
case "UPDATE_ODOO_LIST_DOMAIN":
if (!(cmd.listId in this.lists)) {
return CommandResult.ListIdNotFound;
}
break;
case "ADD_GLOBAL_FILTER":
case "EDIT_GLOBAL_FILTER":
if (cmd.list) {
return checkFilterFieldMatching(cmd.list);
}
}
return CommandResult.Success;
}
/**
* Handle a spreadsheet command
*
* @param {Object} cmd Command
*/
handle(cmd) {
switch (cmd.type) {
case "INSERT_ODOO_LIST": {
const {
sheetId,
col,
row,
id,
definition,
dataSourceId,
linesNumber,
columns,
} = cmd;
const anchor = [col, row];
this._addList(id, definition, dataSourceId, linesNumber);
this._insertList(sheetId, anchor, id, linesNumber, columns);
this.history.update("nextId", parseInt(id, 10) + 1);
break;
}
case "RE_INSERT_ODOO_LIST": {
const { sheetId, col, row, id, linesNumber, columns } = cmd;
const anchor = [col, row];
this._insertList(sheetId, anchor, id, linesNumber, columns);
break;
}
case "RENAME_ODOO_LIST": {
this.history.update("lists", cmd.listId, "definition", "name", cmd.name);
break;
}
case "REMOVE_ODOO_LIST": {
const lists = { ...this.lists };
delete lists[cmd.listId];
this.history.update("lists", lists);
break;
}
case "UPDATE_ODOO_LIST_DOMAIN": {
this.history.update(
"lists",
cmd.listId,
"definition",
"searchParams",
"domain",
cmd.domain
);
const list = this.lists[cmd.listId];
this.dataSources.add(list.dataSourceId, ListDataSource, list.definition);
break;
}
case "UNDO":
case "REDO": {
const domainEditionCommands = cmd.commands.filter(
(cmd) => cmd.type === "UPDATE_ODOO_LIST_DOMAIN"
);
for (const cmd of domainEditionCommands) {
const list = this.lists[cmd.listId];
this.dataSources.add(list.dataSourceId, ListDataSource, list.definition);
}
break;
}
case "ADD_GLOBAL_FILTER":
case "EDIT_GLOBAL_FILTER":
if (cmd.list) {
this._setListFieldMatching(cmd.filter.id, cmd.list);
}
break;
case "REMOVE_GLOBAL_FILTER":
this._onFilterDeletion(cmd.id);
break;
case "START":
for (const sheetId of this.getters.getSheetIds()) {
const cells = this.getters.getCells(sheetId);
for (const cell of Object.values(cells)) {
if (cell.isFormula()) {
this._addListPositionToDataSource(cell.content);
}
}
}
break;
case "UPDATE_CELL":
if (cmd.content) {
this._addListPositionToDataSource(cmd.content);
}
break;
}
}
/**
* Extract the position of the records asked in the given formula and
* increase the max position of the corresponding data source.
*
* @param {string} content Odoo list formula
*/
_addListPositionToDataSource(content) {
if (getNumberOfListFormulas(content) !== 1) {
return;
}
const { functionName, args } = getFirstListFunction(content);
if (functionName !== "ODOO.LIST") {
return;
}
const [listId, positionArg] = args.map((arg) => arg.value.toString());
if (!(listId in this.lists)) {
return;
}
const position = parseInt(positionArg, 10);
if (isNaN(position)) {
return;
}
const dataSourceId = this.lists[listId].dataSourceId;
this.dataSources.get(dataSourceId).increaseMaxPosition(position);
}
// -------------------------------------------------------------------------
// Getters
// -------------------------------------------------------------------------
/**
* @param {string} id
* @returns {import("@spreadsheet/list/list_data_source").default|undefined}
*/
getListDataSource(id) {
const dataSourceId = this.lists[id].dataSourceId;
return this.dataSources.get(dataSourceId);
}
/**
* @param {string} id
* @returns {string}
*/
getListDisplayName(id) {
return `(#${id}) ${this.getListName(id)}`;
}
/**
* @param {string} id
* @returns {string}
*/
getListName(id) {
return _t(this.lists[id].definition.name);
}
/**
* @param {string} id
* @returns {string}
*/
getListFieldMatch(id) {
return this.lists[id].fieldMatching;
}
/**
* @param {string} id
* @returns {Promise<import("@spreadsheet/list/list_data_source").default>}
*/
async getAsyncListDataSource(id) {
const dataSourceId = this.lists[id].dataSourceId;
await this.dataSources.load(dataSourceId);
return this.getListDataSource(id);
}
/**
* Retrieve all the list ids
*
* @returns {Array<string>} list ids
*/
getListIds() {
return Object.keys(this.lists);
}
/**
* Retrieve the next available id for a new list
*
* @returns {string} id
*/
getNextListId() {
return this.nextId.toString();
}
/**
* @param {string} id
* @returns {ListDefinition}
*/
getListDefinition(id) {
const def = this.lists[id].definition;
return {
columns: [...def.metaData.columns],
domain: [...def.searchParams.domain],
model: def.metaData.resModel,
context: { ...def.searchParams.context },
orderBy: [...def.searchParams.orderBy],
id,
name: def.name,
};
}
/**
* Check if an id is an id of an existing list
*
* @param {string} id Id of the list
*
* @returns {boolean}
*/
isExistingList(id) {
return id in this.lists;
}
// ---------------------------------------------------------------------
// Private
// ---------------------------------------------------------------------
/**
*
* @return {Promise[]}
*/
getListsWaitForReady() {
return this.getListIds().map((ListId) => this.getListDataSource(ListId).loadMetadata());
}
/**
* Get the current FieldMatching on a list
*
* @param {string} listId
* @param {string} filterId
*/
getListFieldMatching(listId, filterId) {
return this.lists[listId].fieldMatching[filterId];
}
/**
* Sets the current FieldMatching on a list
*
* @param {string} filterId
* @param {Record<string,FieldMatching>} listFieldMatches
*/
_setListFieldMatching(filterId, listFieldMatches) {
const lists = { ...this.lists };
for (const [listId, fieldMatch] of Object.entries(listFieldMatches)) {
lists[listId].fieldMatching[filterId] = fieldMatch;
}
this.history.update("lists", lists);
}
_onFilterDeletion(filterId) {
const lists = { ...this.lists };
for (const listId in lists) {
this.history.update("lists", listId, "fieldMatching", filterId, undefined);
}
}
_addList(id, definition, dataSourceId, limit, fieldMatching = {}) {
const lists = { ...this.lists };
lists[id] = {
id,
definition,
dataSourceId,
fieldMatching,
};
if (!this.dataSources.contains(dataSourceId)) {
this.dataSources.add(dataSourceId, ListDataSource, {
...definition,
limit,
});
}
this.history.update("lists", lists);
}
/**
* Build an Odoo List
* @param {string} sheetId Id of the sheet
* @param {[number,number]} anchor Top-left cell in which the list should be inserted
* @param {string} id Id of the list
* @param {number} linesNumber Number of records to insert
* @param {Array<Object>} columns Columns ({name, type})
*/
_insertList(sheetId, anchor, id, linesNumber, columns) {
this._resizeSheet(sheetId, anchor, columns.length, linesNumber + 1);
this._insertHeaders(sheetId, anchor, id, columns);
this._insertValues(sheetId, anchor, id, columns, linesNumber);
}
_insertHeaders(sheetId, anchor, id, columns) {
let [col, row] = anchor;
for (const column of columns) {
this.dispatch("UPDATE_CELL", {
sheetId,
col,
row,
content: `=ODOO.LIST.HEADER(${id},"${column.name}")`,
});
col++;
}
this.dispatch("SET_FORMATTING", {
sheetId,
style: TOP_LEVEL_STYLE,
target: [
{
top: anchor[1],
bottom: anchor[1],
left: anchor[0],
right: anchor[0] + columns.length - 1,
},
],
});
}
_insertValues(sheetId, anchor, id, columns, linesNumber) {
let col = anchor[0];
let row = anchor[1] + 1;
for (let i = 1; i <= linesNumber; i++) {
col = anchor[0];
for (const column of columns) {
this.dispatch("UPDATE_CELL", {
sheetId,
col,
row,
content: `=ODOO.LIST(${id},${i},"${column.name}")`,
});
col++;
}
row++;
}
}
/**
* Resize the sheet to match the size of the listing. Columns and/or rows
* could be added to be sure to insert the entire sheet.
*
* @param {string} sheetId Id of the sheet
* @param {[number,number]} anchor Anchor of the list [col,row]
* @param {number} columns Number of columns of the list
* @param {number} rows Number of rows of the list
*/
_resizeSheet(sheetId, anchor, columns, rows) {
const numberCols = this.getters.getNumberCols(sheetId);
const deltaCol = numberCols - anchor[0];
if (deltaCol < columns) {
this.dispatch("ADD_COLUMNS_ROWS", {
dimension: "COL",
base: numberCols - 1,
sheetId: sheetId,
quantity: columns - deltaCol,
position: "after",
});
}
const numberRows = this.getters.getNumberRows(sheetId);
const deltaRow = numberRows - anchor[1];
if (deltaRow < rows) {
this.dispatch("ADD_COLUMNS_ROWS", {
dimension: "ROW",
base: numberRows - 1,
sheetId: sheetId,
quantity: rows - deltaRow,
position: "after",
});
}
}
// ---------------------------------------------------------------------
// Import/Export
// ---------------------------------------------------------------------
/**
* Import the lists
*
* @param {Object} data
*/
import(data) {
if (data.lists) {
for (const [id, list] of Object.entries(data.lists)) {
const definition = {
metaData: {
resModel: list.model,
columns: list.columns,
},
searchParams: {
domain: list.domain,
context: list.context,
orderBy: list.orderBy,
},
name: list.name,
};
this._addList(id, definition, this.uuidGenerator.uuidv4(), 0, list.fieldMatching);
}
}
this.nextId = data.listNextId || getMaxObjectId(this.lists) + 1;
}
/**
* Export the lists
*
* @param {Object} data
*/
export(data) {
data.lists = {};
for (const id in this.lists) {
data.lists[id] = JSON.parse(JSON.stringify(this.getListDefinition(id)));
data.lists[id].fieldMatching = this.lists[id].fieldMatching;
}
data.listNextId = this.nextId;
}
}
ListCorePlugin.getters = [
"getListDataSource",
"getListDisplayName",
"getAsyncListDataSource",
"getListDefinition",
"getListIds",
"getListName",
"getNextListId",
"isExistingList",
"getListFieldMatch",
"getListFieldMatching",
];

View file

@ -0,0 +1,216 @@
/** @odoo-module */
import spreadsheet from "../../o_spreadsheet/o_spreadsheet_extended";
import { getFirstListFunction } from "../list_helpers";
import { Domain } from "@web/core/domain";
const { astToFormula } = spreadsheet;
/**
* @typedef {import("./list_core_plugin").SpreadsheetList} SpreadsheetList
*/
export default class ListUIPlugin extends spreadsheet.UIPlugin {
constructor(getters, history, dispatch, config, selection) {
super(getters, history, dispatch, config, selection);
/** @type {string} */
this.selectedListId = undefined;
this.env = config.evalContext.env;
}
beforeHandle(cmd) {
switch (cmd.type) {
case "START":
// make sure the domains are correctly set before
// any evaluation
this._addDomains();
break;
}
}
/**
* Handle a spreadsheet command
* @param {Object} cmd Command
*/
handle(cmd) {
switch (cmd.type) {
case "SELECT_ODOO_LIST":
this._selectList(cmd.listId);
break;
case "REMOVE_ODOO_LIST":
if (cmd.listId === this.selectedListId) {
this.selectedListId = undefined;
}
break;
case "REFRESH_ODOO_LIST":
this._refreshOdooList(cmd.listId);
break;
case "REFRESH_ALL_DATA_SOURCES":
this._refreshOdooLists();
break;
case "UPDATE_ODOO_LIST_DOMAIN":
this._addDomain(cmd.listId);
break;
case "ADD_GLOBAL_FILTER":
case "EDIT_GLOBAL_FILTER":
case "REMOVE_GLOBAL_FILTER":
case "SET_GLOBAL_FILTER_VALUE":
case "CLEAR_GLOBAL_FILTER_VALUE":
this._addDomains();
break;
case "UNDO":
case "REDO":
if (
cmd.commands.find((command) =>
[
"ADD_GLOBAL_FILTER",
"EDIT_GLOBAL_FILTER",
"REMOVE_GLOBAL_FILTER",
"UPDATE_ODOO_LIST_DOMAIN",
].includes(command.type)
)
) {
this._addDomains();
}
if (!this.getters.getListIds().length) {
this.selectedListId = undefined;
}
break;
}
}
// -------------------------------------------------------------------------
// Handlers
// -------------------------------------------------------------------------
/**
* Add an additional domain to a list
*
* @private
*
* @param {string} listId list id
*
*/
_addDomain(listId) {
const domainList = [];
for (const [filterId, fieldMatch] of Object.entries(
this.getters.getListFieldMatch(listId)
)) {
domainList.push(this.getters.getGlobalFilterDomain(filterId, fieldMatch));
}
const domain = Domain.combine(domainList, "AND").toString();
this.getters.getListDataSource(listId).addDomain(domain);
}
/**
* Add an additional domain to all lists
*
* @private
*
*/
_addDomains() {
for (const listId of this.getters.getListIds()) {
this._addDomain(listId);
}
}
/**
* Refresh the cache of a list
* @param {string} listId Id of the list
*/
_refreshOdooList(listId) {
this.getters.getListDataSource(listId).load({ reload: true });
}
/**
* Refresh the cache of all the lists
*/
_refreshOdooLists() {
for (const listId of this.getters.getListIds()) {
this._refreshOdooList(listId);
}
}
/**
* Select the given list id. If the id is undefined, it unselect the list.
* @param {number|undefined} listId Id of the list, or undefined to remove
* the selected list
*/
_selectList(listId) {
this.selectedListId = listId;
}
// -------------------------------------------------------------------------
// Getters
// -------------------------------------------------------------------------
/**
* Get the computed domain of a list
*
* @param {string} listId Id of the list
* @returns {Array}
*/
getListComputedDomain(listId) {
return this.getters.getListDataSource(listId).getComputedDomain();
}
/**
* Get the id of the list at the given position. Returns undefined if there
* is no list at this position
*
* @param {string} sheetId Id of the sheet
* @param {number} col Index of the col
* @param {number} row Index of the row
*
* @returns {string|undefined}
*/
getListIdFromPosition(sheetId, col, row) {
const cell = this.getters.getCell(sheetId, col, row);
if (cell && cell.isFormula()) {
const listFunction = getFirstListFunction(cell.content);
if (listFunction) {
const content = astToFormula(listFunction.args[0]);
return this.getters.evaluateFormula(content).toString();
}
}
return undefined;
}
/**
* Get the value of a list header
*
* @param {string} listId Id of a list
* @param {string} fieldName
*/
getListHeaderValue(listId, fieldName) {
return this.getters.getListDataSource(listId).getListHeaderValue(fieldName);
}
/**
* Get the value for a field of a record in the list
* @param {string} listId Id of the list
* @param {number} position Position of the record in the list
* @param {string} fieldName Field Name
*
* @returns {string|undefined}
*/
getListCellValue(listId, position, fieldName) {
return this.getters.getListDataSource(listId).getListCellValue(position, fieldName);
}
/**
* Get the currently selected list id
* @returns {number|undefined} Id of the list, undefined if no one is selected
*/
getSelectedListId() {
return this.selectedListId;
}
}
ListUIPlugin.getters = [
"getListComputedDomain",
"getListHeaderValue",
"getListIdFromPosition",
"getListCellValue",
"getSelectedListId",
];

View file

@ -0,0 +1,15 @@
/** @odoo-module */
export default {
Success: 0, // should be imported from o-spreadsheet instead of redefined here
FilterNotFound: 1000,
DuplicatedFilterLabel: 1001,
PivotCacheNotLoaded: 1002,
InvalidValueTypeCombination: 1003,
ListIdDuplicated: 1004,
InvalidNextId: 1005,
ListIdNotFound: 1006,
EmptyName: 1007,
PivotIdNotFound: 1008,
InvalidFieldMatch: 1009,
};

View file

@ -0,0 +1,12 @@
/** @odoo-module */
import { _t } from "@web/core/l10n/translation";
import spreadsheet from "./o_spreadsheet_extended";
const { EvaluationError, CellErrorLevel } = spreadsheet.helpers;
export class LoadingDataError extends EvaluationError {
constructor() {
super(_t("Loading..."), _t("Data is loading"), CellErrorLevel.silent);
}
}

View file

@ -0,0 +1,229 @@
/** @odoo-module */
import spreadsheet from "./o_spreadsheet_extended";
const { load, CorePlugin, tokenize, parse, convertAstNodes, astToFormula } = spreadsheet;
const { corePluginRegistry } = spreadsheet.registries;
export const ODOO_VERSION = 5;
const MAP = {
PIVOT: "ODOO.PIVOT",
"PIVOT.HEADER": "ODOO.PIVOT.HEADER",
"PIVOT.POSITION": "ODOO.PIVOT.POSITION",
"FILTER.VALUE": "ODOO.FILTER.VALUE",
LIST: "ODOO.LIST",
"LIST.HEADER": "ODOO.LIST.HEADER",
};
const dmyRegex = /^([0|1|2|3][1-9])\/(0[1-9]|1[0-2])\/(\d{4})$/i;
export function migrate(data) {
let _data = load(data, !!odoo.debug);
const version = _data.odooVersion || 0;
if (version < 1) {
_data = migrate0to1(_data);
}
if (version < 2) {
_data = migrate1to2(_data);
}
if (version < 3) {
_data = migrate2to3(_data);
}
if (version < 4) {
_data = migrate3to4(_data);
}
if (version < 5) {
_data = migrate4to5(_data);
}
return _data;
}
function tokensToString(tokens) {
return tokens.reduce((acc, token) => acc + token.value, "");
}
function migrate0to1(data) {
for (const sheet of data.sheets) {
for (const xc in sheet.cells || []) {
const cell = sheet.cells[xc];
if (cell.content && cell.content.startsWith("=")) {
const tokens = tokenize(cell.content);
for (const token of tokens) {
if (token.type === "SYMBOL" && token.value.toUpperCase() in MAP) {
token.value = MAP[token.value.toUpperCase()];
}
}
cell.content = tokensToString(tokens);
}
}
}
return data;
}
function migrate1to2(data) {
for (const sheet of data.sheets) {
for (const xc in sheet.cells || []) {
const cell = sheet.cells[xc];
if (cell.content && cell.content.startsWith("=")) {
try {
cell.content = migratePivotDaysParameters(cell.content);
} catch {
continue;
}
}
}
}
return data;
}
/**
* Migration of global filters
*/
function migrate2to3(data) {
if (data.globalFilters) {
for (const gf of data.globalFilters) {
if (gf.fields) {
gf.pivotFields = gf.fields;
delete gf.fields;
}
if (
gf.type === "date" &&
typeof gf.defaultValue === "object" &&
"year" in gf.defaultValue
) {
switch (gf.defaultValue.year) {
case "last_year":
gf.defaultValue.yearOffset = -1;
break;
case "antepenultimate_year":
gf.defaultValue.yearOffset = -2;
break;
case "this_year":
case undefined:
gf.defaultValue.yearOffset = 0;
break;
}
delete gf.defaultValue.year;
}
if (!gf.listFields) {
gf.listFields = {};
}
if (!gf.graphFields) {
gf.graphFields = {};
}
}
}
return data;
}
/**
* Migration of list/pivot names
*/
function migrate3to4(data) {
if (data.lists) {
for (const list of Object.values(data.lists)) {
list.name = list.name || list.model;
}
}
if (data.pivots) {
for (const pivot of Object.values(data.pivots)) {
pivot.name = pivot.name || pivot.model;
}
}
return data;
}
function migrate4to5(data) {
for (const filter of data.globalFilters || []) {
for (const [id, fm] of Object.entries(filter.pivotFields || {})) {
if (!(data.pivots && id in data.pivots)) {
delete filter.pivotFields[id];
continue;
}
if (!data.pivots[id].fieldMatching) {
data.pivots[id].fieldMatching = {};
}
data.pivots[id].fieldMatching[filter.id] = { chain: fm.field, type: fm.type };
if ("offset" in fm) {
data.pivots[id].fieldMatching[filter.id].offset = fm.offset;
}
}
delete filter.pivotFields;
for (const [id, fm] of Object.entries(filter.listFields || {})) {
if (!(data.lists && id in data.lists)) {
delete filter.listFields[id];
continue;
}
if (!data.lists[id].fieldMatching) {
data.lists[id].fieldMatching = {};
}
data.lists[id].fieldMatching[filter.id] = { chain: fm.field, type: fm.type };
if ("offset" in fm) {
data.lists[id].fieldMatching[filter.id].offset = fm.offset;
}
}
delete filter.listFields;
const findFigureFromId = (id) => {
for (const sheet of data.sheets) {
const fig = sheet.figures.find((f) => f.id === id);
if (fig) {
return fig;
}
}
return undefined;
};
for (const [id, fm] of Object.entries(filter.graphFields || {})) {
const figure = findFigureFromId(id);
if (!figure) {
delete filter.graphFields[id];
continue;
}
if (!figure.data.fieldMatching) {
figure.data.fieldMatching = {};
}
figure.data.fieldMatching[filter.id] = { chain: fm.field, type: fm.type };
if ("offset" in fm) {
figure.data.fieldMatching[filter.id].offset = fm.offset;
}
}
delete filter.graphFields;
}
return data;
}
/**
* Convert pivot formulas days parameters from day/month/year
* format to the standard spreadsheet month/day/year format.
* e.g. =PIVOT.HEADER(1,"create_date:day","30/07/2022") becomes =PIVOT.HEADER(1,"create_date:day","07/30/2022")
* @param {string} formulaString
* @returns {string}
*/
function migratePivotDaysParameters(formulaString) {
const ast = parse(formulaString);
const convertedAst = convertAstNodes(ast, "FUNCALL", (ast) => {
if (["ODOO.PIVOT", "ODOO.PIVOT.HEADER"].includes(ast.value.toUpperCase())) {
for (const subAst of ast.args) {
if (subAst.type === "STRING") {
const date = subAst.value.match(dmyRegex);
if (date) {
subAst.value = `${[date[2], date[1], date[3]].join("/")}`;
}
}
}
}
return ast;
});
return "=" + astToFormula(convertedAst);
}
export default class OdooVersion extends CorePlugin {
export(data) {
data.odooVersion = ODOO_VERSION;
}
}
OdooVersion.getters = [];
corePluginRegistry.add("odooMigration", OdooVersion);

View file

@ -0,0 +1,26 @@
// Force light theme for everything in o-spreadsheet until
// we have the proper scss tool chain to support dark theme properly
// in the library
.o-spreadsheet {
.o-filter-menu {
background-color: #fff !important;
}
.o_field_tags .badge {
background-color: #fff !important;
color: #777 !important;
}
.o_field_selector_chain_part {
background: #f6f7fa !important;
border-color: #d5dae8 !important;
}
input {
color: #333 !important;
}
.o_input {
color: #777 !important;
}
.o_domain_leaf_info {
background: #f6f7fa !important;
border-color: #d5dae8 !important;
}
}

View file

@ -0,0 +1,10 @@
/** @odoo-module */
const spreadsheet = window.o_spreadsheet;
export const initCallbackRegistry = new spreadsheet.Registry();
import { _t } from "@web/core/l10n/translation";
spreadsheet.setTranslationMethod(_t);
// export * from spreadsheet ?
export default spreadsheet;

View file

@ -0,0 +1,37 @@
.o-spreadsheet {
height: 100%;
.fa {
font-family: FontAwesome;
}
.o-selection input {
display: initial;
}
}
.o-sidePanel {
.o-input{
background-origin: content-box;
}
.o-sidePanelButtons .o-sidePanelButton {
color: #666;
&.o_global_filter_save {
color: $o-brand-primary;
border-color: $o-brand-primary;
}
&.o_delete_element {
color: #fff;
background-color: map-get($theme-colors, "danger");
border-color: map-get($theme-colors, "danger");
&:hover:enabled {
background-color: darken(map-get($theme-colors, "danger"), 7%);
border-color: #bd2130;
}
}
}
}

View file

@ -0,0 +1,18 @@
/** @odoo-module **/
import spreadsheet from "@spreadsheet/o_spreadsheet/o_spreadsheet_extended";
import { _t } from "@web/core/l10n/translation";
const { args, toString } = spreadsheet.helpers;
const { functionRegistry } = spreadsheet.registries;
functionRegistry.add("_t", {
description: _t("Get the translated value of the given string"),
args: args(`
value (string) ${_t("Value to translate.")}
`),
compute: function (value) {
return _t(toString(value));
},
returns: ["STRING"],
});

View file

@ -0,0 +1,50 @@
/** @odoo-module */
import { _lt } from "@web/core/l10n/translation";
import spreadsheet from "@spreadsheet/o_spreadsheet/o_spreadsheet_extended";
import PivotCorePlugin from "./plugins/pivot_core_plugin";
import PivotUIPlugin from "./plugins/pivot_ui_plugin";
import { SEE_RECORDS_PIVOT, SEE_RECORDS_PIVOT_VISIBLE } from "./pivot_actions";
const { coreTypes, invalidateEvaluationCommands } = spreadsheet;
const { cellMenuRegistry } = spreadsheet.registries;
const { inverseCommandRegistry } = spreadsheet.registries;
function identity(cmd) {
return [cmd];
}
coreTypes.add("INSERT_PIVOT");
coreTypes.add("RENAME_ODOO_PIVOT");
coreTypes.add("REMOVE_PIVOT");
coreTypes.add("RE_INSERT_PIVOT");
coreTypes.add("UPDATE_ODOO_PIVOT_DOMAIN");
invalidateEvaluationCommands.add("UPDATE_ODOO_PIVOT_DOMAIN");
invalidateEvaluationCommands.add("REMOVE_PIVOT");
invalidateEvaluationCommands.add("INSERT_PIVOT");
cellMenuRegistry.add("pivot_see_records", {
name: _lt("See records"),
sequence: 175,
action: async (env) => {
const cell = env.model.getters.getActiveCell();
await SEE_RECORDS_PIVOT(cell, env);
},
isVisible: (env) => {
const cell = env.model.getters.getActiveCell();
return SEE_RECORDS_PIVOT_VISIBLE(cell, env);
},
});
inverseCommandRegistry
.add("INSERT_PIVOT", identity)
.add("RENAME_ODOO_PIVOT", identity)
.add("REMOVE_PIVOT", identity)
.add("UPDATE_ODOO_PIVOT_DOMAIN", identity)
.add("RE_INSERT_PIVOT", identity);
export { PivotCorePlugin, PivotUIPlugin };

View file

@ -0,0 +1,72 @@
/** @odoo-module */
import spreadsheet from "@spreadsheet/o_spreadsheet/o_spreadsheet_extended";
import { getFirstPivotFunction, getNumberOfPivotFormulas } from "./pivot_helpers";
const { astToFormula } = spreadsheet;
export const SEE_RECORDS_PIVOT = async (cell, env) => {
const { col, row, sheetId } = env.model.getters.getCellPosition(cell.id);
const { args, functionName } = getFirstPivotFunction(cell.content);
const evaluatedArgs = args
.map(astToFormula)
.map((arg) => env.model.getters.evaluateFormula(arg));
const pivotId = env.model.getters.getPivotIdFromPosition(sheetId, col, row);
const { model } = env.model.getters.getPivotDefinition(pivotId);
const dataSource = await env.model.getters.getAsyncPivotDataSource(pivotId);
const slice = functionName === "ODOO.PIVOT.HEADER" ? 1 : 2;
let argsDomain = evaluatedArgs.slice(slice);
if (argsDomain[argsDomain.length - 2] === "measure") {
// We have to remove the measure from the domain
argsDomain = argsDomain.slice(0, argsDomain.length - 2);
}
const domain = dataSource.getPivotCellDomain(argsDomain);
const name = await dataSource.getModelLabel();
await env.services.action.doAction({
type: "ir.actions.act_window",
name,
res_model: model,
view_mode: "list",
views: [
[false, "list"],
[false, "form"],
],
target: "current",
domain,
});
};
export const SEE_RECORDS_PIVOT_VISIBLE = (cell, env) => {
if (!cell) {
return false;
}
const { sheetId, col, row } = env.model.getters.getCellPosition(cell.id);
const pivotId = env.model.getters.getPivotIdFromPosition(sheetId, col, row);
if (!env.model.getters.isExistingPivot(pivotId)) {
return false;
}
const { args, functionName } = getFirstPivotFunction(cell.content);
const evaluatedArgs = args
.map(astToFormula)
.map((arg) => env.model.getters.evaluateFormula(arg));
const dataSource = env.model.getters.getPivotDataSource(pivotId);
const slice = functionName === "ODOO.PIVOT.HEADER" ? 1 : 2;
let argsDomain = evaluatedArgs.slice(slice);
if (argsDomain[argsDomain.length - 2] === "measure") {
// We have to remove the measure from the domain
argsDomain = argsDomain.slice(0, argsDomain.length - 2);
}
try {
// parse the domain (field, value) to ensure they are of the correct type
dataSource.getPivotCellDomain(argsDomain);
return (
cell &&
dataSource.isReady() &&
cell.evaluated.value !== "" &&
!cell.evaluated.error &&
getNumberOfPivotFormulas(cell.content) === 1
);
} catch (_) {
// if the arguments of the domain are not correct, don't let the user click on it.
return false;
}
};

View file

@ -0,0 +1,239 @@
/** @odoo-module */
import { _t } from "@web/core/l10n/translation";
import { OdooViewsDataSource } from "../data_sources/odoo_views_data_source";
import { SpreadsheetPivotModel } from "./pivot_model";
import { omit } from "@web/core/utils/objects";
export default class PivotDataSource extends OdooViewsDataSource {
/**
*
* @override
* @param {Object} services Services (see DataSource)
* @param {Object} params
* @param {import("./pivot_model").PivotMetaData} params.metaData
* @param {import("./pivot_model").PivotSearchParams} params.searchParams
*/
constructor(services, params) {
const filteredParams = {
...params,
searchParams: {
...params.searchParams,
context: omit(
params.searchParams.context,
"pivot_measures",
"pivot_row_groupby",
"pivot_column_groupby"
),
},
};
super(services, filteredParams);
}
async _load() {
await super._load();
/** @type {SpreadsheetPivotModel} */
this._model = new SpreadsheetPivotModel(
{ _t },
{
metaData: this._metaData,
searchParams: this._searchParams,
},
{
orm: this._orm,
metadataRepository: this._metadataRepository,
}
);
await this._model.load(this._searchParams);
}
async copyModelWithOriginalDomain() {
await this.loadMetadata();
const model = new SpreadsheetPivotModel(
{ _t },
{
metaData: this._metaData,
searchParams: this._initialSearchParams,
},
{
orm: this._orm,
metadataRepository: this._metadataRepository,
}
);
await model.load(this._initialSearchParams);
return model;
}
getReportMeasures() {
this._assertDataIsLoaded();
return this._model.getReportMeasures();
}
/**
* @param {string[]} domain
*/
getDisplayedPivotHeaderValue(domain) {
this._assertDataIsLoaded();
return this._model.getDisplayedPivotHeaderValue(domain);
}
/**
* @param {string[]} domain
*/
getPivotHeaderValue(domain) {
this._assertDataIsLoaded();
return this._model.getPivotHeaderValue(domain);
}
/**
* @param {string} measure Field name of the measures
* @param {string[]} domain
*/
markAsValueUsed(measure, domain) {
if (this._model) {
this._model.markAsValueUsed(measure, domain);
}
}
/**
* @param {string[]} domain
*/
markAsHeaderUsed(domain) {
if (this._model) {
this._model.markAsHeaderUsed(domain);
}
}
/**
* @param {string} measure Field name of the measures
* @param {string[]} domain
* @returns {boolean}
*/
isUsedValue(measure, domain) {
this._assertDataIsLoaded();
return this._model.isUsedValue(measure, domain);
}
/**
* @param {string[]} domain
* @returns {boolean}
*/
isUsedHeader(domain) {
this._assertDataIsLoaded();
return this._model.isUsedHeader(domain);
}
clearUsedValues() {
if (this._model) {
this._model.clearUsedValues();
}
}
getTableStructure() {
this._assertDataIsLoaded();
return this._model.getTableStructure();
}
/**
* @param {string} measure Field name of the measures
* @param {string[]} domain
*/
getPivotCellValue(measure, domain) {
this._assertDataIsLoaded();
return this._model.getPivotCellValue(measure, domain);
}
/**
* @param {string[]}
*/
getPivotCellDomain(domain) {
this._assertDataIsLoaded();
return this._model.getPivotCellDomain(domain);
}
/**
* @param {string} fieldName
* @param {string} value raw string value
* @returns {string}
*/
getGroupByDisplayLabel(fieldName, value) {
this._assertDataIsLoaded();
return this._model.getGroupByDisplayLabel(fieldName, value);
}
/**
* @param {string} fieldName
* @returns {string}
*/
getFormattedGroupBy(fieldName) {
this._assertDataIsLoaded();
return this._model.getFormattedGroupBy(fieldName);
}
/**
* @param {string} groupFieldString
*/
parseGroupField(groupFieldString) {
this._assertDataIsLoaded();
return this._model.parseGroupField(groupFieldString);
}
/**
* @param {"COLUMN" | "ROW"} dimension
* @returns {boolean}
*/
isGroupedOnlyByOneDate(dimension) {
this._assertDataIsLoaded();
return this._model.isGroupedOnlyByOneDate(dimension);
}
/**
* @param {"COLUMN" | "ROW"} dimension
* @returns {string}
*/
getGroupOfFirstDate(dimension) {
this._assertDataIsLoaded();
return this._model.getGroupOfFirstDate(dimension);
}
/**
* @param {"COLUMN" | "ROW"} dimension
* @param {number} index
* @returns {string}
*/
getGroupByAtIndex(dimension, index) {
this._assertDataIsLoaded();
return this._model.getGroupByAtIndex(dimension, index);
}
/**
* @param {string} fieldName
* @returns {boolean}
*/
isColumnGroupBy(fieldName) {
this._assertDataIsLoaded();
return this._model.isColumnGroupBy(fieldName);
}
/**
* @param {string} fieldName
* @returns {boolean}
*/
isRowGroupBy(fieldName) {
this._assertDataIsLoaded();
return this._model.isRowGroupBy(fieldName);
}
/**
* @returns {number}
*/
getNumberOfColGroupBys() {
this._assertDataIsLoaded();
return this._model.getNumberOfColGroupBys();
}
async prepareForTemplateGeneration() {
this._assertDataIsLoaded();
await this._model.prepareForTemplateGeneration();
}
}

View file

@ -0,0 +1,145 @@
/** @odoo-module **/
import { _t } from "@web/core/l10n/translation";
import { sprintf } from "@web/core/utils/strings";
import spreadsheet from "@spreadsheet/o_spreadsheet/o_spreadsheet_extended";
const { args, toString } = spreadsheet.helpers;
const { functionRegistry } = spreadsheet.registries;
//--------------------------------------------------------------------------
// Spreadsheet functions
//--------------------------------------------------------------------------
function assertPivotsExists(pivotId, getters) {
if (!getters.isExistingPivot(pivotId)) {
throw new Error(sprintf(_t('There is no pivot with id "%s"'), pivotId));
}
}
function assertMeasureExist(pivotId, measure, getters) {
const { measures } = getters.getPivotDefinition(pivotId);
if (!measures.includes(measure)) {
const validMeasures = `(${measures})`;
throw new Error(
sprintf(
_t("The argument %s is not a valid measure. Here are the measures: %s"),
measure,
validMeasures
)
);
}
}
function assertDomainLength(domain) {
if (domain.length % 2 !== 0) {
throw new Error(_t("Function PIVOT takes an even number of arguments."));
}
}
functionRegistry
.add("ODOO.FILTER.VALUE", {
description: _t("Return the current value of a spreadsheet filter."),
args: args(`
filter_name (string) ${_t("The label of the filter whose value to return.")}
`),
compute: function (filterName) {
const unEscapedFilterName = toString(filterName).replaceAll('\\"', '"');
return this.getters.getFilterDisplayValue(unEscapedFilterName);
},
returns: ["STRING"],
})
.add("ODOO.PIVOT", {
description: _t("Get the value from a pivot."),
args: args(`
pivot_id (string) ${_t("ID of the pivot.")}
measure_name (string) ${_t("Name of the measure.")}
domain_field_name (string,optional,repeating) ${_t("Field name.")}
domain_value (string,optional,repeating) ${_t("Value.")}
`),
compute: function (pivotId, measureName, ...domain) {
pivotId = toString(pivotId);
const measure = toString(measureName);
const args = domain.map(toString);
assertPivotsExists(pivotId, this.getters);
assertMeasureExist(pivotId, measure, this.getters);
assertDomainLength(args);
return this.getters.getPivotCellValue(pivotId, measure, args);
},
computeFormat: function (pivotId, measureName, ...domain) {
pivotId = toString(pivotId.value);
const measure = toString(measureName.value);
const field = this.getters.getPivotDataSource(pivotId).getReportMeasures()[measure];
if (!field) {
return undefined;
}
switch (field.type) {
case "integer":
return "0";
case "float":
return "#,##0.00";
case "monetary":
return this.getters.getCompanyCurrencyFormat() || "#,##0.00";
default:
return undefined;
}
},
returns: ["NUMBER", "STRING"],
})
.add("ODOO.PIVOT.HEADER", {
description: _t("Get the header of a pivot."),
args: args(`
pivot_id (string) ${_t("ID of the pivot.")}
domain_field_name (string,optional,repeating) ${_t("Field name.")}
domain_value (string,optional,repeating) ${_t("Value.")}
`),
compute: function (pivotId, ...domain) {
pivotId = toString(pivotId);
const args = domain.map(toString);
assertPivotsExists(pivotId, this.getters);
assertDomainLength(args);
return this.getters.getDisplayedPivotHeaderValue(pivotId, args);
},
computeFormat: function (pivotId, ...domain) {
pivotId = toString(pivotId.value);
const pivot = this.getters.getPivotDataSource(pivotId);
const len = domain.length;
if (!len) {
return undefined;
}
const fieldName = toString(domain[len - 2].value);
const value = toString(domain[len - 1].value);
if (fieldName === "measure" || value === "false") {
return undefined;
}
const { aggregateOperator, field } = pivot.parseGroupField(fieldName);
switch (field.type) {
case "integer":
return "0";
case "float":
case "monetary":
return "#,##0.00";
case "date":
case "datetime":
if (aggregateOperator === "day") {
return "mm/dd/yyyy";
}
return undefined;
default:
return undefined;
}
},
returns: ["NUMBER", "STRING"],
})
.add("ODOO.PIVOT.POSITION", {
description: _t("Get the absolute ID of an element in the pivot"),
args: args(`
pivot_id (string) ${_t("ID of the pivot.")}
field_name (string) ${_t("Name of the field.")}
position (number) ${_t("Position in the pivot")}
`),
compute: function () {
throw new Error(_t(`[[FUNCTION_NAME]] cannot be called from the spreadsheet.`));
},
returns: ["STRING"],
});

View file

@ -0,0 +1,86 @@
/** @odoo-module **/
import { _t } from "web.core";
import { FORMATS } from "../helpers/constants";
import { getOdooFunctions } from "../helpers/odoo_functions_helpers";
export const pivotFormulaRegex = /^=.*PIVOT/;
//--------------------------------------------------------------------------
// Public
//--------------------------------------------------------------------------
/**
* Format a data
*
* @param {string} interval aggregate interval i.e. month, week, quarter, ...
* @param {string} value
*/
export function formatDate(interval, value) {
const output = FORMATS[interval].display;
const input = FORMATS[interval].out;
let date = moment(value, input);
if (interval === "week") {
date = date.endOf("week");
}
return date.isValid() ? date.format(output) : _t("None");
}
/**
* Parse a spreadsheet formula and detect the number of PIVOT functions that are
* present in the given formula.
*
* @param {string} formula
*
* @returns {number}
*/
export function getNumberOfPivotFormulas(formula) {
return getOdooFunctions(formula, [
"ODOO.PIVOT",
"ODOO.PIVOT.HEADER",
"ODOO.PIVOT.POSITION",
]).filter((fn) => fn.isMatched).length;
}
/**
* Get the first Pivot function description of the given formula.
*
* @param {string} formula
*
* @returns {import("../helpers/odoo_functions_helpers").OdooFunctionDescription|undefined}
*/
export function getFirstPivotFunction(formula) {
return getOdooFunctions(formula, [
"ODOO.PIVOT",
"ODOO.PIVOT.HEADER",
"ODOO.PIVOT.POSITION",
]).find((fn) => fn.isMatched);
}
/**
* Build a pivot formula expression
*
* @param {string} formula formula to be used (PIVOT or PIVOT.HEADER)
* @param {*} args arguments of the formula
*
* @returns {string}
*/
export function makePivotFormula(formula, args) {
return `=${formula}(${args
.map((arg) => {
const stringIsNumber =
typeof arg == "string" && !isNaN(arg) && Number(arg).toString() === arg;
const convertToNumber = typeof arg == "number" || stringIsNumber;
return convertToNumber ? `${arg}` : `"${arg.toString().replace(/"/g, '\\"')}"`;
})
.join(",")})`;
}
export const PERIODS = {
day: _t("Day"),
week: _t("Week"),
month: _t("Month"),
quarter: _t("Quarter"),
year: _t("Year"),
};

View file

@ -0,0 +1,668 @@
/** @odoo-module */
import { _t } from "@web/core/l10n/translation";
import { Domain } from "@web/core/domain";
import { sprintf } from "@web/core/utils/strings";
import { PivotModel } from "@web/views/pivot/pivot_model";
import { computeReportMeasures } from "@web/views/utils";
import { session } from "@web/session";
import { FORMATS } from "../helpers/constants";
import spreadsheet from "../o_spreadsheet/o_spreadsheet_extended";
import { formatDate } from "./pivot_helpers";
import { PERIODS } from "@spreadsheet/pivot/pivot_helpers";
import { SpreadsheetPivotTable } from "@spreadsheet/pivot/pivot_table";
const { toString, toNumber, toBoolean } = spreadsheet.helpers;
/**
* @typedef {import("@spreadsheet/data_sources/metadata_repository").Field} Field
* @typedef {import("@spreadsheet/pivot/pivot_table").Row} Row
* @typedef {import("@spreadsheet/pivot/pivot_table").Column} Column
*
* @typedef {Object} PivotMetaData
* @property {Array<string>} colGroupBys
* @property {Array<string>} rowGroupBys
* @property {Array<string>} activeMeasures
* @property {string} resModel
* @property {Record<string, Field>} fields
* @property {string|undefined} modelLabel
*
* @typedef {Object} PivotSearchParams
* @property {Array<string>} groupBy
* @property {Array<string>} orderBy
* @property {Object} domain
* @property {Object} context
*/
/**
* Parses the positional char (#), the field and operator string of pivot group.
* e.g. "create_date:month"
* @param {Record<string, Field>} allFields
* @param {string} groupFieldString
* @returns {{field: Field, aggregateOperator: string, isPositional: boolean}}
*/
function parseGroupField(allFields, groupFieldString) {
let [fieldName, aggregateOperator] = groupFieldString.split(":");
const isPositional = fieldName.startsWith("#");
fieldName = isPositional ? fieldName.substring(1) : fieldName;
const field = allFields[fieldName];
if (field === undefined) {
throw new Error(sprintf(_t("Field %s does not exist"), fieldName));
}
if (["date", "datetime"].includes(field.type)) {
aggregateOperator = aggregateOperator || "month";
}
return {
isPositional,
field,
aggregateOperator,
};
}
const UNSUPPORTED_FIELD_TYPES = ["one2many", "binary", "html"];
export const NO_RECORD_AT_THIS_POSITION = Symbol("NO_RECORD_AT_THIS_POSITION");
function isNotSupported(fieldType) {
return UNSUPPORTED_FIELD_TYPES.includes(fieldType);
}
function throwUnsupportedFieldError(field) {
throw new Error(
sprintf(_t("Field %s is not supported because of its type (%s)"), field.string, field.type)
);
}
/**
* Parses the value defining a pivot group in a PIVOT formula
* e.g. given the following formula PIVOT("1", "stage_id", "42", "status", "won"),
* the two group values are "42" and "won".
* @param {object} field
* @param {number | boolean | string} groupValue
* @returns {number | boolean | string}
*/
export function parsePivotFormulaFieldValue(field, groupValue) {
const groupValueString =
typeof groupValue === "boolean"
? toString(groupValue).toLocaleLowerCase()
: toString(groupValue);
if (isNotSupported(field.type)) {
throwUnsupportedFieldError(field);
}
// represents a field which is not set (=False server side)
if (groupValueString === "false") {
return false;
}
switch (field.type) {
case "datetime":
case "date":
return toString(groupValueString);
case "selection":
case "char":
case "text":
return toString(groupValueString);
case "boolean":
return toBoolean(groupValueString);
case "float":
case "integer":
case "monetary":
case "many2one":
case "many2many":
return toNumber(groupValueString);
default:
throwUnsupportedFieldError(field);
}
}
/**
* This class is an extension of PivotModel with some additional information
* that we need in spreadsheet (name_get, isUsedInSheet, ...)
*/
export class SpreadsheetPivotModel extends PivotModel {
/**
* @param {Object} params
* @param {PivotMetaData} params.metaData
* @param {PivotSearchParams} params.searchParams
* @param {Object} services
* @param {import("../data_sources/metadata_repository").MetadataRepository} services.metadataRepository
*/
setup(params, services) {
// fieldAttrs is required, but not needed in Spreadsheet, so we define it as empty
(params.metaData.fieldAttrs = {}), super.setup(params);
this.metadataRepository = services.metadataRepository;
/**
* Contains the domain of the values used during the evaluation of the formula =Pivot(...)
* Is used to know if a pivot cell is missing or not
* */
this._usedValueDomains = new Set();
/**
* Contains the domain of the headers used during the evaluation of the formula =Pivot.header(...)
* Is used to know if a pivot cell is missing or not
* */
this._usedHeaderDomains = new Set();
/**
* Display name of the model
*/
this._modelLabel = params.metaData.modelLabel;
}
//--------------------------------------------------------------------------
// Metadata getters
//--------------------------------------------------------------------------
/**
* Return true if the given field name is part of the col group bys
* @param {string} fieldName
* @returns {boolean}
*/
isColumnGroupBy(fieldName) {
try {
const { field } = this.parseGroupField(fieldName);
return this._isCol(field);
} catch (_) {
return false;
}
}
/**
* Return true if the given field name is part of the row group bys
* @param {string} fieldName
* @returns {boolean}
*/
isRowGroupBy(fieldName) {
try {
const { field } = this.parseGroupField(fieldName);
return this._isRow(field);
} catch (_) {
return false;
}
}
/**
* Get the display name of a group by
* @param {string} fieldName
* @returns {string}
*/
getFormattedGroupBy(fieldName) {
const { field, aggregateOperator } = this.parseGroupField(fieldName);
return field.string + (aggregateOperator ? ` (${PERIODS[aggregateOperator]})` : "");
}
getReportMeasures() {
return computeReportMeasures(this.metaData.fields, this.metaData.fieldAttrs, []);
}
//--------------------------------------------------------------------------
// Cell missing
//--------------------------------------------------------------------------
/**
* Reset the used values and headers
*/
clearUsedValues() {
this._usedHeaderDomains.clear();
this._usedValueDomains.clear();
}
/**
* Check if the given domain with the given measure has been used
*/
isUsedValue(domain, measure) {
const tag = [measure, ...domain];
return this._usedValueDomains.has(tag.join());
}
/**
* Check if the given domain has been used
*/
isUsedHeader(domain) {
return this._usedHeaderDomains.has(domain.join());
}
/**
* Indicate that the given domain has been used with the given measure
*/
markAsValueUsed(domain, measure) {
const toTag = [measure, ...domain];
this._usedValueDomains.add(toTag.join());
}
/**
* Indicate that the given domain has been used
*/
markAsHeaderUsed(domain) {
this._usedHeaderDomains.add(domain.join());
}
//--------------------------------------------------------------------------
// Autofill
//--------------------------------------------------------------------------
/**
* @param {string} dimension COLUMN | ROW
*/
isGroupedOnlyByOneDate(dimension) {
const groupBys =
dimension === "COLUMN" ? this.metaData.fullColGroupBys : this.metaData.fullRowGroupBys;
return groupBys.length === 1 && this._isDateField(this.parseGroupField(groupBys[0]).field);
}
/**
* @param {string} dimension COLUMN | ROW
*/
getGroupOfFirstDate(dimension) {
if (!this.isGroupedOnlyByOneDate(dimension)) {
return undefined;
}
const groupBys =
dimension === "COLUMN" ? this.metaData.fullColGroupBys : this.metaData.fullRowGroupBys;
return this.parseGroupField(groupBys[0]).aggregateOperator;
}
/**
* @param {string} dimension COLUMN | ROW
* @param {number} index
*/
getGroupByAtIndex(dimension, index) {
const groupBys =
dimension === "COLUMN" ? this.metaData.fullColGroupBys : this.metaData.fullRowGroupBys;
return groupBys[index];
}
getNumberOfColGroupBys() {
return this.metaData.fullColGroupBys.length;
}
//--------------------------------------------------------------------------
// Evaluation
//--------------------------------------------------------------------------
/**
* Get the value of the given domain for the given measure
*/
getPivotCellValue(measure, domain) {
const { cols, rows } = this._getColsRowsValuesFromDomain(domain);
const group = JSON.stringify([rows, cols]);
const values = this.data.measurements[group];
return (values && values[0][measure]) || "";
}
/**
* Get the label the given field-value
*
* @param {string} groupFieldString Name of the field
* @param {string} groupValueString Value of the group by
* @returns {string}
*/
getGroupByDisplayLabel(groupFieldString, groupValueString) {
if (groupValueString === NO_RECORD_AT_THIS_POSITION) {
return "";
}
if (groupFieldString === "measure") {
if (groupValueString === "__count") {
return _t("Count");
}
// the value is actually the measure field name
return this.parseGroupField(groupValueString).field.string;
}
const { field, aggregateOperator } = this.parseGroupField(groupFieldString);
const value = parsePivotFormulaFieldValue(field, groupValueString);
const undef = _t("None");
if (this._isDateField(field)) {
if (value && aggregateOperator === "day") {
return toNumber(value);
}
return formatDate(aggregateOperator, value);
}
if (field.relation) {
const label = this.metadataRepository.getRecordDisplayName(field.relation, value);
if (!label) {
return undef;
}
return label;
}
const label = this.metadataRepository.getLabel(this.metaData.resModel, field.name, value);
if (!label) {
return undef;
}
return label;
}
/**
* Get the label of the last group by of the domain
*
* @param {any[]} domain Domain of the formula
*/
getPivotHeaderValue(domain) {
const groupFieldString = domain[domain.length - 2];
if (groupFieldString.startsWith("#")) {
const { field } = this.parseGroupField(groupFieldString);
const { cols, rows } = this._getColsRowsValuesFromDomain(domain);
return this._isCol(field) ? cols[cols.length - 1] : rows[rows.length - 1];
}
return domain[domain.length - 1];
}
/**
* Get the displayed label of the last group by of the domain
*
* @param {string[]} domain Domain of the formula
* @returns {string}
*/
getDisplayedPivotHeaderValue(domain) {
const groupFieldString = domain[domain.length - 2];
return this.getGroupByDisplayLabel(groupFieldString, this.getPivotHeaderValue(domain));
}
//--------------------------------------------------------------------------
// Misc
//--------------------------------------------------------------------------
/**
* Get the Odoo domain corresponding to the given domain
*/
getPivotCellDomain(domain) {
const { cols, rows } = this._getColsRowsValuesFromDomain(domain);
const key = JSON.stringify([rows, cols]);
const domains = this.data.groupDomains[key];
return domains ? domains[0] : Domain.FALSE.toList();
}
/**
* @returns {SpreadsheetPivotTable}
*/
getTableStructure() {
const cols = this._getSpreadsheetCols();
const rows = this._getSpreadsheetRows(this.data.rowGroupTree);
rows.push(rows.shift()); //Put the Total row at the end.
const measures = this.metaData.activeMeasures;
return new SpreadsheetPivotTable(cols, rows, measures);
}
//--------------------------------------------------------------------------
// Private
//--------------------------------------------------------------------------
/**
* @override
*/
async _loadData(config) {
/** @type {(groupFieldString: string) => ReturnType<parseGroupField>} */
this.parseGroupField = parseGroupField.bind(null, this.metaData.fields);
/*
* prune is manually set to false in order to expand all the groups
* automatically
*/
const prune = false;
await super._loadData(config, prune);
const metadataRepository = this.metadataRepository;
const registerLabels = (tree, groupBys) => {
const group = tree.root;
if (!tree.directSubTrees.size) {
for (let i = 0; i < group.values.length; i++) {
const { field } = this.parseGroupField(groupBys[i]);
if (!field.relation) {
metadataRepository.registerLabel(
config.metaData.resModel,
field.name,
group.values[i],
group.labels[i]
);
} else {
metadataRepository.setDisplayName(
field.relation,
group.values[i],
group.labels[i]
);
}
}
}
[...tree.directSubTrees.values()].forEach((subTree) => {
registerLabels(subTree, groupBys);
});
};
registerLabels(this.data.colGroupTree, this.metaData.fullColGroupBys);
registerLabels(this.data.rowGroupTree, this.metaData.fullRowGroupBys);
}
/**
* Determines if the given field is a date or datetime field.
*
* @param {Field} field Field description
* @private
* @returns {boolean} True if the type of the field is date or datetime
*/
_isDateField(field) {
return ["date", "datetime"].includes(field.type);
}
/**
* @override
*/
_getGroupValues(group, groupBys) {
return groupBys.map((groupBy) => {
const { field, aggregateOperator } = this.parseGroupField(groupBy);
if (this._isDateField(field)) {
const value = this._getGroupStartingDay(groupBy, group);
if (!value) {
return false;
}
const fOut = FORMATS[aggregateOperator]["out"];
// eslint-disable-next-line no-undef
let date = moment(value);
if (aggregateOperator === "week") {
date = date.endOf("week");
}
return date.isValid() ? date.format(fOut) : false;
}
return this._sanitizeValue(group[groupBy]);
});
}
/**
* When grouping by a time field, return
* the group starting day (local to the timezone)
* @param {string} groupBy
* @param {object} readGroup
* @returns {string | undefined}
*/
_getGroupStartingDay(groupBy, readGroup) {
if (!readGroup["__range"] || !readGroup["__range"][groupBy]) {
return undefined;
}
const { field } = this.parseGroupField(groupBy);
const sqlValue = readGroup["__range"][groupBy].from;
if (this.metaData.fields[field.name].type === "date") {
return sqlValue;
}
const userTz = session.user_context.tz || luxon.Settings.defaultZoneName;
return luxon.DateTime.fromSQL(sqlValue, { zone: "utc" }).setZone(userTz).toISODate();
}
/**
* Check if the given field is used as col group by
*/
_isCol(field) {
return this.metaData.fullColGroupBys
.map(this.parseGroupField)
.map(({ field }) => field.name)
.includes(field.name);
}
/**
* Check if the given field is used as row group by
*/
_isRow(field) {
return this.metaData.fullRowGroupBys
.map(this.parseGroupField)
.map(({ field }) => field.name)
.includes(field.name);
}
/**
* Get the value of a field-value for a positional group by
*
* @param {object} field Field of the group by
* @param {unknown} groupValueString Value of the group by
* @param {(number | boolean | string)[]} rows Values for the previous row group bys
* @param {(number | boolean | string)[]} cols Values for the previous col group bys
*
* @private
* @returns {number | boolean | string}
*/
_parsePivotFormulaWithPosition(field, groupValueString, cols, rows) {
const position = toNumber(groupValueString) - 1;
let tree;
if (this._isCol(field)) {
tree = this.data.colGroupTree;
for (const col of cols) {
tree = tree && tree.directSubTrees.get(col);
}
} else {
tree = this.data.rowGroupTree;
for (const row of rows) {
tree = tree && tree.directSubTrees.get(row);
}
}
if (tree) {
const treeKeys = tree.sortedKeys || [...tree.directSubTrees.keys()];
const sortedKey = treeKeys[position];
return sortedKey !== undefined ? sortedKey : NO_RECORD_AT_THIS_POSITION;
}
return NO_RECORD_AT_THIS_POSITION;
}
/**
* Transform the given domain in the structure used in this class
*
* @param {(number | boolean | string)[]} domain Domain
*
* @private
*/
_getColsRowsValuesFromDomain(domain) {
const rows = [];
const cols = [];
let i = 0;
while (i < domain.length) {
const groupFieldString = domain[i];
const groupValue = domain[i + 1];
const { field, isPositional } = this.parseGroupField(groupFieldString);
let value;
if (isPositional) {
value = this._parsePivotFormulaWithPosition(field, groupValue, cols, rows);
} else {
value = parsePivotFormulaFieldValue(field, groupValue);
}
if (this._isCol(field)) {
cols.push(value);
} else if (this._isRow(field)) {
rows.push(value);
}
i += 2;
}
return { rows, cols };
}
/**
* Get the row structure
* @returns {Row[]}
*/
_getSpreadsheetRows(tree) {
/**@type {Row[]}*/
const rows = [];
const group = tree.root;
const indent = group.labels.length;
const rowGroupBys = this.metaData.fullRowGroupBys;
rows.push({
fields: rowGroupBys.slice(0, indent),
values: group.values.map((val) => val.toString()),
indent,
});
const subTreeKeys = tree.sortedKeys || [...tree.directSubTrees.keys()];
subTreeKeys.forEach((subTreeKey) => {
const subTree = tree.directSubTrees.get(subTreeKey);
rows.push(...this._getSpreadsheetRows(subTree));
});
return rows;
}
/**
* Get the col structure
* @returns {Column[][]}
*/
_getSpreadsheetCols() {
const colGroupBys = this.metaData.fullColGroupBys;
const height = colGroupBys.length;
const measureCount = this.metaData.activeMeasures.length;
const leafCounts = this._getLeafCounts(this.data.colGroupTree);
const headers = new Array(height).fill(0).map(() => []);
function generateTreeHeaders(tree, fields) {
const group = tree.root;
const rowIndex = group.values.length;
if (rowIndex !== 0) {
const row = headers[rowIndex - 1];
const leafCount = leafCounts[JSON.stringify(tree.root.values)];
const cell = {
fields: colGroupBys.slice(0, rowIndex),
values: group.values.map((val) => val.toString()),
width: leafCount * measureCount,
};
row.push(cell);
}
[...tree.directSubTrees.values()].forEach((subTree) => {
generateTreeHeaders(subTree, fields);
});
}
generateTreeHeaders(this.data.colGroupTree, this.metaData.fields);
const hasColGroupBys = this.metaData.colGroupBys.length;
// 2) generate measures row
const measureRow = [];
if (hasColGroupBys) {
headers[headers.length - 1].forEach((cell) => {
this.metaData.activeMeasures.forEach((measureName) => {
const measureCell = {
fields: [...cell.fields, "measure"],
values: [...cell.values, measureName],
width: 1,
};
measureRow.push(measureCell);
});
});
}
this.metaData.activeMeasures.forEach((measureName) => {
const measureCell = {
fields: ["measure"],
values: [measureName],
width: 1,
};
measureRow.push(measureCell);
});
headers.push(measureRow);
// 3) Add the total cell
if (headers.length === 1) {
headers.unshift([]); // Will add the total there
}
headers[headers.length - 2].push({
fields: [],
values: [],
width: this.metaData.activeMeasures.length,
});
return headers;
}
}

View file

@ -0,0 +1,181 @@
/** @odoo-module */
/**
* @typedef {Object} Column
* @property {string[]} fields
* @property {string[]} values
* @property {number} width
*
* @typedef {Object} Row
* @property {string[]} fields
* @property {string[]} values
* @property {number} intend
*
* @typedef {Object} SpreadsheetTableData
* @property {Column[][]} cols
* @property {Row[]} rows
* @property {string[]} measures
*/
/**
* Class used to ease the construction of a pivot table.
* Let's consider the following example, with:
* - columns groupBy: [sales_team, create_date]
* - rows groupBy: [continent, city]
* - measures: [revenues]
* _____________________________________________________________________________________| ----|
* | | Sale Team 1 | Sale Team 2 | | |
* | |___________________________|_________________________|_____________| |
* | | May 2020 | June 2020 | May 2020 | June 2020 | Total | |<---- `cols`
* | |______________|____________|____________|____________|_____________| | ----|
* | | Revenues | Revenues | Revenues | Revenues | Revenues | | |<--- `measureRow`
* |________________|______________|____________|____________|____________|_____________| ----| ----|
* |Europe | 25 | 35 | 40 | 30 | 65 | ----|
* | Brussels | 0 | 15 | 30 | 30 | 30 | |
* | Paris | 25 | 20 | 10 | 0 | 35 | |
* |North America | 60 | 75 | | | 60 | |<---- `body`
* | Washington | 60 | 75 | | | 60 | |
* |Total | 85 | 110 | 40 | 30 | 125 | |
* |________________|______________|____________|____________|____________|_____________| ----|
*
* | |
* |----------------|
* |
* |
* `rows`
*
* `rows` is an array of cells, each cells contains the indent level, the fields used for the group by and the values for theses fields.
* For example:
* `Europe`: { indent: 1, fields: ["continent"], values: ["id_of_Europe"]}
* `Brussels`: { indent: 2, fields: ["continent", "city"], values: ["id_of_Europe", "id_of_Brussels"]}
* `Total`: { indent: 0, fields: [], values: []}
*
* `columns` is an double array, first by row and then by cell. So, in this example, it looks like:
* [[row1], [row2], [measureRow]]
* Each cell of a column's row contains the width (span) of the cells, the fields used for the group by and the values for theses fields.
* For example:
* `Sale Team 1`: { width: 2, fields: ["sales_team"], values: ["id_of_SaleTeam1"]}
* `May 2020` (the one under Sale Team 2): { width: 1, fields: ["sales_team", "create_date"], values: ["id_of_SaleTeam2", "May 2020"]}
* `Revenues` (the one under Total): { width: 1, fields: ["measure"], values: ["revenues"]}
*
*/
export class SpreadsheetPivotTable {
/**
* @param {Column[][]} cols
* @param {Row[]} rows
* @param {string[]} measures
*/
constructor(cols, rows, measures) {
this._cols = cols;
this._rows = rows;
this._measures = measures;
}
/**
* @returns {number}
*/
getNumberOfMeasures() {
return this._measures.length;
}
/**
* @returns {Column[][]}
*/
getColHeaders() {
return this._cols;
}
/**
* Get the last row of the columns (i.e. the one with the measures)
* @returns {Column[]}
*/
getMeasureHeaders() {
return this._cols[this._cols.length - 1];
}
/**
* Get the number of columns leafs (i.e. the number of the last row of columns)
* @returns {number}
*/
getColWidth() {
return this._cols[this._cols.length - 1].length;
}
/**
* Get the number of row in each columns
* @return {number}
*/
getColHeight() {
return this._cols.length;
}
/**
* @returns {Row[]}
*/
getRowHeaders() {
return this._rows;
}
/**
* Get the number of rows
*
* @returns {number}
*/
getRowHeight() {
return this._rows.length;
}
/**
* Get the index of the cell in the measure row (i.e. the last one) which
* correspond to the given values
*
* @returns {number}
*/
getColMeasureIndex(values) {
const vals = JSON.stringify(values);
const maxLength = Math.max(...this._cols.map((col) => col.length));
for (let i = 0; i < maxLength; i++) {
const cellValues = this._cols.map((col) => JSON.stringify((col[i] || {}).values));
if (cellValues.includes(vals)) {
return i;
}
}
return -1;
}
/**
*
* @param {number} colIndex
* @param {number} rowIndex
* @returns {Column}
*/
getNextColCell(colIndex, rowIndex) {
return this._cols[rowIndex][colIndex];
}
getRowIndex(values) {
const vals = JSON.stringify(values);
return this._rows.findIndex(
(cell) => JSON.stringify(cell.values.map((val) => val.toString())) === vals
);
}
getCellFromMeasureRowAtIndex(index) {
return this.getMeasureHeaders()[index];
}
getCellsFromRowAtIndex(index) {
return this._rows[index];
}
/**
* @returns {SpreadsheetTableData}
*/
export() {
return {
cols: this._cols,
rows: this._rows,
measures: this._measures,
};
}
}

View file

@ -0,0 +1,560 @@
/** @odoo-module */
/**
*
* @typedef {Object} PivotDefinition
* @property {Array<string>} colGroupBys
* @property {Array<string>} rowGroupBys
* @property {Array<string>} measures
* @property {string} model
* @property {Array} domain
* @property {Object} context
* @property {string} name
* @property {string} id
* @property {Object | null} sortedColumn
*
* @typedef {Object} Pivot
* @property {string} id
* @property {string} dataSourceId
* @property {PivotDefinition} definition
* @property {Object} fieldMatching
*
* @typedef {import("@spreadsheet/global_filters/plugins/global_filters_core_plugin").FieldMatching} FieldMatching
*/
import spreadsheet from "@spreadsheet/o_spreadsheet/o_spreadsheet_extended";
import { makePivotFormula } from "../pivot_helpers";
import { getMaxObjectId } from "@spreadsheet/helpers/helpers";
import { HEADER_STYLE, TOP_LEVEL_STYLE, MEASURE_STYLE } from "@spreadsheet/helpers/constants";
import PivotDataSource from "../pivot_data_source";
import { SpreadsheetPivotTable } from "../pivot_table";
import CommandResult from "../../o_spreadsheet/cancelled_reason";
import { _t } from "@web/core/l10n/translation";
import { globalFiltersFieldMatchers } from "@spreadsheet/global_filters/plugins/global_filters_core_plugin";
import { sprintf } from "@web/core/utils/strings";
import { checkFilterFieldMatching } from "@spreadsheet/global_filters/helpers";
const { CorePlugin } = spreadsheet;
export default class PivotCorePlugin extends CorePlugin {
constructor(getters, history, range, dispatch, config, uuidGenerator) {
super(getters, history, range, dispatch, config, uuidGenerator);
this.dataSources = config.dataSources;
this.nextId = 1;
/** @type {Object.<string, Pivot>} */
this.pivots = {};
globalFiltersFieldMatchers["pivot"] = {
geIds: () => this.getters.getPivotIds(),
getDisplayName: (pivotId) => this.getters.getPivotName(pivotId),
getTag: (pivotId) => sprintf(_t("Pivot #%s"), pivotId),
getFieldMatching: (pivotId, filterId) => this.getPivotFieldMatching(pivotId, filterId),
waitForReady: () => this.getPivotsWaitForReady(),
getModel: (pivotId) => this.getPivotDefinition(pivotId).model,
getFields: (pivotId) => this.getPivotDataSource(pivotId).getFields(),
};
}
allowDispatch(cmd) {
switch (cmd.type) {
case "RENAME_ODOO_PIVOT":
if (!(cmd.pivotId in this.pivots)) {
return CommandResult.PivotIdNotFound;
}
if (cmd.name === "") {
return CommandResult.EmptyName;
}
break;
case "INSERT_PIVOT":
if (cmd.id !== this.nextId.toString()) {
return CommandResult.InvalidNextId;
}
break;
case "ADD_GLOBAL_FILTER":
case "EDIT_GLOBAL_FILTER":
if (cmd.pivot) {
return checkFilterFieldMatching(cmd.pivot);
}
}
return CommandResult.Success;
}
/**
* Handle a spreadsheet command
*
* @param {Object} cmd Command
*/
handle(cmd) {
switch (cmd.type) {
case "INSERT_PIVOT": {
const { sheetId, col, row, id, definition, dataSourceId } = cmd;
/** @type [number,number] */
const anchor = [col, row];
const { cols, rows, measures } = cmd.table;
const table = new SpreadsheetPivotTable(cols, rows, measures);
this._addPivot(id, definition, dataSourceId);
this._insertPivot(sheetId, anchor, id, table);
this.history.update("nextId", parseInt(id, 10) + 1);
break;
}
case "RE_INSERT_PIVOT": {
const { sheetId, col, row, id } = cmd;
/** @type [number,number] */
const anchor = [col, row];
const { cols, rows, measures } = cmd.table;
const table = new SpreadsheetPivotTable(cols, rows, measures);
this._insertPivot(sheetId, anchor, id, table);
break;
}
case "RENAME_ODOO_PIVOT": {
this.history.update("pivots", cmd.pivotId, "definition", "name", cmd.name);
break;
}
case "REMOVE_PIVOT": {
const pivots = { ...this.pivots };
delete pivots[cmd.pivotId];
this.history.update("pivots", pivots);
break;
}
case "UPDATE_ODOO_PIVOT_DOMAIN": {
this.history.update(
"pivots",
cmd.pivotId,
"definition",
"searchParams",
"domain",
cmd.domain
);
const pivot = this.pivots[cmd.pivotId];
this.dataSources.add(pivot.dataSourceId, PivotDataSource, pivot.definition);
break;
}
case "UNDO":
case "REDO": {
const domainEditionCommands = cmd.commands.filter(
(cmd) => cmd.type === "UPDATE_ODOO_PIVOT_DOMAIN"
);
for (const cmd of domainEditionCommands) {
const pivot = this.pivots[cmd.pivotId];
this.dataSources.add(pivot.dataSourceId, PivotDataSource, pivot.definition);
}
break;
}
case "ADD_GLOBAL_FILTER":
case "EDIT_GLOBAL_FILTER":
if (cmd.pivot) {
this._setPivotFieldMatching(cmd.filter.id, cmd.pivot);
}
break;
case "REMOVE_GLOBAL_FILTER":
this._onFilterDeletion(cmd.id);
break;
}
}
// -------------------------------------------------------------------------
// Getters
// -------------------------------------------------------------------------
/**
* @param {string} id
* @returns {PivotDataSource|undefined}
*/
getPivotDataSource(id) {
const dataSourceId = this.pivots[id].dataSourceId;
return this.dataSources.get(dataSourceId);
}
/**
* @param {string} id
* @returns {string}
*/
getPivotDisplayName(id) {
return `(#${id}) ${this.getPivotName(id)}`;
}
/**
* @param {string} id
* @returns {string}
*/
getPivotName(id) {
return _t(this.pivots[id].definition.name);
}
/**
* @param {string} id
* @returns {string}
*/
getPivotFieldMatch(id) {
return this.pivots[id].fieldMatching;
}
/**
* @param {string} id
* @returns {Promise<PivotDataSource>}
*/
async getAsyncPivotDataSource(id) {
const dataSourceId = this.pivots[id].dataSourceId;
await this.dataSources.load(dataSourceId);
return this.getPivotDataSource(id);
}
/**
* Retrieve the next available id for a new pivot
*
* @returns {string} id
*/
getNextPivotId() {
return this.nextId.toString();
}
/**
* @param {string} id Id of the pivot
*
* @returns {PivotDefinition}
*/
getPivotDefinition(id) {
const def = this.pivots[id].definition;
return {
colGroupBys: [...def.metaData.colGroupBys],
context: { ...def.searchParams.context },
domain: [...def.searchParams.domain],
id,
measures: [...def.metaData.activeMeasures],
model: def.metaData.resModel,
rowGroupBys: [...def.metaData.rowGroupBys],
name: def.name,
sortedColumn: def.metaData.sortedColumn ? { ...def.metaData.sortedColumn } : null,
};
}
/**
* Retrieve all the pivot ids
*
* @returns {Array<string>}
*/
getPivotIds() {
return Object.keys(this.pivots);
}
/**
* Check if an id is an id of an existing pivot
*
* @param {number} pivotId Id of the pivot
*
* @returns {boolean}
*/
isExistingPivot(pivotId) {
return pivotId in this.pivots;
}
/**
* Get the current pivotFieldMatching on a pivot
*
* @param {string} pivotId
* @param {string} filterId
*/
getPivotFieldMatching(pivotId, filterId) {
return this.pivots[pivotId].fieldMatching[filterId];
}
// -------------------------------------------------------------------------
// Private
// -------------------------------------------------------------------------
/**
*
* @return {Promise[]}
*/
getPivotsWaitForReady() {
return this.getPivotIds().map((pivotId) => this.getPivotDataSource(pivotId).loadMetadata());
}
/**
* Sets the current pivotFieldMatching on a pivot
*
* @param {string} filterId
* @param {Record<string,FieldMatching>} pivotFieldMatches
*/
_setPivotFieldMatching(filterId, pivotFieldMatches) {
const pivots = { ...this.pivots };
for (const [pivotId, fieldMatch] of Object.entries(pivotFieldMatches)) {
pivots[pivotId].fieldMatching[filterId] = fieldMatch;
}
this.history.update("pivots", pivots);
}
_onFilterDeletion(filterId) {
const pivots = { ...this.pivots };
for (const pivotId in pivots) {
this.history.update("pivots", pivotId, "fieldMatching", filterId, undefined);
}
}
/**
* @param {string} id
* @param {PivotDefinition} definition
* @param {string} dataSourceId
*/
_addPivot(id, definition, dataSourceId, fieldMatching = {}) {
const pivots = { ...this.pivots };
pivots[id] = {
id,
definition,
dataSourceId,
fieldMatching,
};
if (!this.dataSources.contains(dataSourceId)) {
this.dataSources.add(dataSourceId, PivotDataSource, definition);
}
this.history.update("pivots", pivots);
}
/**
* @param {string} sheetId
* @param {[number, number]} anchor
* @param {string} id
* @param {SpreadsheetPivotTable} table
*/
_insertPivot(sheetId, anchor, id, table) {
this._resizeSheet(sheetId, anchor, table);
this._insertColumns(sheetId, anchor, id, table);
this._insertRows(sheetId, anchor, id, table);
this._insertBody(sheetId, anchor, id, table);
}
/**
* @param {string} sheetId
* @param {[number, number]} anchor
* @param {string} id
* @param {SpreadsheetPivotTable} table
*/
_insertColumns(sheetId, anchor, id, table) {
let anchorLeft = anchor[0] + 1;
let anchorTop = anchor[1];
for (const _row of table.getColHeaders()) {
anchorLeft = anchor[0] + 1;
for (const cell of _row) {
const args = [id];
for (let i = 0; i < cell.fields.length; i++) {
args.push(cell.fields[i]);
args.push(cell.values[i]);
}
if (cell.width > 1) {
this._merge(sheetId, {
top: anchorTop,
bottom: anchorTop,
left: anchorLeft,
right: anchorLeft + cell.width - 1,
});
}
this._addPivotFormula(sheetId, anchorLeft, anchorTop, "ODOO.PIVOT.HEADER", args);
anchorLeft += cell.width;
}
anchorTop++;
}
const colHeight = table.getColHeight();
const colWidth = table.getColWidth();
const lastRowBeforeMeasureRow = anchor[1] + colHeight - 2;
const right = anchor[0] + colWidth;
const left = right - table.getNumberOfMeasures() + 1;
for (let anchorTop = anchor[1]; anchorTop < lastRowBeforeMeasureRow; anchorTop++) {
this._merge(sheetId, { top: anchorTop, bottom: anchorTop, left, right });
}
const headersZone = {
top: anchor[1],
bottom: lastRowBeforeMeasureRow,
left: anchor[0],
right: anchor[0] + colWidth,
};
const measuresZone = {
top: anchor[1] + colHeight - 1,
bottom: anchor[1] + colHeight - 1,
left: anchor[0],
right: anchor[0] + colWidth,
};
this.dispatch("SET_FORMATTING", { sheetId, target: [headersZone], style: TOP_LEVEL_STYLE });
this.dispatch("SET_FORMATTING", { sheetId, target: [measuresZone], style: MEASURE_STYLE });
}
/**
* Merge a zone
*
* @param {string} sheetId
* @param {Object} zone
*
* @private
*/
_merge(sheetId, zone) {
this.dispatch("ADD_MERGE", { sheetId, target: [zone] });
}
/**
* @param {string} sheetId
* @param {[number,number]} anchor
* @param {SpreadsheetPivotTable} table
*/
_resizeSheet(sheetId, anchor, table) {
const colLimit = table.getColWidth() + 1; // +1 for the Top-Left
const numberCols = this.getters.getNumberCols(sheetId);
const deltaCol = numberCols - anchor[0];
if (deltaCol < colLimit) {
this.dispatch("ADD_COLUMNS_ROWS", {
dimension: "COL",
base: numberCols - 1,
sheetId: sheetId,
quantity: colLimit - deltaCol,
position: "after",
});
}
const rowLimit = table.getColHeight() + table.getRowHeight();
const numberRows = this.getters.getNumberRows(sheetId);
const deltaRow = numberRows - anchor[1];
if (deltaRow < rowLimit) {
this.dispatch("ADD_COLUMNS_ROWS", {
dimension: "ROW",
base: numberRows - 1,
sheetId: sheetId,
quantity: rowLimit - deltaRow,
position: "after",
});
}
}
/**
* @param {string} sheetId
* @param {[number, number]} anchor
* @param {string} id
* @param {SpreadsheetPivotTable} table
*/
_insertRows(sheetId, anchor, id, table) {
let y = anchor[1] + table.getColHeight();
const x = anchor[0];
for (const row of table.getRowHeaders()) {
const args = [id];
for (let i = 0; i < row.fields.length; i++) {
args.push(row.fields[i]);
args.push(row.values[i]);
}
this._addPivotFormula(sheetId, x, y, "ODOO.PIVOT.HEADER", args);
if (row.indent <= 2) {
const target = [{ top: y, bottom: y, left: x, right: x }];
const style = row.indent === 2 ? HEADER_STYLE : TOP_LEVEL_STYLE;
this.dispatch("SET_FORMATTING", { sheetId, target, style });
}
y++;
}
}
/**
* @param {string} sheetId
* @param {[number, number]} anchor
* @param {string} id
* @param {SpreadsheetPivotTable} table
*/
_insertBody(sheetId, anchor, id, table) {
let x = anchor[0] + 1;
for (const col of table.getMeasureHeaders()) {
let y = anchor[1] + table.getColHeight();
const measure = col.values[col.values.length - 1];
for (const row of table.getRowHeaders()) {
const args = [id, measure];
for (let i = 0; i < row.fields.length; i++) {
args.push(row.fields[i]);
args.push(row.values[i]);
}
for (let i = 0; i < col.fields.length - 1; i++) {
args.push(col.fields[i]);
args.push(col.values[i]);
}
this._addPivotFormula(sheetId, x, y, "ODOO.PIVOT", args);
y++;
}
x++;
}
}
/**
* @param {string} sheetId
* @param {number} col
* @param {number} row
* @param {string} formula
* @param {Array<string>} args
*/
_addPivotFormula(sheetId, col, row, formula, args) {
this.dispatch("UPDATE_CELL", {
sheetId,
col,
row,
content: makePivotFormula(formula, args),
});
}
// ---------------------------------------------------------------------
// Import/Export
// ---------------------------------------------------------------------
/**
* Import the pivots
*
* @param {Object} data
*/
import(data) {
if (data.pivots) {
for (const [id, pivot] of Object.entries(data.pivots)) {
const definition = {
metaData: {
colGroupBys: pivot.colGroupBys,
rowGroupBys: pivot.rowGroupBys,
activeMeasures: pivot.measures.map((elt) => elt.field),
resModel: pivot.model,
sortedColumn: !pivot.sortedColumn
? undefined
: {
groupId: pivot.sortedColumn.groupId,
measure: pivot.sortedColumn.measure,
order: pivot.sortedColumn.order,
},
},
searchParams: {
groupBy: [],
orderBy: [],
domain: pivot.domain,
context: pivot.context,
},
name: pivot.name,
};
this._addPivot(id, definition, this.uuidGenerator.uuidv4(), pivot.fieldMatching);
}
}
this.nextId = data.pivotNextId || getMaxObjectId(this.pivots) + 1;
}
/**
* Export the pivots
*
* @param {Object} data
*/
export(data) {
data.pivots = {};
for (const id in this.pivots) {
data.pivots[id] = JSON.parse(JSON.stringify(this.getPivotDefinition(id)));
data.pivots[id].measures = data.pivots[id].measures.map((elt) => ({ field: elt }));
data.pivots[id].fieldMatching = this.pivots[id].fieldMatching;
}
data.pivotNextId = this.nextId;
}
}
PivotCorePlugin.getters = [
"getNextPivotId",
"getPivotDefinition",
"getPivotDisplayName",
"getPivotIds",
"getPivotName",
"getAsyncPivotDataSource",
"isExistingPivot",
"getPivotDataSource",
"getPivotFieldMatch",
"getPivotFieldMatching",
];

View file

@ -0,0 +1,380 @@
/** @odoo-module */
import { _t } from "@web/core/l10n/translation";
import spreadsheet from "@spreadsheet/o_spreadsheet/o_spreadsheet_extended";
import { getFirstPivotFunction } from "../pivot_helpers";
import { FILTER_DATE_OPTION, monthsOptions } from "@spreadsheet/assets_backend/constants";
import { Domain } from "@web/core/domain";
import { NO_RECORD_AT_THIS_POSITION } from "../pivot_model";
const { astToFormula } = spreadsheet;
const { DateTime } = luxon;
/**
* Convert pivot period to the related filter value
*
* @param {import("@spreadsheet/global_filters/plugins/global_filters_core_plugin").RangeType} timeRange
* @param {string|number} value
* @returns {object}
*/
function pivotPeriodToFilterValue(timeRange, value) {
// reuse the same logic as in `parseAccountingDate`?
if (typeof value === "number") {
value = value.toString(10);
}
if (
value === "false" || // the value "false" is the default value when there is no data for a group header
typeof value !== "string"
) {
// anything else then a string at this point is incorrect, so no filtering
return undefined;
}
const yearValue = value.split("/").at(-1);
if (!yearValue) {
return undefined;
}
const yearOffset = yearValue - DateTime.now().year;
switch (timeRange) {
case "year":
return {
yearOffset,
};
case "month": {
const month = value.includes("/") ? Number.parseInt(value.split("/")[0]) : -1;
if (!(month in monthsOptions)) {
return { yearOffset, period: undefined };
}
return {
yearOffset,
period: monthsOptions[month - 1].id,
};
}
case "quarter": {
const quarter = value.includes("/") ? Number.parseInt(value.split("/")[0]) : -1;
if (!(quarter in FILTER_DATE_OPTION.quarter)) {
return { yearOffset, period: undefined };
}
return {
yearOffset,
period: FILTER_DATE_OPTION.quarter[quarter - 1],
};
}
}
}
export default class PivotUIPlugin extends spreadsheet.UIPlugin {
constructor() {
super(...arguments);
/** @type {string} */
this.selectedPivotId = undefined;
this.selection.observe(this, {
handleEvent: this.handleEvent.bind(this),
});
}
handleEvent(event) {
if (!this.getters.isDashboard()) {
return;
}
switch (event.type) {
case "ZonesSelected": {
const sheetId = this.getters.getActiveSheetId();
const { col, row } = event.anchor.cell;
const cell = this.getters.getCell(sheetId, col, row);
if (cell !== undefined && cell.content.startsWith("=ODOO.PIVOT.HEADER(")) {
const filters = this.getFiltersMatchingPivot(cell.content);
this.dispatch("SET_MANY_GLOBAL_FILTER_VALUE", { filters });
}
break;
}
}
}
beforeHandle(cmd) {
switch (cmd.type) {
case "START":
// make sure the domains are correctly set before
// any evaluation
this._addDomains();
break;
}
}
/**
* Handle a spreadsheet command
* @param {Object} cmd Command
*/
handle(cmd) {
switch (cmd.type) {
case "SELECT_PIVOT":
this.selectedPivotId = cmd.pivotId;
break;
case "REMOVE_PIVOT":
if (this.selectedPivotId === cmd.pivotId) {
this.selectedPivotId = undefined;
}
break;
case "REFRESH_PIVOT":
this._refreshOdooPivot(cmd.id);
break;
case "REFRESH_ALL_DATA_SOURCES":
this._refreshOdooPivots();
break;
case "UPDATE_ODOO_PIVOT_DOMAIN":
this._addDomain(cmd.pivotId);
break;
case "ADD_GLOBAL_FILTER":
case "EDIT_GLOBAL_FILTER":
case "REMOVE_GLOBAL_FILTER":
case "SET_GLOBAL_FILTER_VALUE":
case "CLEAR_GLOBAL_FILTER_VALUE":
this._addDomains();
break;
case "UNDO":
case "REDO":
if (
cmd.commands.find((command) =>
[
"ADD_GLOBAL_FILTER",
"EDIT_GLOBAL_FILTER",
"REMOVE_GLOBAL_FILTER",
"UPDATE_ODOO_PIVOT_DOMAIN",
].includes(command.type)
)
) {
this._addDomains();
}
if (!this.getters.getPivotIds().length) {
this.selectedPivotId = undefined;
}
break;
}
}
// ---------------------------------------------------------------------
// Getters
// ---------------------------------------------------------------------
/**
* Retrieve the pivotId of the current selected cell
*
* @returns {string}
*/
getSelectedPivotId() {
return this.selectedPivotId;
}
/**
* Get the id of the pivot at the given position. Returns undefined if there
* is no pivot at this position
*
* @param {string} sheetId Id of the sheet
* @param {number} col Index of the col
* @param {number} row Index of the row
*
* @returns {string|undefined}
*/
getPivotIdFromPosition(sheetId, col, row) {
const cell = this.getters.getCell(sheetId, col, row);
if (cell && cell.isFormula()) {
const pivotFunction = getFirstPivotFunction(cell.content);
if (pivotFunction && pivotFunction.args[0]) {
const content = astToFormula(pivotFunction.args[0]);
return this.getters.evaluateFormula(content).toString();
}
}
return undefined;
}
/**
* Get the computed domain of a pivot
* CLEAN ME not used outside of tests
* @param {string} pivotId Id of the pivot
* @returns {Array}
*/
getPivotComputedDomain(pivotId) {
return this.getters.getPivotDataSource(pivotId).getComputedDomain();
}
/**
* Return all possible values in the pivot for a given field.
*
* @param {string} pivotId Id of the pivot
* @param {string} fieldName
* @returns {Array<string>}
*/
getPivotGroupByValues(pivotId, fieldName) {
return this.getters.getPivotDataSource(pivotId).getPossibleValuesForGroupBy(fieldName);
}
/**
* Get the value of a pivot header
*
* @param {string} pivotId Id of a pivot
* @param {Array<string>} domain Domain
*/
getDisplayedPivotHeaderValue(pivotId, domain) {
const dataSource = this.getters.getPivotDataSource(pivotId);
dataSource.markAsHeaderUsed(domain);
const len = domain.length;
if (len === 0) {
return _t("Total");
}
return dataSource.getDisplayedPivotHeaderValue(domain);
}
/**
* Get the value for a pivot cell
*
* @param {string} pivotId Id of a pivot
* @param {string} measure Field name of the measures
* @param {Array<string>} domain Domain
*
* @returns {string|number|undefined}
*/
getPivotCellValue(pivotId, measure, domain) {
const dataSource = this.getters.getPivotDataSource(pivotId);
dataSource.markAsValueUsed(domain, measure);
return dataSource.getPivotCellValue(measure, domain);
}
/**
* Get the filter impacted by a pivot formula's argument
*
* @param {string} formula Formula of the pivot cell
*
* @returns {Array<Object>}
*/
getFiltersMatchingPivot(formula) {
const functionDescription = getFirstPivotFunction(formula);
if (!functionDescription) {
return [];
}
const { args } = functionDescription;
const evaluatedArgs = args
.map(astToFormula)
.map((arg) => this.getters.evaluateFormula(arg));
if (evaluatedArgs.length <= 2) {
return [];
}
const pivotId = evaluatedArgs[0];
const argField = evaluatedArgs[evaluatedArgs.length - 2];
if (argField === "measure") {
return [];
}
const filters = this.getters.getGlobalFilters();
const matchingFilters = [];
for (const filter of filters) {
const dataSource = this.getters.getPivotDataSource(pivotId);
const { field, aggregateOperator: time } = dataSource.parseGroupField(argField);
const pivotFieldMatching = this.getters.getPivotFieldMatching(pivotId, filter.id);
if (pivotFieldMatching && pivotFieldMatching.chain === field.name) {
let value = dataSource.getPivotHeaderValue(evaluatedArgs.slice(-2));
if (value === NO_RECORD_AT_THIS_POSITION) {
continue;
}
let transformedValue;
const currentValue = this.getters.getGlobalFilterValue(filter.id);
switch (filter.type) {
case "date":
if (time === filter.rangeType) {
transformedValue = pivotPeriodToFilterValue(time, value);
if (JSON.stringify(transformedValue) === JSON.stringify(currentValue)) {
transformedValue = undefined;
}
} else {
continue;
}
break;
case "relation":
if (typeof value == "string") {
value = Number(value);
if (Number.isNaN(value)) {
break;
}
}
// A group by value of "none"
if (value === false) break;
if (JSON.stringify(currentValue) !== `[${value}]`) {
transformedValue = [value];
}
break;
case "text":
if (currentValue !== value) {
transformedValue = value;
}
break;
}
matchingFilters.push({ filterId: filter.id, value: transformedValue });
}
}
return matchingFilters;
}
// ---------------------------------------------------------------------
// Private
// ---------------------------------------------------------------------
/**
* Refresh the cache of a pivot
*
* @param {string} pivotId Id of the pivot
*/
_refreshOdooPivot(pivotId) {
const dataSource = this.getters.getPivotDataSource(pivotId);
dataSource.clearUsedValues();
dataSource.load({ reload: true });
}
/**
* Refresh the cache of all the pivots
*/
_refreshOdooPivots() {
for (const pivotId of this.getters.getPivotIds()) {
this._refreshOdooPivot(pivotId, false);
}
}
/**
* Add an additional domain to a pivot
*
* @private
*
* @param {string} pivotId pivot id
*/
_addDomain(pivotId) {
const domainList = [];
for (const [filterId, fieldMatch] of Object.entries(
this.getters.getPivotFieldMatch(pivotId)
)) {
domainList.push(this.getters.getGlobalFilterDomain(filterId, fieldMatch));
}
const domain = Domain.combine(domainList, "AND").toString();
this.getters.getPivotDataSource(pivotId).addDomain(domain);
}
/**
* Add an additional domain to all pivots
*
* @private
*
*/
_addDomains() {
for (const pivotId of this.getters.getPivotIds()) {
this._addDomain(pivotId);
}
}
}
PivotUIPlugin.getters = [
"getSelectedPivotId",
"getPivotComputedDomain",
"getDisplayedPivotHeaderValue",
"getPivotIdFromPosition",
"getPivotCellValue",
"getPivotGroupByValues",
"getFiltersMatchingPivot",
];

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

Some files were not shown because too many files have changed in this diff Show more