[MOV] spreadsheet: move module to community
The spreadsheet module is moved to the community version for the upcoming spreadsheet dashboards This module is the core engine of spreadsheets in Odoo. It contains `o-spreadsheet` library (o_spreadsheet.js) and all basic integrations with odoo such as accounting formulas, lists, pivot, filters, etc. This module alone does not have any action or menu to open a spreadsheet. Those actions come with `documents_spreadsheet` module or the upcoming `spreadsheet_dashboard` module. Task 2799682 Part-of: odoo/odoo#97606
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from . import models
|
||||
@@ -0,0 +1,35 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
{
|
||||
'name': "Spreadsheet",
|
||||
'version': '1.0',
|
||||
'category': 'Hidden',
|
||||
'summary': 'Spreadsheet',
|
||||
'description': 'Spreadsheet',
|
||||
'depends': ['bus', 'web'],
|
||||
'data': [],
|
||||
'demo': [],
|
||||
'installable': True,
|
||||
'auto_install': False,
|
||||
'license': 'LGPL-3',
|
||||
'assets': {
|
||||
'spreadsheet.o_spreadsheet': [
|
||||
'spreadsheet/static/src/o_spreadsheet/o_spreadsheet.js',
|
||||
'spreadsheet/static/src/**/*.js',
|
||||
('remove', 'spreadsheet/static/src/assets_backend/**/*.js')
|
||||
],
|
||||
'web.assets_backend': [
|
||||
'spreadsheet/static/src/**/*.scss',
|
||||
'spreadsheet/static/src/assets_backend/**/*.js'
|
||||
],
|
||||
'web.assets_qweb': [
|
||||
# Load all o_spreadsheet templates first to allow to inherit them
|
||||
'spreadsheet/static/src/o_spreadsheet/o_spreadsheet.xml',
|
||||
'spreadsheet/static/src/**/*.xml',
|
||||
],
|
||||
'web.qunit_suite_tests': [
|
||||
'spreadsheet/static/tests/**/*',
|
||||
('include', 'spreadsheet.o_spreadsheet')
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from . import res_currency
|
||||
from . import res_currency_rate
|
||||
@@ -0,0 +1,54 @@
|
||||
from odoo import api, models
|
||||
|
||||
|
||||
class ResCurrency(models.Model):
|
||||
_inherit = "res.currency"
|
||||
|
||||
@api.model
|
||||
def get_currencies_for_spreadsheet(self, currency_names):
|
||||
"""
|
||||
Returns the currency structure of provided currency names.
|
||||
This function is meant to be called by the spreadsheet js lib,
|
||||
hence the formatting of the result.
|
||||
|
||||
:currency_names list(str): list of currency names (e.g. ["EUR", "USD", "CAD"])
|
||||
:return: list of dicts of the form `{ "code": str, "symbol": str, "decimalPlaces": int, "position":str }`
|
||||
"""
|
||||
currencies = self.with_context(active_test=False).search(
|
||||
[("name", "in", currency_names)],
|
||||
)
|
||||
result = []
|
||||
for currency_name in currency_names:
|
||||
currency = next(filter(lambda curr: curr.name == currency_name, currencies), None)
|
||||
if currency:
|
||||
currency_data = {
|
||||
"code": currency.name,
|
||||
"symbol": currency.symbol,
|
||||
"decimalPlaces": currency.decimal_places,
|
||||
"position": currency.position,
|
||||
}
|
||||
else:
|
||||
currency_data = None
|
||||
result.append(currency_data)
|
||||
return result
|
||||
|
||||
@api.model
|
||||
def get_company_currency_for_spreadsheet(self, company_id=None):
|
||||
"""
|
||||
Returns the currency structure for the currency of the company.
|
||||
This function is meant to be called by the spreadsheet js lib,
|
||||
hence the formatting of the result.
|
||||
|
||||
:company_id int: Id of the company
|
||||
:return: dict of the form `{ "code": str, "symbol": str, "decimalPlaces": int, "position":str }`
|
||||
"""
|
||||
company = self.env["res.company"].browse(company_id) if company_id else self.env.company
|
||||
if not company.exists():
|
||||
return False
|
||||
currency = company.currency_id
|
||||
return {
|
||||
"code": currency.name,
|
||||
"symbol": currency.symbol,
|
||||
"decimalPlaces": currency.decimal_places,
|
||||
"position": currency.position,
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
from odoo import api, fields, models
|
||||
|
||||
|
||||
class ResCurrencyRate(models.Model):
|
||||
_inherit = "res.currency.rate"
|
||||
|
||||
@api.model
|
||||
def _get_rate_for_spreadsheet(self, currency_from_code, currency_to_code, date=None):
|
||||
if not currency_from_code or not currency_to_code:
|
||||
return False
|
||||
Currency = self.env["res.currency"].with_context({"active_test": False})
|
||||
currency_from = Currency.search([("name", "=", currency_from_code)])
|
||||
currency_to = Currency.search([("name", "=", currency_to_code)])
|
||||
if not currency_from or not currency_to:
|
||||
return False
|
||||
company = self.env.company
|
||||
date = fields.Date.from_string(date) if date else fields.Date.context_today(self)
|
||||
return Currency._get_conversion_rate(currency_from, currency_to, company, date)
|
||||
|
||||
@api.model
|
||||
def get_rates_for_spreadsheet(self, requests):
|
||||
result = []
|
||||
for request in requests:
|
||||
record = request.copy()
|
||||
record.update({
|
||||
"rate": self._get_rate_for_spreadsheet(request["from"], request["to"], request.get("date")),
|
||||
})
|
||||
result.append(record)
|
||||
return result
|
||||
@@ -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 |
@@ -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;
|
||||
|
||||
})));
|
||||
@@ -0,0 +1,58 @@
|
||||
/** @odoo-module */
|
||||
|
||||
import { DataSources } from "@spreadsheet/data_sources/data_sources";
|
||||
import { LoadingDataError } from "@spreadsheet/o_spreadsheet/errors";
|
||||
import { migrate } from "@spreadsheet/o_spreadsheet/migration";
|
||||
import { download } from "@web/core/network/download";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { browser } from "@web/core/browser/browser";
|
||||
import spreadsheet from "../o_spreadsheet/o_spreadsheet_extended";
|
||||
|
||||
const { Model } = spreadsheet;
|
||||
|
||||
async function downloadSpreadsheet(env, action) {
|
||||
const { orm, name, data, stateUpdateMessages } = action.params;
|
||||
const dataSources = new DataSources(orm);
|
||||
const model = new Model(migrate(data), { dataSources }, stateUpdateMessages);
|
||||
await dataSources.waitForAllLoaded();
|
||||
await waitForDataLoaded(model);
|
||||
const { files } = model.exportXLSX();
|
||||
await download({
|
||||
url: "/spreadsheet/xlsx",
|
||||
data: {
|
||||
zip_name: `${name}.xlsx`,
|
||||
files: JSON.stringify(files),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure that the spreadsheet does not contains cells that are in loading state
|
||||
* @param {Model} model
|
||||
* @returns {Promise}
|
||||
*/
|
||||
async function waitForDataLoaded(model) {
|
||||
model.dispatch("EVALUATE_ALL_SHEETS");
|
||||
return new Promise((resolve, reject) => {
|
||||
let interval = undefined;
|
||||
interval = browser.setInterval(() => {
|
||||
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 instanceof LoadingDataError
|
||||
) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
browser.clearInterval(interval);
|
||||
resolve();
|
||||
}, 50);
|
||||
});
|
||||
}
|
||||
|
||||
registry
|
||||
.category("actions")
|
||||
.add("action_download_spreadsheet", downloadSpreadsheet, { force: true });
|
||||
@@ -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") },
|
||||
];
|
||||
@@ -0,0 +1,47 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import { registry } from "@web/core/registry";
|
||||
import { 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) {
|
||||
await loadBundle("spreadsheet.o_spreadsheet");
|
||||
|
||||
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);
|
||||
@@ -0,0 +1,44 @@
|
||||
/** @odoo-module */
|
||||
|
||||
import { OdooViewsDataSource } from "@spreadsheet/data_sources/odoo_views_data_source";
|
||||
import { _t } from "@web/core/l10n/translation";
|
||||
import { GraphModel } from "@web/views/graph/graph_model";
|
||||
|
||||
export default class GraphDataSource 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 GraphModel(
|
||||
{
|
||||
_t,
|
||||
},
|
||||
metaData,
|
||||
{
|
||||
orm: this._orm,
|
||||
}
|
||||
);
|
||||
await this._model.load(this._searchParams);
|
||||
}
|
||||
|
||||
getData() {
|
||||
if (!this.isReady()) {
|
||||
this.load();
|
||||
return { datasets: [], labels: [] };
|
||||
}
|
||||
return this._model.data;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
/** @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);
|
||||
@@ -0,0 +1,101 @@
|
||||
/** @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 dataSource = getters.getGraphDataSource(chart.id);
|
||||
const { datasets, labels } = 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;
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
/** @odoo-module */
|
||||
|
||||
import spreadsheet from "@spreadsheet/o_spreadsheet/o_spreadsheet_extended";
|
||||
|
||||
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} id
|
||||
* @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.id = definition.id;
|
||||
this.type = definition.type;
|
||||
this.metaData = definition.metaData;
|
||||
this.searchParams = definition.searchParams;
|
||||
this.legendPosition = definition.legendPosition;
|
||||
this.background = definition.background;
|
||||
}
|
||||
|
||||
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,
|
||||
id: this.id,
|
||||
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;
|
||||
}
|
||||
|
||||
getContextCreation() {
|
||||
return {};
|
||||
}
|
||||
|
||||
getSheetIdsUsedInChartRanges() {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
/** @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 OdooLineChart extends OdooChart {
|
||||
constructor(definition, sheetId, getters) {
|
||||
super(definition, sheetId, getters);
|
||||
this.verticalAxisPosition = definition.verticalAxisPosition;
|
||||
}
|
||||
|
||||
getDefinition() {
|
||||
return {
|
||||
...super.getDefinition(),
|
||||
verticalAxisPosition: this.verticalAxisPosition,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
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 dataSource = getters.getGraphDataSource(chart.id);
|
||||
const background = chart.background || "#FFFFFF";
|
||||
const { datasets, labels } = dataSource.getData();
|
||||
const chartJsConfig = getLineConfiguration(chart, labels);
|
||||
const colors = new ChartColors();
|
||||
for (const { label, data } of datasets) {
|
||||
const color = colors.next();
|
||||
const dataset = {
|
||||
label,
|
||||
data,
|
||||
lineTension: 0,
|
||||
borderColor: color,
|
||||
backgroundColor: color,
|
||||
};
|
||||
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 },
|
||||
};
|
||||
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
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
return config;
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
/** @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 dataSource = getters.getGraphDataSource(chart.id);
|
||||
const { datasets, labels } = 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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
.o-chart-menu {
|
||||
.o-chart-menu-item {
|
||||
padding-left: 7px;
|
||||
}
|
||||
|
||||
.o-chart-external-link {
|
||||
font-size: 15px;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -0,0 +1,54 @@
|
||||
/** @odoo-module */
|
||||
|
||||
import spreadsheet from "@spreadsheet/o_spreadsheet/o_spreadsheet_extended";
|
||||
const { corePluginRegistry } = spreadsheet.registries;
|
||||
const { coreTypes } = spreadsheet;
|
||||
|
||||
/** 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.chartId, undefined);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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"];
|
||||
|
||||
corePluginRegistry.add("chartOdooMenuPlugin", ChartOdooMenuPlugin);
|
||||
coreTypes.add("LINK_ODOO_MENU_TO_CHART");
|
||||
@@ -0,0 +1,106 @@
|
||||
/** @odoo-module */
|
||||
|
||||
import spreadsheet from "../../o_spreadsheet/o_spreadsheet_extended";
|
||||
import GraphDataSource from "../data_source/graph_data_source";
|
||||
|
||||
const { CorePlugin } = spreadsheet;
|
||||
const { corePluginRegistry } = spreadsheet.registries;
|
||||
|
||||
/**
|
||||
* @typedef {import("@web/views/graph/graph_model").GraphModel} GraphModel
|
||||
*/
|
||||
|
||||
export default class OdooGraphPlugin extends CorePlugin {
|
||||
constructor(getters, history, range, dispatch, config, uuidGenerator) {
|
||||
super(getters, history, range, dispatch, config, uuidGenerator);
|
||||
this.dataSources = config.dataSources;
|
||||
|
||||
/** @type {Object.<string, string>} */
|
||||
this.graphsDataSources = {};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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._addGraphDataSource(cmd.id, cmd.definition.dataSourceId);
|
||||
break;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "ADD_GRAPH_DOMAIN":
|
||||
this.getters.getGraphDataSource(cmd.id).addDomain(cmd.domain);
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 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} id
|
||||
* @returns {GraphDataSource|undefined}
|
||||
*/
|
||||
getGraphDataSource(id) {
|
||||
const dataSourceId = this.graphsDataSources[id];
|
||||
return this.dataSources.get(dataSourceId);
|
||||
}
|
||||
|
||||
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._addGraphDataSource(figure.id, this.uuidGenerator.uuidv4());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Private
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @param {string} id
|
||||
* @param {string} dataSourceId
|
||||
*/
|
||||
_addGraphDataSource(id, dataSourceId) {
|
||||
const graphsDataSources = { ...this.graphsDataSources };
|
||||
graphsDataSources[id] = dataSourceId;
|
||||
const definition = this.getters.getChart(id).getDefinitionForDataSource();
|
||||
if (!this.dataSources.contains(dataSourceId)) {
|
||||
this.dataSources.add(dataSourceId, GraphDataSource, definition);
|
||||
}
|
||||
this.history.update("graphsDataSources", graphsDataSources);
|
||||
}
|
||||
}
|
||||
|
||||
OdooGraphPlugin.getters = ["getGraphDataSource", "getOdooChartIds"];
|
||||
|
||||
corePluginRegistry.add("odoo_graph_plugin", OdooGraphPlugin);
|
||||
@@ -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);
|
||||
@@ -0,0 +1,68 @@
|
||||
/** @odoo-module */
|
||||
|
||||
import { _t } from "@web/core/l10n/translation";
|
||||
import { ServerData } from "../data_sources/server_data";
|
||||
|
||||
/**
|
||||
* @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,
|
||||
});
|
||||
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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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"],
|
||||
});
|
||||
@@ -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);
|
||||
@@ -0,0 +1,76 @@
|
||||
/** @odoo-module */
|
||||
|
||||
import { LoadingDataError } from "@spreadsheet/o_spreadsheet/errors";
|
||||
|
||||
/**
|
||||
* 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(services) {
|
||||
this._orm = services.orm;
|
||||
this._metadataRepository = services.metadataRepository;
|
||||
this._notify = services.notify;
|
||||
|
||||
/**
|
||||
* Last time that this dataSource has been updated
|
||||
*/
|
||||
this._lastUpdate = undefined;
|
||||
|
||||
/**
|
||||
* Promise to control the loading of data
|
||||
*/
|
||||
this._loadPromise = undefined;
|
||||
this._isFullyLoaded = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load data in the model
|
||||
* @param {Object} params Params for fetching data
|
||||
* @param {boolean=false} params.reload Force the reload of the data
|
||||
*
|
||||
* @returns {Promise} Resolved when data are fetched.
|
||||
*/
|
||||
async load(params) {
|
||||
if (params && params.reload) {
|
||||
this._loadPromise = undefined;
|
||||
}
|
||||
if (!this._loadPromise) {
|
||||
this._isFullyLoaded = false;
|
||||
this._loadPromise = this._load().then(() => {
|
||||
this._lastUpdate = Date.now();
|
||||
this._isFullyLoaded = true;
|
||||
this._notify();
|
||||
});
|
||||
}
|
||||
return this._loadPromise;
|
||||
}
|
||||
|
||||
get lastUpdate() {
|
||||
return this._lastUpdate;
|
||||
}
|
||||
|
||||
/**
|
||||
* @protected
|
||||
*/
|
||||
_assertDataIsLoaded() {
|
||||
if (!this._isFullyLoaded) {
|
||||
this.load();
|
||||
throw new LoadingDataError();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the data in the model
|
||||
*
|
||||
* @abstract
|
||||
* @protected
|
||||
*/
|
||||
async _load() {}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
/** @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;
|
||||
this._metadataRepository = new MetadataRepository(orm);
|
||||
this._metadataRepository.addEventListener("labels-fetched", () => this.notify());
|
||||
/** @type {Object.<string, any>} */
|
||||
this._dataSources = {};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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(),
|
||||
},
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify that a data source has been updated. Could be useful to
|
||||
* request a re-evaluation.
|
||||
*/
|
||||
notify() {
|
||||
this.trigger("data-source-updated");
|
||||
}
|
||||
|
||||
async waitForAllLoaded() {
|
||||
await Promise.all(
|
||||
Object.values(this._dataSources).map(
|
||||
(ds) => ds instanceof LoadableDataSource && ds.load()
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
/** @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";
|
||||
|
||||
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)
|
||||
*/
|
||||
/**
|
||||
* 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 label 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.silent;
|
||||
/**
|
||||
* Contains the labels of records. It's organized in the following way:
|
||||
* {
|
||||
* "crm.lead": {
|
||||
* "city": {
|
||||
* "bruxelles": "Bruxelles",
|
||||
* }
|
||||
* },
|
||||
* }
|
||||
*/
|
||||
this._labels = {};
|
||||
|
||||
this.serverData = new ServerData(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) {
|
||||
if (!this._labels[model]) {
|
||||
this._labels[model] = {};
|
||||
}
|
||||
if (!this._labels[model][field]) {
|
||||
this._labels[model][field] = {};
|
||||
}
|
||||
this._labels[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._labels[model] && this._labels[model][field] && this._labels[model][field][value]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
const result = this.serverData.batch.get(model, "name_get", id);
|
||||
return result[1];
|
||||
} 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
/** @odoo-module */
|
||||
|
||||
import { LoadableDataSource } from "./data_source";
|
||||
import { Domain } from "@web/core/domain";
|
||||
import { LoadingDataError } from "@spreadsheet/o_spreadsheet/errors";
|
||||
|
||||
/**
|
||||
* @typedef {import("@spreadsheet/data_sources/metadata_repository").Field} Field
|
||||
*/
|
||||
|
||||
/**
|
||||
* Remove user specific info from the context
|
||||
* @param {Object} context
|
||||
* @returns {Object}
|
||||
*/
|
||||
function removeContextUserInfo(context) {
|
||||
context = { ...context };
|
||||
delete context.allowed_company_ids;
|
||||
delete context.tz;
|
||||
delete context.lang;
|
||||
delete context.uid;
|
||||
return context;
|
||||
}
|
||||
|
||||
/**
|
||||
* @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 = removeContextUserInfo(
|
||||
this._initialSearchParams.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();
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isReady() {
|
||||
return this._isFullyLoaded;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
/** @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));
|
||||
}
|
||||
|
||||
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) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
/** @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;
|
||||
|
||||
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 (!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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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_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]]);
|
||||
}
|
||||
|
||||
/*
|
||||
* Return 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)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
/** @odoo-module */
|
||||
|
||||
import spreadsheet from "@spreadsheet/o_spreadsheet/o_spreadsheet_extended";
|
||||
import "@spreadsheet/pivot"; // filter depends on pivot and lists for its getters
|
||||
import "@spreadsheet/list"; // filter depends on pivot and lists for its getters
|
||||
import FiltersEvaluationPlugin from "./plugins/filters_evaluation_plugin";
|
||||
import FiltersPlugin from "./plugins/filters_plugin";
|
||||
const { inverseCommandRegistry } = spreadsheet.registries;
|
||||
|
||||
function identity(cmd) {
|
||||
return [cmd];
|
||||
}
|
||||
|
||||
const { coreTypes, invalidateEvaluationCommands, readonlyAllowedCommands } = spreadsheet;
|
||||
const { corePluginRegistry, uiPluginRegistry } = spreadsheet.registries;
|
||||
|
||||
corePluginRegistry.add("odooFiltersPlugin", FiltersPlugin);
|
||||
uiPluginRegistry.add("odooFiltersEvaluationPlugin", FiltersEvaluationPlugin);
|
||||
|
||||
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");
|
||||
|
||||
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: {},
|
||||
},
|
||||
];
|
||||
});
|
||||
@@ -0,0 +1,514 @@
|
||||
/** @odoo-module */
|
||||
|
||||
/**
|
||||
* @typedef {import("@spreadsheet/data_sources/metadata_repository").Field} Field
|
||||
* @typedef {import("./filters_plugin").GlobalFilter} GlobalFilter
|
||||
*/
|
||||
|
||||
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 } = spreadsheet.helpers;
|
||||
const uuidGenerator = new UuidGenerator();
|
||||
|
||||
export default class FiltersEvaluationPlugin 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;
|
||||
}
|
||||
|
||||
beforeHandle(cmd) {
|
||||
switch (cmd.type) {
|
||||
case "START":
|
||||
// make sure the domains are correctly set before
|
||||
// any evaluation
|
||||
this._updateAllDomains();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
switch (cmd.type) {
|
||||
case "ADD_GLOBAL_FILTER":
|
||||
case "EDIT_GLOBAL_FILTER":
|
||||
case "SET_GLOBAL_FILTER_VALUE":
|
||||
case "REMOVE_GLOBAL_FILTER":
|
||||
case "CLEAR_GLOBAL_FILTER_VALUE":
|
||||
this._updateAllDomains();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Getters
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 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;
|
||||
|
||||
if (filter.type === "date" && isEmpty(value) && filter.defaultsToCurrentPeriod) {
|
||||
return this._getValueOfCurrentPeriod(filterId);
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} id Id of the filter
|
||||
*
|
||||
* @returns { boolean } true if the given filter is active
|
||||
*/
|
||||
isFilterActive(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.isFilterActive(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 };
|
||||
break;
|
||||
case "relation":
|
||||
value = [];
|
||||
break;
|
||||
}
|
||||
this.values[id] = { value, rangeType };
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the domain of all pivots and all lists
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_updateAllDomains() {
|
||||
for (const pivotId of this.getters.getPivotIds()) {
|
||||
const domain = this._getComputedDomain((filterId) =>
|
||||
this.getters.getGlobalFilterFieldPivot(filterId, pivotId)
|
||||
);
|
||||
this.dispatch("ADD_PIVOT_DOMAIN", { id: pivotId, domain });
|
||||
}
|
||||
for (const listId of this.getters.getListIds()) {
|
||||
const domain = this._getComputedDomain((filterId) =>
|
||||
this.getters.getGlobalFilterFieldList(filterId, listId)
|
||||
);
|
||||
this.dispatch("ADD_LIST_DOMAIN", { id: listId, domain });
|
||||
}
|
||||
for (const chartId of this.getters.getOdooChartIds()) {
|
||||
const domain = this._getComputedDomain((filterId) =>
|
||||
this.getters.getGlobalFilterFieldGraph(filterId, chartId)
|
||||
);
|
||||
this.dispatch("ADD_GRAPH_DOMAIN", { id: chartId, domain });
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Private
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Get the domain relative to a date field
|
||||
*
|
||||
* @private
|
||||
*
|
||||
* @param {GlobalFilter} filter
|
||||
* @param {Field} fieldDesc
|
||||
*
|
||||
* @returns {Domain|undefined}
|
||||
*/
|
||||
_getDateDomain(filter, fieldDesc) {
|
||||
if (!this.isFilterActive(filter.id)) {
|
||||
return undefined;
|
||||
}
|
||||
let granularity;
|
||||
const value = this.getGlobalFilterValue(filter.id);
|
||||
const field = fieldDesc.field;
|
||||
const type = fieldDesc.type;
|
||||
const offset = fieldDesc.offset || 0;
|
||||
const now = DateTime.local();
|
||||
|
||||
if (filter.rangeType === "relative") {
|
||||
return getRelativeDateDomain(now, offset, value, field, type);
|
||||
}
|
||||
|
||||
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 {Field} fieldDesc
|
||||
*
|
||||
* @returns {Domain|undefined}
|
||||
*/
|
||||
_getTextDomain(filter, fieldDesc) {
|
||||
const value = this.getGlobalFilterValue(filter.id);
|
||||
if (!value) {
|
||||
return undefined;
|
||||
}
|
||||
const field = fieldDesc.field;
|
||||
return new Domain([[field, "ilike", value]]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the domain relative to a relation field
|
||||
*
|
||||
* @private
|
||||
*
|
||||
* @param {GlobalFilter} filter
|
||||
* @param {Field} fieldDesc
|
||||
*
|
||||
* @returns {Domain|undefined}
|
||||
*/
|
||||
_getRelationDomain(filter, fieldDesc) {
|
||||
const values = this.getGlobalFilterValue(filter.id);
|
||||
if (!values || values.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
const field = fieldDesc.field;
|
||||
return new Domain([[field, "in", values]]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the computed domain of a global filters applied to the field given
|
||||
* by the callback
|
||||
*
|
||||
* @private
|
||||
*
|
||||
* @param {Function<Field>} getFieldDesc Callback to get the field description
|
||||
* on which the global filters is applied
|
||||
*
|
||||
* @returns {string}
|
||||
*/
|
||||
_getComputedDomain(getFieldDesc) {
|
||||
let domain = new Domain([]);
|
||||
for (const filter of this.getters.getGlobalFilters()) {
|
||||
const fieldDesc = getFieldDesc(filter.id);
|
||||
if (!fieldDesc || !fieldDesc.field) {
|
||||
continue;
|
||||
}
|
||||
let domainToAdd = undefined;
|
||||
switch (filter.type) {
|
||||
case "date":
|
||||
domainToAdd = this._getDateDomain(filter, fieldDesc);
|
||||
break;
|
||||
case "text":
|
||||
domainToAdd = this._getTextDomain(filter, fieldDesc);
|
||||
break;
|
||||
case "relation":
|
||||
domainToAdd = this._getRelationDomain(filter, fieldDesc);
|
||||
break;
|
||||
}
|
||||
if (domainToAdd) {
|
||||
domain = Domain.and([domain, domainToAdd]);
|
||||
}
|
||||
}
|
||||
return domain.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* 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({
|
||||
id: uuidGenerator.uuidv4(),
|
||||
name: "Active Filters",
|
||||
cells,
|
||||
colNumber: 2,
|
||||
rowNumber: this.getters.getGlobalFilters().length + 1,
|
||||
cols: {},
|
||||
rows: {},
|
||||
merges: [],
|
||||
figures: [],
|
||||
conditionalFormats: [],
|
||||
charts: [],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
FiltersEvaluationPlugin.getters = [
|
||||
"getFilterDisplayValue",
|
||||
"getGlobalFilterValue",
|
||||
"getActiveFilterCount",
|
||||
"isFilterActive",
|
||||
];
|
||||
@@ -0,0 +1,288 @@
|
||||
/** @odoo-module */
|
||||
|
||||
/**
|
||||
* @typedef {"year"|"month"|"quarter"|"relative"} RangeType
|
||||
*
|
||||
/**
|
||||
* @typedef {Object} FilterMatchingField
|
||||
* @property {string} field 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 {Record<string, FilterMatchingField>} pivotFields
|
||||
* @property {Record<string, FilterMatchingField>} listFields
|
||||
* @property {Record<string, FilterMatchingField>} graphFields
|
||||
* @property {string|Array<string>|Object} defaultValue Default Value
|
||||
* @property {number} [modelID] ID of the related model
|
||||
* @property {string} [modelName] Name of the related model
|
||||
*/
|
||||
|
||||
import spreadsheet from "@spreadsheet/o_spreadsheet/o_spreadsheet_extended";
|
||||
import CommandResult from "@spreadsheet/o_spreadsheet/cancelled_reason";
|
||||
import { checkFiltersTypeValueCombination } from "@spreadsheet/global_filters/helpers";
|
||||
|
||||
|
||||
export default class FiltersPlugin 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 field name on which the global filter is applied for a pivot
|
||||
*
|
||||
* @param {string} filterId Id of the filter
|
||||
* @param {string} pivotId Id of the pivot
|
||||
* @returns {FilterMatchingField|undefined}
|
||||
*/
|
||||
getGlobalFilterFieldPivot(filterId, pivotId) {
|
||||
return this.getGlobalFilter(filterId).pivotFields[pivotId];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the field name on which the global filter is applied for a list
|
||||
*
|
||||
* @param {string} filterId Id of the filter
|
||||
* @param {string} listId Id of the list
|
||||
* @returns {FilterMatchingField|undefined}
|
||||
*/
|
||||
getGlobalFilterFieldList(filterId, listId) {
|
||||
return this.getGlobalFilter(filterId).listFields[listId];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the field name on which the global filter is applied for a graph
|
||||
*
|
||||
* @param {string} filterId Id of the filter
|
||||
* @param {string} graphId Id of the Graph
|
||||
* @returns {FilterMatchingField|undefined}
|
||||
*/
|
||||
getGlobalFilterFieldGraph(filterId, graphId) {
|
||||
return this.getGlobalFilter(filterId).graphFields[graphId];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the global filter with the given name
|
||||
*
|
||||
* @param {string} label Label
|
||||
*
|
||||
* @returns {GlobalFilter|undefined}
|
||||
*/
|
||||
getGlobalFilterLabel(label) {
|
||||
return this.getGlobalFilters().find((filter) => filter.label === 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 (let sheetId of sheetIds) {
|
||||
for (let 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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
FiltersPlugin.getters = [
|
||||
"getGlobalFilter",
|
||||
"getGlobalFilters",
|
||||
"getGlobalFilterDefaultValue",
|
||||
"getGlobalFilterLabel",
|
||||
"getGlobalFilterFieldPivot",
|
||||
"getGlobalFilterFieldList",
|
||||
"getGlobalFilterFieldGraph",
|
||||
];
|
||||
@@ -0,0 +1,26 @@
|
||||
/** @odoo-module */
|
||||
|
||||
import { _lt } from "@web/core/l10n/translation";
|
||||
|
||||
export const DEFAULT_LINES_NUMBER = 20;
|
||||
|
||||
export const FORMATS = {
|
||||
day: { in: "DD MMM YYYY", out: "MM/DD/YYYY", display: "DD MMM YYYY", interval: "d" },
|
||||
week: { in: "[W]W YYYY", out: "WW/YYYY", display: "[W]W YYYY", interval: "w" },
|
||||
month: { in: "MMMM YYYY", out: "MM/YYYY", display: "MMMM YYYY", interval: "M" },
|
||||
quarter: { in: "Q YYYY", out: "Q/YYYY", display: "[Q]Q YYYY", interval: "Q" },
|
||||
year: { in: "YYYY", 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_year", description: _lt("Last 365 Days") },
|
||||
{ type: "last_three_years", description: _lt("Last 3 Years") },
|
||||
];
|
||||
@@ -0,0 +1,178 @@
|
||||
/** @odoo-module */
|
||||
|
||||
import spreadsheet from "../o_spreadsheet/o_spreadsheet_extended";
|
||||
import { DataSources } from "../data_sources/data_sources";
|
||||
import { serializeDate } from "@web/core/l10n/dates";
|
||||
import { migrate } from "@spreadsheet/o_spreadsheet/migration";
|
||||
|
||||
const { DateTime } = luxon;
|
||||
|
||||
const Model = spreadsheet.Model;
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* see https://stackoverflow.com/a/30106551
|
||||
* @param {string} string
|
||||
* @returns {string}
|
||||
*/
|
||||
function utf8ToBase64(str) {
|
||||
// first we use encodeURIComponent to get percent-encoded UTF-8,
|
||||
// then we convert the percent encodings into raw bytes which
|
||||
// can be fed into btoa.
|
||||
return btoa(
|
||||
encodeURIComponent(str).replace(/%([0-9A-F]{2})/g, function toSolidBytes(match, p1) {
|
||||
return String.fromCharCode("0x" + p1);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* see https://stackoverflow.com/a/30106551
|
||||
* @param {string} string
|
||||
* @returns {string}
|
||||
*/
|
||||
function base64ToUtf8(str) {
|
||||
// Going backwards: from bytestream, to percent-encoding, to original string.
|
||||
return decodeURIComponent(
|
||||
atob(str)
|
||||
.split("")
|
||||
.map((c) => "%" + ("00" + c.charCodeAt(0).toString(16)).slice(-2))
|
||||
.join("")
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode a json to a base64 string
|
||||
* @param {object} json
|
||||
*/
|
||||
export function jsonToBase64(json) {
|
||||
return utf8ToBase64(JSON.stringify(json));
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a base64 encoded json
|
||||
* @param {string} string
|
||||
*/
|
||||
export function base64ToJson(string) {
|
||||
return JSON.parse(base64ToUtf8(string));
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes a template id as input, will convert the formulas
|
||||
* from relative to absolute in a way that they can be used to create a sheet.
|
||||
*
|
||||
* @param {Function} rpc
|
||||
* @param {number} templateId
|
||||
* @returns {Promise<Object>} spreadsheetData
|
||||
*/
|
||||
export async function getDataFromTemplate(env, orm, templateId) {
|
||||
let [{ data }] = await orm.read("spreadsheet.template", [templateId], ["data"]);
|
||||
data = base64ToJson(data);
|
||||
|
||||
const model = new Model(migrate(data), {
|
||||
dataSources: new DataSources(orm),
|
||||
});
|
||||
await model.config.dataSources.waitForAllLoaded();
|
||||
const proms = [];
|
||||
for (const pivotId of model.getters.getPivotIds()) {
|
||||
proms.push(model.getters.getPivotDataSource(pivotId).prepareForTemplateGeneration());
|
||||
}
|
||||
await Promise.all(proms);
|
||||
model.dispatch("CONVERT_PIVOT_FROM_TEMPLATE");
|
||||
return model.exportData();
|
||||
}
|
||||
|
||||
/** 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) {
|
||||
const date = DateTime.fromJSDate(value);
|
||||
return serializeDate(date);
|
||||
}
|
||||
|
||||
/**
|
||||
* @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;
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
/** @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 {Function} matcher a predicate that matches a function name
|
||||
* @private
|
||||
* @returns {Array<OdooFunctionDescription>}
|
||||
*/
|
||||
export function getOdooFunctions(formula, matcher) {
|
||||
let ast;
|
||||
try {
|
||||
ast = parse(formula);
|
||||
} catch (_) {
|
||||
return [];
|
||||
}
|
||||
return _getOdooFunctionsFromAST(ast, matcher);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 {Function} matcher a predicate that matches a function name
|
||||
*
|
||||
* @private
|
||||
* @returns {Array<OdooFunctionDescription>}
|
||||
*/
|
||||
function _getOdooFunctionsFromAST(ast, matcher) {
|
||||
switch (ast.type) {
|
||||
case "UNARY_OPERATION":
|
||||
return _getOdooFunctionsFromAST(ast.operand, matcher);
|
||||
case "BIN_OPERATION": {
|
||||
return _getOdooFunctionsFromAST(ast.left, matcher).concat(
|
||||
_getOdooFunctionsFromAST(ast.right, matcher)
|
||||
);
|
||||
}
|
||||
case "FUNCALL": {
|
||||
const functionName = ast.value.toUpperCase();
|
||||
|
||||
if (matcher(functionName)) {
|
||||
return [{ functionName, args: ast.args, isMatched: true }];
|
||||
} else {
|
||||
return ast.args.map((arg) => _getOdooFunctionsFromAST(arg, matcher)).flat();
|
||||
}
|
||||
}
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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"];
|
||||
@@ -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,
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
/** @odoo-module */
|
||||
|
||||
import { _lt } from "@web/core/l10n/translation";
|
||||
|
||||
import spreadsheet from "@spreadsheet/o_spreadsheet/o_spreadsheet_extended";
|
||||
|
||||
import "./list_functions";
|
||||
|
||||
import ListPlugin from "@spreadsheet/list/plugins/list_plugin";
|
||||
import ListStructurePlugin from "@spreadsheet/list/plugins/list_structure_plugin";
|
||||
|
||||
import { SEE_RECORD_LIST, SEE_RECORD_LIST_VISIBLE } from "./list_actions";
|
||||
const { inverseCommandRegistry } = spreadsheet.registries;
|
||||
|
||||
function identity(cmd) {
|
||||
return [cmd];
|
||||
}
|
||||
|
||||
const { coreTypes, readonlyAllowedCommands, invalidateEvaluationCommands } = spreadsheet;
|
||||
const { corePluginRegistry, uiPluginRegistry, cellMenuRegistry } = spreadsheet.registries;
|
||||
|
||||
corePluginRegistry.add("odooListPlugin", ListPlugin);
|
||||
|
||||
uiPluginRegistry.add("odooListStructurePlugin", ListStructurePlugin);
|
||||
|
||||
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");
|
||||
|
||||
invalidateEvaluationCommands.add("UPDATE_ODOO_LIST_DOMAIN");
|
||||
invalidateEvaluationCommands.add("REMOVE_ODOO_LIST");
|
||||
|
||||
readonlyAllowedCommands.add("ADD_LIST_DOMAIN");
|
||||
|
||||
cellMenuRegistry.add("list_see_record", {
|
||||
name: _lt("See record"),
|
||||
sequence: 200,
|
||||
action: SEE_RECORD_LIST,
|
||||
isVisible: SEE_RECORD_LIST_VISIBLE,
|
||||
});
|
||||
|
||||
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);
|
||||
@@ -0,0 +1,37 @@
|
||||
/** @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 (env) => {
|
||||
const cell = env.model.getters.getActiveCell();
|
||||
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 = (env) => {
|
||||
const cell = env.model.getters.getActiveCell();
|
||||
return cell && getNumberOfListFormulas(cell.content) === 1;
|
||||
};
|
||||
@@ -0,0 +1,160 @@
|
||||
/** @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 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.limit = params.limit;
|
||||
this._orm = services.orm;
|
||||
this.data = [];
|
||||
}
|
||||
|
||||
async _load() {
|
||||
await super._load();
|
||||
if (this.limit === 0) {
|
||||
this.data = [];
|
||||
return;
|
||||
}
|
||||
const { domain, orderBy, context } = this._searchParams;
|
||||
this.data = await this._orm.searchRead(
|
||||
this._metaData.resModel,
|
||||
domain,
|
||||
this._metaData.columns.filter((f) => this.getField(f)),
|
||||
{
|
||||
order: orderByToString(orderBy),
|
||||
limit: this.limit,
|
||||
context,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @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.limit) {
|
||||
this.limit = 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();
|
||||
return undefined;
|
||||
}
|
||||
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":
|
||||
case "datetime":
|
||||
return record[fieldName] ? toNumber(record[fieldName]) : "";
|
||||
default:
|
||||
return record[fieldName] || "";
|
||||
}
|
||||
}
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Private
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 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();
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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"],
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
/** @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, (functionName) =>
|
||||
["ODOO.LIST", "ODOO.LIST.HEADER"].includes(functionName)
|
||||
).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, (functionName) =>
|
||||
["ODOO.LIST", "ODOO.LIST.HEADER"].includes(functionName)
|
||||
).find((fn) => fn.isMatched);
|
||||
}
|
||||
@@ -0,0 +1,369 @@
|
||||
/** @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";
|
||||
|
||||
/**
|
||||
* @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
|
||||
*
|
||||
*/
|
||||
export default class ListPlugin extends spreadsheet.CorePlugin {
|
||||
constructor(getters, history, range, dispatch, config, uuidGenerator) {
|
||||
super(getters, history, range, dispatch, config, uuidGenerator);
|
||||
this.dataSources = config.dataSources;
|
||||
|
||||
this.nextId = 1;
|
||||
/** @type {Object.<number, Pivot>} */
|
||||
this.lists = {};
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
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);
|
||||
this._insertList(sheetId, anchor, id, linesNumber, columns);
|
||||
this.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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Getters
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @param {number} id
|
||||
* @returns {import("@spreadsheet/list/list_data_source").ListDataSource|undefined}
|
||||
*/
|
||||
getListDataSource(id) {
|
||||
const dataSourceId = this.lists[id].dataSourceId;
|
||||
return this.dataSources.get(dataSourceId);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} id
|
||||
* @returns {string}
|
||||
*/
|
||||
getListDisplayName(id) {
|
||||
return `(#${id}) ${this.getListName(id)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} id
|
||||
* @returns {string}
|
||||
*/
|
||||
getListName(id) {
|
||||
return this.lists[id].definition.name;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} id
|
||||
* @returns {Promise<import("@spreadsheet/list/list_data_source").ListDataSource>}
|
||||
*/
|
||||
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 {number} 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 {number} id Id of the list
|
||||
*
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isExistingList(id) {
|
||||
return id in this.lists;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Private
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
_addList(id, definition, dataSourceId) {
|
||||
const lists = { ...this.lists };
|
||||
lists[id] = {
|
||||
id,
|
||||
definition,
|
||||
dataSourceId,
|
||||
};
|
||||
|
||||
if (!this.dataSources.contains(dataSourceId)) {
|
||||
this.dataSources.add(dataSourceId, ListDataSource, definition);
|
||||
}
|
||||
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,
|
||||
},
|
||||
/**
|
||||
* As we are not able to migrate the json, we have to fallback
|
||||
* in the case where the name is not yet present
|
||||
*/
|
||||
name: list.name || list.model,
|
||||
};
|
||||
this._addList(id, definition, this.uuidGenerator.uuidv4());
|
||||
}
|
||||
}
|
||||
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.listNextId = this.nextId;
|
||||
}
|
||||
}
|
||||
|
||||
ListPlugin.getters = [
|
||||
"getListDataSource",
|
||||
"getListDisplayName",
|
||||
"getAsyncListDataSource",
|
||||
"getListDefinition",
|
||||
"getListIds",
|
||||
"getListName",
|
||||
"getNextListId",
|
||||
"isExistingList",
|
||||
];
|
||||
@@ -0,0 +1,194 @@
|
||||
/** @odoo-module */
|
||||
|
||||
import spreadsheet from "../../o_spreadsheet/o_spreadsheet_extended";
|
||||
import { getFirstListFunction } from "../list_helpers";
|
||||
|
||||
const { astToFormula } = spreadsheet;
|
||||
|
||||
/**
|
||||
* @typedef {import("./list_plugin").SpreadsheetList} SpreadsheetList
|
||||
*/
|
||||
|
||||
export default class ListStructurePlugin extends spreadsheet.UIPlugin {
|
||||
constructor(getters, history, dispatch, config, selection) {
|
||||
super(getters, history, dispatch, config, selection);
|
||||
/** @type {string} */
|
||||
this.selectedListId = undefined;
|
||||
this.selection.observe(this, {
|
||||
handleEvent: this.handleEvent.bind(this),
|
||||
});
|
||||
this.env = config.evalContext.env;
|
||||
}
|
||||
|
||||
handleEvent(event) {
|
||||
switch (event.type) {
|
||||
case "ZonesSelected":
|
||||
if (this.getters.isDashboard()) {
|
||||
const sheetId = this.getters.getActiveSheetId();
|
||||
const { col, row } = event.anchor.cell;
|
||||
const cell = this.getters.getCell(sheetId, col, row);
|
||||
if (cell && cell.content.startsWith("=ODOO.LIST(")) {
|
||||
const { args } = getFirstListFunction(cell.content);
|
||||
const evaluatedArgs = args
|
||||
.map(astToFormula)
|
||||
.map((arg) => this.getters.evaluateFormula(arg));
|
||||
if (evaluatedArgs.length < 3) {
|
||||
return;
|
||||
}
|
||||
const listId = this.getters.getListIdFromPosition(sheetId, col, row);
|
||||
const { model } = this.getters.getListDefinition(listId);
|
||||
const dataSource = this.getters.getListDataSource(listId);
|
||||
const recordId = dataSource.getIdFromPosition(evaluatedArgs[1] - 1);
|
||||
if (!recordId) {
|
||||
return;
|
||||
}
|
||||
this.env.services.action.doAction({
|
||||
type: "ir.actions.act_window",
|
||||
res_model: model,
|
||||
res_id: recordId,
|
||||
views: [[false, "form"]],
|
||||
view_mode: "form",
|
||||
});
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a spreadsheet command
|
||||
* @param {Object} cmd Command
|
||||
*/
|
||||
handle(cmd) {
|
||||
switch (cmd.type) {
|
||||
case "SELECT_ODOO_LIST":
|
||||
this._selectList(cmd.listId);
|
||||
break;
|
||||
case "ADD_LIST_DOMAIN":
|
||||
this._addDomain(cmd.id, cmd.domain);
|
||||
break;
|
||||
case "REFRESH_ODOO_LIST":
|
||||
this._refreshOdooList(cmd.listId);
|
||||
break;
|
||||
case "REFRESH_ALL_DATA_SOURCES":
|
||||
this._refreshOdooLists();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Handlers
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Add an additional domain to a list
|
||||
*
|
||||
* @private
|
||||
*
|
||||
* @param {string} listId pivot id
|
||||
* @param {Array<Array<any>>} domain
|
||||
*/
|
||||
_addDomain(listId, domain) {
|
||||
this.getters.getListDataSource(listId).addDomain(domain);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
|
||||
ListStructurePlugin.getters = [
|
||||
"getListComputedDomain",
|
||||
"getListHeaderValue",
|
||||
"getListIdFromPosition",
|
||||
"getListCellValue",
|
||||
"getSelectedListId",
|
||||
];
|
||||
@@ -0,0 +1,14 @@
|
||||
/** @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,
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
/** @odoo-module */
|
||||
|
||||
import spreadsheet from "./o_spreadsheet_extended";
|
||||
const { load, CorePlugin, tokenize, parse, convertAstNodes, astToFormula } = spreadsheet;
|
||||
const { corePluginRegistry } = spreadsheet.registries;
|
||||
|
||||
export const ODOO_VERSION = 3;
|
||||
|
||||
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);
|
||||
const version = _data.odooVersion || 0;
|
||||
if (version < 1) {
|
||||
_data = migrate0to1(_data);
|
||||
}
|
||||
if (version < 2) {
|
||||
_data = migrate1to2(_data);
|
||||
}
|
||||
if (version < 3) {
|
||||
_data = migrate2to3(_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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 (let 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);
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,10 @@
|
||||
/** @odoo-module */
|
||||
|
||||
const spreadsheet = window.o_spreadsheet;
|
||||
export const initCallbackRegistry = new spreadsheet.Registry();
|
||||
|
||||
import { _t } from "@web/core/l10n/translation";
|
||||
window.o_spreadsheet.setTranslationMethod(_t);
|
||||
|
||||
// export * from spreadsheet ?
|
||||
export default spreadsheet;
|
||||
@@ -0,0 +1,42 @@
|
||||
.o-spreadsheet {
|
||||
height: 100%;
|
||||
.fa {
|
||||
font-family: FontAwesome;
|
||||
}
|
||||
.o-selection input {
|
||||
display: initial;
|
||||
}
|
||||
|
||||
// Align spreadsheet menus and toolbar with the breadcrumb in Odoo
|
||||
.o-spreadsheet-topbar {
|
||||
.o_topbar_filter_icon {
|
||||
padding: 2px 3px;
|
||||
font-size: 20px;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background-color: #ebebeb;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.o-sidePanel .o-sidePanelButtons .o-sidePanelButton {
|
||||
color: #666;
|
||||
|
||||
&.o_global_filter_save {
|
||||
color: #008784;
|
||||
border-color: #008784;
|
||||
}
|
||||
|
||||
&.o_delete_element {
|
||||
color: #fff;
|
||||
background-color: #dc3545;
|
||||
border-color: #dc3545;
|
||||
|
||||
&:hover:enabled {
|
||||
background-color: #c82333;
|
||||
border-color: #bd2130;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
/** @odoo-module */
|
||||
|
||||
import { _lt } from "@web/core/l10n/translation";
|
||||
|
||||
import spreadsheet from "@spreadsheet/o_spreadsheet/o_spreadsheet_extended";
|
||||
|
||||
import PivotPlugin from "./plugins/pivot_plugin";
|
||||
import PivotStructurePlugin from "./plugins/pivot_structure_plugin";
|
||||
|
||||
import { SEE_RECORDS_PIVOT, SEE_RECORDS_PIVOT_VISIBLE } from "./pivot_actions";
|
||||
|
||||
const {
|
||||
coreTypes,
|
||||
readonlyAllowedCommands,
|
||||
invalidateEvaluationCommands,
|
||||
} = spreadsheet;
|
||||
const { corePluginRegistry, uiPluginRegistry, cellMenuRegistry } = spreadsheet.registries;
|
||||
|
||||
const { inverseCommandRegistry } = spreadsheet.registries;
|
||||
|
||||
function identity(cmd) {
|
||||
return [cmd];
|
||||
}
|
||||
|
||||
corePluginRegistry.add("odooPivotPlugin", PivotPlugin);
|
||||
|
||||
uiPluginRegistry.add("odooPivotStructurePlugin", PivotStructurePlugin);
|
||||
|
||||
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");
|
||||
|
||||
readonlyAllowedCommands.add("ADD_PIVOT_DOMAIN");
|
||||
|
||||
cellMenuRegistry.add("pivot_see_records", {
|
||||
name: _lt("See records"),
|
||||
sequence: 175,
|
||||
action: SEE_RECORDS_PIVOT,
|
||||
isVisible: SEE_RECORDS_PIVOT_VISIBLE,
|
||||
});
|
||||
|
||||
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);
|
||||
@@ -0,0 +1,47 @@
|
||||
/** @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 (env) => {
|
||||
const cell = env.model.getters.getActiveCell();
|
||||
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 = (env) => {
|
||||
const cell = env.model.getters.getActiveCell();
|
||||
return (
|
||||
cell &&
|
||||
cell.evaluated.value !== "" &&
|
||||
!cell.evaluated.error &&
|
||||
getNumberOfPivotFormulas(cell.content) === 1
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,234 @@
|
||||
/** @odoo-module */
|
||||
|
||||
import { _t } from "@web/core/l10n/translation";
|
||||
import { OdooViewsDataSource } from "../data_sources/odoo_views_data_source";
|
||||
import { SpreadsheetPivotModel } from "./pivot_model";
|
||||
|
||||
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) {
|
||||
super(services, params);
|
||||
}
|
||||
|
||||
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} fieldName
|
||||
*/
|
||||
getPossibleValuesForGroupBy(fieldName) {
|
||||
this._assertDataIsLoaded();
|
||||
return this._model.getPossibleValuesForGroupBy(fieldName);
|
||||
}
|
||||
|
||||
/**
|
||||
* @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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
/** @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) {
|
||||
return this.getters.getFilterDisplayValue(filterName);
|
||||
},
|
||||
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"],
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
/** @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;
|
||||
const date = moment(value, input);
|
||||
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, (functionName) =>
|
||||
["ODOO.PIVOT", "ODOO.PIVOT.HEADER", "ODOO.PIVOT.POSITION"].includes(functionName)
|
||||
).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, (functionName) =>
|
||||
["ODOO.PIVOT", "ODOO.PIVOT.HEADER", "ODOO.PIVOT.POSITION"].includes(functionName)
|
||||
).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) =>
|
||||
typeof arg == "number" || (typeof arg == "string" && !isNaN(arg))
|
||||
? `${arg}`
|
||||
: `"${arg.toString().replace(/"/g, '\\"')}"`
|
||||
)
|
||||
.join(",")})`;
|
||||
}
|
||||
|
||||
export const PERIODS = {
|
||||
day: _t("Day"),
|
||||
week: _t("Week"),
|
||||
month: _t("Month"),
|
||||
quarter: _t("Quarter"),
|
||||
year: _t("Year"),
|
||||
};
|
||||
@@ -0,0 +1,723 @@
|
||||
/** @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 { 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 {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"];
|
||||
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 possible values for each group by of the pivot. This attribute is used *only* for templates,
|
||||
* so it's computed only in prepareForTemplateGeneration
|
||||
*/
|
||||
this._fieldsValue = {};
|
||||
|
||||
/**
|
||||
* 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 (_) {
|
||||
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());
|
||||
}
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Template
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Get the possible values for the given groupBy
|
||||
*/
|
||||
getPossibleValuesForGroupBy(groupBy) {
|
||||
return this._fieldsValue[groupBy];
|
||||
}
|
||||
|
||||
/**
|
||||
* This method is used to compute the possible values for each group bys.
|
||||
* It should be run before using templates
|
||||
*/
|
||||
async prepareForTemplateGeneration() {
|
||||
const colValues = [];
|
||||
const rowValues = [];
|
||||
|
||||
function collectValues(tree, collector) {
|
||||
const group = tree.root;
|
||||
if (!tree.directSubTrees.size) {
|
||||
//It's a leaf, we can fill the cols
|
||||
collector.push([...group.values]);
|
||||
}
|
||||
[...tree.directSubTrees.values()].forEach((subTree) => {
|
||||
collectValues(subTree, collector);
|
||||
});
|
||||
}
|
||||
|
||||
collectValues(this.data.colGroupTree, colValues);
|
||||
collectValues(this.data.rowGroupTree, rowValues);
|
||||
|
||||
for (let i = 0; i < this.metaData.fullRowGroupBys.length; i++) {
|
||||
let vals = [...new Set(rowValues.map((array) => array[i]))];
|
||||
if (i !== 0) {
|
||||
vals = await this._orderValues(vals, this.metaData.fullRowGroupBys[i]);
|
||||
}
|
||||
this._fieldsValue[this.metaData.fullRowGroupBys[i]] = vals;
|
||||
}
|
||||
for (let i = 0; i < this.metaData.fullColGroupBys.length; i++) {
|
||||
let vals;
|
||||
if (i !== 0) {
|
||||
vals = await this._orderValues(vals, this.metaData.fullColGroupBys[i]);
|
||||
} else {
|
||||
vals = colValues.map((array) => array[i]);
|
||||
vals = [...new Set(vals)];
|
||||
}
|
||||
this._fieldsValue[this.metaData.fullColGroupBys[i]] = vals;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Order the given values for the given groupBy. This is done by executing a
|
||||
* search_read
|
||||
*/
|
||||
async _orderValues(values, groupBy) {
|
||||
const field = this.parseGroupField(groupBy).field;
|
||||
const model = this.metaData.resModel;
|
||||
const context = this.searchParams.context;
|
||||
const baseDomain = this.searchParams.domain;
|
||||
const requestField = field.relation ? "id" : field.name;
|
||||
const domain = Domain.and([
|
||||
field.relation ? [] : baseDomain,
|
||||
[[requestField, "in", values]],
|
||||
]).toList();
|
||||
// orderby is omitted for relational fields on purpose to have the default order of the model
|
||||
const records = await this.orm.searchRead(
|
||||
field.relation ? field.relation : model,
|
||||
domain,
|
||||
[requestField],
|
||||
{
|
||||
order: field.relation ? undefined : [{ name: field.name, asc: true }],
|
||||
context: { ...context, active_test: false },
|
||||
}
|
||||
);
|
||||
return [...new Set(records.map((record) => record[requestField].toString()))];
|
||||
}
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// 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 {string[]} 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];
|
||||
}
|
||||
const groupValueString = domain[domain.length - 1];
|
||||
return groupValueString;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
[...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) => {
|
||||
return this._sanitizeValue(group[groupBy], groupBy);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
_sanitizeValue(value, groupBy) {
|
||||
const { aggregateOperator, field } = this.parseGroupField(groupBy);
|
||||
if (this._isDateField(field)) {
|
||||
const fIn = FORMATS[aggregateOperator]["in"];
|
||||
const fOut = FORMATS[aggregateOperator]["out"];
|
||||
// eslint-disable-next-line no-undef
|
||||
const date = moment(value, fIn);
|
||||
return date.isValid() ? date.format(fOut) : false;
|
||||
}
|
||||
return super._sanitizeValue(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
_getSpreadsheetRows(tree) {
|
||||
let 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],
|
||||
indent,
|
||||
});
|
||||
|
||||
const subTreeKeys = tree.sortedKeys || [...tree.directSubTrees.keys()];
|
||||
subTreeKeys.forEach((subTreeKey) => {
|
||||
const subTree = tree.directSubTrees.get(subTreeKey);
|
||||
rows = rows.concat(this._getSpreadsheetRows(subTree));
|
||||
});
|
||||
return rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the col structure
|
||||
*/
|
||||
_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],
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
/** @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;
|
||||
}
|
||||
|
||||
getCellFromMeasureRowWithDomain(values) {
|
||||
const vals = JSON.stringify(values).slice(0, -1); //Remove the last "]"
|
||||
return this.getMeasureHeaders().find((cell) =>
|
||||
JSON.stringify(cell.values).startsWith(vals)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
return this.getMeasureHeaders().findIndex((cell) => JSON.stringify(cell.values) === vals);
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,481 @@
|
||||
/** @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 {number} id
|
||||
* @property {string} dataSourceId
|
||||
* @property {PivotDefinition} definition
|
||||
*/
|
||||
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";
|
||||
|
||||
const { CorePlugin } = spreadsheet;
|
||||
|
||||
export default class PivotPlugin 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.<number, Pivot>} */
|
||||
this.pivots = {};
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
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.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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Getters
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @param {number} id
|
||||
* @returns {PivotDataSource|undefined}
|
||||
*/
|
||||
getPivotDataSource(id) {
|
||||
const dataSourceId = this.pivots[id].dataSourceId;
|
||||
return this.dataSources.get(dataSourceId);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} id
|
||||
* @returns {string}
|
||||
*/
|
||||
getPivotDisplayName(id) {
|
||||
return `(#${id}) ${this.getPivotName(id)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} id
|
||||
* @returns {string}
|
||||
*/
|
||||
getPivotName(id) {
|
||||
return this.pivots[id].definition.name;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} 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 {number} 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<number>}
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Private
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @param {number} id
|
||||
* @param {PivotDefinition} definition
|
||||
* @param {string} dataSourceId
|
||||
*/
|
||||
_addPivot(id, definition, dataSourceId) {
|
||||
const pivots = { ...this.pivots };
|
||||
pivots[id] = {
|
||||
id,
|
||||
definition,
|
||||
dataSourceId,
|
||||
};
|
||||
|
||||
if (!this.dataSources.contains(dataSourceId)) {
|
||||
this.dataSources.add(dataSourceId, PivotDataSource, definition);
|
||||
}
|
||||
this.history.update("pivots", pivots);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} sheetId
|
||||
* @param {[number, number]} anchor
|
||||
* @param {number} 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 {number} 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 {number} 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 {number} 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,
|
||||
},
|
||||
/**
|
||||
* As we are not able to migrate the json, we have to fallback
|
||||
* in the case where the name is not yet present
|
||||
*/
|
||||
name: pivot.name || pivot.model,
|
||||
};
|
||||
this._addPivot(id, definition, this.uuidGenerator.uuidv4());
|
||||
}
|
||||
}
|
||||
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.pivotNextId = this.nextId;
|
||||
}
|
||||
}
|
||||
|
||||
PivotPlugin.getters = [
|
||||
"getNextPivotId",
|
||||
"getPivotDefinition",
|
||||
"getPivotDisplayName",
|
||||
"getPivotIds",
|
||||
"getPivotName",
|
||||
"getAsyncPivotDataSource",
|
||||
"isExistingPivot",
|
||||
"getPivotDataSource",
|
||||
];
|
||||
@@ -0,0 +1,286 @@
|
||||
/** @odoo-module */
|
||||
|
||||
import { _t } from "web.core";
|
||||
import spreadsheet from "@spreadsheet/o_spreadsheet/o_spreadsheet_extended";
|
||||
import { getFirstPivotFunction } from "../pivot_helpers";
|
||||
import { FILTER_DATE_OPTION, monthsOptions } from "@spreadsheet/assets_backend/constants";
|
||||
|
||||
const { astToFormula } = spreadsheet;
|
||||
const { DateTime } = luxon;
|
||||
|
||||
/**
|
||||
* Convert pivot period to the related filter value
|
||||
*
|
||||
* @param {import("@spreadsheet/global_filters/plugins/filters_plugin").RangeType} timeRange
|
||||
* @param {string} value
|
||||
* @returns {object}
|
||||
*/
|
||||
function pivotPeriodToFilterValue(timeRange, value) {
|
||||
// reuse the same logic as in `parseAccountingDate`?
|
||||
const yearOffset = (value.split("/").pop() | 0) - DateTime.now().year;
|
||||
switch (timeRange) {
|
||||
case "year":
|
||||
return {
|
||||
yearOffset,
|
||||
};
|
||||
case "month": {
|
||||
const month = value.split("/")[0] | 0;
|
||||
return {
|
||||
yearOffset,
|
||||
period: monthsOptions[month - 1].id,
|
||||
};
|
||||
}
|
||||
case "quarter": {
|
||||
const quarter = value.split("/")[0] | 0;
|
||||
return {
|
||||
yearOffset,
|
||||
period: FILTER_DATE_OPTION.quarter[quarter - 1],
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default class PivotStructurePlugin 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a spreadsheet command
|
||||
* @param {Object} cmd Command
|
||||
*/
|
||||
handle(cmd) {
|
||||
switch (cmd.type) {
|
||||
case "SELECT_PIVOT":
|
||||
this.selectedPivotId = cmd.pivotId;
|
||||
break;
|
||||
case "ADD_PIVOT_DOMAIN":
|
||||
this._addDomain(cmd.id, cmd.domain);
|
||||
break;
|
||||
case "REFRESH_PIVOT":
|
||||
this._refreshOdooPivot(cmd.id);
|
||||
break;
|
||||
case "REFRESH_ALL_DATA_SOURCES":
|
||||
this._refreshOdooPivots();
|
||||
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) {
|
||||
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));
|
||||
const pivotId = evaluatedArgs[0];
|
||||
const argField = evaluatedArgs[evaluatedArgs.length - 2];
|
||||
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);
|
||||
if (filter.pivotFields[pivotId].field === field.name) {
|
||||
let value = dataSource.getPivotHeaderValue(evaluatedArgs.slice(1));
|
||||
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;
|
||||
}
|
||||
}
|
||||
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 lists
|
||||
*/
|
||||
_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
|
||||
* @param {Array<Array<any>>} domain
|
||||
*/
|
||||
_addDomain(pivotId, domain) {
|
||||
this.getters.getPivotDataSource(pivotId).addDomain(domain);
|
||||
}
|
||||
}
|
||||
|
||||
PivotStructurePlugin.getters = [
|
||||
"getSelectedPivotId",
|
||||
"getPivotComputedDomain",
|
||||
"getDisplayedPivotHeaderValue",
|
||||
"getPivotIdFromPosition",
|
||||
"getPivotCellValue",
|
||||
"getPivotGroupByValues",
|
||||
"getFiltersMatchingPivot",
|
||||
];
|
||||
@@ -0,0 +1,159 @@
|
||||
/** @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 { 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 } });
|
||||
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);
|
||||
});
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,269 @@
|
||||
/** @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 { createSpreadsheetWithGraph, insertGraphInSpreadsheet } from "../../utils/chart";
|
||||
import { createModelWithDataSource } from "../../utils/model";
|
||||
|
||||
QUnit.module("spreadsheet > odoo chart plugin", {}, () => {
|
||||
QUnit.test("Can add an Odoo Bar chart", async (assert) => {
|
||||
const { model } = await createSpreadsheetWithGraph({ 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 createSpreadsheetWithGraph({ 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 createSpreadsheetWithGraph({ 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 createSpreadsheetWithGraph();
|
||||
const sheetId = model.getters.getActiveSheetId();
|
||||
const chartId = model.getters.getChartIds(sheetId)[0];
|
||||
assert.ok(model.getters.getGraphDataSource(chartId));
|
||||
});
|
||||
|
||||
QUnit.test("Odoo bar chart runtime loads the data", async (assert) => {
|
||||
const { model } = await createSpreadsheetWithGraph({
|
||||
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 createSpreadsheetWithGraph({
|
||||
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 createSpreadsheetWithGraph({
|
||||
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: "rgb(31,119,180)",
|
||||
borderColor: "rgb(31,119,180)",
|
||||
data: [1, 3],
|
||||
label: "Count",
|
||||
lineTension: 0,
|
||||
},
|
||||
],
|
||||
labels: ["false", "true"],
|
||||
});
|
||||
assert.verifySteps(["web_read_group"], "it should have loaded the data");
|
||||
});
|
||||
|
||||
QUnit.test("Can import/export an Odoo chart", async (assert) => {
|
||||
const model = await createModelWithDataSource();
|
||||
insertGraphInSpreadsheet(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.getGraphDataSource(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();
|
||||
insertGraphInSpreadsheet(model, "odoo_line");
|
||||
const sheetId = model.getters.getActiveSheetId();
|
||||
const chartId = model.getters.getChartIds(sheetId)[0];
|
||||
assert.ok(model.getters.getGraphDataSource(chartId));
|
||||
model.dispatch("REQUEST_UNDO");
|
||||
assert.notOk(model.getters.getGraphDataSource(chartId));
|
||||
assert.strictEqual(model.getters.getChartIds(sheetId).length, 0);
|
||||
model.dispatch("REQUEST_REDO");
|
||||
assert.ok(model.getters.getGraphDataSource(chartId));
|
||||
assert.strictEqual(model.getters.getChartIds(sheetId).length, 1);
|
||||
});
|
||||
|
||||
QUnit.test("charts with no legend", async (assert) => {
|
||||
const { model } = await createSpreadsheetWithGraph({ type: "odoo_pie" });
|
||||
insertGraphInSpreadsheet(model, "odoo_bar");
|
||||
insertGraphInSpreadsheet(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 createSpreadsheetWithGraph({ 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
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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"]);
|
||||
});
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,75 @@
|
||||
/** @odoo-module */
|
||||
|
||||
import { setCellContent } from "@spreadsheet/../tests/utils/commands";
|
||||
import { getCell, getCellValue } from "@spreadsheet/../tests/utils/getters";
|
||||
import { nextTick } from "@web/../tests/helpers/utils";
|
||||
import { createModelWithDataSource } 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];
|
||||
return [{ ...info, rate: 0.9 }];
|
||||
}
|
||||
},
|
||||
});
|
||||
setCellContent(model, "A1", `=ODOO.CURRENCY.RATE("EUR","USD")`);
|
||||
assert.strictEqual(getCellValue(model, "A1"), "Loading...");
|
||||
await nextTick();
|
||||
assert.strictEqual(getCellValue(model, "A1"), 0.9);
|
||||
});
|
||||
|
||||
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 nextTick();
|
||||
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 nextTick();
|
||||
assert.verifySteps(["FETCH"]);
|
||||
setCellContent(model, "A2", `=ODOO.CURRENCY.RATE("EUR","USD")`);
|
||||
await nextTick();
|
||||
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 nextTick();
|
||||
assert.verifySteps(["FETCH:2"]);
|
||||
});
|
||||
@@ -0,0 +1,169 @@
|
||||
/** @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 = {
|
||||
silent: {
|
||||
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 = {
|
||||
silent: {
|
||||
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({ silent: {} });
|
||||
|
||||
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 = {
|
||||
silent: {
|
||||
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 = {
|
||||
silent: {
|
||||
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(
|
||||
"Name_get will retry with one id by request in case of failure",
|
||||
async function (assert) {
|
||||
const orm = {
|
||||
silent: {
|
||||
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");
|
||||
}
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,282 @@
|
||||
/** @odoo-module */
|
||||
|
||||
import { nextTick } from "@web/../tests/helpers/utils";
|
||||
import { ServerData } from "@spreadsheet/data_sources/server_data";
|
||||
import { LoadingDataError } from "@spreadsheet/o_spreadsheet/errors";
|
||||
|
||||
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([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,146 @@
|
||||
/** @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_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
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
/** @odoo-module */
|
||||
|
||||
import { createSpreadsheetWithGraph } from "../utils/chart";
|
||||
import { addGlobalFilter } from "../utils/commands";
|
||||
|
||||
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 },
|
||||
graphFields: { [chartId]: { field: "date", type: "date" } },
|
||||
};
|
||||
await addGlobalFilter(model, { filter });
|
||||
}
|
||||
|
||||
QUnit.module("spreadsheet > Global filters chart", {}, () => {
|
||||
QUnit.test("Can add a chart global filter", async function (assert) {
|
||||
const { model } = await createSpreadsheetWithGraph();
|
||||
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.getGraphDataSource(chartId).getComputedDomain();
|
||||
assert.equal(computedDomain.length, 3);
|
||||
assert.equal(computedDomain[0], "&");
|
||||
});
|
||||
|
||||
QUnit.test("Chart is loaded with computed domain", async function (assert) {
|
||||
assert.expect(3);
|
||||
|
||||
const { model } = await createSpreadsheetWithGraph({
|
||||
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);
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
}
|
||||
@@ -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"
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,448 @@
|
||||
/** @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 { 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";
|
||||
|
||||
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("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 nextTick(); // 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("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("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" });
|
||||
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],
|
||||
default_stage_id: 5,
|
||||
search_default_stage_id: 5,
|
||||
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);
|
||||
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;
|
||||
}
|
||||
},
|
||||
});
|
||||
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 nextTick();
|
||||
assert.strictEqual(getCellValue(model, "B2"), "");
|
||||
model.dispatch("REQUEST_UNDO");
|
||||
await nextTick();
|
||||
assert.deepEqual(model.getters.getListDefinition(listId).domain, []);
|
||||
await nextTick();
|
||||
assert.strictEqual(getCellValue(model, "B2"), "TRUE");
|
||||
model.dispatch("REQUEST_REDO");
|
||||
assert.deepEqual(model.getters.getListDefinition(listId).domain, [["foo", "in", [55]]]);
|
||||
await nextTick();
|
||||
assert.strictEqual(getCellValue(model, "B2"), "");
|
||||
});
|
||||
|
||||
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("Can see record of a list in dashboard mode", 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();
|
||||
model.updateMode("dashboard");
|
||||
const listId = model.getters.getListIds()[0];
|
||||
const dataSource = model.getters.getListDataSource(listId);
|
||||
selectCell(model, "A2");
|
||||
assert.verifySteps(["partner", dataSource.getIdFromPosition(0).toString()]);
|
||||
});
|
||||
|
||||
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([]);
|
||||
}
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,170 @@
|
||||
/** @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: "1",
|
||||
type: "relation",
|
||||
label: "Relation Filter",
|
||||
fields: {
|
||||
1: {
|
||||
field: "foo",
|
||||
type: "char",
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
const migratedData = migrate(data);
|
||||
const filter = migratedData.globalFilters[0];
|
||||
assert.deepEqual(filter.pivotFields, {
|
||||
1: {
|
||||
field: "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("Global filters: list and graph fields are added", (assert) => {
|
||||
const data = {
|
||||
globalFilters: [
|
||||
{
|
||||
id: "1",
|
||||
type: "relation",
|
||||
},
|
||||
{
|
||||
id: "1",
|
||||
type: "relation",
|
||||
listFields: {
|
||||
1: {
|
||||
field: "foo",
|
||||
type: "char",
|
||||
},
|
||||
},
|
||||
graphFields: {
|
||||
1: {
|
||||
field: "foo",
|
||||
type: "char",
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
const migratedData = migrate(data);
|
||||
const [f1, f2] = migratedData.globalFilters;
|
||||
assert.deepEqual(f1.listFields, {});
|
||||
assert.deepEqual(f1.graphFields, {});
|
||||
assert.deepEqual(f2.listFields, {
|
||||
1: {
|
||||
field: "foo",
|
||||
type: "char",
|
||||
},
|
||||
});
|
||||
assert.deepEqual(f2.graphFields, {
|
||||
1: {
|
||||
field: "foo",
|
||||
type: "char",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
QUnit.test("Odoo version is exported", (assert) => {
|
||||
const model = new Model();
|
||||
assert.strictEqual(model.exportData().odooVersion, ODOO_VERSION);
|
||||
});
|
||||
@@ -0,0 +1,675 @@
|
||||
/** @odoo-module */
|
||||
|
||||
import {
|
||||
getCell,
|
||||
getCellContent,
|
||||
getCellFormula,
|
||||
getCellValue,
|
||||
} from "@spreadsheet/../tests/utils/getters";
|
||||
import { createSpreadsheetWithPivot } from "@spreadsheet/../tests/utils/pivot";
|
||||
import CommandResult from "@spreadsheet/o_spreadsheet/cancelled_reason";
|
||||
import { setCellContent } from "@spreadsheet/../tests/utils/commands";
|
||||
import { createModelWithDataSource } from "@spreadsheet/../tests/utils/model";
|
||||
import { makeDeferred, nextTick, patchWithCleanup } from "@web/../tests/helpers/utils";
|
||||
import { session } from "@web/session";
|
||||
|
||||
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],
|
||||
default_stage_id: 5,
|
||||
search_default_stage_id: 5,
|
||||
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);
|
||||
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;
|
||||
}
|
||||
},
|
||||
});
|
||||
assert.verifySteps(["read_group", "read_group", "read_group", "read_group"]);
|
||||
}
|
||||
);
|
||||
|
||||
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: {},
|
||||
},
|
||||
},
|
||||
};
|
||||
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}`);
|
||||
}
|
||||
},
|
||||
});
|
||||
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("display loading while data is not fully available", async function (assert) {
|
||||
const metadataPromise = makeDeferred();
|
||||
const dataPromise = makeDeferred();
|
||||
const namePromise = 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.step(`${model}/${method}`);
|
||||
await namePromise;
|
||||
}
|
||||
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"), "Loading...");
|
||||
assert.strictEqual(getCellValue(model, "A3"), 131);
|
||||
namePromise.resolve();
|
||||
await nextTick();
|
||||
setCellContent(model, "A10", "3");
|
||||
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", "product/name_get"]);
|
||||
});
|
||||
|
||||
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 nextTick();
|
||||
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 nextTick();
|
||||
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: {},
|
||||
},
|
||||
},
|
||||
};
|
||||
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 nextTick();
|
||||
assert.strictEqual(getCellValue(model, "B4"), "");
|
||||
model.dispatch("REQUEST_UNDO");
|
||||
await nextTick();
|
||||
assert.deepEqual(model.getters.getPivotDefinition(pivotId).domain, []);
|
||||
await nextTick();
|
||||
assert.strictEqual(getCellValue(model, "B4"), 11);
|
||||
model.dispatch("REQUEST_REDO");
|
||||
assert.deepEqual(model.getters.getPivotDefinition(pivotId).domain, [["foo", "in", [55]]]);
|
||||
await nextTick();
|
||||
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]]]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,341 @@
|
||||
/** @odoo-module */
|
||||
|
||||
import { nextTick } from "@web/../tests/helpers/utils";
|
||||
import { setCellContent } from "@spreadsheet/../tests/utils/commands";
|
||||
import { getCell, getCellValue } from "@spreadsheet/../tests/utils/getters";
|
||||
import { createSpreadsheetWithPivot } from "@spreadsheet/../tests/utils/pivot";
|
||||
import { createModelWithDataSource } 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 nextTick();
|
||||
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 nextTick();
|
||||
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 nextTick();
|
||||
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 nextTick();
|
||||
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 nextTick();
|
||||
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 nextTick();
|
||||
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, "01/20/2016");
|
||||
});
|
||||
});
|
||||
@@ -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"));
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
/** @odoo-module */
|
||||
import { 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";
|
||||
|
||||
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"]);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
/** @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 insertGraphInSpreadsheet(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 {string} [params.type]
|
||||
*
|
||||
* @returns { Promise<{ model: Model, env: Object }>}
|
||||
*/
|
||||
export async function createSpreadsheetWithGraph(params = {}) {
|
||||
const model = await createModelWithDataSource({
|
||||
mockRPC: params.mockRPC,
|
||||
});
|
||||
|
||||
insertGraphInSpreadsheet(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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
/** @odoo-module */
|
||||
|
||||
import spreadsheet from "@spreadsheet/o_spreadsheet/o_spreadsheet_extended";
|
||||
import { waitForDataSourcesLoaded } from "@spreadsheet/../tests/utils/model";
|
||||
|
||||
const { toCartesian, toZone } = spreadsheet.helpers;
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
export async function addGlobalFilter(model, filter) {
|
||||
const result = model.dispatch("ADD_GLOBAL_FILTER", filter);
|
||||
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 });
|
||||
}
|
||||
|
||||
/** 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",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,408 @@
|
||||
/** @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/>`,
|
||||
};
|
||||
}
|
||||
|
||||
export function getBasicData() {
|
||||
return {
|
||||
"documents.document": {
|
||||
fields: {
|
||||
name: { string: "Name", type: "char" },
|
||||
raw: { string: "Data", type: "text" },
|
||||
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" },
|
||||
},
|
||||
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 },
|
||||
name: { string: "name", type: "char", store: true, sortable: true },
|
||||
date: { string: "Date", type: "date", store: true, sortable: true },
|
||||
create_date: {
|
||||
string: "Creation Date",
|
||||
type: "datetime",
|
||||
store: true,
|
||||
sortable: true,
|
||||
},
|
||||
active: { string: "Active", type: "bool", default: true },
|
||||
product_id: {
|
||||
string: "Product",
|
||||
type: "many2one",
|
||||
relation: "product",
|
||||
store: true,
|
||||
sortable: true,
|
||||
},
|
||||
tag_ids: {
|
||||
string: "Tags",
|
||||
type: "many2many",
|
||||
relation: "tag",
|
||||
store: true,
|
||||
sortable: 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,
|
||||
},
|
||||
pognon: {
|
||||
string: "Money!",
|
||||
type: "monetary",
|
||||
currency_field: "currency_id",
|
||||
store: true,
|
||||
sortable: true,
|
||||
group_operator: "avg",
|
||||
},
|
||||
},
|
||||
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",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
/** @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, sheetId, 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;
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
/** @odoo-module */
|
||||
|
||||
import { nextTick } from "@web/../tests/helpers/utils";
|
||||
|
||||
import spreadsheet from "@spreadsheet/o_spreadsheet/o_spreadsheet_extended";
|
||||
import { generateListDefinition } from "./data";
|
||||
import { createModelWithDataSource } 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 nextTick();
|
||||
return { model, env };
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
/** @odoo-module */
|
||||
|
||||
import { ormService } from "@web/core/orm_service";
|
||||
import { registry } from "@web/core/registry";
|
||||
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 });
|
||||
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.silent),
|
||||
});
|
||||
setupDataSourceEvaluation(model);
|
||||
await nextTick(); // initial async formulas loading
|
||||
return model;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Model} model
|
||||
*/
|
||||
export async function waitForDataSourcesLoaded(model) {
|
||||
//@ts-ignore
|
||||
await model.config.dataSources.waitForAllLoaded();
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
/** @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 } 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: "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;
|
||||
return { model, env };
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
/** @odoo-module */
|
||||
|
||||
import spreadsheet from "@spreadsheet/o_spreadsheet/o_spreadsheet_extended";
|
||||
import { registerCleanup } from "@web/../tests/helpers/cleanup";
|
||||
import { getFixture } from "@web/../tests/helpers/utils";
|
||||
import { loadJS } from "@web/core/assets";
|
||||
|
||||
const { App } = owl;
|
||||
const { Spreadsheet } = spreadsheet;
|
||||
const { getMenuChildren } = spreadsheet.helpers;
|
||||
|
||||
/** @typedef {import("@spreadsheet/o_spreadsheet/o_spreadsheet").Model} Model */
|
||||
|
||||
/**
|
||||
* Mount o-spreadsheet component with the given spreadsheet model
|
||||
* @param {Model} model
|
||||
* @returns {Promise<HTMLElement>}
|
||||
*/
|
||||
export async function mountSpreadsheet(model) {
|
||||
await loadJS("/web/static/lib/Chart/Chart.js");
|
||||
const app = new App(Spreadsheet, {
|
||||
props: { model },
|
||||
templates: window.__OWL_TEMPLATES__,
|
||||
env: model.config.evalContext.env,
|
||||
test: true,
|
||||
});
|
||||
registerCleanup(() => app.destroy());
|
||||
const fixture = getFixture();
|
||||
await app.mount(fixture);
|
||||
return fixture;
|
||||
}
|
||||
|
||||
export async function doMenuAction(registry, path, env) {
|
||||
const root = path[0];
|
||||
let node = registry.get(root);
|
||||
for (const p of path.slice(1)) {
|
||||
const children = getMenuChildren(node, env);
|
||||
node = children.find((child) => child.id === p);
|
||||
}
|
||||
if (!node) {
|
||||
throw new Error(`Cannot find menu with path "${path.join("/")}"`);
|
||||
}
|
||||
await node.action(env);
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from . import test_currency
|
||||
from . import test_currency_rate
|
||||
@@ -0,0 +1,85 @@
|
||||
from odoo.tests.common import TransactionCase
|
||||
|
||||
class TestCurrencyRates(TransactionCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super(TestCurrencyRates, cls).setUpClass()
|
||||
cls.env["res.currency"].create(
|
||||
[
|
||||
{
|
||||
"name": "MC1",
|
||||
"symbol": ":D",
|
||||
"rounding": 0.001,
|
||||
},
|
||||
{
|
||||
"name": "MC2",
|
||||
"symbol": "§",
|
||||
},
|
||||
]
|
||||
)
|
||||
eur_company = cls.env["res.company"].create(
|
||||
{"name": "Company with EUR", "currency_id": cls.env.ref("base.EUR").id}
|
||||
)
|
||||
usd_company = cls.env["res.company"].create(
|
||||
{"name": "Company with USD", "currency_id": cls.env.ref("base.USD").id}
|
||||
)
|
||||
cls.env.user.company_ids |= eur_company
|
||||
cls.env.user.company_ids |= usd_company
|
||||
cls.env.user.company_id = eur_company
|
||||
cls.usd_company_id = usd_company.id
|
||||
|
||||
def test_get_currencies_for_spreadsheet(self):
|
||||
self.assertEqual(
|
||||
self.env["res.currency"].get_currencies_for_spreadsheet(["MC1", "MC2"]),
|
||||
[
|
||||
{
|
||||
"code": "MC1",
|
||||
"symbol": ":D",
|
||||
"decimalPlaces": 3,
|
||||
"position": "after",
|
||||
},
|
||||
{
|
||||
"code": "MC2",
|
||||
"symbol": "§",
|
||||
"decimalPlaces": 2,
|
||||
"position": "after",
|
||||
},
|
||||
],
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
self.env["res.currency"].get_currencies_for_spreadsheet(["ProbablyNotACurrencyName?", "MC2"]),
|
||||
[
|
||||
None,
|
||||
{
|
||||
"code": "MC2",
|
||||
"symbol": "§",
|
||||
"decimalPlaces": 2,
|
||||
"position": "after",
|
||||
},
|
||||
],
|
||||
)
|
||||
|
||||
def test_get_company_currency_for_spreadsheet(self):
|
||||
self.assertEqual(
|
||||
self.env["res.currency"].get_company_currency_for_spreadsheet(),
|
||||
{
|
||||
"code": "EUR",
|
||||
"symbol": "€",
|
||||
"decimalPlaces": 2,
|
||||
"position": "after",
|
||||
}
|
||||
)
|
||||
self.assertEqual(
|
||||
self.env["res.currency"].get_company_currency_for_spreadsheet(self.usd_company_id),
|
||||
{
|
||||
"code": "USD",
|
||||
"symbol": "$",
|
||||
"decimalPlaces": 2,
|
||||
"position": "before",
|
||||
}
|
||||
)
|
||||
self.assertEqual(
|
||||
self.env["res.currency"].get_company_currency_for_spreadsheet(123456),
|
||||
False
|
||||
)
|
||||
@@ -0,0 +1,136 @@
|
||||
from freezegun import freeze_time
|
||||
|
||||
from odoo.tests.common import TransactionCase
|
||||
|
||||
CURRENT_USD = 1.5
|
||||
CURRENT_EUR = 1
|
||||
CURRENT_CAD = 1.2
|
||||
USD_11 = 1.8
|
||||
CAD_11 = 1.9
|
||||
CAD_UTC = 1.3
|
||||
CAD_AUS = 2
|
||||
|
||||
fake_now_utc = "2020-01-01 21:00:00"
|
||||
|
||||
|
||||
class TestCurrencyRates(TransactionCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super(TestCurrencyRates, cls).setUpClass()
|
||||
usd = cls.env.ref("base.USD")
|
||||
eur = cls.env.ref("base.EUR")
|
||||
cad = cls.env.ref("base.CAD")
|
||||
new_company = cls.env["res.company"].create(
|
||||
{"name": "Test Currency Company", "currency_id": eur.id}
|
||||
)
|
||||
cls.env.user.company_ids |= new_company
|
||||
cls.env.user.company_id = new_company
|
||||
|
||||
cls.env["res.currency.rate"].create(
|
||||
[
|
||||
{
|
||||
"currency_id": usd.id,
|
||||
"rate": CURRENT_USD,
|
||||
},
|
||||
{
|
||||
"currency_id": cad.id,
|
||||
"rate": CURRENT_CAD,
|
||||
},
|
||||
{
|
||||
"name": "2021-11-11",
|
||||
"currency_id": usd.id,
|
||||
"rate": USD_11,
|
||||
},
|
||||
{
|
||||
"name": "2021-11-11",
|
||||
"currency_id": cad.id,
|
||||
"rate": CAD_11,
|
||||
},
|
||||
]
|
||||
)
|
||||
|
||||
def test_currency_without_date(self):
|
||||
self.assertEqual(
|
||||
self.env["res.currency.rate"]._get_rate_for_spreadsheet("USD", "EUR"),
|
||||
CURRENT_EUR / CURRENT_USD,
|
||||
)
|
||||
self.assertEqual(
|
||||
self.env["res.currency.rate"]._get_rate_for_spreadsheet("EUR", "USD"),
|
||||
CURRENT_USD,
|
||||
)
|
||||
self.assertEqual(
|
||||
self.env["res.currency.rate"]._get_rate_for_spreadsheet("USD", "CAD"),
|
||||
CURRENT_CAD / CURRENT_USD,
|
||||
)
|
||||
|
||||
def test_currency_with_date(self):
|
||||
self.assertEqual(
|
||||
self.env["res.currency.rate"]._get_rate_for_spreadsheet(
|
||||
"USD", "EUR", "2021-11-11"
|
||||
),
|
||||
CURRENT_EUR / USD_11,
|
||||
)
|
||||
self.assertEqual(
|
||||
self.env["res.currency.rate"]._get_rate_for_spreadsheet(
|
||||
"EUR", "USD", "2021-11-11"
|
||||
),
|
||||
USD_11,
|
||||
)
|
||||
self.assertEqual(
|
||||
self.env["res.currency.rate"]._get_rate_for_spreadsheet(
|
||||
"USD", "CAD", "2021-11-11"
|
||||
),
|
||||
CAD_11 / USD_11,
|
||||
)
|
||||
|
||||
def test_currency_invalid_args(self):
|
||||
self.assertEqual(
|
||||
self.env["res.currency.rate"]._get_rate_for_spreadsheet("INVALID", "EUR"),
|
||||
False,
|
||||
)
|
||||
self.assertEqual(
|
||||
self.env["res.currency.rate"]._get_rate_for_spreadsheet("EUR", "INVALID"),
|
||||
False,
|
||||
)
|
||||
self.assertEqual(
|
||||
self.env["res.currency.rate"]._get_rate_for_spreadsheet("INVALID", "USD"),
|
||||
False,
|
||||
)
|
||||
self.assertEqual(
|
||||
self.env["res.currency.rate"]._get_rate_for_spreadsheet("USD", "INVALID"),
|
||||
False,
|
||||
)
|
||||
self.assertEqual(
|
||||
self.env["res.currency.rate"]._get_rate_for_spreadsheet(False, "EUR"), False
|
||||
)
|
||||
self.assertEqual(
|
||||
self.env["res.currency.rate"]._get_rate_for_spreadsheet("EUR", False), False
|
||||
)
|
||||
|
||||
@freeze_time(fake_now_utc)
|
||||
def test_rate_by_tz(self):
|
||||
cad = self.env.ref("base.CAD")
|
||||
self.env.user.tz = "UTC"
|
||||
self.env["res.currency.rate"].create(
|
||||
{
|
||||
"currency_id": cad.id,
|
||||
"rate": CAD_UTC,
|
||||
}
|
||||
)
|
||||
self.env.user.tz = "Australia/ACT"
|
||||
self.env["res.currency.rate"].create(
|
||||
{
|
||||
"currency_id": cad.id,
|
||||
"rate": CAD_AUS,
|
||||
}
|
||||
)
|
||||
self.assertEqual(
|
||||
self.env["res.currency.rate"]._get_rate_for_spreadsheet("CAD", "EUR"),
|
||||
CURRENT_EUR / CAD_AUS,
|
||||
)
|
||||
self.assertEqual(
|
||||
self.env["res.currency.rate"]
|
||||
.with_context(tz="UTC")
|
||||
._get_rate_for_spreadsheet("CAD", "EUR"),
|
||||
CURRENT_EUR / CAD_UTC,
|
||||
)
|
||||
@@ -80,11 +80,11 @@ web_map/static/tests/legacy/**/*
|
||||
!documents_spreadsheet/**/*
|
||||
|
||||
# Whitelist spreadsheet
|
||||
!spreadsheet
|
||||
!spreadsheet/**/*
|
||||
!addons/spreadsheet
|
||||
!addons/spreadsheet/**/*
|
||||
|
||||
# blacklist o-spreadsheet lib
|
||||
spreadsheet/static/src/o_spreadsheet/o_spreadsheet.js
|
||||
addons/spreadsheet/static/src/o_spreadsheet/o_spreadsheet.js
|
||||
|
||||
# Whitelist spreadsheet_edition
|
||||
!spreadsheet_edition
|
||||
|
||||
@@ -80,11 +80,11 @@ web_map/static/tests/legacy/**/*
|
||||
!documents_spreadsheet/**/*
|
||||
|
||||
# Whitelist spreadsheet
|
||||
!spreadsheet
|
||||
!spreadsheet/**/*
|
||||
!addons/spreadsheet
|
||||
!addons/spreadsheet/**/*
|
||||
|
||||
# blacklist o-spreadsheet lib
|
||||
spreadsheet/static/src/o_spreadsheet/o_spreadsheet.js
|
||||
addons/spreadsheet/static/src/o_spreadsheet/o_spreadsheet.js
|
||||
|
||||
# Whitelist spreadsheet_edition
|
||||
!spreadsheet_edition
|
||||
|
||||
Reference in New Issue
Block a user