mirror of
https://github.com/bringout/oca-ocb-report.git
synced 2026-04-22 07:02:06 +02:00
Initial commit: Report packages
This commit is contained in:
commit
bc5e1e9efa
604 changed files with 474102 additions and 0 deletions
|
|
@ -0,0 +1,3 @@
|
|||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from . import models
|
||||
|
|
@ -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')
|
||||
]
|
||||
}
|
||||
}
|
||||
Binary file not shown.
5785
odoo-bringout-oca-ocb-spreadsheet/spreadsheet/i18n/af.po
Normal file
5785
odoo-bringout-oca-ocb-spreadsheet/spreadsheet/i18n/af.po
Normal file
File diff suppressed because it is too large
Load diff
5781
odoo-bringout-oca-ocb-spreadsheet/spreadsheet/i18n/am.po
Normal file
5781
odoo-bringout-oca-ocb-spreadsheet/spreadsheet/i18n/am.po
Normal file
File diff suppressed because it is too large
Load diff
5895
odoo-bringout-oca-ocb-spreadsheet/spreadsheet/i18n/ar.po
Normal file
5895
odoo-bringout-oca-ocb-spreadsheet/spreadsheet/i18n/ar.po
Normal file
File diff suppressed because it is too large
Load diff
5810
odoo-bringout-oca-ocb-spreadsheet/spreadsheet/i18n/az.po
Normal file
5810
odoo-bringout-oca-ocb-spreadsheet/spreadsheet/i18n/az.po
Normal file
File diff suppressed because it is too large
Load diff
5807
odoo-bringout-oca-ocb-spreadsheet/spreadsheet/i18n/be.po
Normal file
5807
odoo-bringout-oca-ocb-spreadsheet/spreadsheet/i18n/be.po
Normal file
File diff suppressed because it is too large
Load diff
5821
odoo-bringout-oca-ocb-spreadsheet/spreadsheet/i18n/bg.po
Normal file
5821
odoo-bringout-oca-ocb-spreadsheet/spreadsheet/i18n/bg.po
Normal file
File diff suppressed because it is too large
Load diff
5803
odoo-bringout-oca-ocb-spreadsheet/spreadsheet/i18n/bs.po
Normal file
5803
odoo-bringout-oca-ocb-spreadsheet/spreadsheet/i18n/bs.po
Normal file
File diff suppressed because it is too large
Load diff
5968
odoo-bringout-oca-ocb-spreadsheet/spreadsheet/i18n/ca.po
Normal file
5968
odoo-bringout-oca-ocb-spreadsheet/spreadsheet/i18n/ca.po
Normal file
File diff suppressed because it is too large
Load diff
5870
odoo-bringout-oca-ocb-spreadsheet/spreadsheet/i18n/cs.po
Normal file
5870
odoo-bringout-oca-ocb-spreadsheet/spreadsheet/i18n/cs.po
Normal file
File diff suppressed because it is too large
Load diff
5838
odoo-bringout-oca-ocb-spreadsheet/spreadsheet/i18n/da.po
Normal file
5838
odoo-bringout-oca-ocb-spreadsheet/spreadsheet/i18n/da.po
Normal file
File diff suppressed because it is too large
Load diff
6005
odoo-bringout-oca-ocb-spreadsheet/spreadsheet/i18n/de.po
Normal file
6005
odoo-bringout-oca-ocb-spreadsheet/spreadsheet/i18n/de.po
Normal file
File diff suppressed because it is too large
Load diff
5974
odoo-bringout-oca-ocb-spreadsheet/spreadsheet/i18n/es.po
Normal file
5974
odoo-bringout-oca-ocb-spreadsheet/spreadsheet/i18n/es.po
Normal file
File diff suppressed because it is too large
Load diff
5963
odoo-bringout-oca-ocb-spreadsheet/spreadsheet/i18n/es_MX.po
Normal file
5963
odoo-bringout-oca-ocb-spreadsheet/spreadsheet/i18n/es_MX.po
Normal file
File diff suppressed because it is too large
Load diff
5825
odoo-bringout-oca-ocb-spreadsheet/spreadsheet/i18n/et.po
Normal file
5825
odoo-bringout-oca-ocb-spreadsheet/spreadsheet/i18n/et.po
Normal file
File diff suppressed because it is too large
Load diff
5910
odoo-bringout-oca-ocb-spreadsheet/spreadsheet/i18n/fa.po
Normal file
5910
odoo-bringout-oca-ocb-spreadsheet/spreadsheet/i18n/fa.po
Normal file
File diff suppressed because it is too large
Load diff
5959
odoo-bringout-oca-ocb-spreadsheet/spreadsheet/i18n/fi.po
Normal file
5959
odoo-bringout-oca-ocb-spreadsheet/spreadsheet/i18n/fi.po
Normal file
File diff suppressed because it is too large
Load diff
5984
odoo-bringout-oca-ocb-spreadsheet/spreadsheet/i18n/fr.po
Normal file
5984
odoo-bringout-oca-ocb-spreadsheet/spreadsheet/i18n/fr.po
Normal file
File diff suppressed because it is too large
Load diff
5785
odoo-bringout-oca-ocb-spreadsheet/spreadsheet/i18n/gu.po
Normal file
5785
odoo-bringout-oca-ocb-spreadsheet/spreadsheet/i18n/gu.po
Normal file
File diff suppressed because it is too large
Load diff
5822
odoo-bringout-oca-ocb-spreadsheet/spreadsheet/i18n/he.po
Normal file
5822
odoo-bringout-oca-ocb-spreadsheet/spreadsheet/i18n/he.po
Normal file
File diff suppressed because it is too large
Load diff
5810
odoo-bringout-oca-ocb-spreadsheet/spreadsheet/i18n/hi.po
Normal file
5810
odoo-bringout-oca-ocb-spreadsheet/spreadsheet/i18n/hi.po
Normal file
File diff suppressed because it is too large
Load diff
5821
odoo-bringout-oca-ocb-spreadsheet/spreadsheet/i18n/hr.po
Normal file
5821
odoo-bringout-oca-ocb-spreadsheet/spreadsheet/i18n/hr.po
Normal file
File diff suppressed because it is too large
Load diff
5815
odoo-bringout-oca-ocb-spreadsheet/spreadsheet/i18n/hu.po
Normal file
5815
odoo-bringout-oca-ocb-spreadsheet/spreadsheet/i18n/hu.po
Normal file
File diff suppressed because it is too large
Load diff
5781
odoo-bringout-oca-ocb-spreadsheet/spreadsheet/i18n/hy.po
Normal file
5781
odoo-bringout-oca-ocb-spreadsheet/spreadsheet/i18n/hy.po
Normal file
File diff suppressed because it is too large
Load diff
5937
odoo-bringout-oca-ocb-spreadsheet/spreadsheet/i18n/id.po
Normal file
5937
odoo-bringout-oca-ocb-spreadsheet/spreadsheet/i18n/id.po
Normal file
File diff suppressed because it is too large
Load diff
5790
odoo-bringout-oca-ocb-spreadsheet/spreadsheet/i18n/is.po
Normal file
5790
odoo-bringout-oca-ocb-spreadsheet/spreadsheet/i18n/is.po
Normal file
File diff suppressed because it is too large
Load diff
5994
odoo-bringout-oca-ocb-spreadsheet/spreadsheet/i18n/it.po
Normal file
5994
odoo-bringout-oca-ocb-spreadsheet/spreadsheet/i18n/it.po
Normal file
File diff suppressed because it is too large
Load diff
5820
odoo-bringout-oca-ocb-spreadsheet/spreadsheet/i18n/ja.po
Normal file
5820
odoo-bringout-oca-ocb-spreadsheet/spreadsheet/i18n/ja.po
Normal file
File diff suppressed because it is too large
Load diff
5809
odoo-bringout-oca-ocb-spreadsheet/spreadsheet/i18n/km.po
Normal file
5809
odoo-bringout-oca-ocb-spreadsheet/spreadsheet/i18n/km.po
Normal file
File diff suppressed because it is too large
Load diff
5821
odoo-bringout-oca-ocb-spreadsheet/spreadsheet/i18n/ko.po
Normal file
5821
odoo-bringout-oca-ocb-spreadsheet/spreadsheet/i18n/ko.po
Normal file
File diff suppressed because it is too large
Load diff
5787
odoo-bringout-oca-ocb-spreadsheet/spreadsheet/i18n/lo.po
Normal file
5787
odoo-bringout-oca-ocb-spreadsheet/spreadsheet/i18n/lo.po
Normal file
File diff suppressed because it is too large
Load diff
5816
odoo-bringout-oca-ocb-spreadsheet/spreadsheet/i18n/lt.po
Normal file
5816
odoo-bringout-oca-ocb-spreadsheet/spreadsheet/i18n/lt.po
Normal file
File diff suppressed because it is too large
Load diff
5813
odoo-bringout-oca-ocb-spreadsheet/spreadsheet/i18n/lv.po
Normal file
5813
odoo-bringout-oca-ocb-spreadsheet/spreadsheet/i18n/lv.po
Normal file
File diff suppressed because it is too large
Load diff
5786
odoo-bringout-oca-ocb-spreadsheet/spreadsheet/i18n/ml.po
Normal file
5786
odoo-bringout-oca-ocb-spreadsheet/spreadsheet/i18n/ml.po
Normal file
File diff suppressed because it is too large
Load diff
5817
odoo-bringout-oca-ocb-spreadsheet/spreadsheet/i18n/mn.po
Normal file
5817
odoo-bringout-oca-ocb-spreadsheet/spreadsheet/i18n/mn.po
Normal file
File diff suppressed because it is too large
Load diff
5791
odoo-bringout-oca-ocb-spreadsheet/spreadsheet/i18n/ms.po
Normal file
5791
odoo-bringout-oca-ocb-spreadsheet/spreadsheet/i18n/ms.po
Normal file
File diff suppressed because it is too large
Load diff
5812
odoo-bringout-oca-ocb-spreadsheet/spreadsheet/i18n/nb.po
Normal file
5812
odoo-bringout-oca-ocb-spreadsheet/spreadsheet/i18n/nb.po
Normal file
File diff suppressed because it is too large
Load diff
5972
odoo-bringout-oca-ocb-spreadsheet/spreadsheet/i18n/nl.po
Normal file
5972
odoo-bringout-oca-ocb-spreadsheet/spreadsheet/i18n/nl.po
Normal file
File diff suppressed because it is too large
Load diff
5785
odoo-bringout-oca-ocb-spreadsheet/spreadsheet/i18n/no.po
Normal file
5785
odoo-bringout-oca-ocb-spreadsheet/spreadsheet/i18n/no.po
Normal file
File diff suppressed because it is too large
Load diff
5959
odoo-bringout-oca-ocb-spreadsheet/spreadsheet/i18n/pl.po
Normal file
5959
odoo-bringout-oca-ocb-spreadsheet/spreadsheet/i18n/pl.po
Normal file
File diff suppressed because it is too large
Load diff
5823
odoo-bringout-oca-ocb-spreadsheet/spreadsheet/i18n/pt.po
Normal file
5823
odoo-bringout-oca-ocb-spreadsheet/spreadsheet/i18n/pt.po
Normal file
File diff suppressed because it is too large
Load diff
5956
odoo-bringout-oca-ocb-spreadsheet/spreadsheet/i18n/pt_BR.po
Normal file
5956
odoo-bringout-oca-ocb-spreadsheet/spreadsheet/i18n/pt_BR.po
Normal file
File diff suppressed because it is too large
Load diff
5969
odoo-bringout-oca-ocb-spreadsheet/spreadsheet/i18n/ro.po
Normal file
5969
odoo-bringout-oca-ocb-spreadsheet/spreadsheet/i18n/ro.po
Normal file
File diff suppressed because it is too large
Load diff
5970
odoo-bringout-oca-ocb-spreadsheet/spreadsheet/i18n/ru.po
Normal file
5970
odoo-bringout-oca-ocb-spreadsheet/spreadsheet/i18n/ru.po
Normal file
File diff suppressed because it is too large
Load diff
5816
odoo-bringout-oca-ocb-spreadsheet/spreadsheet/i18n/sk.po
Normal file
5816
odoo-bringout-oca-ocb-spreadsheet/spreadsheet/i18n/sk.po
Normal file
File diff suppressed because it is too large
Load diff
5820
odoo-bringout-oca-ocb-spreadsheet/spreadsheet/i18n/sl.po
Normal file
5820
odoo-bringout-oca-ocb-spreadsheet/spreadsheet/i18n/sl.po
Normal file
File diff suppressed because it is too large
Load diff
5803
odoo-bringout-oca-ocb-spreadsheet/spreadsheet/i18n/spreadsheet.pot
Normal file
5803
odoo-bringout-oca-ocb-spreadsheet/spreadsheet/i18n/spreadsheet.pot
Normal file
File diff suppressed because it is too large
Load diff
5781
odoo-bringout-oca-ocb-spreadsheet/spreadsheet/i18n/sq.po
Normal file
5781
odoo-bringout-oca-ocb-spreadsheet/spreadsheet/i18n/sq.po
Normal file
File diff suppressed because it is too large
Load diff
5913
odoo-bringout-oca-ocb-spreadsheet/spreadsheet/i18n/sr.po
Normal file
5913
odoo-bringout-oca-ocb-spreadsheet/spreadsheet/i18n/sr.po
Normal file
File diff suppressed because it is too large
Load diff
5952
odoo-bringout-oca-ocb-spreadsheet/spreadsheet/i18n/sv.po
Normal file
5952
odoo-bringout-oca-ocb-spreadsheet/spreadsheet/i18n/sv.po
Normal file
File diff suppressed because it is too large
Load diff
5781
odoo-bringout-oca-ocb-spreadsheet/spreadsheet/i18n/sw.po
Normal file
5781
odoo-bringout-oca-ocb-spreadsheet/spreadsheet/i18n/sw.po
Normal file
File diff suppressed because it is too large
Load diff
5781
odoo-bringout-oca-ocb-spreadsheet/spreadsheet/i18n/ta.po
Normal file
5781
odoo-bringout-oca-ocb-spreadsheet/spreadsheet/i18n/ta.po
Normal file
File diff suppressed because it is too large
Load diff
5872
odoo-bringout-oca-ocb-spreadsheet/spreadsheet/i18n/th.po
Normal file
5872
odoo-bringout-oca-ocb-spreadsheet/spreadsheet/i18n/th.po
Normal file
File diff suppressed because it is too large
Load diff
5892
odoo-bringout-oca-ocb-spreadsheet/spreadsheet/i18n/tr.po
Normal file
5892
odoo-bringout-oca-ocb-spreadsheet/spreadsheet/i18n/tr.po
Normal file
File diff suppressed because it is too large
Load diff
5921
odoo-bringout-oca-ocb-spreadsheet/spreadsheet/i18n/uk.po
Normal file
5921
odoo-bringout-oca-ocb-spreadsheet/spreadsheet/i18n/uk.po
Normal file
File diff suppressed because it is too large
Load diff
5891
odoo-bringout-oca-ocb-spreadsheet/spreadsheet/i18n/vi.po
Normal file
5891
odoo-bringout-oca-ocb-spreadsheet/spreadsheet/i18n/vi.po
Normal file
File diff suppressed because it is too large
Load diff
5824
odoo-bringout-oca-ocb-spreadsheet/spreadsheet/i18n/zh_CN.po
Normal file
5824
odoo-bringout-oca-ocb-spreadsheet/spreadsheet/i18n/zh_CN.po
Normal file
File diff suppressed because it is too large
Load diff
5812
odoo-bringout-oca-ocb-spreadsheet/spreadsheet/i18n/zh_TW.po
Normal file
5812
odoo-bringout-oca-ocb-spreadsheet/spreadsheet/i18n/zh_TW.po
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -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,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 });
|
||||
|
|
@ -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,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);
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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,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");
|
||||
|
|
@ -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",
|
||||
];
|
||||
|
|
@ -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 = [];
|
||||
|
|
@ -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,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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,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();
|
||||
|
|
@ -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()
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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 },
|
||||
};
|
||||
|
|
@ -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>
|
||||
|
|
@ -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 },
|
||||
};
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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 && filterValue.period"
|
||||
yearOffset="filterValue && 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>
|
||||
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue