Initial commit: Report packages

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

View file

@ -0,0 +1,3 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import models

View file

@ -0,0 +1,37 @@
# -*- 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',
# Load all o_spreadsheet templates first to allow to inherit them
'spreadsheet/static/src/o_spreadsheet/o_spreadsheet.xml',
'spreadsheet/static/src/**/*.xml',
('remove', 'spreadsheet/static/src/assets_backend/**/*')
],
'web.assets_backend': [
'spreadsheet/static/src/**/*.scss',
'spreadsheet/static/src/assets_backend/**/*',
('remove', 'spreadsheet/static/src/**/*.dark.scss'),
],
"web.dark_mode_assets_backend": [
'spreadsheet/static/src/**/*.dark.scss',
],
'web.qunit_suite_tests': [
'spreadsheet/static/tests/**/*',
('include', 'spreadsheet.o_spreadsheet')
]
}
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -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

View file

@ -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,
}

View file

@ -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

View file

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

After

Width:  |  Height:  |  Size: 1.3 KiB

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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