Начальное наполнение
This commit is contained in:
parent
e138446add
commit
18852beb75
3
__init__.py
Normal file
3
__init__.py
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||||
|
|
||||||
|
from . import models
|
96
__manifest__.py
Normal file
96
__manifest__.py
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
# -*- 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', 'portal'],
|
||||||
|
'installable': True,
|
||||||
|
'license': 'LGPL-3',
|
||||||
|
'data': [
|
||||||
|
'views/public_readonly_spreadsheet_templates.xml',
|
||||||
|
],
|
||||||
|
'assets': {
|
||||||
|
'spreadsheet.dependencies': [
|
||||||
|
'web/static/lib/Chart/Chart.js',
|
||||||
|
'web/static/lib/chartjs-adapter-luxon/chartjs-adapter-luxon.js',
|
||||||
|
],
|
||||||
|
'spreadsheet.o_spreadsheet': [
|
||||||
|
'web/static/src/polyfills/clipboard.js',
|
||||||
|
'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/**/*'),
|
||||||
|
('remove', 'spreadsheet/static/src/public_readonly_app/**/*'),
|
||||||
|
],
|
||||||
|
'spreadsheet.assets_print': [
|
||||||
|
'spreadsheet/static/src/print_assets/**/*',
|
||||||
|
],
|
||||||
|
'spreadsheet.public_spreadsheet': [
|
||||||
|
('include', 'web.assets_frontend_minimal'),
|
||||||
|
('include', 'web._assets_helpers'), # bootstrap variables
|
||||||
|
'web/static/src/scss/bootstrap_overridden.scss',
|
||||||
|
'web/static/src/scss/pre_variables.scss',
|
||||||
|
'web/static/lib/bootstrap/scss/_variables.scss',
|
||||||
|
('include', 'web._assets_bootstrap'),
|
||||||
|
'web/static/lib/popper/popper.js',
|
||||||
|
'web/static/lib/bootstrap/js/dist/dom/data.js',
|
||||||
|
'web/static/lib/bootstrap/js/dist/dom/event-handler.js',
|
||||||
|
'web/static/lib/bootstrap/js/dist/dom/manipulator.js',
|
||||||
|
'web/static/lib/bootstrap/js/dist/dom/selector-engine.js',
|
||||||
|
'web/static/lib/bootstrap/js/dist/base-component.js',
|
||||||
|
'web/static/lib/bootstrap/js/dist/collapse.js',
|
||||||
|
'web/static/lib/bootstrap/js/dist/dropdown.js',
|
||||||
|
|
||||||
|
'web/static/src/libs/fontawesome/css/font-awesome.css',
|
||||||
|
'web/static/lib/owl/owl.js',
|
||||||
|
'web/static/lib/luxon/luxon.js',
|
||||||
|
'web/static/lib/owl/odoo_module.js',
|
||||||
|
'web/static/src/core/utils/**/*.js',
|
||||||
|
'web/static/src/core/browser/browser.js',
|
||||||
|
'web/static/src/core/browser/feature_detection.js',
|
||||||
|
'web/static/src/core/registry.js',
|
||||||
|
'web/static/src/core/assets.js',
|
||||||
|
'web/static/src/session.js',
|
||||||
|
'web/static/src/env.js',
|
||||||
|
'web/static/src/core/network/http_service.js',
|
||||||
|
'web/static/src/core/network/rpc_service.js',
|
||||||
|
'web/static/src/core/user_service.js',
|
||||||
|
'web/static/src/core/l10n/**/*.js',
|
||||||
|
'web/static/src/core/network/download.js',
|
||||||
|
('include', 'spreadsheet.dependencies'),
|
||||||
|
'spreadsheet/static/src/o_spreadsheet/o_spreadsheet.js',
|
||||||
|
'spreadsheet/static/src/o_spreadsheet/o_spreadsheet.xml',
|
||||||
|
'spreadsheet/static/src/o_spreadsheet/icons.xml',
|
||||||
|
'spreadsheet/static/src/o_spreadsheet/o_spreadsheet_extended.scss',
|
||||||
|
'spreadsheet/static/src/o_spreadsheet/migration.js',
|
||||||
|
'spreadsheet/static/src/o_spreadsheet/odoo_module.js',
|
||||||
|
'spreadsheet/static/src/helpers/helpers.js',
|
||||||
|
'spreadsheet/static/src/public_readonly_app/**/*.xml',
|
||||||
|
'spreadsheet/static/src/public_readonly_app/**/*.scss',
|
||||||
|
'spreadsheet/static/src/public_readonly_app/**/*',
|
||||||
|
'spreadsheet/static/src/hooks.js',
|
||||||
|
],
|
||||||
|
'web.assets_backend': [
|
||||||
|
'spreadsheet/static/src/**/*.scss',
|
||||||
|
'spreadsheet/static/src/assets_backend/**/*',
|
||||||
|
('remove', 'spreadsheet/static/src/public_readonly_app/**/*.scss'),
|
||||||
|
('remove', 'spreadsheet/static/src/**/*.dark.scss'),
|
||||||
|
],
|
||||||
|
"web.assets_web_dark": [
|
||||||
|
'spreadsheet/static/src/**/*.dark.scss',
|
||||||
|
],
|
||||||
|
'web.qunit_suite_tests': [
|
||||||
|
('include', 'spreadsheet.dependencies'),
|
||||||
|
'spreadsheet/static/tests/**/*',
|
||||||
|
('include', 'spreadsheet.o_spreadsheet'),
|
||||||
|
'spreadsheet/static/src/public_readonly_app/**/*.xml',
|
||||||
|
'spreadsheet/static/src/public_readonly_app/**/*.js',
|
||||||
|
('remove', 'spreadsheet/static/src/public_readonly_app/main.js'),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
11745
i18n/ar.po
Normal file
11745
i18n/ar.po
Normal file
File diff suppressed because it is too large
Load Diff
11472
i18n/bg.po
Normal file
11472
i18n/bg.po
Normal file
File diff suppressed because it is too large
Load Diff
11622
i18n/ca.po
Normal file
11622
i18n/ca.po
Normal file
File diff suppressed because it is too large
Load Diff
11500
i18n/cs.po
Normal file
11500
i18n/cs.po
Normal file
File diff suppressed because it is too large
Load Diff
11496
i18n/da.po
Normal file
11496
i18n/da.po
Normal file
File diff suppressed because it is too large
Load Diff
12092
i18n/de.po
Normal file
12092
i18n/de.po
Normal file
File diff suppressed because it is too large
Load Diff
11968
i18n/es.po
Normal file
11968
i18n/es.po
Normal file
File diff suppressed because it is too large
Load Diff
11964
i18n/es_419.po
Normal file
11964
i18n/es_419.po
Normal file
File diff suppressed because it is too large
Load Diff
11477
i18n/et.po
Normal file
11477
i18n/et.po
Normal file
File diff suppressed because it is too large
Load Diff
11482
i18n/fa.po
Normal file
11482
i18n/fa.po
Normal file
File diff suppressed because it is too large
Load Diff
11921
i18n/fi.po
Normal file
11921
i18n/fi.po
Normal file
File diff suppressed because it is too large
Load Diff
12040
i18n/fr.po
Normal file
12040
i18n/fr.po
Normal file
File diff suppressed because it is too large
Load Diff
11485
i18n/he.po
Normal file
11485
i18n/he.po
Normal file
File diff suppressed because it is too large
Load Diff
11483
i18n/hu.po
Normal file
11483
i18n/hu.po
Normal file
File diff suppressed because it is too large
Load Diff
11913
i18n/id.po
Normal file
11913
i18n/id.po
Normal file
File diff suppressed because it is too large
Load Diff
12008
i18n/it.po
Normal file
12008
i18n/it.po
Normal file
File diff suppressed because it is too large
Load Diff
11503
i18n/ja.po
Normal file
11503
i18n/ja.po
Normal file
File diff suppressed because it is too large
Load Diff
11514
i18n/ko.po
Normal file
11514
i18n/ko.po
Normal file
File diff suppressed because it is too large
Load Diff
11483
i18n/lt.po
Normal file
11483
i18n/lt.po
Normal file
File diff suppressed because it is too large
Load Diff
11467
i18n/lv.po
Normal file
11467
i18n/lv.po
Normal file
File diff suppressed because it is too large
Load Diff
12044
i18n/nl.po
Normal file
12044
i18n/nl.po
Normal file
File diff suppressed because it is too large
Load Diff
11795
i18n/pl.po
Normal file
11795
i18n/pl.po
Normal file
File diff suppressed because it is too large
Load Diff
11463
i18n/pt.po
Normal file
11463
i18n/pt.po
Normal file
File diff suppressed because it is too large
Load Diff
11979
i18n/pt_BR.po
Normal file
11979
i18n/pt_BR.po
Normal file
File diff suppressed because it is too large
Load Diff
11988
i18n/ru.po
Normal file
11988
i18n/ru.po
Normal file
File diff suppressed because it is too large
Load Diff
11460
i18n/sk.po
Normal file
11460
i18n/sk.po
Normal file
File diff suppressed because it is too large
Load Diff
11469
i18n/sl.po
Normal file
11469
i18n/sl.po
Normal file
File diff suppressed because it is too large
Load Diff
11456
i18n/spreadsheet.pot
Normal file
11456
i18n/spreadsheet.pot
Normal file
File diff suppressed because it is too large
Load Diff
11565
i18n/sr.po
Normal file
11565
i18n/sr.po
Normal file
File diff suppressed because it is too large
Load Diff
11473
i18n/sv.po
Normal file
11473
i18n/sv.po
Normal file
File diff suppressed because it is too large
Load Diff
11692
i18n/th.po
Normal file
11692
i18n/th.po
Normal file
File diff suppressed because it is too large
Load Diff
11538
i18n/tr.po
Normal file
11538
i18n/tr.po
Normal file
File diff suppressed because it is too large
Load Diff
11567
i18n/uk.po
Normal file
11567
i18n/uk.po
Normal file
File diff suppressed because it is too large
Load Diff
11761
i18n/vi.po
Normal file
11761
i18n/vi.po
Normal file
File diff suppressed because it is too large
Load Diff
11488
i18n/zh_CN.po
Normal file
11488
i18n/zh_CN.po
Normal file
File diff suppressed because it is too large
Load Diff
11487
i18n/zh_TW.po
Normal file
11487
i18n/zh_TW.po
Normal file
File diff suppressed because it is too large
Load Diff
6
models/__init__.py
Normal file
6
models/__init__.py
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||||
|
|
||||||
|
from . import res_currency
|
||||||
|
from . import res_currency_rate
|
||||||
|
from . import res_lang
|
||||||
|
from . import spreadsheet_mixin
|
54
models/res_currency.py
Normal file
54
models/res_currency.py
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
from odoo import api, models
|
||||||
|
|
||||||
|
|
||||||
|
class ResCurrency(models.Model):
|
||||||
|
_inherit = "res.currency"
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def get_currencies_for_spreadsheet(self, currency_names):
|
||||||
|
"""
|
||||||
|
Returns the currency structure of provided currency names.
|
||||||
|
This function is meant to be called by the spreadsheet js lib,
|
||||||
|
hence the formatting of the result.
|
||||||
|
|
||||||
|
:currency_names list(str): list of currency names (e.g. ["EUR", "USD", "CAD"])
|
||||||
|
:return: list of dicts of the form `{ "code": str, "symbol": str, "decimalPlaces": int, "position":str }`
|
||||||
|
"""
|
||||||
|
currencies = self.with_context(active_test=False).search(
|
||||||
|
[("name", "in", currency_names)],
|
||||||
|
)
|
||||||
|
result = []
|
||||||
|
for currency_name in currency_names:
|
||||||
|
currency = next(filter(lambda curr: curr.name == currency_name, currencies), None)
|
||||||
|
if currency:
|
||||||
|
currency_data = {
|
||||||
|
"code": currency.name,
|
||||||
|
"symbol": currency.symbol,
|
||||||
|
"decimalPlaces": currency.decimal_places,
|
||||||
|
"position": currency.position,
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
currency_data = None
|
||||||
|
result.append(currency_data)
|
||||||
|
return result
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def get_company_currency_for_spreadsheet(self, company_id=None):
|
||||||
|
"""
|
||||||
|
Returns the currency structure for the currency of the company.
|
||||||
|
This function is meant to be called by the spreadsheet js lib,
|
||||||
|
hence the formatting of the result.
|
||||||
|
|
||||||
|
:company_id int: Id of the company
|
||||||
|
:return: dict of the form `{ "code": str, "symbol": str, "decimalPlaces": int, "position":str }`
|
||||||
|
"""
|
||||||
|
company = self.env["res.company"].browse(company_id) if company_id else self.env.company
|
||||||
|
if not company.exists():
|
||||||
|
return False
|
||||||
|
currency = company.currency_id
|
||||||
|
return {
|
||||||
|
"code": currency.name,
|
||||||
|
"symbol": currency.symbol,
|
||||||
|
"decimalPlaces": currency.decimal_places,
|
||||||
|
"position": currency.position,
|
||||||
|
}
|
29
models/res_currency_rate.py
Normal file
29
models/res_currency_rate.py
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
from odoo import api, fields, models
|
||||||
|
|
||||||
|
|
||||||
|
class ResCurrencyRate(models.Model):
|
||||||
|
_inherit = "res.currency.rate"
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def _get_rate_for_spreadsheet(self, currency_from_code, currency_to_code, date=None):
|
||||||
|
if not currency_from_code or not currency_to_code:
|
||||||
|
return False
|
||||||
|
Currency = self.env["res.currency"].with_context({"active_test": False})
|
||||||
|
currency_from = Currency.search([("name", "=", currency_from_code)])
|
||||||
|
currency_to = Currency.search([("name", "=", currency_to_code)])
|
||||||
|
if not currency_from or not currency_to:
|
||||||
|
return False
|
||||||
|
company = self.env.company
|
||||||
|
date = fields.Date.from_string(date) if date else fields.Date.context_today(self)
|
||||||
|
return Currency._get_conversion_rate(currency_from, currency_to, company, date)
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def get_rates_for_spreadsheet(self, requests):
|
||||||
|
result = []
|
||||||
|
for request in requests:
|
||||||
|
record = request.copy()
|
||||||
|
record.update({
|
||||||
|
"rate": self._get_rate_for_spreadsheet(request["from"], request["to"], request.get("date")),
|
||||||
|
})
|
||||||
|
result.append(record)
|
||||||
|
return result
|
38
models/res_lang.py
Normal file
38
models/res_lang.py
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||||
|
|
||||||
|
from odoo import api, models
|
||||||
|
|
||||||
|
from odoo.addons.spreadsheet.utils import (
|
||||||
|
strftime_format_to_spreadsheet_date_format,
|
||||||
|
strftime_format_to_spreadsheet_time_format,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Lang(models.Model):
|
||||||
|
_inherit = "res.lang"
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def get_locales_for_spreadsheet(self):
|
||||||
|
"""Return the list of locales available for a spreadsheet."""
|
||||||
|
langs = self.with_context(active_test=False).search([])
|
||||||
|
|
||||||
|
spreadsheet_locales = [lang._odoo_lang_to_spreadsheet_locale() for lang in langs]
|
||||||
|
return spreadsheet_locales
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def _get_user_spreadsheet_locale(self):
|
||||||
|
"""Convert the odoo lang to a spreadsheet locale."""
|
||||||
|
lang = self._lang_get(self.env.user.lang)
|
||||||
|
return lang._odoo_lang_to_spreadsheet_locale()
|
||||||
|
|
||||||
|
def _odoo_lang_to_spreadsheet_locale(self):
|
||||||
|
"""Convert an odoo lang to a spreadsheet locale."""
|
||||||
|
return {
|
||||||
|
"name": self.name,
|
||||||
|
"code": self.code,
|
||||||
|
"thousandsSeparator": self.thousands_sep,
|
||||||
|
"decimalSeparator": self.decimal_point,
|
||||||
|
"dateFormat": strftime_format_to_spreadsheet_date_format(self.date_format),
|
||||||
|
"timeFormat": strftime_format_to_spreadsheet_time_format(self.time_format),
|
||||||
|
"formulaArgSeparator": ";" if self.decimal_point == "," else ",",
|
||||||
|
}
|
101
models/spreadsheet_mixin.py
Normal file
101
models/spreadsheet_mixin.py
Normal file
@ -0,0 +1,101 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||||
|
import io
|
||||||
|
import zipfile
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
from odoo import api, fields, models, _
|
||||||
|
from odoo.exceptions import ValidationError, MissingError
|
||||||
|
|
||||||
|
class SpreadsheetMixin(models.AbstractModel):
|
||||||
|
_name = "spreadsheet.mixin"
|
||||||
|
_description = "Spreadsheet mixin"
|
||||||
|
_auto = False
|
||||||
|
|
||||||
|
spreadsheet_binary_data = fields.Binary(
|
||||||
|
required=True,
|
||||||
|
string="Spreadsheet file",
|
||||||
|
default=lambda self: self._empty_spreadsheet_data_base64(),
|
||||||
|
)
|
||||||
|
spreadsheet_data = fields.Text(compute='_compute_spreadsheet_data', inverse='_inverse_spreadsheet_data')
|
||||||
|
thumbnail = fields.Binary()
|
||||||
|
|
||||||
|
@api.depends("spreadsheet_binary_data")
|
||||||
|
def _compute_spreadsheet_data(self):
|
||||||
|
for spreadsheet in self.with_context(bin_size=False):
|
||||||
|
if not spreadsheet.spreadsheet_binary_data:
|
||||||
|
spreadsheet.spreadsheet_data = False
|
||||||
|
else:
|
||||||
|
spreadsheet.spreadsheet_data = base64.b64decode(spreadsheet.spreadsheet_binary_data).decode()
|
||||||
|
|
||||||
|
def _inverse_spreadsheet_data(self):
|
||||||
|
for spreadsheet in self:
|
||||||
|
if not spreadsheet.spreadsheet_data:
|
||||||
|
spreadsheet.spreadsheet_binary_data = False
|
||||||
|
else:
|
||||||
|
spreadsheet.spreadsheet_binary_data = base64.b64encode(spreadsheet.spreadsheet_data.encode())
|
||||||
|
|
||||||
|
@api.onchange('spreadsheet_binary_data')
|
||||||
|
def _onchange_data_(self):
|
||||||
|
if self.spreadsheet_binary_data:
|
||||||
|
try:
|
||||||
|
data_str = base64.b64decode(self.spreadsheet_binary_data).decode('utf-8')
|
||||||
|
json.loads(data_str)
|
||||||
|
except:
|
||||||
|
raise ValidationError(_('Invalid JSON Data'))
|
||||||
|
|
||||||
|
def _empty_spreadsheet_data_base64(self):
|
||||||
|
"""Create an empty spreadsheet workbook.
|
||||||
|
Encoded as base64
|
||||||
|
"""
|
||||||
|
data = json.dumps(self._empty_spreadsheet_data())
|
||||||
|
return base64.b64encode(data.encode())
|
||||||
|
|
||||||
|
def _empty_spreadsheet_data(self):
|
||||||
|
"""Create an empty spreadsheet workbook.
|
||||||
|
The sheet name should be the same for all users to allow consistent references
|
||||||
|
in formulas. It is translated for the user creating the spreadsheet.
|
||||||
|
"""
|
||||||
|
lang = self.env["res.lang"]._lang_get(self.env.user.lang)
|
||||||
|
locale = lang._odoo_lang_to_spreadsheet_locale()
|
||||||
|
return {
|
||||||
|
"version": 1,
|
||||||
|
"sheets": [
|
||||||
|
{
|
||||||
|
"id": "sheet1",
|
||||||
|
"name": _("Sheet1"),
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"settings": {
|
||||||
|
"locale": locale,
|
||||||
|
},
|
||||||
|
"revisionId": "START_REVISION",
|
||||||
|
}
|
||||||
|
|
||||||
|
def _zip_xslx_files(self, files):
|
||||||
|
stream = io.BytesIO()
|
||||||
|
with zipfile.ZipFile(stream, 'w', compression=zipfile.ZIP_DEFLATED) as doc_zip:
|
||||||
|
for f in files:
|
||||||
|
# to reduce networking load, only the image path is sent.
|
||||||
|
# It's replaced by the image content here.
|
||||||
|
if 'imageSrc' in f:
|
||||||
|
try:
|
||||||
|
content = self._get_file_content(f['imageSrc'])
|
||||||
|
doc_zip.writestr(f['path'], content)
|
||||||
|
except MissingError:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
doc_zip.writestr(f['path'], f['content'])
|
||||||
|
|
||||||
|
return stream.getvalue()
|
||||||
|
|
||||||
|
def _get_file_content(self, file_path):
|
||||||
|
if file_path.startswith('data:image/png;base64,'):
|
||||||
|
return base64.b64decode(file_path.split(',')[1])
|
||||||
|
match = re.match(r'/web/image/(\d+)', file_path)
|
||||||
|
file_record = self.env['ir.binary']._find_record(
|
||||||
|
res_model='ir.attachment',
|
||||||
|
res_id=int(match.group(1)),
|
||||||
|
)
|
||||||
|
return self.env['ir.binary']._get_stream_from(file_record).read()
|
1
static/img/spreadsheet.svg
Normal file
1
static/img/spreadsheet.svg
Normal file
@ -0,0 +1 @@
|
|||||||
|
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 38 38"><defs><style>.cls-1{fill:#429646;}.cls-1,.cls-2{fill-rule:evenodd;}.cls-2{fill:#fff;fill-opacity:0.93;}.cls-3,.cls-4{fill:none;stroke:#429646;}.cls-3{stroke-width:2px;}.cls-4{stroke-miterlimit:10;stroke-width:3px;}</style></defs><title>E1</title><polygon class="cls-1" points="34 2 34 34 4 34 34 2"/><path id="pdf-a" class="cls-2" d="M6,1H32a3,3,0,0,1,3,3V33a3,3,0,0,1-3,3H6a3,3,0,0,1-3-3V4A3,3,0,0,1,6,1Z"/><path class="cls-3" d="M7,2H31a3,3,0,0,1,3,3V32a3,3,0,0,1-3,3H7a3,3,0,0,1-3-3V5A3,3,0,0,1,7,2Z"/><line class="cls-4" x1="9.26" y1="26.6" x2="14.22" y2="26.6"/><line class="cls-4" x1="9.26" y1="21.28" x2="14.22" y2="21.28"/><line class="cls-4" x1="9.26" y1="15.95" x2="14.22" y2="15.95"/><line class="cls-4" x1="9.26" y1="10.62" x2="14.22" y2="10.62"/><line class="cls-4" x1="16.52" y1="26.6" x2="21.48" y2="26.6"/><line class="cls-4" x1="16.52" y1="21.28" x2="21.48" y2="21.28"/><line class="cls-4" x1="16.52" y1="15.95" x2="21.48" y2="15.95"/><line class="cls-4" x1="16.52" y1="10.62" x2="21.48" y2="10.62"/><line class="cls-4" x1="23.77" y1="26.6" x2="28.73" y2="26.6"/><line class="cls-4" x1="23.77" y1="21.28" x2="28.73" y2="21.28"/><line class="cls-4" x1="23.77" y1="15.95" x2="28.73" y2="15.95"/><line class="cls-4" x1="23.77" y1="10.62" x2="28.73" y2="10.62"/></svg>
|
After Width: | Height: | Size: 1.3 KiB |
29
static/src/actions/spreadsheet_download_action.js
Normal file
29
static/src/actions/spreadsheet_download_action.js
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
/** @odoo-module */
|
||||||
|
|
||||||
|
import { download } from "@web/core/network/download";
|
||||||
|
import { registry } from "@web/core/registry";
|
||||||
|
import { createSpreadsheetModel, waitForDataLoaded } from "@spreadsheet/helpers/model";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {import("@web/env").OdooEnv} env
|
||||||
|
* @param {object} action
|
||||||
|
*/
|
||||||
|
async function downloadSpreadsheet(env, action) {
|
||||||
|
let { name, data, stateUpdateMessages, xlsxData } = action.params;
|
||||||
|
if (!xlsxData) {
|
||||||
|
const model = await createSpreadsheetModel({ env, data, revisions: stateUpdateMessages });
|
||||||
|
await waitForDataLoaded(model);
|
||||||
|
xlsxData = model.exportXLSX();
|
||||||
|
}
|
||||||
|
await download({
|
||||||
|
url: "/spreadsheet/xlsx",
|
||||||
|
data: {
|
||||||
|
zip_name: `${name}.xlsx`,
|
||||||
|
files: JSON.stringify(xlsxData.files),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
registry
|
||||||
|
.category("actions")
|
||||||
|
.add("action_download_spreadsheet", downloadSpreadsheet, { force: true });
|
24
static/src/assets_backend/constants.js
Normal file
24
static/src/assets_backend/constants.js
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
/** @odoo-module */
|
||||||
|
|
||||||
|
import { _t } from "@web/core/l10n/translation";
|
||||||
|
|
||||||
|
export const FILTER_DATE_OPTION = {
|
||||||
|
quarter: ["first_quarter", "second_quarter", "third_quarter", "fourth_quarter"],
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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: _t("January") },
|
||||||
|
{ id: "february", description: _t("February") },
|
||||||
|
{ id: "march", description: _t("March") },
|
||||||
|
{ id: "april", description: _t("April") },
|
||||||
|
{ id: "may", description: _t("May") },
|
||||||
|
{ id: "june", description: _t("June") },
|
||||||
|
{ id: "july", description: _t("July") },
|
||||||
|
{ id: "august", description: _t("August") },
|
||||||
|
{ id: "september", description: _t("September") },
|
||||||
|
{ id: "october", description: _t("October") },
|
||||||
|
{ id: "november", description: _t("November") },
|
||||||
|
{ id: "december", description: _t("December") },
|
||||||
|
];
|
11
static/src/assets_backend/helpers.js
Normal file
11
static/src/assets_backend/helpers.js
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
/** @odoo-module */
|
||||||
|
|
||||||
|
import { loadBundle } from "@web/core/assets";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load external libraries required for o-spreadsheet
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
export async function loadSpreadsheetDependencies() {
|
||||||
|
await loadBundle("web.chartjs_lib");
|
||||||
|
}
|
50
static/src/assets_backend/spreadsheet_action_loader.js
Normal file
50
static/src/assets_backend/spreadsheet_action_loader.js
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
/** @odoo-module **/
|
||||||
|
|
||||||
|
import { _t } from "@web/core/l10n/translation";
|
||||||
|
import { registry } from "@web/core/registry";
|
||||||
|
import { loadBundle } from "@web/core/assets";
|
||||||
|
import { sprintf } from "@web/core/utils/strings";
|
||||||
|
import { loadSpreadsheetDependencies } from "./helpers";
|
||||||
|
|
||||||
|
const actionRegistry = registry.category("actions");
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param {object} env
|
||||||
|
* @param {string} actionName
|
||||||
|
* @param {function} actionLazyLoader
|
||||||
|
*/
|
||||||
|
export async function loadSpreadsheetAction(env, actionName, actionLazyLoader) {
|
||||||
|
await loadSpreadsheetDependencies();
|
||||||
|
await loadBundle("spreadsheet.o_spreadsheet");
|
||||||
|
|
||||||
|
if (actionRegistry.get(actionName) === actionLazyLoader) {
|
||||||
|
// At this point, the real spreadsheet client action should be loaded and have
|
||||||
|
// replaced this function in the action registry. If it's not the case,
|
||||||
|
// it probably means that there was a crash in the bundle (e.g. syntax
|
||||||
|
// error). In this case, this action will remain in the registry, which
|
||||||
|
// will lead to an infinite loop. To prevent that, we push another action
|
||||||
|
// in the registry.
|
||||||
|
actionRegistry.add(
|
||||||
|
actionName,
|
||||||
|
() => {
|
||||||
|
const msg = sprintf(_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);
|
47
static/src/chart/data_source/chart_data_source.js
Normal file
47
static/src/chart/data_source/chart_data_source.js
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
/** @odoo-module */
|
||||||
|
|
||||||
|
import { OdooViewsDataSource } from "@spreadsheet/data_sources/odoo_views_data_source";
|
||||||
|
import { _t } from "@web/core/l10n/translation";
|
||||||
|
import { GraphModel as ChartModel } from "@web/views/graph/graph_model";
|
||||||
|
|
||||||
|
export 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;
|
||||||
|
}
|
||||||
|
}
|
16
static/src/chart/index.js
Normal file
16
static/src/chart/index.js
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
/** @odoo-module */
|
||||||
|
|
||||||
|
import * as spreadsheet from "@odoo/o-spreadsheet";
|
||||||
|
|
||||||
|
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 };
|
98
static/src/chart/odoo_chart/odoo_bar_chart.js
Normal file
98
static/src/chart/odoo_chart/odoo_bar_chart.js
Normal file
@ -0,0 +1,98 @@
|
|||||||
|
/** @odoo-module */
|
||||||
|
|
||||||
|
import * as spreadsheet from "@odoo/o-spreadsheet";
|
||||||
|
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 locale = getters.getLocale();
|
||||||
|
const chartJsConfig = getBarConfiguration(chart, labels, locale);
|
||||||
|
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, locale) {
|
||||||
|
const fontColor = chartFontColor(chart.background);
|
||||||
|
const config = getDefaultChartJsRuntime(chart, labels, fontColor, { locale });
|
||||||
|
config.type = chart.type.replace("odoo_", "");
|
||||||
|
const legend = {
|
||||||
|
...config.options.legend,
|
||||||
|
display: chart.legendPosition !== "none",
|
||||||
|
labels: { fontColor },
|
||||||
|
};
|
||||||
|
legend.position = chart.legendPosition;
|
||||||
|
config.options.plugins = config.options.plugins || {};
|
||||||
|
config.options.plugins.legend = legend;
|
||||||
|
config.options.layout = {
|
||||||
|
padding: { left: 20, right: 20, top: chart.title ? 10 : 25, bottom: 10 },
|
||||||
|
};
|
||||||
|
config.options.scales = {
|
||||||
|
x: {
|
||||||
|
ticks: {
|
||||||
|
// x axis configuration
|
||||||
|
maxRotation: 60,
|
||||||
|
minRotation: 15,
|
||||||
|
padding: 5,
|
||||||
|
labelOffset: 2,
|
||||||
|
color: fontColor,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
y: {
|
||||||
|
position: chart.verticalAxisPosition,
|
||||||
|
ticks: {
|
||||||
|
color: fontColor,
|
||||||
|
// y axis configuration
|
||||||
|
},
|
||||||
|
beginAtZero: true, // the origin of the y axis is always zero
|
||||||
|
},
|
||||||
|
};
|
||||||
|
if (chart.stacked) {
|
||||||
|
config.options.scales.x.stacked = true;
|
||||||
|
config.options.scales.y.stacked = true;
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
}
|
135
static/src/chart/odoo_chart/odoo_chart.js
Normal file
135
static/src/chart/odoo_chart/odoo_chart.js
Normal file
@ -0,0 +1,135 @@
|
|||||||
|
/** @odoo-module */
|
||||||
|
|
||||||
|
import { AbstractChart, CommandResult } from "@odoo/o-spreadsheet";
|
||||||
|
import { ChartDataSource } from "../data_source/chart_data_source";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @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
|
||||||
|
* @property {boolean} cumulative
|
||||||
|
*
|
||||||
|
* @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,
|
||||||
|
mode: this.type.replace("odoo_", ""),
|
||||||
|
cumulated: definition.cumulative,
|
||||||
|
// if a chart is cumulated, the first data point should take into
|
||||||
|
// account past data, even if a domain on a specific period is applied
|
||||||
|
cumulatedStart: definition.cumulative,
|
||||||
|
};
|
||||||
|
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,
|
||||||
|
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.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
133
static/src/chart/odoo_chart/odoo_line_chart.js
Normal file
133
static/src/chart/odoo_chart/odoo_line_chart.js
Normal file
@ -0,0 +1,133 @@
|
|||||||
|
/** @odoo-module */
|
||||||
|
|
||||||
|
import * as spreadsheet from "@odoo/o-spreadsheet";
|
||||||
|
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 locale = getters.getLocale();
|
||||||
|
const chartJsConfig = getLineConfiguration(chart, labels, locale);
|
||||||
|
const colors = new ChartColors();
|
||||||
|
for (let [index, { label, data, cumulatedStart }] 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 = cumulatedStart;
|
||||||
|
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, locale) {
|
||||||
|
const fontColor = chartFontColor(chart.background);
|
||||||
|
const config = getDefaultChartJsRuntime(chart, labels, fontColor, { locale });
|
||||||
|
config.type = chart.type.replace("odoo_", "");
|
||||||
|
const legend = {
|
||||||
|
...config.options.legend,
|
||||||
|
display: chart.legendPosition !== "none",
|
||||||
|
labels: {
|
||||||
|
color: fontColor,
|
||||||
|
generateLabels(chart) {
|
||||||
|
const { data } = chart;
|
||||||
|
const labels = window.Chart.defaults.plugins.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.plugins = config.options.plugins || {};
|
||||||
|
config.options.plugins.legend = legend;
|
||||||
|
config.options.layout = {
|
||||||
|
padding: { left: 20, right: 20, top: chart.title ? 10 : 25, bottom: 10 },
|
||||||
|
};
|
||||||
|
config.options.scales = {
|
||||||
|
x: {
|
||||||
|
ticks: {
|
||||||
|
// x axis configuration
|
||||||
|
maxRotation: 60,
|
||||||
|
minRotation: 15,
|
||||||
|
padding: 5,
|
||||||
|
labelOffset: 2,
|
||||||
|
color: fontColor,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
y: {
|
||||||
|
position: chart.verticalAxisPosition,
|
||||||
|
ticks: {
|
||||||
|
color: fontColor,
|
||||||
|
// y axis configuration
|
||||||
|
},
|
||||||
|
beginAtZero: true, // the origin of the y axis is always zero
|
||||||
|
},
|
||||||
|
};
|
||||||
|
if (chart.stacked) {
|
||||||
|
config.options.scales.y.stacked = true;
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
}
|
74
static/src/chart/odoo_chart/odoo_pie_chart.js
Normal file
74
static/src/chart/odoo_chart/odoo_pie_chart.js
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
/** @odoo-module */
|
||||||
|
|
||||||
|
import * as spreadsheet from "@odoo/o-spreadsheet";
|
||||||
|
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 locale = getters.getLocale();
|
||||||
|
const chartJsConfig = getPieConfiguration(chart, labels, locale);
|
||||||
|
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, locale) {
|
||||||
|
const fontColor = chartFontColor(chart.background);
|
||||||
|
const config = getDefaultChartJsRuntime(chart, labels, fontColor, { locale });
|
||||||
|
config.type = chart.type.replace("odoo_", "");
|
||||||
|
const legend = {
|
||||||
|
...config.options.legend,
|
||||||
|
display: chart.legendPosition !== "none",
|
||||||
|
labels: { fontColor },
|
||||||
|
};
|
||||||
|
legend.position = chart.legendPosition;
|
||||||
|
config.options.plugins = config.options.plugins || {};
|
||||||
|
config.options.plugins.legend = legend;
|
||||||
|
config.options.layout = {
|
||||||
|
padding: { left: 20, right: 20, top: chart.title ? 10 : 25, bottom: 10 },
|
||||||
|
};
|
||||||
|
config.options.plugins.tooltip = {
|
||||||
|
callbacks: {
|
||||||
|
title: function (tooltipItem) {
|
||||||
|
return tooltipItem.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;
|
||||||
|
}
|
39
static/src/chart/odoo_menu/figure_component.js
Normal file
39
static/src/chart/odoo_menu/figure_component.js
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
/** @odoo-module **/
|
||||||
|
|
||||||
|
import { patch } from "@web/core/utils/patch";
|
||||||
|
import * as spreadsheet from "@odoo/o-spreadsheet";
|
||||||
|
import { useService } from "@web/core/utils/hooks";
|
||||||
|
import { _t } from "@web/core/l10n/translation";
|
||||||
|
|
||||||
|
patch(spreadsheet.components.FigureComponent.prototype, {
|
||||||
|
setup() {
|
||||||
|
super.setup();
|
||||||
|
this.menuService = useService("menu");
|
||||||
|
this.actionService = useService("action");
|
||||||
|
this.notificationService = useService("notification");
|
||||||
|
},
|
||||||
|
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`);
|
||||||
|
}
|
||||||
|
if (!menu.actionID) {
|
||||||
|
this.notificationService.add(
|
||||||
|
_t(
|
||||||
|
"The menu linked to this chart doesn't have an corresponding action. Please link the chart to another menu."
|
||||||
|
),
|
||||||
|
{ type: "danger" }
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
9
static/src/chart/odoo_menu/figure_component.scss
Normal file
9
static/src/chart/odoo_menu/figure_component.scss
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
.o-figure-menu {
|
||||||
|
.o-figure-menu-item {
|
||||||
|
padding-left: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.o-chart-external-link {
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
}
|
16
static/src/chart/odoo_menu/figure_component.xml
Normal file
16
static/src/chart/odoo_menu/figure_component.xml
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
<odoo>
|
||||||
|
<div t-name="spreadsheet.FigureComponent" t-inherit="o-spreadsheet-FigureComponent" t-inherit-mode="extension">
|
||||||
|
<xpath expr="//div[hasclass('o-figure-menu-item')]" position="before">
|
||||||
|
<div
|
||||||
|
t-if="hasOdooMenu and !env.isDashboard()"
|
||||||
|
class="o-figure-menu-item o-chart-external-link"
|
||||||
|
t-on-click="navigateToOdooMenu">
|
||||||
|
<span class="fa fa-external-link" />
|
||||||
|
</div>
|
||||||
|
</xpath>
|
||||||
|
<xpath expr="//div[hasclass('o-figure')]" position="attributes">
|
||||||
|
<attribute name="t-on-click">() => this.onClick()</attribute>
|
||||||
|
<attribute name="t-att-role">env.isDashboard() and hasOdooMenu ? "button" : ""</attribute>
|
||||||
|
</xpath>
|
||||||
|
</div>
|
||||||
|
</odoo>
|
50
static/src/chart/plugins/chart_odoo_menu_plugin.js
Normal file
50
static/src/chart/plugins/chart_odoo_menu_plugin.js
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
/** @odoo-module */
|
||||||
|
|
||||||
|
import { coreTypes, CorePlugin } from "@odoo/o-spreadsheet";
|
||||||
|
|
||||||
|
/** Plugin that link charts with Odoo menus. It can contain either the Id of the odoo menu, or its xml id. */
|
||||||
|
export class ChartOdooMenuPlugin extends CorePlugin {
|
||||||
|
constructor(config) {
|
||||||
|
super(config);
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.getters = ["getChartOdooMenu"];
|
||||||
|
|
||||||
|
coreTypes.add("LINK_ODOO_MENU_TO_CHART");
|
203
static/src/chart/plugins/odoo_chart_core_plugin.js
Normal file
203
static/src/chart/plugins/odoo_chart_core_plugin.js
Normal file
@ -0,0 +1,203 @@
|
|||||||
|
/** @odoo-module */
|
||||||
|
import * as spreadsheet from "@odoo/o-spreadsheet";
|
||||||
|
import { globalFiltersFieldMatchers } from "@spreadsheet/global_filters/plugins/global_filters_core_plugin";
|
||||||
|
import { checkFilterFieldMatching } from "@spreadsheet/global_filters/helpers";
|
||||||
|
import { CommandResult } from "../../o_spreadsheet/cancelled_reason";
|
||||||
|
import { Domain } from "@web/core/domain";
|
||||||
|
|
||||||
|
const { CorePlugin } = spreadsheet;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Object} Chart
|
||||||
|
* @property {Object} fieldMatching
|
||||||
|
*
|
||||||
|
* @typedef {import("@spreadsheet/global_filters/plugins/global_filters_core_plugin").FieldMatching} FieldMatching
|
||||||
|
*/
|
||||||
|
|
||||||
|
export class OdooChartCorePlugin extends CorePlugin {
|
||||||
|
constructor(config) {
|
||||||
|
super(config);
|
||||||
|
|
||||||
|
/** @type {Object.<string, Chart>} */
|
||||||
|
this.charts = {};
|
||||||
|
|
||||||
|
globalFiltersFieldMatchers["chart"] = {
|
||||||
|
getIds: () => this.getters.getOdooChartIds(),
|
||||||
|
getDisplayName: (chartId) => this.getters.getOdooChartDisplayName(chartId),
|
||||||
|
getFieldMatching: (chartId, filterId) =>
|
||||||
|
this.getOdooChartFieldMatching(chartId, filterId),
|
||||||
|
getModel: (chartId) =>
|
||||||
|
this.getters.getChart(chartId).getDefinitionForDataSource().metaData.resModel,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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 "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() {
|
||||||
|
return Object.keys(this.charts);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} chartId
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
getChartFieldMatch(chartId) {
|
||||||
|
return this.charts[chartId].fieldMatching;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param {string} chartId
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
getOdooChartDisplayName(chartId) {
|
||||||
|
return `(#${this.getOdooChartIds().indexOf(chartId) + 1}) ${
|
||||||
|
this.getters.getChart(chartId).title
|
||||||
|
}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Import the charts
|
||||||
|
*
|
||||||
|
* @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 chart
|
||||||
|
*
|
||||||
|
* @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);
|
||||||
|
figure.data.searchParams.domain = new Domain(
|
||||||
|
figure.data.searchParams.domain
|
||||||
|
).toJson();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Private
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the current odooChartFieldMatching of a chart
|
||||||
|
*
|
||||||
|
* @param {string} chartId
|
||||||
|
* @param {string} filterId
|
||||||
|
*/
|
||||||
|
getOdooChartFieldMatching(chartId, filterId) {
|
||||||
|
return this.charts[chartId].fieldMatching[filterId];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the current odooChartFieldMatching 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 {Object} fieldMatching
|
||||||
|
*/
|
||||||
|
_addOdooChart(chartId, fieldMatching = undefined) {
|
||||||
|
const charts = { ...this.charts };
|
||||||
|
if (!fieldMatching) {
|
||||||
|
const model = this.getters.getChartDefinition(chartId).metaData.resModel;
|
||||||
|
fieldMatching = this.getters.getFieldMatchingForModel(model);
|
||||||
|
}
|
||||||
|
charts[chartId] = {
|
||||||
|
fieldMatching,
|
||||||
|
};
|
||||||
|
this.history.update("charts", charts);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
OdooChartCorePlugin.getters = [
|
||||||
|
"getOdooChartIds",
|
||||||
|
"getChartFieldMatch",
|
||||||
|
"getOdooChartDisplayName",
|
||||||
|
"getOdooChartFieldMatching",
|
||||||
|
];
|
230
static/src/chart/plugins/odoo_chart_ui_plugin.js
Normal file
230
static/src/chart/plugins/odoo_chart_ui_plugin.js
Normal file
@ -0,0 +1,230 @@
|
|||||||
|
/** @odoo-module */
|
||||||
|
|
||||||
|
import * as spreadsheet from "@odoo/o-spreadsheet";
|
||||||
|
import { Domain } from "@web/core/domain";
|
||||||
|
import { globalFiltersFieldMatchers } from "@spreadsheet/global_filters/plugins/global_filters_core_plugin";
|
||||||
|
import { ChartDataSource } from "../data_source/chart_data_source";
|
||||||
|
import { sprintf } from "@web/core/utils/strings";
|
||||||
|
import { _t } from "@web/core/l10n/translation";
|
||||||
|
|
||||||
|
const { UIPlugin } = spreadsheet;
|
||||||
|
|
||||||
|
export class OdooChartUIPlugin extends UIPlugin {
|
||||||
|
constructor(config) {
|
||||||
|
super(config);
|
||||||
|
this.dataSources = config.custom.dataSources;
|
||||||
|
|
||||||
|
globalFiltersFieldMatchers["chart"] = {
|
||||||
|
...globalFiltersFieldMatchers["chart"],
|
||||||
|
getTag: async (chartId) => {
|
||||||
|
const model = await this.getChartDataSource(chartId).getModelLabel();
|
||||||
|
return sprintf(_t("Chart - %s"), model);
|
||||||
|
},
|
||||||
|
waitForReady: () => this._getOdooChartsWaitForReady(),
|
||||||
|
getFields: (chartId) => this.getChartDataSource(chartId).getFields(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeHandle(cmd) {
|
||||||
|
switch (cmd.type) {
|
||||||
|
case "START":
|
||||||
|
for (const chartId of this.getters.getOdooChartIds()) {
|
||||||
|
this._setupChartDataSource(chartId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 "CREATE_CHART": {
|
||||||
|
switch (cmd.definition.type) {
|
||||||
|
case "odoo_pie":
|
||||||
|
case "odoo_bar":
|
||||||
|
case "odoo_line":
|
||||||
|
this._setupChartDataSource(cmd.id);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "UPDATE_CHART": {
|
||||||
|
switch (cmd.definition.type) {
|
||||||
|
case "odoo_pie":
|
||||||
|
case "odoo_bar":
|
||||||
|
case "odoo_line": {
|
||||||
|
const dataSource = this.getChartDataSource(cmd.id);
|
||||||
|
if (
|
||||||
|
dataSource.getInitialDomainString() !==
|
||||||
|
new Domain(cmd.definition.searchParams.domain).toString()
|
||||||
|
) {
|
||||||
|
this._resetChartDataSource(cmd.id);
|
||||||
|
}
|
||||||
|
this._setChartDataSource(cmd.id);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "ADD_GLOBAL_FILTER":
|
||||||
|
case "EDIT_GLOBAL_FILTER":
|
||||||
|
case "REMOVE_GLOBAL_FILTER":
|
||||||
|
case "SET_GLOBAL_FILTER_VALUE":
|
||||||
|
case "CLEAR_GLOBAL_FILTER_VALUE":
|
||||||
|
this._addDomains();
|
||||||
|
break;
|
||||||
|
case "UNDO":
|
||||||
|
case "REDO": {
|
||||||
|
if (
|
||||||
|
cmd.commands.find((command) =>
|
||||||
|
[
|
||||||
|
"ADD_GLOBAL_FILTER",
|
||||||
|
"EDIT_GLOBAL_FILTER",
|
||||||
|
"REMOVE_GLOBAL_FILTER",
|
||||||
|
].includes(command.type)
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
this._addDomains();
|
||||||
|
}
|
||||||
|
|
||||||
|
const domainEditionCommands = cmd.commands.filter(
|
||||||
|
(cmd) => cmd.type === "UPDATE_CHART" || cmd.type === "CREATE_CHART"
|
||||||
|
);
|
||||||
|
for (const cmd of domainEditionCommands) {
|
||||||
|
if (!this.getters.getOdooChartIds().includes(cmd.id)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const dataSource = this.getChartDataSource(cmd.id);
|
||||||
|
if (
|
||||||
|
dataSource.getInitialDomainString() !==
|
||||||
|
new Domain(cmd.definition.searchParams.domain).toString()
|
||||||
|
) {
|
||||||
|
this._resetChartDataSource(cmd.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "REFRESH_ODOO_CHART":
|
||||||
|
this._refreshOdooChart(cmd.chartId);
|
||||||
|
break;
|
||||||
|
case "REFRESH_ALL_DATA_SOURCES":
|
||||||
|
this._refreshOdooCharts();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} chartId
|
||||||
|
* @returns {ChartDataSource|undefined}
|
||||||
|
*/
|
||||||
|
getChartDataSource(chartId) {
|
||||||
|
const dataSourceId = this._getOdooChartDataSourceId(chartId);
|
||||||
|
return this.dataSources.get(dataSourceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// 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.getChartDataSource(chartId).addDomain(domain);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add an additional domain to all chart
|
||||||
|
*
|
||||||
|
* @private
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
_addDomains() {
|
||||||
|
for (const chartId of this.getters.getOdooChartIds()) {
|
||||||
|
this._addDomain(chartId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} chartId
|
||||||
|
* @param {string} dataSourceId
|
||||||
|
*/
|
||||||
|
_setupChartDataSource(chartId) {
|
||||||
|
const dataSourceId = this._getOdooChartDataSourceId(chartId);
|
||||||
|
if (!this.dataSources.contains(dataSourceId)) {
|
||||||
|
this._resetChartDataSource(chartId);
|
||||||
|
}
|
||||||
|
this._setChartDataSource(chartId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the datasource on the corresponding chart
|
||||||
|
* @param {string} chartId
|
||||||
|
*/
|
||||||
|
_resetChartDataSource(chartId) {
|
||||||
|
const definition = this.getters.getChart(chartId).getDefinitionForDataSource();
|
||||||
|
const dataSourceId = this._getOdooChartDataSourceId(chartId);
|
||||||
|
this.dataSources.add(dataSourceId, ChartDataSource, definition);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the datasource on the corresponding chart
|
||||||
|
* @param {string} chartId
|
||||||
|
*/
|
||||||
|
_setChartDataSource(chartId) {
|
||||||
|
const chart = this.getters.getChart(chartId);
|
||||||
|
chart.setDataSource(this.getChartDataSource(chartId));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @return {Promise[]}
|
||||||
|
*/
|
||||||
|
_getOdooChartsWaitForReady() {
|
||||||
|
return this.getters
|
||||||
|
.getOdooChartIds()
|
||||||
|
.map((chartId) => this.getChartDataSource(chartId).loadMetadata());
|
||||||
|
}
|
||||||
|
|
||||||
|
_getOdooChartDataSourceId(chartId) {
|
||||||
|
return `chart-${chartId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refresh the cache of a chart
|
||||||
|
* @param {string} chartId Id of the chart
|
||||||
|
*/
|
||||||
|
_refreshOdooChart(chartId) {
|
||||||
|
this.getChartDataSource(chartId).load({ reload: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refresh the cache of all the charts
|
||||||
|
*/
|
||||||
|
_refreshOdooCharts() {
|
||||||
|
for (const chartId of this.getters.getOdooChartIds()) {
|
||||||
|
this._refreshOdooChart(chartId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
OdooChartUIPlugin.getters = ["getChartDataSource"];
|
21
static/src/chart/plugins/operational_transform.js
Normal file
21
static/src/chart/plugins/operational_transform.js
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
/** @odoo-module */
|
||||||
|
|
||||||
|
import * as spreadsheet from "@odoo/o-spreadsheet";
|
||||||
|
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);
|
74
static/src/components/share_button/share_button.js
Normal file
74
static/src/components/share_button/share_button.js
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
/** @odoo-module **/
|
||||||
|
|
||||||
|
import { Component, useState } from "@odoo/owl";
|
||||||
|
import { browser } from "@web/core/browser/browser";
|
||||||
|
import { _t } from "@web/core/l10n/translation";
|
||||||
|
|
||||||
|
import { Dropdown } from "@web/core/dropdown/dropdown";
|
||||||
|
import { DropdownItem } from "@web/core/dropdown/dropdown_item";
|
||||||
|
import { CopyButton } from "@web/views/fields/copy_clipboard/copy_button";
|
||||||
|
import { waitForDataLoaded, freezeOdooData } from "@spreadsheet/helpers/model";
|
||||||
|
import { Model } from "@odoo/o-spreadsheet";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Share button to share a spreadsheet
|
||||||
|
*/
|
||||||
|
export class SpreadsheetShareButton extends Component {
|
||||||
|
static template = "spreadsheet.ShareButton";
|
||||||
|
static components = { Dropdown, DropdownItem, CopyButton };
|
||||||
|
static props = {
|
||||||
|
model: { type: Model, optional: true },
|
||||||
|
onSpreadsheetShared: Function,
|
||||||
|
togglerClass: { type: String, optional: true },
|
||||||
|
};
|
||||||
|
|
||||||
|
setup() {
|
||||||
|
this.copiedText = _t("Copied");
|
||||||
|
this.state = useState({ url: undefined });
|
||||||
|
}
|
||||||
|
|
||||||
|
get togglerClass() {
|
||||||
|
return ["btn btn-light", this.props.togglerClass].join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
async onOpened() {
|
||||||
|
const model = this.props.model;
|
||||||
|
await waitForDataLoaded(model);
|
||||||
|
const data = await freezeOdooData(model);
|
||||||
|
if (!this.isChanged(data)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const url = await this.props.onSpreadsheetShared(data, model.exportXLSX());
|
||||||
|
this.state.url = url;
|
||||||
|
setTimeout(async () => {
|
||||||
|
await browser.navigator.clipboard.writeText(url);
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check whether the locale/global filters/contents have changed
|
||||||
|
* compared to the last time of sharing (in the same session)
|
||||||
|
*/
|
||||||
|
isChanged(data) {
|
||||||
|
const contentsChanged = data.revisionId !== this.lastRevisionId;
|
||||||
|
let globalFilterChanged = this.lastGlobalFilters === undefined;
|
||||||
|
const newCells = data.sheets[data.sheets.length - 1].cells;
|
||||||
|
if (this.lastGlobalFilters !== undefined) {
|
||||||
|
for (const key of Object.keys(newCells)) {
|
||||||
|
if (this.lastGlobalFilters[key]?.content !== newCells[key].content) {
|
||||||
|
globalFilterChanged = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const localeChanged = data.settings.locale.code !== this.lastLocale;
|
||||||
|
if (!(localeChanged || globalFilterChanged || contentsChanged)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.lastRevisionId = data.revisionId;
|
||||||
|
this.lastGlobalFilters = newCells;
|
||||||
|
this.lastLocale = data.settings.locale.code;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
4
static/src/components/share_button/share_button.scss
Normal file
4
static/src/components/share_button/share_button.scss
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
.spreadsheet_share_dropdown {
|
||||||
|
width: 320px;
|
||||||
|
height: 100px;
|
||||||
|
}
|
38
static/src/components/share_button/share_button.xml
Normal file
38
static/src/components/share_button/share_button.xml
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<templates>
|
||||||
|
|
||||||
|
<t t-name="spreadsheet.ShareButton">
|
||||||
|
<Dropdown
|
||||||
|
togglerClass="togglerClass"
|
||||||
|
menuClass="'spreadsheet_share_dropdown d-flex flex-column'"
|
||||||
|
position="'bottom-end'"
|
||||||
|
onOpened.bind="onOpened"
|
||||||
|
disabled="!props.model"
|
||||||
|
>
|
||||||
|
<t t-set-slot="toggler">
|
||||||
|
<i class="fa fa-share-alt"/>
|
||||||
|
Share
|
||||||
|
</t>
|
||||||
|
<t t-if="state.url">
|
||||||
|
<div class="d-flex px-3">
|
||||||
|
<div class="align-self-center d-flex justify-content-center align-items-center flex-shrink-0">
|
||||||
|
<i class="fa fa-globe fa-2x" title="Share to web"></i>
|
||||||
|
</div>
|
||||||
|
<div class="flex-grow-1 px-3">
|
||||||
|
<div class="lead">Spreadsheet published</div>
|
||||||
|
<div>Frozen version - Anyone can view</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class=" px-3 o_field_widget o_readonly_modifier o_field_CopyClipboardChar">
|
||||||
|
<div class="d-grid rounded-2 overflow-hidden">
|
||||||
|
<span t-out="state.url"/>
|
||||||
|
<CopyButton className="'o_btn_char_copy btn-sm'" content="state.url" successText="copiedText"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
<div t-else="" class="d-flex align-items-center justify-content-center h-100">
|
||||||
|
<i class="fa fa-spin fa-spinner px-2"/><span>Generating sharing link</span>
|
||||||
|
</div>
|
||||||
|
</Dropdown>
|
||||||
|
</t>
|
||||||
|
</templates>
|
69
static/src/currency/currency_data_source.js
Normal file
69
static/src/currency/currency_data_source.js
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
/** @odoo-module */
|
||||||
|
|
||||||
|
import { _t } from "@web/core/l10n/translation";
|
||||||
|
import { ServerData } from "../data_sources/server_data";
|
||||||
|
import { toServerDateString } from "../helpers/helpers";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef Currency
|
||||||
|
* @property {string} name
|
||||||
|
* @property {string} code
|
||||||
|
* @property {string} symbol
|
||||||
|
* @property {number} decimalPlaces
|
||||||
|
* @property {"before" | "after"} position
|
||||||
|
*/
|
||||||
|
export class CurrencyDataSource {
|
||||||
|
constructor(services) {
|
||||||
|
this.serverData = new ServerData(services.orm, {
|
||||||
|
whenDataIsFetched: () => services.notify(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the currency rate between the two given currencies
|
||||||
|
* @param {string} from Currency from
|
||||||
|
* @param {string} to Currency to
|
||||||
|
* @param {string|undefined} date
|
||||||
|
* @returns {number|undefined}
|
||||||
|
*/
|
||||||
|
getCurrencyRate(from, to, date) {
|
||||||
|
const data = this.serverData.batch.get("res.currency.rate", "get_rates_for_spreadsheet", {
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
date: date ? toServerDateString(date) : undefined,
|
||||||
|
});
|
||||||
|
const rate = data !== undefined ? data.rate : undefined;
|
||||||
|
if (rate === false) {
|
||||||
|
throw new Error(_t("Currency rate unavailable."));
|
||||||
|
}
|
||||||
|
return rate;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param {number|undefined} companyId
|
||||||
|
* @returns {Currency}
|
||||||
|
*/
|
||||||
|
getCompanyCurrencyFormat(companyId) {
|
||||||
|
const result = this.serverData.get("res.currency", "get_company_currency_for_spreadsheet", [
|
||||||
|
companyId,
|
||||||
|
]);
|
||||||
|
if (result === false) {
|
||||||
|
throw new Error(_t("Currency not available for this company."));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all currencies from the server
|
||||||
|
* @param {string} currencyName
|
||||||
|
* @returns {Currency}
|
||||||
|
*/
|
||||||
|
getCurrency(currencyName) {
|
||||||
|
return this.serverData.batch.get(
|
||||||
|
"res.currency",
|
||||||
|
"get_currencies_for_spreadsheet",
|
||||||
|
currencyName
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
25
static/src/currency/formulas.js
Normal file
25
static/src/currency/formulas.js
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
/** @odoo-module **/
|
||||||
|
|
||||||
|
import { _t } from "@web/core/l10n/translation";
|
||||||
|
import * as spreadsheet from "@odoo/o-spreadsheet";
|
||||||
|
const { arg, 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."
|
||||||
|
),
|
||||||
|
category: "Odoo",
|
||||||
|
compute: function (currencyFrom, currencyTo, date) {
|
||||||
|
const from = toString(currencyFrom);
|
||||||
|
const to = toString(currencyTo);
|
||||||
|
const _date = date ? toJsDate(date, this.locale) : undefined;
|
||||||
|
return this.getters.getCurrencyRate(from, to, _date);
|
||||||
|
},
|
||||||
|
args: [
|
||||||
|
arg("currency_from (string)", _t("First currency code.")),
|
||||||
|
arg("currency_to (string)", _t("Second currency code.")),
|
||||||
|
arg("date (date, optional)", _t("Date of the rate.")),
|
||||||
|
],
|
||||||
|
returns: ["NUMBER"],
|
||||||
|
});
|
17
static/src/currency/helpers.js
Normal file
17
static/src/currency/helpers.js
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
/** @odoo-module **/
|
||||||
|
|
||||||
|
import { helpers } from "@odoo/o-spreadsheet";
|
||||||
|
|
||||||
|
const { createCurrencyFormat } = helpers;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {object} currency
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
export function createDefaultCurrencyFormat(currency) {
|
||||||
|
return createCurrencyFormat({
|
||||||
|
symbol: currency.symbol,
|
||||||
|
position: currency.position,
|
||||||
|
decimalPlaces: currency.decimalPlaces,
|
||||||
|
});
|
||||||
|
}
|
90
static/src/currency/plugins/currency.js
Normal file
90
static/src/currency/plugins/currency.js
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
/** @odoo-module */
|
||||||
|
|
||||||
|
import { helpers, registries, UIPlugin } from "@odoo/o-spreadsheet";
|
||||||
|
import { CurrencyDataSource } from "../currency_data_source";
|
||||||
|
const { featurePluginRegistry } = registries;
|
||||||
|
const { createCurrencyFormat } = helpers;
|
||||||
|
|
||||||
|
const DATA_SOURCE_ID = "CURRENCIES";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {import("../currency_data_source").Currency} Currency
|
||||||
|
*/
|
||||||
|
|
||||||
|
class CurrencyPlugin extends UIPlugin {
|
||||||
|
constructor(config) {
|
||||||
|
super(config);
|
||||||
|
this.currentCompanyCurrencyFormat = config.defaultCurrencyFormat;
|
||||||
|
this.dataSources = config.custom.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;
|
||||||
|
}
|
||||||
|
return createCurrencyFormat({
|
||||||
|
symbol: currency.symbol,
|
||||||
|
position: currency.position,
|
||||||
|
decimalPlaces: currency.decimalPlaces,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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) {
|
||||||
|
if (!companyId && this.currentCompanyCurrencyFormat) {
|
||||||
|
return this.currentCompanyCurrencyFormat;
|
||||||
|
}
|
||||||
|
const currency =
|
||||||
|
this.dataSources &&
|
||||||
|
this.dataSources.get(DATA_SOURCE_ID).getCompanyCurrencyFormat(companyId);
|
||||||
|
return this.computeFormatFromCurrency(currency);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
CurrencyPlugin.getters = ["getCurrencyRate", "getCurrencyFormat", "getCompanyCurrencyFormat"];
|
||||||
|
|
||||||
|
featurePluginRegistry.add("odooCurrency", CurrencyPlugin);
|
103
static/src/data_sources/data_source.js
Normal file
103
static/src/data_sources/data_source.js
Normal file
@ -0,0 +1,103 @@
|
|||||||
|
/** @odoo-module */
|
||||||
|
|
||||||
|
import { LoadingDataError } from "@spreadsheet/o_spreadsheet/errors";
|
||||||
|
import { RPCError } from "@web/core/network/rpc_service";
|
||||||
|
import { KeepLast } from "@web/core/utils/concurrency";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DataSource is an abstract class that contains the logic of fetching and
|
||||||
|
* maintaining access to data that have to be loaded.
|
||||||
|
*
|
||||||
|
* A class which extends this class have to implement the `_load` method
|
||||||
|
* * which should load the data it needs
|
||||||
|
*
|
||||||
|
* Subclass can implement concrete methods to have access to a
|
||||||
|
* particular data.
|
||||||
|
*/
|
||||||
|
export class LoadableDataSource {
|
||||||
|
constructor(params) {
|
||||||
|
this._orm = params.orm;
|
||||||
|
this._metadataRepository = params.metadataRepository;
|
||||||
|
this._notifyWhenPromiseResolves = params.notifyWhenPromiseResolves;
|
||||||
|
this._cancelPromise = params.cancelPromise;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Last time that this dataSource has been updated
|
||||||
|
*/
|
||||||
|
this._lastUpdate = undefined;
|
||||||
|
|
||||||
|
this._concurrency = new KeepLast();
|
||||||
|
/**
|
||||||
|
* Promise to control the loading of data
|
||||||
|
*/
|
||||||
|
this._loadPromise = undefined;
|
||||||
|
this._isFullyLoaded = false;
|
||||||
|
this._isValid = true;
|
||||||
|
this._loadErrorMessage = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load data in the model
|
||||||
|
* @param {object} [params] Params for fetching data
|
||||||
|
* @param {boolean} [params.reload=false] Force the reload of the data
|
||||||
|
*
|
||||||
|
* @returns {Promise} Resolved when data are fetched.
|
||||||
|
*/
|
||||||
|
async load(params) {
|
||||||
|
if (params && params.reload) {
|
||||||
|
this._cancelPromise(this._loadPromise);
|
||||||
|
this._loadPromise = undefined;
|
||||||
|
}
|
||||||
|
if (!this._loadPromise) {
|
||||||
|
this._isFullyLoaded = false;
|
||||||
|
this._isValid = true;
|
||||||
|
this._loadErrorMessage = "";
|
||||||
|
this._loadPromise = this._concurrency
|
||||||
|
.add(this._load())
|
||||||
|
.catch((e) => {
|
||||||
|
this._isValid = false;
|
||||||
|
this._loadErrorMessage = e instanceof RPCError ? e.data.message : e.message;
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
this._lastUpdate = Date.now();
|
||||||
|
this._isFullyLoaded = true;
|
||||||
|
});
|
||||||
|
await this._notifyWhenPromiseResolves(this._loadPromise);
|
||||||
|
}
|
||||||
|
return this._loadPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
get lastUpdate() {
|
||||||
|
return this._lastUpdate;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
isReady() {
|
||||||
|
return this._isFullyLoaded;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @protected
|
||||||
|
*/
|
||||||
|
_assertDataIsLoaded() {
|
||||||
|
if (!this._isFullyLoaded) {
|
||||||
|
this.load();
|
||||||
|
throw LOADING_ERROR;
|
||||||
|
}
|
||||||
|
if (!this._isValid) {
|
||||||
|
throw new Error(this._loadErrorMessage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load the data in the model
|
||||||
|
*
|
||||||
|
* @abstract
|
||||||
|
* @protected
|
||||||
|
*/
|
||||||
|
async _load() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const LOADING_ERROR = new LoadingDataError();
|
140
static/src/data_sources/data_sources.js
Normal file
140
static/src/data_sources/data_sources.js
Normal file
@ -0,0 +1,140 @@
|
|||||||
|
/** @odoo-module */
|
||||||
|
|
||||||
|
import { LoadableDataSource } from "./data_source";
|
||||||
|
import { MetadataRepository } from "./metadata_repository";
|
||||||
|
|
||||||
|
import { EventBus } from "@odoo/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 {
|
||||||
|
/**
|
||||||
|
* @param {import("@web/env").OdooEnv} env
|
||||||
|
*/
|
||||||
|
constructor(env) {
|
||||||
|
super();
|
||||||
|
this._orm = env.services.orm.silent;
|
||||||
|
this._metadataRepository = new MetadataRepository(env);
|
||||||
|
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()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
136
static/src/data_sources/display_name_repository.js
Normal file
136
static/src/data_sources/display_name_repository.js
Normal file
@ -0,0 +1,136 @@
|
|||||||
|
/** @odoo-module */
|
||||||
|
|
||||||
|
import { _t } from "@web/core/l10n/translation";
|
||||||
|
import { LoadingDataError } from "../o_spreadsheet/errors";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef PendingDisplayName
|
||||||
|
* @property {"PENDING"} state
|
||||||
|
*
|
||||||
|
* @typedef ErrorDisplayName
|
||||||
|
* @property {"ERROR"} state
|
||||||
|
* @property {Error} error
|
||||||
|
*
|
||||||
|
* @typedef CompletedDisplayName
|
||||||
|
* @property {"COMPLETED"} state
|
||||||
|
* @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/env").OdooEnv} env
|
||||||
|
* @param {Object} params
|
||||||
|
* @param {function} params.whenDataIsFetched Callback to call when the
|
||||||
|
* display name of a record is fetched.
|
||||||
|
*/
|
||||||
|
constructor(env, { whenDataIsFetched }) {
|
||||||
|
this.dataFetchedCallback = whenDataIsFetched;
|
||||||
|
/**
|
||||||
|
* Contains the display names of records. It's organized in the following way:
|
||||||
|
* {
|
||||||
|
* "res.country": {
|
||||||
|
* 1: {
|
||||||
|
* "state": "COMPLETED"
|
||||||
|
* "value": "Belgium",
|
||||||
|
* },
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
/** @type {Object.<string, Object.<number, DisplayNameResult>>}*/
|
||||||
|
this._displayNames = {};
|
||||||
|
this._blockNotification = false;
|
||||||
|
this._nameService = env.services.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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) {
|
||||||
|
this._nameService.addDisplayNames(model, { [id]: displayName });
|
||||||
|
if (!this._displayNames[model]) {
|
||||||
|
this._displayNames[model] = {};
|
||||||
|
}
|
||||||
|
this._displayNames[model][id] = {
|
||||||
|
state: "COMPLETED",
|
||||||
|
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) {
|
||||||
|
if (!id) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
const displayNameResult = this._displayNames[model]?.[id];
|
||||||
|
if (!displayNameResult) {
|
||||||
|
// Catch the error to prevent the error from being thrown in the
|
||||||
|
// background.
|
||||||
|
this._fetchDisplayName(model, id);
|
||||||
|
throw new LoadingDataError();
|
||||||
|
}
|
||||||
|
switch (displayNameResult.state) {
|
||||||
|
case "ERROR":
|
||||||
|
throw displayNameResult.error;
|
||||||
|
case "COMPLETED":
|
||||||
|
return displayNameResult.value;
|
||||||
|
default:
|
||||||
|
throw new LoadingDataError();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This method is called when the display name of a record is not in the
|
||||||
|
* cache. It fetches the display name in the background.
|
||||||
|
*
|
||||||
|
* @param {string} model
|
||||||
|
* @param {number} id
|
||||||
|
*
|
||||||
|
* @private
|
||||||
|
* @returns {Deferred<string>}
|
||||||
|
*/
|
||||||
|
async _fetchDisplayName(model, id) {
|
||||||
|
if (!this._displayNames[model]) {
|
||||||
|
this._displayNames[model] = {};
|
||||||
|
}
|
||||||
|
this._displayNames[model][id] = { state: "PENDING" };
|
||||||
|
const displayNames = await this._nameService.loadDisplayNames(model, [id]);
|
||||||
|
if (typeof displayNames[id] === "string") {
|
||||||
|
this._displayNames[model][id].state = "COMPLETED";
|
||||||
|
this._displayNames[model][id].value = displayNames[id];
|
||||||
|
} else {
|
||||||
|
this._displayNames[model][id].state = "ERROR";
|
||||||
|
this._displayNames[model][id].error = new Error(
|
||||||
|
_t("Name not found. You may not have the required access rights.")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (this._blockNotification) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this._blockNotification = true;
|
||||||
|
await Promise.resolve();
|
||||||
|
this._blockNotification = false;
|
||||||
|
this.dataFetchedCallback();
|
||||||
|
}
|
||||||
|
}
|
54
static/src/data_sources/labels_repository.js
Normal file
54
static/src/data_sources/labels_repository.js
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
/** @odoo-module */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This class is responsible for keeping track of the labels of records. It
|
||||||
|
* caches the labels of records that have already been fetched.
|
||||||
|
* This class will not fetch the labels of records, it is the responsibility of
|
||||||
|
* the caller to fetch the labels and insert them in this repository.
|
||||||
|
*/
|
||||||
|
export class LabelsRepository {
|
||||||
|
constructor() {
|
||||||
|
/**
|
||||||
|
* Contains the labels of records. It's organized in the following way:
|
||||||
|
* {
|
||||||
|
* "crm.lead": {
|
||||||
|
* "city": {
|
||||||
|
* "bruxelles": "Bruxelles",
|
||||||
|
* }
|
||||||
|
* },
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
this._labels = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the label of a record.
|
||||||
|
* @param {string} model technical name of the model
|
||||||
|
* @param {string} field name of the field
|
||||||
|
* @param {any} value value of the field
|
||||||
|
*
|
||||||
|
* @returns {string|undefined} label of the record
|
||||||
|
*/
|
||||||
|
getLabel(model, field, value) {
|
||||||
|
return (
|
||||||
|
this._labels[model] && this._labels[model][field] && this._labels[model][field][value]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the label of a record.
|
||||||
|
* @param {string} model
|
||||||
|
* @param {string} field
|
||||||
|
* @param {string|number} value
|
||||||
|
* @param {string|undefined} label
|
||||||
|
*/
|
||||||
|
setLabel(model, field, value, label) {
|
||||||
|
if (!this._labels[model]) {
|
||||||
|
this._labels[model] = {};
|
||||||
|
}
|
||||||
|
if (!this._labels[model][field]) {
|
||||||
|
this._labels[model][field] = {};
|
||||||
|
}
|
||||||
|
this._labels[model][field][value] = label;
|
||||||
|
}
|
||||||
|
}
|
130
static/src/data_sources/metadata_repository.js
Normal file
130
static/src/data_sources/metadata_repository.js
Normal file
@ -0,0 +1,130 @@
|
|||||||
|
/** @odoo-module */
|
||||||
|
|
||||||
|
import { _t } from "@web/core/l10n/translation";
|
||||||
|
import { sprintf } from "@web/core/utils/strings";
|
||||||
|
import { ServerData } from "../data_sources/server_data";
|
||||||
|
|
||||||
|
import { LoadingDataError } from "../o_spreadsheet/errors";
|
||||||
|
import { DisplayNameRepository } from "./display_name_repository";
|
||||||
|
import { LabelsRepository } from "./labels_repository";
|
||||||
|
|
||||||
|
import { EventBus } from "@odoo/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 to read display_name 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 {
|
||||||
|
/**
|
||||||
|
* @param {import("@web/env").OdooEnv} env
|
||||||
|
*/
|
||||||
|
constructor(env) {
|
||||||
|
super();
|
||||||
|
this.orm = env.services.orm.silent;
|
||||||
|
this.nameService = env.services.name;
|
||||||
|
|
||||||
|
this.serverData = new ServerData(this.orm, {
|
||||||
|
whenDataIsFetched: () => this.trigger("labels-fetched"),
|
||||||
|
});
|
||||||
|
|
||||||
|
this.labelsRepository = new LabelsRepository();
|
||||||
|
|
||||||
|
this.displayNameRepository = new DisplayNameRepository(env, {
|
||||||
|
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 display_name read 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
132
static/src/data_sources/odoo_views_data_source.js
Normal file
132
static/src/data_sources/odoo_views_data_source.js
Normal file
@ -0,0 +1,132 @@
|
|||||||
|
/** @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<Field>|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));
|
||||||
|
const userContext = this._orm.user.context;
|
||||||
|
this._initialSearchParams.context = omit(
|
||||||
|
this._initialSearchParams.context || {},
|
||||||
|
...Object.keys(userContext)
|
||||||
|
);
|
||||||
|
/** @private */
|
||||||
|
this._customDomain = this._initialSearchParams.domain;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @protected
|
||||||
|
*/
|
||||||
|
get _searchParams() {
|
||||||
|
return {
|
||||||
|
...this._initialSearchParams,
|
||||||
|
domain: this.getComputedDomain(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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() {
|
||||||
|
const userContext = this._orm.user.context;
|
||||||
|
return new Domain(this._customDomain).toList({
|
||||||
|
...this._initialSearchParams.context,
|
||||||
|
...userContext,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the current domain as a string
|
||||||
|
* @returns { string }
|
||||||
|
*/
|
||||||
|
getInitialDomainString() {
|
||||||
|
return new Domain(this._initialSearchParams.domain).toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param {string} domain
|
||||||
|
*/
|
||||||
|
addDomain(domain) {
|
||||||
|
const newDomain = Domain.and([this._initialSearchParams.domain, domain]).toString();
|
||||||
|
if (newDomain.toString() === new Domain(this._customDomain).toString()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this._customDomain = newDomain;
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
312
static/src/data_sources/server_data.js
Normal file
312
static/src/data_sources/server_data.js
Normal file
@ -0,0 +1,312 @@
|
|||||||
|
/** @odoo-module */
|
||||||
|
import { LoadingDataError } from "../o_spreadsheet/errors";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {T[]} array
|
||||||
|
* @returns {T[]}
|
||||||
|
* @template T
|
||||||
|
*/
|
||||||
|
function removeDuplicates(array) {
|
||||||
|
return [...new Set(array.map((el) => JSON.stringify(el)))].map((el) => JSON.parse(el));
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Request {
|
||||||
|
/**
|
||||||
|
* @param {string} resModel
|
||||||
|
* @param {string} method
|
||||||
|
* @param {unknown[]} args
|
||||||
|
*/
|
||||||
|
constructor(resModel, method, args) {
|
||||||
|
this.resModel = resModel;
|
||||||
|
this.method = method;
|
||||||
|
this.args = args;
|
||||||
|
this.key = `${resModel}/${method}(${JSON.stringify(args)})`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A batch request consists of multiple requests which are combined into a single RPC.
|
||||||
|
*
|
||||||
|
* The batch responsibility is to combine individual requests into a single RPC payload
|
||||||
|
* and to split the response back for individual requests.
|
||||||
|
*
|
||||||
|
* The server method must have the following API:
|
||||||
|
* - The input is a list of arguments. Each list item being the arguments of a single request.
|
||||||
|
* - The output is a list of results, ordered according to the input list
|
||||||
|
*
|
||||||
|
* ```
|
||||||
|
* [result1, result2] = self.env['my.model'].my_batched_method([request_1_args, request_2_args])
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
class ListRequestBatch {
|
||||||
|
/**
|
||||||
|
* @param {string} resModel
|
||||||
|
* @param {string} method
|
||||||
|
* @param {Request[]} requests
|
||||||
|
*/
|
||||||
|
constructor(resModel, method, requests = []) {
|
||||||
|
this.resModel = resModel;
|
||||||
|
this.method = method;
|
||||||
|
this.requests = requests;
|
||||||
|
}
|
||||||
|
|
||||||
|
get payload() {
|
||||||
|
const payload = removeDuplicates(this.requests.map((request) => request.args).flat());
|
||||||
|
return [payload];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Request} request
|
||||||
|
*/
|
||||||
|
add(request) {
|
||||||
|
if (request.resModel !== this.resModel || request.method !== this.method) {
|
||||||
|
throw new Error(
|
||||||
|
`Request ${request.resModel}/${request.method} cannot be added to the batch ${this.resModel}/${this.method}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
this.requests.push(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Split the batched RPC response into single request results
|
||||||
|
*
|
||||||
|
* @param {T[]} results
|
||||||
|
* @returns {Map<Request, T>}
|
||||||
|
* @template T
|
||||||
|
*/
|
||||||
|
splitResponse(results) {
|
||||||
|
const split = new Map();
|
||||||
|
for (let i = 0; i < this.requests.length; i++) {
|
||||||
|
split.set(this.requests[i], results[i]);
|
||||||
|
}
|
||||||
|
return split;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ServerData {
|
||||||
|
/**
|
||||||
|
* @param {any} orm
|
||||||
|
* @param {object} params
|
||||||
|
* @param {function} params.whenDataIsFetched
|
||||||
|
*/
|
||||||
|
constructor(orm, { whenDataIsFetched }) {
|
||||||
|
this.orm = orm;
|
||||||
|
this.dataFetchedCallback = whenDataIsFetched;
|
||||||
|
/** @type {Record<string, unknown>}*/
|
||||||
|
this.cache = {};
|
||||||
|
/** @type {Record<string, Promise<unknown>>}*/
|
||||||
|
this.asyncCache = {};
|
||||||
|
this.batchEndpoints = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @returns {{get: (resModel:string, method: string, args: unknown) => any}}
|
||||||
|
*/
|
||||||
|
get batch() {
|
||||||
|
return { get: (resModel, method, args) => this._getBatchItem(resModel, method, args) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @private
|
||||||
|
* @param {string} resModel
|
||||||
|
* @param {string} method
|
||||||
|
* @param {unknown} args
|
||||||
|
* @returns {any}
|
||||||
|
*/
|
||||||
|
_getBatchItem(resModel, method, args) {
|
||||||
|
const request = new Request(resModel, method, [args]);
|
||||||
|
if (!(request.key in this.cache)) {
|
||||||
|
const error = new LoadingDataError();
|
||||||
|
this.cache[request.key] = error;
|
||||||
|
this._batch(request);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
return this._getOrThrowCachedResponse(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} resModel
|
||||||
|
* @param {string} method
|
||||||
|
* @param {unknown[]} args
|
||||||
|
* @returns {any}}
|
||||||
|
*/
|
||||||
|
get(resModel, method, args) {
|
||||||
|
const request = new Request(resModel, method, args);
|
||||||
|
if (!(request.key in this.cache)) {
|
||||||
|
const error = new LoadingDataError();
|
||||||
|
this.cache[request.key] = error;
|
||||||
|
this.orm
|
||||||
|
.call(resModel, method, args)
|
||||||
|
.then((result) => (this.cache[request.key] = result))
|
||||||
|
.catch((error) => (this.cache[request.key] = error))
|
||||||
|
.finally(() => this.dataFetchedCallback());
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
return this._getOrThrowCachedResponse(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the request result if cached or the associated promise
|
||||||
|
* @param {string} resModel
|
||||||
|
* @param {string} method
|
||||||
|
* @param {unknown[]} [args]
|
||||||
|
* @returns {Promise<any>}
|
||||||
|
*/
|
||||||
|
async fetch(resModel, method, args) {
|
||||||
|
const request = new Request(resModel, method, args);
|
||||||
|
if (!(request.key in this.asyncCache)) {
|
||||||
|
this.asyncCache[request.key] = this.orm.call(resModel, method, args);
|
||||||
|
}
|
||||||
|
return this.asyncCache[request.key];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @private
|
||||||
|
* @param {Request} request
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
_batch(request) {
|
||||||
|
const endpoint = this._getBatchEndPoint(request.resModel, request.method);
|
||||||
|
endpoint.call(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @private
|
||||||
|
* @param {Request} request
|
||||||
|
* @return {unknown}
|
||||||
|
*/
|
||||||
|
_getOrThrowCachedResponse(request) {
|
||||||
|
const data = this.cache[request.key];
|
||||||
|
if (data instanceof Error) {
|
||||||
|
throw data;
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @private
|
||||||
|
* @param {string} resModel
|
||||||
|
* @param {string} method
|
||||||
|
*/
|
||||||
|
_getBatchEndPoint(resModel, method) {
|
||||||
|
if (!this.batchEndpoints[resModel] || !this.batchEndpoints[resModel][method]) {
|
||||||
|
this.batchEndpoints[resModel] = {
|
||||||
|
...this.batchEndpoints[resModel],
|
||||||
|
[method]: this._createBatchEndpoint(resModel, method),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return this.batchEndpoints[resModel][method];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @private
|
||||||
|
* @param {string} resModel
|
||||||
|
* @param {string} method
|
||||||
|
*/
|
||||||
|
_createBatchEndpoint(resModel, method) {
|
||||||
|
return new BatchEndpoint(this.orm, resModel, method, {
|
||||||
|
whenDataIsFetched: () => this.dataFetchedCallback(),
|
||||||
|
successCallback: (request, result) => (this.cache[request.key] = result),
|
||||||
|
failureCallback: (request, error) => (this.cache[request.key] = error),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collect multiple requests into a single batch.
|
||||||
|
*/
|
||||||
|
export 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,40 @@
|
|||||||
|
/** @odoo-module */
|
||||||
|
|
||||||
|
import { Component } from "@odoo/owl";
|
||||||
|
import { _t } from "@web/core/l10n/translation";
|
||||||
|
import { DateTimeInput } from "@web/core/datetime/datetime_input";
|
||||||
|
import { serializeDate, deserializeDate } from "@web/core/l10n/dates";
|
||||||
|
|
||||||
|
export class DateFromToValue extends Component {
|
||||||
|
static template = "spreadsheet.DateFromToValue";
|
||||||
|
static components = { DateTimeInput };
|
||||||
|
static props = {
|
||||||
|
onFromToChanged: Function,
|
||||||
|
from: { type: String, optional: true },
|
||||||
|
to: { type: String, optional: true },
|
||||||
|
};
|
||||||
|
fromPlaceholder = _t("Date from...");
|
||||||
|
toPlaceholder = _t("Date to...");
|
||||||
|
|
||||||
|
onDateFromChanged(dateFrom) {
|
||||||
|
this.props.onFromToChanged({
|
||||||
|
from: dateFrom && serializeDate(dateFrom.startOf("day")),
|
||||||
|
to: this.props.to,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onDateToChanged(dateTo) {
|
||||||
|
this.props.onFromToChanged({
|
||||||
|
from: this.props.from,
|
||||||
|
to: dateTo && serializeDate(dateTo.endOf("day")),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
get dateFrom() {
|
||||||
|
return this.props.from && deserializeDate(this.props.from);
|
||||||
|
}
|
||||||
|
|
||||||
|
get dateTo() {
|
||||||
|
return this.props.to && deserializeDate(this.props.to);
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,18 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<templates>
|
||||||
|
<div t-name="spreadsheet.DateFromToValue" class="date_filter_values">
|
||||||
|
<DateTimeInput
|
||||||
|
value="dateFrom"
|
||||||
|
type="'date'"
|
||||||
|
placeholder="fromPlaceholder"
|
||||||
|
onChange.bind="onDateFromChanged"
|
||||||
|
/>
|
||||||
|
<i class="oi oi-arrow-right"></i>
|
||||||
|
<DateTimeInput
|
||||||
|
value="dateTo"
|
||||||
|
type="'date'"
|
||||||
|
placeholder="toPlaceholder"
|
||||||
|
onChange.bind="onDateToChanged"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</templates>
|
@ -0,0 +1,76 @@
|
|||||||
|
/** @odoo-module */
|
||||||
|
|
||||||
|
import { Component, onWillUpdateProps } from "@odoo/owl";
|
||||||
|
import { DateTimeInput } from "@web/core/datetime/datetime_input";
|
||||||
|
import { FILTER_DATE_OPTION, monthsOptions } from "@spreadsheet/assets_backend/constants";
|
||||||
|
import { getPeriodOptions } from "@web/search/utils/dates";
|
||||||
|
|
||||||
|
const { DateTime } = luxon;
|
||||||
|
|
||||||
|
export class DateFilterValue extends Component {
|
||||||
|
setup() {
|
||||||
|
this._setStateFromProps(this.props);
|
||||||
|
onWillUpdateProps(this._setStateFromProps);
|
||||||
|
this.dateOptions = this.getDateOptions();
|
||||||
|
}
|
||||||
|
_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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a list of time options to choose from according to the requested
|
||||||
|
* type. Each option contains its (translated) description.
|
||||||
|
* see getPeriodOptions
|
||||||
|
*
|
||||||
|
* @returns {Array<Object>}
|
||||||
|
*/
|
||||||
|
getDateOptions() {
|
||||||
|
const periodOptions = getPeriodOptions(DateTime.local());
|
||||||
|
const quarters = FILTER_DATE_OPTION["quarter"].map((quarterId) =>
|
||||||
|
periodOptions.find((option) => option.id === quarterId)
|
||||||
|
);
|
||||||
|
return quarters.concat(monthsOptions);
|
||||||
|
}
|
||||||
|
|
||||||
|
isSelected(periodId) {
|
||||||
|
return this.period === periodId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Event & { target: HTMLSelectElement }} ev
|
||||||
|
*/
|
||||||
|
onPeriodChanged(ev) {
|
||||||
|
this.period = ev.target.value;
|
||||||
|
this._updateFilter();
|
||||||
|
}
|
||||||
|
|
||||||
|
onYearChanged(date) {
|
||||||
|
this.date = date;
|
||||||
|
this.yearOffset = 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 = { DateTimeInput };
|
||||||
|
|
||||||
|
DateFilterValue.props = {
|
||||||
|
// See @spreadsheet_edition/bundle/global_filters/filters_plugin.RangeType
|
||||||
|
onTimeRangeChanged: Function,
|
||||||
|
yearOffset: { type: Number, optional: true },
|
||||||
|
period: { type: String, optional: true },
|
||||||
|
};
|
@ -0,0 +1,24 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<templates>
|
||||||
|
<div t-name="spreadsheet_edition.DateFilterValue" class="date_filter_values">
|
||||||
|
<select class="o_input me-3" t-on-change="onPeriodChanged">
|
||||||
|
<option value="empty">Select period...</option>
|
||||||
|
<t t-foreach="dateOptions" 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>
|
||||||
|
<DateTimeInput
|
||||||
|
value="date"
|
||||||
|
format="'yyyy'"
|
||||||
|
minPrecision="'years'"
|
||||||
|
type="'date'"
|
||||||
|
placeholder="'Select year...'"
|
||||||
|
onChange.bind="onYearChanged"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</templates>
|
@ -0,0 +1,26 @@
|
|||||||
|
/** @odoo-module */
|
||||||
|
|
||||||
|
import { Component } from "@odoo/owl";
|
||||||
|
import { _t } from "@web/core/l10n/translation";
|
||||||
|
|
||||||
|
export class TextFilterValue extends Component {
|
||||||
|
static template = "spreadsheet.TextFilterValue";
|
||||||
|
static props = {
|
||||||
|
label: { type: String, optional: true },
|
||||||
|
onValueChanged: Function,
|
||||||
|
value: { type: String, optional: true },
|
||||||
|
options: {
|
||||||
|
type: Array,
|
||||||
|
element: {
|
||||||
|
type: Object,
|
||||||
|
shape: { value: String, formattedValue: String },
|
||||||
|
optional: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
translate(label) {
|
||||||
|
// the filter label is extracted from the spreadsheet json file.
|
||||||
|
return _t(label);
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,29 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<templates>
|
||||||
|
<t t-name="spreadsheet.TextFilterValue">
|
||||||
|
<select t-if="props.options?.length"
|
||||||
|
t-on-change="(e) => props.onValueChanged(e.target.value)"
|
||||||
|
class="o_input me-3"
|
||||||
|
required="true"
|
||||||
|
>
|
||||||
|
<option value="">Choose a value...</option>
|
||||||
|
<t
|
||||||
|
t-foreach="props.options"
|
||||||
|
t-as="option"
|
||||||
|
t-key="option.formattedValue"
|
||||||
|
>
|
||||||
|
<option
|
||||||
|
t-att-selected="option.value === props.value"
|
||||||
|
t-att-value="option.value"
|
||||||
|
t-esc="option.formattedValue"
|
||||||
|
/>
|
||||||
|
</t>
|
||||||
|
</select>
|
||||||
|
<input type="text"
|
||||||
|
t-else=""
|
||||||
|
class="o_input o-global-filter-text-value text-truncate"
|
||||||
|
t-att-placeholder="translate(props.label)"
|
||||||
|
t-att-value="props.value"
|
||||||
|
t-on-change="(e) => props.onValueChanged(e.target.value)" />
|
||||||
|
</t>
|
||||||
|
</templates>
|
@ -0,0 +1,70 @@
|
|||||||
|
/** @odoo-module */
|
||||||
|
|
||||||
|
import { MultiRecordSelector } from "@web/core/record_selectors/multi_record_selector";
|
||||||
|
import { RELATIVE_DATE_RANGE_TYPES } from "@spreadsheet/helpers/constants";
|
||||||
|
import { DateFilterValue } from "../filter_date_value/filter_date_value";
|
||||||
|
import { DateFromToValue } from "../filter_date_from_to_value/filter_date_from_to_value";
|
||||||
|
|
||||||
|
import { Component } from "@odoo/owl";
|
||||||
|
import { _t } from "@web/core/l10n/translation";
|
||||||
|
import { useService } from "@web/core/utils/hooks";
|
||||||
|
import { TextFilterValue } from "../filter_text_value/filter_text_value";
|
||||||
|
|
||||||
|
export class FilterValue extends Component {
|
||||||
|
setup() {
|
||||||
|
this.getters = this.props.model.getters;
|
||||||
|
this.relativeDateRangesTypes = RELATIVE_DATE_RANGE_TYPES;
|
||||||
|
this.nameService = useService("name");
|
||||||
|
}
|
||||||
|
|
||||||
|
get filter() {
|
||||||
|
return this.props.filter;
|
||||||
|
}
|
||||||
|
|
||||||
|
get filterValue() {
|
||||||
|
return this.getters.getGlobalFilterValue(this.filter.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
get textAllowedValues() {
|
||||||
|
return this.getters.getTextFilterOptions(this.filter.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
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, resIds) {
|
||||||
|
if (!resIds.length) {
|
||||||
|
// force clear, even automatic default values
|
||||||
|
this.clear(id);
|
||||||
|
} else {
|
||||||
|
const displayNames = await this.nameService.loadDisplayNames(
|
||||||
|
this.filter.modelName,
|
||||||
|
resIds
|
||||||
|
);
|
||||||
|
this.props.model.dispatch("SET_GLOBAL_FILTER_VALUE", {
|
||||||
|
id,
|
||||||
|
value: resIds,
|
||||||
|
displayNames: Object.values(displayNames),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
translate(text) {
|
||||||
|
return _t(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
clear(id) {
|
||||||
|
this.props.model.dispatch("CLEAR_GLOBAL_FILTER_VALUE", { id });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
FilterValue.template = "spreadsheet_edition.FilterValue";
|
||||||
|
FilterValue.components = { DateFilterValue, DateFromToValue, MultiRecordSelector, TextFilterValue };
|
||||||
|
FilterValue.props = {
|
||||||
|
filter: Object,
|
||||||
|
model: Object,
|
||||||
|
};
|
@ -0,0 +1,10 @@
|
|||||||
|
.o-filter-value {
|
||||||
|
.o_datepicker_input {
|
||||||
|
color: $o-main-text-color;
|
||||||
|
}
|
||||||
|
.date_filter_values {
|
||||||
|
display: flex;
|
||||||
|
gap: map-get($spacers, 2);
|
||||||
|
align-items: baseline;
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,52 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<templates>
|
||||||
|
<t t-name="spreadsheet_edition.FilterValue"
|
||||||
|
>
|
||||||
|
<div class="o-filter-value d-flex align-items-start w-100">
|
||||||
|
<div t-if="filter.type === 'text'" class="w-100">
|
||||||
|
<TextFilterValue
|
||||||
|
value="filterValue"
|
||||||
|
label="filter.label"
|
||||||
|
options="textAllowedValues"
|
||||||
|
onValueChanged="(value) => this.onTextInput(filter.id, value)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span t-if="filter.type === 'relation'" class="w-100">
|
||||||
|
<MultiRecordSelector
|
||||||
|
placeholder="' ' + translate(filter.label)"
|
||||||
|
resModel="filter.modelName"
|
||||||
|
resIds="filterValue || []"
|
||||||
|
update="(resIds) => this.onTagSelected(filter.id, resIds)" />
|
||||||
|
</span>
|
||||||
|
<div t-if="filter.type === 'date'" class="w-100">
|
||||||
|
<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"
|
||||||
|
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>
|
||||||
|
<DateFromToValue t-elif="filter.rangeType === 'from_to'"
|
||||||
|
from="filterValue?.from"
|
||||||
|
to="filterValue?.to"
|
||||||
|
onFromToChanged="(value) => this.onDateInput(filter.id, value)"/>
|
||||||
|
|
||||||
|
<DateFilterValue t-else=""
|
||||||
|
period="filterValue?.period"
|
||||||
|
yearOffset="filterValue?.yearOffset"
|
||||||
|
onTimeRangeChanged="(value) => this.onDateInput(filter.id, value)"/>
|
||||||
|
</div>
|
||||||
|
<i t-if="getters.isGlobalFilterActive(filter.id)"
|
||||||
|
class="fa fa-times btn btn-link text-muted o_side_panel_filter_icon ms-1"
|
||||||
|
title="Clear"
|
||||||
|
t-on-click="() => this.clear(filter.id)"/>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
</templates>
|
137
static/src/global_filters/helpers.js
Normal file
137
static/src/global_filters/helpers.js
Normal file
@ -0,0 +1,137 @@
|
|||||||
|
/** @odoo-module */
|
||||||
|
|
||||||
|
import { serializeDate, serializeDateTime } from "@web/core/l10n/dates";
|
||||||
|
import { Domain } from "@web/core/domain";
|
||||||
|
|
||||||
|
import { CommandResult } from "@spreadsheet/o_spreadsheet/cancelled_reason";
|
||||||
|
import { RELATIVE_DATE_RANGE_TYPES } from "@spreadsheet/helpers/constants";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {import("@spreadsheet/global_filters/plugins/global_filters_core_plugin").FieldMatching} FieldMatching
|
||||||
|
*/
|
||||||
|
|
||||||
|
export function checkFiltersTypeValueCombination(type, value) {
|
||||||
|
if (value !== undefined) {
|
||||||
|
switch (type) {
|
||||||
|
case "text":
|
||||||
|
if (typeof value !== "string") {
|
||||||
|
return CommandResult.InvalidValueTypeCombination;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "date": {
|
||||||
|
if (value === "") {
|
||||||
|
return CommandResult.Success;
|
||||||
|
} else if (typeof value === "string") {
|
||||||
|
const expectedValues = RELATIVE_DATE_RANGE_TYPES.map((val) => val.type);
|
||||||
|
expectedValues.push("this_month", "this_quarter", "this_year");
|
||||||
|
if (expectedValues.includes(value)) {
|
||||||
|
return CommandResult.Success;
|
||||||
|
}
|
||||||
|
return CommandResult.InvalidValueTypeCombination;
|
||||||
|
} else if (typeof value !== "object") {
|
||||||
|
return CommandResult.InvalidValueTypeCombination;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "relation":
|
||||||
|
if (value === "current_user") {
|
||||||
|
return CommandResult.Success;
|
||||||
|
}
|
||||||
|
if (!Array.isArray(value)) {
|
||||||
|
return CommandResult.InvalidValueTypeCombination;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return CommandResult.Success;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param {Record<string, FieldMatching>} fieldMatchings
|
||||||
|
*/
|
||||||
|
export function checkFilterFieldMatching(fieldMatchings) {
|
||||||
|
for (const fieldMatch of Object.values(fieldMatchings)) {
|
||||||
|
if (fieldMatch.offset && (!fieldMatch.chain || !fieldMatch.type)) {
|
||||||
|
return CommandResult.InvalidFieldMatch;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return CommandResult.Success;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a date domain relative to the current date.
|
||||||
|
* The domain will span the amount of time specified in rangeType and end the day before the current day.
|
||||||
|
*
|
||||||
|
*
|
||||||
|
* @param {Object} now current time, as luxon time
|
||||||
|
* @param {number} offset offset to add to the date
|
||||||
|
* @param {"last_month" | "last_week" | "last_year" | "last_three_years"} rangeType
|
||||||
|
* @param {string} fieldName
|
||||||
|
* @param {"date" | "datetime"} fieldType
|
||||||
|
*
|
||||||
|
* @returns {Domain|undefined}
|
||||||
|
*/
|
||||||
|
export function getRelativeDateDomain(now, offset, rangeType, fieldName, fieldType) {
|
||||||
|
const startOfNextDay = now.plus({ days: 1 }).startOf("day");
|
||||||
|
let endDate = now.endOf("day");
|
||||||
|
let startDate = endDate;
|
||||||
|
switch (rangeType) {
|
||||||
|
case "year_to_date": {
|
||||||
|
const offsetParam = { years: offset };
|
||||||
|
startDate = now.startOf("year").plus(offsetParam);
|
||||||
|
endDate = now.endOf("day").plus(offsetParam);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "last_week": {
|
||||||
|
const offsetParam = { days: 7 * offset };
|
||||||
|
endDate = endDate.plus(offsetParam);
|
||||||
|
startDate = startOfNextDay.minus({ days: 7 }).plus(offsetParam);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "last_month": {
|
||||||
|
const offsetParam = { days: 30 * offset };
|
||||||
|
endDate = endDate.plus(offsetParam);
|
||||||
|
startDate = startOfNextDay.minus({ days: 30 }).plus(offsetParam);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "last_three_months": {
|
||||||
|
const offsetParam = { days: 90 * offset };
|
||||||
|
endDate = endDate.plus(offsetParam);
|
||||||
|
startDate = startOfNextDay.minus({ days: 90 }).plus(offsetParam);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "last_six_months": {
|
||||||
|
const offsetParam = { days: 180 * offset };
|
||||||
|
endDate = endDate.plus(offsetParam);
|
||||||
|
startDate = startOfNextDay.minus({ days: 180 }).plus(offsetParam);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "last_year": {
|
||||||
|
const offsetParam = { days: 365 * offset };
|
||||||
|
endDate = endDate.plus(offsetParam);
|
||||||
|
startDate = startOfNextDay.minus({ days: 365 }).plus(offsetParam);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "last_three_years": {
|
||||||
|
const offsetParam = { days: 3 * 365 * offset };
|
||||||
|
endDate = endDate.plus(offsetParam);
|
||||||
|
startDate = startOfNextDay.minus({ days: 3 * 365 }).plus(offsetParam);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
let leftBound, rightBound;
|
||||||
|
if (fieldType === "date") {
|
||||||
|
leftBound = serializeDate(startDate);
|
||||||
|
rightBound = serializeDate(endDate);
|
||||||
|
} else {
|
||||||
|
leftBound = serializeDateTime(startDate);
|
||||||
|
rightBound = serializeDateTime(endDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Domain(["&", [fieldName, ">=", leftBound], [fieldName, "<=", rightBound]]);
|
||||||
|
}
|
68
static/src/global_filters/index.js
Normal file
68
static/src/global_filters/index.js
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
/** @odoo-module */
|
||||||
|
|
||||||
|
import * as spreadsheet from "@odoo/o-spreadsheet";
|
||||||
|
|
||||||
|
import { GlobalFiltersUIPlugin } from "./plugins/global_filters_ui_plugin";
|
||||||
|
import { GlobalFiltersCorePlugin } from "./plugins/global_filters_core_plugin";
|
||||||
|
const { inverseCommandRegistry } = spreadsheet.registries;
|
||||||
|
|
||||||
|
function identity(cmd) {
|
||||||
|
return [cmd];
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
coreTypes,
|
||||||
|
invalidateEvaluationCommands,
|
||||||
|
invalidateCFEvaluationCommands,
|
||||||
|
invalidateDependenciesCommands,
|
||||||
|
readonlyAllowedCommands,
|
||||||
|
} = spreadsheet;
|
||||||
|
|
||||||
|
coreTypes.add("ADD_GLOBAL_FILTER");
|
||||||
|
coreTypes.add("EDIT_GLOBAL_FILTER");
|
||||||
|
coreTypes.add("REMOVE_GLOBAL_FILTER");
|
||||||
|
coreTypes.add("MOVE_GLOBAL_FILTER");
|
||||||
|
|
||||||
|
invalidateEvaluationCommands.add("ADD_GLOBAL_FILTER");
|
||||||
|
invalidateEvaluationCommands.add("EDIT_GLOBAL_FILTER");
|
||||||
|
invalidateEvaluationCommands.add("REMOVE_GLOBAL_FILTER");
|
||||||
|
invalidateEvaluationCommands.add("SET_GLOBAL_FILTER_VALUE");
|
||||||
|
invalidateEvaluationCommands.add("CLEAR_GLOBAL_FILTER_VALUE");
|
||||||
|
|
||||||
|
invalidateDependenciesCommands.add("ADD_GLOBAL_FILTER");
|
||||||
|
invalidateDependenciesCommands.add("EDIT_GLOBAL_FILTER");
|
||||||
|
invalidateDependenciesCommands.add("REMOVE_GLOBAL_FILTER");
|
||||||
|
invalidateDependenciesCommands.add("SET_GLOBAL_FILTER_VALUE");
|
||||||
|
invalidateDependenciesCommands.add("CLEAR_GLOBAL_FILTER_VALUE");
|
||||||
|
|
||||||
|
invalidateCFEvaluationCommands.add("ADD_GLOBAL_FILTER");
|
||||||
|
invalidateCFEvaluationCommands.add("EDIT_GLOBAL_FILTER");
|
||||||
|
invalidateCFEvaluationCommands.add("REMOVE_GLOBAL_FILTER");
|
||||||
|
invalidateCFEvaluationCommands.add("SET_GLOBAL_FILTER_VALUE");
|
||||||
|
invalidateCFEvaluationCommands.add("CLEAR_GLOBAL_FILTER_VALUE");
|
||||||
|
|
||||||
|
readonlyAllowedCommands.add("SET_GLOBAL_FILTER_VALUE");
|
||||||
|
readonlyAllowedCommands.add("SET_MANY_GLOBAL_FILTER_VALUE");
|
||||||
|
readonlyAllowedCommands.add("CLEAR_GLOBAL_FILTER_VALUE");
|
||||||
|
readonlyAllowedCommands.add("UPDATE_OBJECT_DOMAINS");
|
||||||
|
|
||||||
|
inverseCommandRegistry
|
||||||
|
.add("EDIT_GLOBAL_FILTER", identity)
|
||||||
|
.add("ADD_GLOBAL_FILTER", (cmd) => {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
type: "REMOVE_GLOBAL_FILTER",
|
||||||
|
id: cmd.filter.id,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
})
|
||||||
|
.add("REMOVE_GLOBAL_FILTER", (cmd) => {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
type: "ADD_GLOBAL_FILTER",
|
||||||
|
filter: {},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
export { GlobalFiltersCorePlugin, GlobalFiltersUIPlugin };
|
364
static/src/global_filters/plugins/global_filters_core_plugin.js
Normal file
364
static/src/global_filters/plugins/global_filters_core_plugin.js
Normal file
@ -0,0 +1,364 @@
|
|||||||
|
/** @odoo-module */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {"fixedPeriod"|"relative"|"from_to"} RangeType
|
||||||
|
*
|
||||||
|
* @typedef {"last_month" | "last_week" | "last_year" | "last_three_years" | "this_month" | "this_quarter" | "this_year"} RelativePeriod
|
||||||
|
*
|
||||||
|
* @typedef {Object} FieldMatching
|
||||||
|
* @property {string} chain name of the field
|
||||||
|
* @property {string} type type of the field
|
||||||
|
* @property {number} [offset] offset to apply to the field (for date filters)
|
||||||
|
*
|
||||||
|
* @typedef TextGlobalFilter
|
||||||
|
* @property {"text"} type
|
||||||
|
* @property {string} id
|
||||||
|
* @property {string} label
|
||||||
|
* @property {object} [rangeOfAllowedValues]
|
||||||
|
* @property {string} [defaultValue]
|
||||||
|
*
|
||||||
|
* @typedef DateGlobalFilter
|
||||||
|
* @property {"date"} type
|
||||||
|
* @property {string} id
|
||||||
|
* @property {string} label
|
||||||
|
* @property {RangeType} rangeType
|
||||||
|
* @property {RelativePeriod} [defaultValue]
|
||||||
|
*
|
||||||
|
* @typedef RelationalGlobalFilter
|
||||||
|
* @property {"relation"} type
|
||||||
|
* @property {string} id
|
||||||
|
* @property {string} label
|
||||||
|
* @property {string} modelName
|
||||||
|
* @property {"current_user" | number[]} [defaultValue]
|
||||||
|
*
|
||||||
|
* @typedef {TextGlobalFilter | DateGlobalFilter | RelationalGlobalFilter} GlobalFilter
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const globalFiltersFieldMatchers = {};
|
||||||
|
|
||||||
|
import * as spreadsheet from "@odoo/o-spreadsheet";
|
||||||
|
import { CommandResult } from "@spreadsheet/o_spreadsheet/cancelled_reason";
|
||||||
|
import { checkFiltersTypeValueCombination } from "@spreadsheet/global_filters/helpers";
|
||||||
|
import { _t } from "@web/core/l10n/translation";
|
||||||
|
import { escapeRegExp } from "@web/core/utils/strings";
|
||||||
|
|
||||||
|
export class GlobalFiltersCorePlugin extends spreadsheet.CorePlugin {
|
||||||
|
constructor(config) {
|
||||||
|
super(config);
|
||||||
|
/** @type {Array.<GlobalFilter>} */
|
||||||
|
this.globalFilters = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the given command can be dispatched
|
||||||
|
*
|
||||||
|
* @param {Object} cmd Command
|
||||||
|
*/
|
||||||
|
allowDispatch(cmd) {
|
||||||
|
switch (cmd.type) {
|
||||||
|
case "EDIT_GLOBAL_FILTER":
|
||||||
|
if (!this.getGlobalFilter(cmd.filter.id)) {
|
||||||
|
return CommandResult.FilterNotFound;
|
||||||
|
} else if (this._isDuplicatedLabel(cmd.filter.id, cmd.filter.label)) {
|
||||||
|
return CommandResult.DuplicatedFilterLabel;
|
||||||
|
}
|
||||||
|
return checkFiltersTypeValueCombination(cmd.filter.type, cmd.filter.defaultValue);
|
||||||
|
case "REMOVE_GLOBAL_FILTER":
|
||||||
|
if (!this.getGlobalFilter(cmd.id)) {
|
||||||
|
return CommandResult.FilterNotFound;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "ADD_GLOBAL_FILTER":
|
||||||
|
if (this._isDuplicatedLabel(cmd.filter.id, cmd.filter.label)) {
|
||||||
|
return CommandResult.DuplicatedFilterLabel;
|
||||||
|
}
|
||||||
|
return checkFiltersTypeValueCombination(cmd.filter.type, cmd.filter.defaultValue);
|
||||||
|
case "MOVE_GLOBAL_FILTER": {
|
||||||
|
const index = this.globalFilters.findIndex((filter) => filter.id === cmd.id);
|
||||||
|
if (index === -1) {
|
||||||
|
return CommandResult.FilterNotFound;
|
||||||
|
}
|
||||||
|
const targetIndex = index + cmd.delta;
|
||||||
|
if (targetIndex < 0 || targetIndex >= this.globalFilters.length) {
|
||||||
|
return CommandResult.InvalidFilterMove;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return CommandResult.Success;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle a spreadsheet command
|
||||||
|
*
|
||||||
|
* @param {Object} cmd Command
|
||||||
|
*/
|
||||||
|
handle(cmd) {
|
||||||
|
switch (cmd.type) {
|
||||||
|
case "ADD_GLOBAL_FILTER": {
|
||||||
|
const filter = { ...cmd.filter };
|
||||||
|
if (filter.type === "text" && filter.rangeOfAllowedValues) {
|
||||||
|
filter.rangeOfAllowedValues = this.getters.getRangeFromRangeData(
|
||||||
|
filter.rangeOfAllowedValues
|
||||||
|
);
|
||||||
|
}
|
||||||
|
this.history.update("globalFilters", [...this.globalFilters, filter]);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "EDIT_GLOBAL_FILTER": {
|
||||||
|
this._editGlobalFilter(cmd.filter);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "REMOVE_GLOBAL_FILTER": {
|
||||||
|
const filters = this.globalFilters.filter((filter) => filter.id !== cmd.id);
|
||||||
|
this.history.update("globalFilters", filters);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "MOVE_GLOBAL_FILTER":
|
||||||
|
this._onMoveFilter(cmd.id, cmd.delta);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
adaptRanges(applyChange) {
|
||||||
|
for (const filterIndex in this.globalFilters) {
|
||||||
|
const filter = this.globalFilters[filterIndex];
|
||||||
|
if (filter.type === "text" && filter.rangeOfAllowedValues) {
|
||||||
|
const change = applyChange(filter.rangeOfAllowedValues);
|
||||||
|
switch (change.changeType) {
|
||||||
|
case "REMOVE": {
|
||||||
|
this.history.update(
|
||||||
|
"globalFilters",
|
||||||
|
filterIndex,
|
||||||
|
"rangeOfAllowedValues",
|
||||||
|
undefined
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "RESIZE":
|
||||||
|
case "MOVE":
|
||||||
|
case "CHANGE": {
|
||||||
|
this.history.update(
|
||||||
|
"globalFilters",
|
||||||
|
filterIndex,
|
||||||
|
"rangeOfAllowedValues",
|
||||||
|
change.range
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// Getters
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieve the global filter with the given id
|
||||||
|
*
|
||||||
|
* @param {string} id
|
||||||
|
* @returns {GlobalFilter|undefined} Global filter
|
||||||
|
*/
|
||||||
|
getGlobalFilter(id) {
|
||||||
|
return this.globalFilters.find((filter) => filter.id === id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the global filter with the given name
|
||||||
|
*
|
||||||
|
* @param {string} label Label
|
||||||
|
*
|
||||||
|
* @returns {GlobalFilter|undefined}
|
||||||
|
*/
|
||||||
|
getGlobalFilterLabel(label) {
|
||||||
|
return this.globalFilters.find((filter) => _t(filter.label) === _t(label));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieve all the global filters
|
||||||
|
*
|
||||||
|
* @returns {Array<GlobalFilter>} Array of Global filters
|
||||||
|
*/
|
||||||
|
getGlobalFilters() {
|
||||||
|
return [...this.globalFilters];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the default value of a global filter
|
||||||
|
*
|
||||||
|
* @param {string} id Id of the filter
|
||||||
|
*
|
||||||
|
* @returns {string|Array<string>|Object}
|
||||||
|
*/
|
||||||
|
getGlobalFilterDefaultValue(id) {
|
||||||
|
return this.getGlobalFilter(id).defaultValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the field matching for a given model by copying the matchings of another DataSource that
|
||||||
|
* share the same model, including only the chain and type.
|
||||||
|
*/
|
||||||
|
getFieldMatchingForModel(newModel) {
|
||||||
|
const globalFilters = this.getGlobalFilters();
|
||||||
|
if (globalFilters.length === 0) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const matcher of Object.values(globalFiltersFieldMatchers)) {
|
||||||
|
for (const dataSourceId of matcher.getIds()) {
|
||||||
|
const model = matcher.getModel(dataSourceId);
|
||||||
|
if (model === newModel) {
|
||||||
|
const fieldMatching = {};
|
||||||
|
for (const filter of globalFilters) {
|
||||||
|
const matchedField = matcher.getFieldMatching(dataSourceId, filter.id);
|
||||||
|
if (matchedField) {
|
||||||
|
fieldMatching[filter.id] = {
|
||||||
|
chain: matchedField.chain,
|
||||||
|
type: matchedField.type,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fieldMatching;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// Handlers
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Edit a global filter
|
||||||
|
*
|
||||||
|
* @param {GlobalFilter} newFilter
|
||||||
|
*/
|
||||||
|
_editGlobalFilter(newFilter) {
|
||||||
|
newFilter = { ...newFilter };
|
||||||
|
if (newFilter.type === "text" && newFilter.rangeOfAllowedValues) {
|
||||||
|
newFilter.rangeOfAllowedValues = this.getters.getRangeFromRangeData(
|
||||||
|
newFilter.rangeOfAllowedValues
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const id = newFilter.id;
|
||||||
|
const currentLabel = this.getGlobalFilter(id).label;
|
||||||
|
const index = this.globalFilters.findIndex((filter) => filter.id === id);
|
||||||
|
if (index === -1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.history.update("globalFilters", index, newFilter);
|
||||||
|
const newLabel = this.getGlobalFilter(id).label;
|
||||||
|
if (currentLabel !== newLabel) {
|
||||||
|
this._updateFilterLabelInFormulas(currentLabel, newLabel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// Import/Export
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Import the filters
|
||||||
|
*
|
||||||
|
* @param {Object} data
|
||||||
|
*/
|
||||||
|
import(data) {
|
||||||
|
for (const globalFilter of data.globalFilters || []) {
|
||||||
|
if (globalFilter.type === "text" && globalFilter.rangeOfAllowedValues) {
|
||||||
|
globalFilter.rangeOfAllowedValues = this.getters.getRangeFromSheetXC(
|
||||||
|
"", // there's no default sheet, global filters are cross-sheet
|
||||||
|
globalFilter.rangeOfAllowedValues
|
||||||
|
);
|
||||||
|
}
|
||||||
|
this.globalFilters.push(globalFilter);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Export the filters
|
||||||
|
*
|
||||||
|
* @param {Object} data
|
||||||
|
*/
|
||||||
|
export(data) {
|
||||||
|
data.globalFilters = this.globalFilters.map((filter) => {
|
||||||
|
filter = { ...filter };
|
||||||
|
if (filter.type === "text" && filter.rangeOfAllowedValues) {
|
||||||
|
filter.rangeOfAllowedValues = this.getters.getRangeString(
|
||||||
|
filter.rangeOfAllowedValues
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return filter;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// Global filters
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update all ODOO.FILTER.VALUE formulas to reference a filter
|
||||||
|
* by its new label.
|
||||||
|
*
|
||||||
|
* @param {string} currentLabel
|
||||||
|
* @param {string} newLabel
|
||||||
|
*/
|
||||||
|
_updateFilterLabelInFormulas(currentLabel, newLabel) {
|
||||||
|
const sheetIds = this.getters.getSheetIds();
|
||||||
|
currentLabel = escapeRegExp(currentLabel);
|
||||||
|
for (const sheetId of sheetIds) {
|
||||||
|
for (const cell of Object.values(this.getters.getCells(sheetId))) {
|
||||||
|
if (cell.isFormula) {
|
||||||
|
const newContent = cell.content.replace(
|
||||||
|
new RegExp(`FILTER\\.VALUE\\(\\s*"${currentLabel}"\\s*\\)`, "g"),
|
||||||
|
`FILTER.VALUE("${newLabel}")`
|
||||||
|
);
|
||||||
|
if (newContent !== cell.content) {
|
||||||
|
const { col, row } = this.getters.getCellPosition(cell.id);
|
||||||
|
this.dispatch("UPDATE_CELL", {
|
||||||
|
sheetId,
|
||||||
|
content: newContent,
|
||||||
|
col,
|
||||||
|
row,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return true if the label is duplicated
|
||||||
|
*
|
||||||
|
* @param {string | undefined} filterId
|
||||||
|
* @param {string} label
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
_isDuplicatedLabel(filterId, label) {
|
||||||
|
return (
|
||||||
|
this.globalFilters.findIndex(
|
||||||
|
(filter) => (!filterId || filter.id !== filterId) && filter.label === label
|
||||||
|
) > -1
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
_onMoveFilter(filterId, delta) {
|
||||||
|
const filters = [...this.globalFilters];
|
||||||
|
const currentIndex = filters.findIndex((s) => s.id === filterId);
|
||||||
|
const filter = filters[currentIndex];
|
||||||
|
const targetIndex = currentIndex + delta;
|
||||||
|
|
||||||
|
filters.splice(currentIndex, 1);
|
||||||
|
filters.splice(targetIndex, 0, filter);
|
||||||
|
|
||||||
|
this.history.update("globalFilters", filters);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
GlobalFiltersCorePlugin.getters = [
|
||||||
|
"getGlobalFilter",
|
||||||
|
"getGlobalFilters",
|
||||||
|
"getGlobalFilterDefaultValue",
|
||||||
|
"getGlobalFilterLabel",
|
||||||
|
"getFieldMatchingForModel",
|
||||||
|
];
|
587
static/src/global_filters/plugins/global_filters_ui_plugin.js
Normal file
587
static/src/global_filters/plugins/global_filters_ui_plugin.js
Normal file
@ -0,0 +1,587 @@
|
|||||||
|
/** @odoo-module */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {import("@spreadsheet/data_sources/metadata_repository").Field} Field
|
||||||
|
* @typedef {import("./global_filters_core_plugin").GlobalFilter} GlobalFilter
|
||||||
|
* @typedef {import("./global_filters_core_plugin").FieldMatching} FieldMatching
|
||||||
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { _t } from "@web/core/l10n/translation";
|
||||||
|
import { sprintf } from "@web/core/utils/strings";
|
||||||
|
import { Domain } from "@web/core/domain";
|
||||||
|
import { constructDateRange, getPeriodOptions, QUARTER_OPTIONS } from "@web/search/utils/dates";
|
||||||
|
|
||||||
|
import * as spreadsheet from "@odoo/o-spreadsheet";
|
||||||
|
import { CommandResult } from "@spreadsheet/o_spreadsheet/cancelled_reason";
|
||||||
|
|
||||||
|
import { isEmpty } from "@spreadsheet/helpers/helpers";
|
||||||
|
import { FILTER_DATE_OPTION } from "@spreadsheet/assets_backend/constants";
|
||||||
|
import {
|
||||||
|
checkFiltersTypeValueCombination,
|
||||||
|
getRelativeDateDomain,
|
||||||
|
} from "@spreadsheet/global_filters/helpers";
|
||||||
|
import { RELATIVE_DATE_RANGE_TYPES } from "@spreadsheet/helpers/constants";
|
||||||
|
import { getItemId } from "../../helpers/model";
|
||||||
|
|
||||||
|
const { DateTime } = luxon;
|
||||||
|
|
||||||
|
const MONTHS = {
|
||||||
|
january: { value: 1, granularity: "month" },
|
||||||
|
february: { value: 2, granularity: "month" },
|
||||||
|
march: { value: 3, granularity: "month" },
|
||||||
|
april: { value: 4, granularity: "month" },
|
||||||
|
may: { value: 5, granularity: "month" },
|
||||||
|
june: { value: 6, granularity: "month" },
|
||||||
|
july: { value: 7, granularity: "month" },
|
||||||
|
august: { value: 8, granularity: "month" },
|
||||||
|
september: { value: 9, granularity: "month" },
|
||||||
|
october: { value: 10, granularity: "month" },
|
||||||
|
november: { value: 11, granularity: "month" },
|
||||||
|
december: { value: 12, granularity: "month" },
|
||||||
|
};
|
||||||
|
|
||||||
|
const { UuidGenerator, createEmptyExcelSheet, createEmptySheet, toXC, toNumber } =
|
||||||
|
spreadsheet.helpers;
|
||||||
|
const uuidGenerator = new UuidGenerator();
|
||||||
|
|
||||||
|
export class GlobalFiltersUIPlugin extends spreadsheet.UIPlugin {
|
||||||
|
constructor(config) {
|
||||||
|
super(config);
|
||||||
|
this.orm = config.custom.env?.services.orm;
|
||||||
|
this.user = config.custom.env?.services.user;
|
||||||
|
/**
|
||||||
|
* Cache record display names for relation filters.
|
||||||
|
* For each filter, contains a promise resolving to
|
||||||
|
* the list of display names.
|
||||||
|
*/
|
||||||
|
this.recordsDisplayName = {};
|
||||||
|
/** @type {Object.<string, string|Array<string>|Object>} */
|
||||||
|
this.values = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the given command can be dispatched
|
||||||
|
*
|
||||||
|
* @param {Object} cmd Command
|
||||||
|
*/
|
||||||
|
allowDispatch(cmd) {
|
||||||
|
switch (cmd.type) {
|
||||||
|
case "SET_GLOBAL_FILTER_VALUE": {
|
||||||
|
const filter = this.getters.getGlobalFilter(cmd.id);
|
||||||
|
if (!filter) {
|
||||||
|
return CommandResult.FilterNotFound;
|
||||||
|
}
|
||||||
|
return checkFiltersTypeValueCombination(filter.type, cmd.value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return CommandResult.Success;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle a spreadsheet command
|
||||||
|
*
|
||||||
|
* @param {Object} cmd Command
|
||||||
|
*/
|
||||||
|
handle(cmd) {
|
||||||
|
switch (cmd.type) {
|
||||||
|
case "ADD_GLOBAL_FILTER":
|
||||||
|
this.recordsDisplayName[cmd.filter.id] = cmd.filter.defaultValueDisplayNames;
|
||||||
|
break;
|
||||||
|
case "EDIT_GLOBAL_FILTER": {
|
||||||
|
const id = cmd.filter.id;
|
||||||
|
if (this.values[id] && this.values[id].rangeType !== cmd.filter.rangeType) {
|
||||||
|
delete this.values[id];
|
||||||
|
}
|
||||||
|
this.recordsDisplayName[id] = cmd.filter.defaultValueDisplayNames;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "SET_GLOBAL_FILTER_VALUE":
|
||||||
|
this.recordsDisplayName[cmd.id] = cmd.displayNames;
|
||||||
|
if (!cmd.value) {
|
||||||
|
this._clearGlobalFilterValue(cmd.id);
|
||||||
|
} else {
|
||||||
|
this._setGlobalFilterValue(cmd.id, cmd.value);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "SET_MANY_GLOBAL_FILTER_VALUE":
|
||||||
|
for (const filter of cmd.filters) {
|
||||||
|
if (filter.value !== undefined) {
|
||||||
|
this.dispatch("SET_GLOBAL_FILTER_VALUE", {
|
||||||
|
id: filter.filterId,
|
||||||
|
value: filter.value,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
this.dispatch("CLEAR_GLOBAL_FILTER_VALUE", { id: filter.filterId });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "REMOVE_GLOBAL_FILTER":
|
||||||
|
delete this.recordsDisplayName[cmd.id];
|
||||||
|
delete this.values[cmd.id];
|
||||||
|
break;
|
||||||
|
case "CLEAR_GLOBAL_FILTER_VALUE":
|
||||||
|
this.recordsDisplayName[cmd.id] = [];
|
||||||
|
this._clearGlobalFilterValue(cmd.id);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Getters
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} filterId
|
||||||
|
* @param {FieldMatching} fieldMatching
|
||||||
|
*
|
||||||
|
* @return {Domain}
|
||||||
|
*/
|
||||||
|
getGlobalFilterDomain(filterId, fieldMatching) {
|
||||||
|
/** @type {GlobalFilter} */
|
||||||
|
const filter = this.getters.getGlobalFilter(filterId);
|
||||||
|
if (!filter) {
|
||||||
|
return new Domain();
|
||||||
|
}
|
||||||
|
switch (filter.type) {
|
||||||
|
case "text":
|
||||||
|
return this._getTextDomain(filter, fieldMatching);
|
||||||
|
case "date":
|
||||||
|
return this._getDateDomain(filter, fieldMatching);
|
||||||
|
case "relation":
|
||||||
|
return this._getRelationDomain(filter, fieldMatching);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the current value of a global filter
|
||||||
|
*
|
||||||
|
* @param {string} filterId Id of the filter
|
||||||
|
*
|
||||||
|
* @returns {string|Array<string>|Object} value Current value to set
|
||||||
|
*/
|
||||||
|
getGlobalFilterValue(filterId) {
|
||||||
|
const filter = this.getters.getGlobalFilter(filterId);
|
||||||
|
|
||||||
|
const value = filterId in this.values ? this.values[filterId].value : undefined;
|
||||||
|
const preventAutomaticValue = this.values[filterId]?.value?.preventAutomaticValue;
|
||||||
|
if (filter.type === "date" && filter.rangeType === "from_to") {
|
||||||
|
return value || { from: undefined, to: undefined };
|
||||||
|
}
|
||||||
|
const defaultValue = (!preventAutomaticValue && filter.defaultValue) || undefined;
|
||||||
|
if (filter.type === "date" && preventAutomaticValue) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (filter.type === "date" && isEmpty(value) && defaultValue) {
|
||||||
|
return this._getValueOfCurrentPeriod(filterId);
|
||||||
|
}
|
||||||
|
if (filter.type === "relation" && preventAutomaticValue) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
if (filter.type === "relation" && isEmpty(value) && defaultValue === "current_user") {
|
||||||
|
return [this.user.userId];
|
||||||
|
}
|
||||||
|
if (filter.type === "text" && preventAutomaticValue) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
return value || defaultValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} id Id of the filter
|
||||||
|
*
|
||||||
|
* @returns { boolean } true if the given filter is active
|
||||||
|
*/
|
||||||
|
isGlobalFilterActive(id) {
|
||||||
|
const { type } = this.getters.getGlobalFilter(id);
|
||||||
|
const value = this.getGlobalFilterValue(id);
|
||||||
|
switch (type) {
|
||||||
|
case "text":
|
||||||
|
return value;
|
||||||
|
case "date":
|
||||||
|
return (
|
||||||
|
value &&
|
||||||
|
(typeof value === "string" ||
|
||||||
|
value.yearOffset !== undefined ||
|
||||||
|
value.period ||
|
||||||
|
value.from ||
|
||||||
|
value.to)
|
||||||
|
);
|
||||||
|
case "relation":
|
||||||
|
return value && value.length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the number of active global filters
|
||||||
|
*
|
||||||
|
* @returns {number}
|
||||||
|
*/
|
||||||
|
getActiveFilterCount() {
|
||||||
|
return this.getters
|
||||||
|
.getGlobalFilters()
|
||||||
|
.filter((filter) => this.isGlobalFilterActive(filter.id)).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
getFilterDisplayValue(filterName) {
|
||||||
|
const filter = this.getters.getGlobalFilterLabel(filterName);
|
||||||
|
if (!filter) {
|
||||||
|
throw new Error(sprintf(_t(`Filter "%s" not found`), filterName));
|
||||||
|
}
|
||||||
|
const value = this.getGlobalFilterValue(filter.id);
|
||||||
|
switch (filter.type) {
|
||||||
|
case "text":
|
||||||
|
return [[{ value: value || "" }]];
|
||||||
|
case "date": {
|
||||||
|
if (filter.rangeType === "from_to") {
|
||||||
|
const locale = this.getters.getLocale();
|
||||||
|
const from = {
|
||||||
|
value: value.from && toNumber(value.from, locale),
|
||||||
|
format: locale.dateFormat,
|
||||||
|
};
|
||||||
|
const to = {
|
||||||
|
value: value.to && toNumber(value.to, locale),
|
||||||
|
format: locale.dateFormat,
|
||||||
|
};
|
||||||
|
return [[from], [to]];
|
||||||
|
}
|
||||||
|
if (value && typeof value === "string") {
|
||||||
|
const type = RELATIVE_DATE_RANGE_TYPES.find((type) => type.type === value);
|
||||||
|
if (!type) {
|
||||||
|
return [[{ value: "" }]];
|
||||||
|
}
|
||||||
|
return [[{ value: type.description.toString() }]];
|
||||||
|
}
|
||||||
|
if (!value || value.yearOffset === undefined) {
|
||||||
|
return [[{ value: "" }]];
|
||||||
|
}
|
||||||
|
const periodOptions = getPeriodOptions(DateTime.local());
|
||||||
|
const year = String(DateTime.local().year + value.yearOffset);
|
||||||
|
const period = periodOptions.find(({ id }) => value.period === id);
|
||||||
|
let periodStr = period && period.description;
|
||||||
|
// Named months aren't in getPeriodOptions
|
||||||
|
if (!period) {
|
||||||
|
periodStr =
|
||||||
|
MONTHS[value.period] && String(MONTHS[value.period].value).padStart(2, "0");
|
||||||
|
}
|
||||||
|
return [[{ value: periodStr ? periodStr + "/" + year : year }]];
|
||||||
|
}
|
||||||
|
case "relation":
|
||||||
|
if (!value?.length || !this.orm) {
|
||||||
|
return [[{ value: "" }]];
|
||||||
|
}
|
||||||
|
if (!this.recordsDisplayName[filter.id]) {
|
||||||
|
this.orm
|
||||||
|
.call(filter.modelName, "read", [value, ["display_name"]])
|
||||||
|
.then((result) => {
|
||||||
|
const names = result.map(({ display_name }) => display_name);
|
||||||
|
this.recordsDisplayName[filter.id] = names;
|
||||||
|
this.dispatch("EVALUATE_CELLS", {
|
||||||
|
sheetId: this.getters.getActiveSheetId(),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return [[{ value: "" }]];
|
||||||
|
}
|
||||||
|
return [[{ value: this.recordsDisplayName[filter.id].join(", ") }]];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the possible values a text global filter can take
|
||||||
|
* if the values are restricted by a range of allowed values
|
||||||
|
* @param {string} filterId
|
||||||
|
* @returns {{value: string, formattedValue: string}[]}
|
||||||
|
*/
|
||||||
|
getTextFilterOptions(filterId) {
|
||||||
|
const filter = this.getters.getGlobalFilter(filterId);
|
||||||
|
const range = filter.rangeOfAllowedValues;
|
||||||
|
if (!range) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const additionOptions = [
|
||||||
|
// add the current value because it might not be in the range
|
||||||
|
// if the range cells changed in the meantime
|
||||||
|
this.getGlobalFilterValue(filterId),
|
||||||
|
filter.defaultValue,
|
||||||
|
];
|
||||||
|
const options = this.getTextFilterOptionsFromRange(range, additionOptions);
|
||||||
|
return options;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the possible values a text global filter can take from a range
|
||||||
|
* or any addition raw string value. Removes duplicates.
|
||||||
|
* @param {object} range
|
||||||
|
* @param {string[]} additionalOptionValues
|
||||||
|
*/
|
||||||
|
getTextFilterOptionsFromRange(range, additionalOptionValues = []) {
|
||||||
|
const cells = this.getters.getEvaluatedCellsInZone(range.sheetId, range.zone);
|
||||||
|
const uniqueFormattedValues = new Set();
|
||||||
|
const uniqueValues = new Set();
|
||||||
|
const allowedValues = cells
|
||||||
|
.filter((cell) => !["empty", "error"].includes(cell.type))
|
||||||
|
.map((cell) => ({
|
||||||
|
value: cell.value.toString(),
|
||||||
|
formattedValue: cell.formattedValue,
|
||||||
|
}))
|
||||||
|
.filter((cell) => {
|
||||||
|
if (uniqueFormattedValues.has(cell.formattedValue)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
uniqueFormattedValues.add(cell.formattedValue);
|
||||||
|
uniqueValues.add(cell.value);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
const additionalOptions = additionalOptionValues
|
||||||
|
.map((value) => ({ value, formattedValue: value }))
|
||||||
|
.filter((cell) => {
|
||||||
|
if (cell.value === undefined || cell.value === "" || uniqueValues.has(cell.value)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
uniqueValues.add(cell.value);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
return allowedValues.concat(additionalOptions);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Handlers
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the current value of a global filter
|
||||||
|
*
|
||||||
|
* @param {string} id Id of the filter
|
||||||
|
* @param {string|Array<string>|Object} value Current value to set
|
||||||
|
*/
|
||||||
|
_setGlobalFilterValue(id, value) {
|
||||||
|
this.values[id] = { value: value, rangeType: this.getters.getGlobalFilter(id).rangeType };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the filter value corresponding to the current period, depending of the type of range of the filter.
|
||||||
|
* For example if rangeType === "month", the value will be the current month of the current year.
|
||||||
|
*
|
||||||
|
* @param {string} filterId a global filter
|
||||||
|
* @return {Object} filter value
|
||||||
|
*/
|
||||||
|
_getValueOfCurrentPeriod(filterId) {
|
||||||
|
const filter = this.getters.getGlobalFilter(filterId);
|
||||||
|
switch (filter.defaultValue) {
|
||||||
|
case "this_year":
|
||||||
|
return { yearOffset: 0 };
|
||||||
|
case "this_month": {
|
||||||
|
const month = new Date().getMonth() + 1;
|
||||||
|
const period = Object.entries(MONTHS).find((item) => item[1].value === month)[0];
|
||||||
|
return { yearOffset: 0, period };
|
||||||
|
}
|
||||||
|
case "this_quarter": {
|
||||||
|
const quarter = Math.floor(new Date().getMonth() / 3);
|
||||||
|
const period = FILTER_DATE_OPTION.quarter[quarter];
|
||||||
|
return { yearOffset: 0, period };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return filter.defaultValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the current value to empty values which functionally deactivate the filter
|
||||||
|
*
|
||||||
|
* @param {string} id Id of the filter
|
||||||
|
*/
|
||||||
|
_clearGlobalFilterValue(id) {
|
||||||
|
const { type, rangeType } = this.getters.getGlobalFilter(id);
|
||||||
|
let value;
|
||||||
|
switch (type) {
|
||||||
|
case "text":
|
||||||
|
value = { preventAutomaticValue: true };
|
||||||
|
break;
|
||||||
|
case "date":
|
||||||
|
value = { preventAutomaticValue: true };
|
||||||
|
break;
|
||||||
|
case "relation":
|
||||||
|
value = { preventAutomaticValue: true };
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
this.values[id] = { value, rangeType };
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Private
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the domain relative to a date field
|
||||||
|
*
|
||||||
|
* @private
|
||||||
|
*
|
||||||
|
* @param {GlobalFilter} filter
|
||||||
|
* @param {FieldMatching} fieldMatching
|
||||||
|
*
|
||||||
|
* @returns {Domain}
|
||||||
|
*/
|
||||||
|
_getDateDomain(filter, fieldMatching) {
|
||||||
|
let granularity;
|
||||||
|
const value = this.getGlobalFilterValue(filter.id);
|
||||||
|
if (!value || !fieldMatching.chain) {
|
||||||
|
return new Domain();
|
||||||
|
}
|
||||||
|
const field = fieldMatching.chain;
|
||||||
|
const type = fieldMatching.type;
|
||||||
|
const offset = fieldMatching.offset || 0;
|
||||||
|
const now = DateTime.local();
|
||||||
|
|
||||||
|
if (filter.rangeType === "from_to") {
|
||||||
|
if (value.from && value.to) {
|
||||||
|
return new Domain(["&", [field, ">=", value.from], [field, "<=", value.to]]);
|
||||||
|
}
|
||||||
|
if (value.from) {
|
||||||
|
return new Domain([[field, ">=", value.from]]);
|
||||||
|
}
|
||||||
|
if (value.to) {
|
||||||
|
return new Domain([[field, "<=", value.to]]);
|
||||||
|
}
|
||||||
|
return new Domain();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter.rangeType === "relative") {
|
||||||
|
return getRelativeDateDomain(now, offset, value, field, type);
|
||||||
|
}
|
||||||
|
const noPeriod = !value.period || value.period === "empty";
|
||||||
|
const noYear = value.yearOffset === undefined;
|
||||||
|
if (noPeriod && noYear) {
|
||||||
|
return new Domain();
|
||||||
|
}
|
||||||
|
const setParam = { year: now.year };
|
||||||
|
const yearOffset = value.yearOffset || 0;
|
||||||
|
const plusParam = { years: yearOffset };
|
||||||
|
if (noPeriod) {
|
||||||
|
granularity = "year";
|
||||||
|
plusParam.years += offset;
|
||||||
|
} else {
|
||||||
|
// value.period is can be "first_quarter", "second_quarter", etc. or
|
||||||
|
// full month name (e.g. "january", "february", "march", etc.)
|
||||||
|
granularity = value.period.endsWith("_quarter") ? "quarter" : "month";
|
||||||
|
switch (granularity) {
|
||||||
|
case "month":
|
||||||
|
setParam.month = MONTHS[value.period].value;
|
||||||
|
plusParam.month = offset;
|
||||||
|
break;
|
||||||
|
case "quarter":
|
||||||
|
setParam.quarter = QUARTER_OPTIONS[value.period].setParam.quarter;
|
||||||
|
plusParam.quarter = offset;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return constructDateRange({
|
||||||
|
referenceMoment: now,
|
||||||
|
fieldName: field,
|
||||||
|
fieldType: type,
|
||||||
|
granularity,
|
||||||
|
setParam,
|
||||||
|
plusParam,
|
||||||
|
}).domain;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the domain relative to a text field
|
||||||
|
*
|
||||||
|
* @private
|
||||||
|
*
|
||||||
|
* @param {GlobalFilter} filter
|
||||||
|
* @param {FieldMatching} fieldMatching
|
||||||
|
*
|
||||||
|
* @returns {Domain}
|
||||||
|
*/
|
||||||
|
_getTextDomain(filter, fieldMatching) {
|
||||||
|
const value = this.getGlobalFilterValue(filter.id);
|
||||||
|
if (!value || !fieldMatching.chain) {
|
||||||
|
return new Domain();
|
||||||
|
}
|
||||||
|
const field = fieldMatching.chain;
|
||||||
|
return new Domain([[field, "ilike", value]]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the domain relative to a relation field
|
||||||
|
*
|
||||||
|
* @private
|
||||||
|
*
|
||||||
|
* @param {GlobalFilter} filter
|
||||||
|
* @param {FieldMatching} fieldMatching
|
||||||
|
*
|
||||||
|
* @returns {Domain}
|
||||||
|
*/
|
||||||
|
_getRelationDomain(filter, fieldMatching) {
|
||||||
|
const values = this.getGlobalFilterValue(filter.id);
|
||||||
|
if (!values || values.length === 0 || !fieldMatching.chain) {
|
||||||
|
return new Domain();
|
||||||
|
}
|
||||||
|
const field = fieldMatching.chain;
|
||||||
|
return new Domain([[field, "in", values]]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds all active filters (and their values) at the time of export in a dedicated sheet
|
||||||
|
*
|
||||||
|
* @param {Object} data
|
||||||
|
*/
|
||||||
|
exportForExcel(data) {
|
||||||
|
if (this.getters.getGlobalFilters().length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.exportSheetWithActiveFilters(data);
|
||||||
|
data.sheets[data.sheets.length - 1] = {
|
||||||
|
...createEmptyExcelSheet(uuidGenerator.uuidv4(), _t("Active Filters")),
|
||||||
|
...data.sheets.at(-1),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
exportSheetWithActiveFilters(data) {
|
||||||
|
if (this.getters.getGlobalFilters().length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const styleId = getItemId({ bold: true }, data.styles);
|
||||||
|
|
||||||
|
const cells = {};
|
||||||
|
cells["A1"] = { content: "Filter", style: styleId };
|
||||||
|
cells["B1"] = { content: "Value", style: styleId };
|
||||||
|
let numberOfCols = 2; // at least 2 cols (filter title and filter value)
|
||||||
|
let filterRowIndex = 1; // first row is the column titles
|
||||||
|
for (const filter of this.getters.getGlobalFilters()) {
|
||||||
|
cells[`A${filterRowIndex + 1}`] = { content: filter.label };
|
||||||
|
const result = this.getFilterDisplayValue(filter.label);
|
||||||
|
for (const colIndex in result) {
|
||||||
|
numberOfCols = Math.max(numberOfCols, Number(colIndex) + 2);
|
||||||
|
for (const rowIndex in result[colIndex]) {
|
||||||
|
const cell = result[colIndex][rowIndex];
|
||||||
|
const xc = toXC(Number(colIndex) + 1, Number(rowIndex) + filterRowIndex);
|
||||||
|
cells[xc] = { content: cell.value.toString() };
|
||||||
|
if (cell.format) {
|
||||||
|
const formatId = getItemId(cell.format, data.formats);
|
||||||
|
cells[xc].format = formatId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
filterRowIndex += result[0].length;
|
||||||
|
}
|
||||||
|
const sheet = {
|
||||||
|
...createEmptySheet(uuidGenerator.uuidv4(), _t("Active Filters")),
|
||||||
|
cells,
|
||||||
|
colNumber: numberOfCols,
|
||||||
|
rowNumber: filterRowIndex,
|
||||||
|
};
|
||||||
|
data.sheets.push(sheet);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
GlobalFiltersUIPlugin.getters = [
|
||||||
|
"getFilterDisplayValue",
|
||||||
|
"getGlobalFilterDomain",
|
||||||
|
"getGlobalFilterValue",
|
||||||
|
"getActiveFilterCount",
|
||||||
|
"isGlobalFilterActive",
|
||||||
|
"getTextFilterOptions",
|
||||||
|
"getTextFilterOptionsFromRange",
|
||||||
|
"exportSheetWithActiveFilters",
|
||||||
|
];
|
21
static/src/helpers/constants.js
Normal file
21
static/src/helpers/constants.js
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
/** @odoo-module */
|
||||||
|
|
||||||
|
import { _t } from "@web/core/l10n/translation";
|
||||||
|
|
||||||
|
export const DEFAULT_LINES_NUMBER = 20;
|
||||||
|
|
||||||
|
export const HEADER_STYLE = { fillColor: "#E6F2F3" };
|
||||||
|
export const TOP_LEVEL_STYLE = { bold: true, fillColor: "#E6F2F3" };
|
||||||
|
export const MEASURE_STYLE = { fillColor: "#E6F2F3", textColor: "#756f6f" };
|
||||||
|
|
||||||
|
export const UNTITLED_SPREADSHEET_NAME = _t("Untitled spreadsheet");
|
||||||
|
|
||||||
|
export const RELATIVE_DATE_RANGE_TYPES = [
|
||||||
|
{ type: "year_to_date", description: _t("Year to Date") },
|
||||||
|
{ type: "last_week", description: _t("Last 7 Days") },
|
||||||
|
{ type: "last_month", description: _t("Last 30 Days") },
|
||||||
|
{ type: "last_three_months", description: _t("Last 90 Days") },
|
||||||
|
{ type: "last_six_months", description: _t("Last 180 Days") },
|
||||||
|
{ type: "last_year", description: _t("Last 365 Days") },
|
||||||
|
{ type: "last_three_years", description: _t("Last 3 Years") },
|
||||||
|
];
|
98
static/src/helpers/helpers.js
Normal file
98
static/src/helpers/helpers.js
Normal file
@ -0,0 +1,98 @@
|
|||||||
|
/** @odoo-module */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the intersection of two arrays
|
||||||
|
*
|
||||||
|
* @param {Array} a
|
||||||
|
* @param {Array} b
|
||||||
|
*
|
||||||
|
* @private
|
||||||
|
* @returns {Array} intersection between a and b
|
||||||
|
*/
|
||||||
|
export function intersect(a, b) {
|
||||||
|
return a.filter((x) => b.includes(x));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Given an object of form {"1": {...}, "2": {...}, ...} get the maximum ID used
|
||||||
|
* in this object
|
||||||
|
* If the object has no keys, return 0
|
||||||
|
*
|
||||||
|
* @param {Object} o an object for which the keys are an ID
|
||||||
|
*
|
||||||
|
* @returns {number}
|
||||||
|
*/
|
||||||
|
export function getMaxObjectId(o) {
|
||||||
|
const keys = Object.keys(o);
|
||||||
|
if (!keys.length) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
const nums = keys.map((id) => parseInt(id, 10));
|
||||||
|
const max = Math.max(...nums);
|
||||||
|
return max;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a spreadsheet date representation to an odoo
|
||||||
|
* server formatted date
|
||||||
|
*
|
||||||
|
* @param {Date} value
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
export function toServerDateString(value) {
|
||||||
|
return `${value.getFullYear()}-${value.getMonth() + 1}-${value.getDate()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {number[]} array
|
||||||
|
* @returns {number}
|
||||||
|
*/
|
||||||
|
export function sum(array) {
|
||||||
|
return array.reduce((acc, n) => acc + n, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function camelToSnakeKey(word) {
|
||||||
|
const result = word.replace(/(.){1}([A-Z])/g, "$1 $2");
|
||||||
|
return result.split(" ").join("_").toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recursively convert camel case object keys to snake case keys
|
||||||
|
* @param {object} obj
|
||||||
|
* @returns {object}
|
||||||
|
*/
|
||||||
|
export function camelToSnakeObject(obj) {
|
||||||
|
const result = {};
|
||||||
|
for (const [key, value] of Object.entries(obj)) {
|
||||||
|
const isPojo = typeof value === "object" && value !== null && value.constructor === Object;
|
||||||
|
result[camelToSnakeKey(key)] = isPojo ? camelToSnakeObject(value) : value;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the argument is falsy or is an empty object/array
|
||||||
|
*
|
||||||
|
* TODO : remove this and replace it by the one in o_spreadsheet xlsx import when its merged
|
||||||
|
*/
|
||||||
|
export function isEmpty(item) {
|
||||||
|
if (!item) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (typeof item === "object") {
|
||||||
|
if (
|
||||||
|
Object.values(item).length === 0 ||
|
||||||
|
Object.values(item).every((val) => val === undefined)
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function containsReferences(cell) {
|
||||||
|
if (!cell.isFormula) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return cell.compiledFormula.tokens.some((token) => token.type === "REFERENCE");
|
||||||
|
}
|
195
static/src/helpers/model.js
Normal file
195
static/src/helpers/model.js
Normal file
@ -0,0 +1,195 @@
|
|||||||
|
/** @odoo-module */
|
||||||
|
import { DataSources } from "@spreadsheet/data_sources/data_sources";
|
||||||
|
import { Model, parse, helpers, iterateAstNodes } from "@odoo/o-spreadsheet";
|
||||||
|
import { migrate } from "@spreadsheet/o_spreadsheet/migration";
|
||||||
|
import { _t } from "@web/core/l10n/translation";
|
||||||
|
import { loadBundle } from "@web/core/assets";
|
||||||
|
|
||||||
|
const { formatValue, isDefined, toCartesian } = helpers;
|
||||||
|
|
||||||
|
export async function fetchSpreadsheetModel(env, resModel, resId) {
|
||||||
|
const { data, revisions } = await env.services.orm.call(resModel, "join_spreadsheet_session", [
|
||||||
|
resId,
|
||||||
|
]);
|
||||||
|
return createSpreadsheetModel({ env, data, revisions });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createSpreadsheetModel({ env, data, revisions }) {
|
||||||
|
const dataSources = new DataSources(env);
|
||||||
|
const model = new Model(migrate(data), { custom: { dataSources } }, revisions);
|
||||||
|
return model;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.custom.dataSources;
|
||||||
|
await dataSources.waitForAllLoaded();
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Model} model
|
||||||
|
* @returns {object}
|
||||||
|
*/
|
||||||
|
export async function freezeOdooData(model) {
|
||||||
|
await waitForDataLoaded(model);
|
||||||
|
const data = model.exportData();
|
||||||
|
for (const sheet of Object.values(data.sheets)) {
|
||||||
|
for (const [xc, cell] of Object.entries(sheet.cells)) {
|
||||||
|
if (containsOdooFunction(cell.content)) {
|
||||||
|
const { col, row } = toCartesian(xc);
|
||||||
|
const sheetId = sheet.id;
|
||||||
|
const evaluatedCell = model.getters.getEvaluatedCell({
|
||||||
|
sheetId,
|
||||||
|
col,
|
||||||
|
row,
|
||||||
|
});
|
||||||
|
cell.content = evaluatedCell.formattedValue;
|
||||||
|
if (evaluatedCell.format) {
|
||||||
|
cell.format = getItemId(evaluatedCell.format, data.formats);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const figure of sheet.figures) {
|
||||||
|
if (figure.tag === "chart" && figure.data.type.startsWith("odoo_")) {
|
||||||
|
await loadBundle("web.chartjs_lib");
|
||||||
|
const img = odooChartToImage(model, figure);
|
||||||
|
figure.tag = "image";
|
||||||
|
figure.data = {
|
||||||
|
path: img,
|
||||||
|
size: { width: figure.width, height: figure.height },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
exportGlobalFiltersToSheet(model, data);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
function exportGlobalFiltersToSheet(model, data) {
|
||||||
|
model.getters.exportSheetWithActiveFilters(data);
|
||||||
|
const locale = model.getters.getLocale();
|
||||||
|
for (const filter of data.globalFilters) {
|
||||||
|
const content = model.getters.getFilterDisplayValue(filter.label);
|
||||||
|
filter["value"] = content
|
||||||
|
.flat()
|
||||||
|
.filter(isDefined)
|
||||||
|
.map(({ value, format }) => formatValue(value, { format, locale }))
|
||||||
|
.join(", ");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* copy-pasted from o-spreadsheet. Should be exported
|
||||||
|
* Get the id of the given item (its key in the given dictionnary).
|
||||||
|
* If the given item does not exist in the dictionary, it creates one with a new id.
|
||||||
|
*/
|
||||||
|
export function getItemId(item, itemsDic) {
|
||||||
|
for (const [key, value] of Object.entries(itemsDic)) {
|
||||||
|
if (value === item) {
|
||||||
|
return parseInt(key, 10);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate new Id if the item didn't exist in the dictionary
|
||||||
|
const ids = Object.keys(itemsDic);
|
||||||
|
const maxId = ids.length === 0 ? 0 : Math.max(...ids.map((id) => parseInt(id, 10)));
|
||||||
|
|
||||||
|
itemsDic[maxId + 1] = item;
|
||||||
|
return maxId + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param {string | undefined} content
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
function containsOdooFunction(content) {
|
||||||
|
if (
|
||||||
|
!content ||
|
||||||
|
!content.startsWith("=") ||
|
||||||
|
(!content.toUpperCase().includes("ODOO.") && !content.toUpperCase().includes("_T"))
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const ast = parse(content);
|
||||||
|
return iterateAstNodes(ast).some(
|
||||||
|
(ast) =>
|
||||||
|
ast.type === "FUNCALL" &&
|
||||||
|
(ast.value.toUpperCase().startsWith("ODOO.") ||
|
||||||
|
ast.value.toUpperCase().startsWith("_T"))
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isLoaded(model) {
|
||||||
|
for (const sheetId of model.getters.getSheetIds()) {
|
||||||
|
for (const cell of Object.values(model.getters.getEvaluatedCells(sheetId))) {
|
||||||
|
if (cell.type === "error" && cell.error.message === _t("Data is loading")) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return the chart figure as a base64 image.
|
||||||
|
* "data:image/png;base64,iVBORw0KGg..."
|
||||||
|
* @param {Model} model
|
||||||
|
* @param {object} figure
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
function odooChartToImage(model, figure) {
|
||||||
|
const runtime = model.getters.getChartRuntime(figure.id);
|
||||||
|
// wrap the canvas in a div with a fixed size because chart.js would
|
||||||
|
// fill the whole page otherwise
|
||||||
|
const div = document.createElement("div");
|
||||||
|
div.style.width = `${figure.width}px`;
|
||||||
|
div.style.height = `${figure.height}px`;
|
||||||
|
const canvas = document.createElement("canvas");
|
||||||
|
div.append(canvas);
|
||||||
|
canvas.setAttribute("width", figure.width);
|
||||||
|
canvas.setAttribute("height", figure.height);
|
||||||
|
// we have to add the canvas to the DOM otherwise it won't be rendered
|
||||||
|
document.body.append(div);
|
||||||
|
runtime.chartJsConfig.plugins = [backgroundColorPlugin];
|
||||||
|
const chart = new Chart(canvas, runtime.chartJsConfig);
|
||||||
|
const img = chart.toBase64Image();
|
||||||
|
chart.destroy();
|
||||||
|
div.remove();
|
||||||
|
return img;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom chart.js plugin to set the background color of the canvas
|
||||||
|
* https://github.com/chartjs/Chart.js/blob/8fdf76f8f02d31684d34704341a5d9217e977491/docs/configuration/canvas-background.md
|
||||||
|
*/
|
||||||
|
const backgroundColorPlugin = {
|
||||||
|
id: "customCanvasBackgroundColor",
|
||||||
|
beforeDraw: (chart, args, options) => {
|
||||||
|
const { ctx } = chart;
|
||||||
|
ctx.save();
|
||||||
|
ctx.globalCompositeOperation = "destination-over";
|
||||||
|
ctx.fillStyle = "#ffffff";
|
||||||
|
ctx.fillRect(0, 0, chart.width, chart.height);
|
||||||
|
ctx.restore();
|
||||||
|
},
|
||||||
|
};
|
56
static/src/helpers/odoo_functions_helpers.js
Normal file
56
static/src/helpers/odoo_functions_helpers.js
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
/** @odoo-module **/
|
||||||
|
|
||||||
|
import * as spreadsheet from "@odoo/o-spreadsheet";
|
||||||
|
|
||||||
|
const { parseTokens, iterateAstNodes } = spreadsheet;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Object} OdooFunctionDescription
|
||||||
|
* @property {string} functionName Name of the function
|
||||||
|
* @property {Array<string>} args Arguments of the function
|
||||||
|
*
|
||||||
|
* @typedef {Object} Token
|
||||||
|
* @property {string} type
|
||||||
|
* @property {string} value
|
||||||
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This function is used to search for the functions which match the given matcher
|
||||||
|
* from the given formula
|
||||||
|
*
|
||||||
|
* @param {Token[]} tokens
|
||||||
|
* @param {string[]} functionNames e.g. ["ODOO.LIST", "ODOO.LIST.HEADER"]
|
||||||
|
* @private
|
||||||
|
* @returns {Array<OdooFunctionDescription>}
|
||||||
|
*/
|
||||||
|
export function getOdooFunctions(tokens, functionNames) {
|
||||||
|
// Parsing is an expensive operation, so we first check if the
|
||||||
|
// formula contains one of the function names
|
||||||
|
if (!tokens.some((t) => t.type === "SYMBOL" && functionNames.includes(t.value.toUpperCase()))) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
let ast;
|
||||||
|
try {
|
||||||
|
ast = parseTokens(tokens);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return _getOdooFunctionsFromAST(ast, functionNames);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This function is used to search for the functions which match the given matcher
|
||||||
|
* from the given AST
|
||||||
|
*
|
||||||
|
* @param {Object} ast (see o-spreadsheet)
|
||||||
|
* @param {string[]} functionNames e.g. ["ODOO.LIST", "ODOO.LIST.HEADER"]
|
||||||
|
*
|
||||||
|
* @private
|
||||||
|
* @returns {Array<OdooFunctionDescription>}
|
||||||
|
*/
|
||||||
|
function _getOdooFunctionsFromAST(ast, functionNames) {
|
||||||
|
return iterateAstNodes(ast)
|
||||||
|
.filter((ast) => ast.type === "FUNCALL" && functionNames.includes(ast.value.toUpperCase()))
|
||||||
|
.map((ast) => ({ functionName: ast.value.toUpperCase(), args: ast.args }));
|
||||||
|
}
|
110
static/src/hooks.js
Normal file
110
static/src/hooks.js
Normal file
@ -0,0 +1,110 @@
|
|||||||
|
/** @odoo-module */
|
||||||
|
|
||||||
|
import { useEffect, useExternalListener, useState } from "@odoo/owl";
|
||||||
|
import { loadBundle } from "@web/core/assets";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook that will capture the 'Ctrl+p' press that corresponds to the user intent to print a spreadsheet.
|
||||||
|
* It will prepare the spreadsheet for printing by:
|
||||||
|
* - displaying it in dashboard mode.
|
||||||
|
* - altering the spreadsheet dimensions to ensure we render the whole sheet.
|
||||||
|
* The hook will also restore the spreadsheet dimensions to their original state after the print.
|
||||||
|
*
|
||||||
|
* The hook will return the print preparation function to be called manually in other contexts than pressing
|
||||||
|
* the common keybind (through a menu for instance).
|
||||||
|
*
|
||||||
|
* @param {() => Model | undefined} model
|
||||||
|
* @returns {() => Promise<void>} preparePrint
|
||||||
|
*/
|
||||||
|
export function useSpreadsheetPrint(model) {
|
||||||
|
let frozenPrintState = undefined;
|
||||||
|
const printState = useState({ active: false });
|
||||||
|
|
||||||
|
useExternalListener(
|
||||||
|
window,
|
||||||
|
"keydown",
|
||||||
|
async (ev) => {
|
||||||
|
const isMeta = ev.metaKey || ev.ctrlKey;
|
||||||
|
if (ev.key === "p" && isMeta) {
|
||||||
|
if (!model()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ev.preventDefault();
|
||||||
|
ev.stopImmediatePropagation();
|
||||||
|
await preparePrint();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ capture: true }
|
||||||
|
);
|
||||||
|
useExternalListener(window, "afterprint", afterPrint)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (printState.active) {
|
||||||
|
window.print();
|
||||||
|
}
|
||||||
|
}, () => [printState.active]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the DOM position & dimensions such that the whole spreadsheet content is visible.
|
||||||
|
* @returns {Rect}
|
||||||
|
*/
|
||||||
|
function getPrintRect() {
|
||||||
|
const sheetId = model().getters.getActiveSheetId();
|
||||||
|
const { bottom, right } = model().getters.getSheetZone(sheetId);
|
||||||
|
const { end: width } = model().getters.getColDimensions(sheetId, right);
|
||||||
|
const { end: height } = model().getters.getRowDimensions(
|
||||||
|
sheetId,
|
||||||
|
bottom
|
||||||
|
);
|
||||||
|
return { x:0, y:0, width, height };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Will alter the spreadsheet dimensions to ensure we render the whole sheet.
|
||||||
|
* invoking this function will ultimately trigger a print of the page after a patch.
|
||||||
|
*/
|
||||||
|
async function preparePrint() {
|
||||||
|
if (!model()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await loadBundle("spreadsheet.assets_print");
|
||||||
|
const { width, height } = model().getters.getSheetViewDimension();
|
||||||
|
const { width: widthAndHeader, height: heightAndHeader } =
|
||||||
|
model().getters.getSheetViewDimension();
|
||||||
|
const viewRect = {
|
||||||
|
x: widthAndHeader - width,
|
||||||
|
y: heightAndHeader - height,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
};
|
||||||
|
frozenPrintState = {
|
||||||
|
viewRect,
|
||||||
|
offset: model().getters.getActiveSheetDOMScrollInfo(),
|
||||||
|
mode: model().config.mode,
|
||||||
|
};
|
||||||
|
model().updateMode("dashboard");
|
||||||
|
// reset the viewport to A1 visibility
|
||||||
|
model().dispatch("SET_VIEWPORT_OFFSET", { offsetX: 0, offsetY: 0 });
|
||||||
|
model().dispatch("RESIZE_SHEETVIEW", {
|
||||||
|
...getPrintRect(),
|
||||||
|
});
|
||||||
|
printState.active = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function afterPrint() {
|
||||||
|
if (!model()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (frozenPrintState) {
|
||||||
|
model().dispatch("RESIZE_SHEETVIEW", frozenPrintState.viewRect);
|
||||||
|
const { scrollX: offsetX, scrollY: offsetY } =
|
||||||
|
frozenPrintState.offset;
|
||||||
|
model().dispatch("SET_VIEWPORT_OFFSET", { offsetX, offsetY });
|
||||||
|
model().updateMode(frozenPrintState.mode);
|
||||||
|
frozenPrintState = undefined;
|
||||||
|
}
|
||||||
|
printState.active = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return preparePrint;
|
||||||
|
}
|
40
static/src/index.js
Normal file
40
static/src/index.js
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
/** @odoo-module */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This file is meant to load the different subparts of the module
|
||||||
|
* to guarantee their plugins are loaded in the right order
|
||||||
|
*
|
||||||
|
* dependency:
|
||||||
|
* other plugins
|
||||||
|
* |
|
||||||
|
* ...
|
||||||
|
* |
|
||||||
|
* filters
|
||||||
|
* /\ \
|
||||||
|
* / \ \
|
||||||
|
* pivot list Odoo chart
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** TODO: Introduce a position parameter to the plugin registry in order to load them in a specific order */
|
||||||
|
import * as spreadsheet from "@odoo/o-spreadsheet";
|
||||||
|
const { corePluginRegistry, coreViewsPluginRegistry } = spreadsheet.registries;
|
||||||
|
|
||||||
|
import { GlobalFiltersCorePlugin, GlobalFiltersUIPlugin } from "@spreadsheet/global_filters/index";
|
||||||
|
import { PivotCorePlugin, PivotUIPlugin } from "@spreadsheet/pivot/index"; // list depends on filter for its getters
|
||||||
|
import { ListCorePlugin, ListUIPlugin } from "@spreadsheet/list/index"; // pivot depends on filter for its getters
|
||||||
|
import {
|
||||||
|
ChartOdooMenuPlugin,
|
||||||
|
OdooChartCorePlugin,
|
||||||
|
OdooChartUIPlugin,
|
||||||
|
} from "@spreadsheet/chart/index"; // Odoochart depends on filter for its getters
|
||||||
|
|
||||||
|
corePluginRegistry.add("OdooGlobalFiltersCorePlugin", GlobalFiltersCorePlugin);
|
||||||
|
corePluginRegistry.add("OdooPivotCorePlugin", PivotCorePlugin);
|
||||||
|
corePluginRegistry.add("OdooListCorePlugin", ListCorePlugin);
|
||||||
|
corePluginRegistry.add("odooChartCorePlugin", OdooChartCorePlugin);
|
||||||
|
corePluginRegistry.add("chartOdooMenuPlugin", ChartOdooMenuPlugin);
|
||||||
|
|
||||||
|
coreViewsPluginRegistry.add("OdooGlobalFiltersUIPlugin", GlobalFiltersUIPlugin);
|
||||||
|
coreViewsPluginRegistry.add("OdooPivotUIPlugin", PivotUIPlugin);
|
||||||
|
coreViewsPluginRegistry.add("OdooListUIPlugin", ListUIPlugin);
|
||||||
|
coreViewsPluginRegistry.add("odooChartUIPlugin", OdooChartUIPlugin);
|
138
static/src/ir_ui_menu/index.js
Normal file
138
static/src/ir_ui_menu/index.js
Normal file
@ -0,0 +1,138 @@
|
|||||||
|
/** @odoo-module */
|
||||||
|
|
||||||
|
import { registry } from "@web/core/registry";
|
||||||
|
import * as spreadsheet from "@odoo/o-spreadsheet";
|
||||||
|
|
||||||
|
import { IrMenuPlugin } from "./ir_ui_menu_plugin";
|
||||||
|
|
||||||
|
import {
|
||||||
|
isMarkdownIrMenuIdUrl,
|
||||||
|
isIrMenuXmlUrl,
|
||||||
|
isMarkdownViewUrl,
|
||||||
|
parseIrMenuXmlUrl,
|
||||||
|
parseViewLink,
|
||||||
|
parseIrMenuIdLink,
|
||||||
|
} from "./odoo_menu_link_cell";
|
||||||
|
import { _t } from "@web/core/l10n/translation";
|
||||||
|
import { sprintf } from "@web/core/utils/strings";
|
||||||
|
|
||||||
|
const { urlRegistry, corePluginRegistry } = spreadsheet.registries;
|
||||||
|
const { EvaluationError } = spreadsheet;
|
||||||
|
|
||||||
|
corePluginRegistry.add("ir_ui_menu_plugin", IrMenuPlugin);
|
||||||
|
|
||||||
|
class BadOdooLinkError extends EvaluationError {
|
||||||
|
constructor(menuId) {
|
||||||
|
super(
|
||||||
|
_t("#LINK"),
|
||||||
|
sprintf(_t("Menu %s not found. You may not have the required access rights."), menuId)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const spreadsheetLinkMenuCellService = {
|
||||||
|
dependencies: ["menu"],
|
||||||
|
start(env) {
|
||||||
|
function _getIrMenuByXmlId(xmlId) {
|
||||||
|
const menu = env.services.menu.getAll().find((menu) => menu.xmlid === xmlId);
|
||||||
|
if (!menu) {
|
||||||
|
throw new BadOdooLinkError(xmlId);
|
||||||
|
}
|
||||||
|
return menu;
|
||||||
|
}
|
||||||
|
|
||||||
|
urlRegistry
|
||||||
|
.add("OdooMenuIdLink", {
|
||||||
|
sequence: 65,
|
||||||
|
match: isMarkdownIrMenuIdUrl,
|
||||||
|
createLink(url, label) {
|
||||||
|
const menuId = parseIrMenuIdLink(url);
|
||||||
|
const menu = env.services.menu.getMenu(menuId);
|
||||||
|
if (!menu) {
|
||||||
|
throw new BadOdooLinkError(menuId);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
url,
|
||||||
|
label: _t(label),
|
||||||
|
isExternal: false,
|
||||||
|
isUrlEditable: false,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
urlRepresentation(url) {
|
||||||
|
const menuId = parseIrMenuIdLink(url);
|
||||||
|
return env.services.menu.getMenu(menuId).name;
|
||||||
|
},
|
||||||
|
open(url) {
|
||||||
|
const menuId = parseIrMenuIdLink(url);
|
||||||
|
const menu = env.services.menu.getMenu(menuId);
|
||||||
|
env.services.action.doAction(menu.actionID);
|
||||||
|
},
|
||||||
|
// createCell: (id, content, properties, sheetId, getters) => {
|
||||||
|
// const { url } = parseMarkdownLink(content);
|
||||||
|
// const menuId = parseIrMenuIdLink(url);
|
||||||
|
// const menuName = env.services.menu.getMenu(menuId).name;
|
||||||
|
// return new OdooMenuLinkCell(id, content, menuId, menuName, properties);
|
||||||
|
// },
|
||||||
|
})
|
||||||
|
.add("OdooMenuXmlLink", {
|
||||||
|
sequence: 66,
|
||||||
|
match: isIrMenuXmlUrl,
|
||||||
|
createLink(url, label) {
|
||||||
|
const xmlId = parseIrMenuXmlUrl(url);
|
||||||
|
_getIrMenuByXmlId(xmlId);
|
||||||
|
return {
|
||||||
|
url,
|
||||||
|
label: _t(label),
|
||||||
|
isExternal: false,
|
||||||
|
isUrlEditable: false,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
urlRepresentation(url) {
|
||||||
|
const xmlId = parseIrMenuXmlUrl(url);
|
||||||
|
const menuId = _getIrMenuByXmlId(xmlId).id;
|
||||||
|
return env.services.menu.getMenu(menuId).name;
|
||||||
|
},
|
||||||
|
open(url) {
|
||||||
|
const xmlId = parseIrMenuXmlUrl(url);
|
||||||
|
const menuId = _getIrMenuByXmlId(xmlId).id;
|
||||||
|
const menu = env.services.menu.getMenu(menuId);
|
||||||
|
env.services.action.doAction(menu.actionID);
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.add("OdooViewLink", {
|
||||||
|
sequence: 67,
|
||||||
|
match: isMarkdownViewUrl,
|
||||||
|
createLink(url, label) {
|
||||||
|
return {
|
||||||
|
url,
|
||||||
|
label: _t(label),
|
||||||
|
isExternal: false,
|
||||||
|
isUrlEditable: false,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
urlRepresentation(url) {
|
||||||
|
const actionDescription = parseViewLink(url);
|
||||||
|
return actionDescription.name;
|
||||||
|
},
|
||||||
|
open(url) {
|
||||||
|
const { viewType, action, name } = parseViewLink(url);
|
||||||
|
env.services.action.doAction(
|
||||||
|
{
|
||||||
|
type: "ir.actions.act_window",
|
||||||
|
name: name,
|
||||||
|
res_model: action.modelName,
|
||||||
|
views: action.views,
|
||||||
|
target: "current",
|
||||||
|
domain: action.domain,
|
||||||
|
context: action.context,
|
||||||
|
},
|
||||||
|
{ viewType }
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
registry.category("services").add("spreadsheetLinkMenuCell", spreadsheetLinkMenuCellService);
|
24
static/src/ir_ui_menu/ir_ui_menu_plugin.js
Normal file
24
static/src/ir_ui_menu/ir_ui_menu_plugin.js
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
/** @odoo-module */
|
||||||
|
import * as spreadsheet from "@odoo/o-spreadsheet";
|
||||||
|
const { CorePlugin } = spreadsheet;
|
||||||
|
|
||||||
|
export class IrMenuPlugin extends CorePlugin {
|
||||||
|
constructor(config) {
|
||||||
|
super(config);
|
||||||
|
this.env = config.custom.env;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get an ir menu from an id or an xml id
|
||||||
|
* @param {number | string} menuId
|
||||||
|
* @returns {object | undefined}
|
||||||
|
*/
|
||||||
|
getIrMenu(menuId) {
|
||||||
|
let menu = this.env.services.menu.getMenu(menuId);
|
||||||
|
if (!menu) {
|
||||||
|
menu = this.env.services.menu.getAll().find((menu) => menu.xmlid === menuId);
|
||||||
|
}
|
||||||
|
return menu;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
IrMenuPlugin.getters = ["getIrMenu"];
|
105
static/src/ir_ui_menu/odoo_menu_link_cell.js
Normal file
105
static/src/ir_ui_menu/odoo_menu_link_cell.js
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
/** @odoo-module */
|
||||||
|
|
||||||
|
const VIEW_PREFIX = "odoo://view/";
|
||||||
|
const IR_MENU_ID_PREFIX = "odoo://ir_menu_id/";
|
||||||
|
const IR_MENU_XML_ID_PREFIX = "odoo://ir_menu_xml_id/";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef Action
|
||||||
|
* @property {Array} domain
|
||||||
|
* @property {Object} context
|
||||||
|
* @property {string} modelName
|
||||||
|
* @property {string} orderBy
|
||||||
|
* @property {Array<[boolean, string]>} views
|
||||||
|
*
|
||||||
|
* @typedef ViewLinkDescription
|
||||||
|
* @property {string} name Action name
|
||||||
|
* @property {Action} action
|
||||||
|
* @property {string} viewType Type of view (list, pivot, ...)
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param {string} url
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
export function isMarkdownViewUrl(url) {
|
||||||
|
return url.startsWith(VIEW_PREFIX);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param {string} viewLink
|
||||||
|
* @returns {ViewLinkDescription}
|
||||||
|
*/
|
||||||
|
export function parseViewLink(viewLink) {
|
||||||
|
if (viewLink.startsWith(VIEW_PREFIX)) {
|
||||||
|
return JSON.parse(viewLink.substr(VIEW_PREFIX.length));
|
||||||
|
}
|
||||||
|
throw new Error(`${viewLink} is not a valid view link`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {ViewLinkDescription} viewDescription Id of the ir.filter
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
export function buildViewLink(viewDescription) {
|
||||||
|
return `${VIEW_PREFIX}${JSON.stringify(viewDescription)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param {string} url
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
export function isMarkdownIrMenuIdUrl(url) {
|
||||||
|
return url.startsWith(IR_MENU_ID_PREFIX);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param {string} irMenuLink
|
||||||
|
* @returns ir.ui.menu record id
|
||||||
|
*/
|
||||||
|
export function parseIrMenuIdLink(irMenuLink) {
|
||||||
|
if (irMenuLink.startsWith(IR_MENU_ID_PREFIX)) {
|
||||||
|
return parseInt(irMenuLink.substr(IR_MENU_ID_PREFIX.length), 10);
|
||||||
|
}
|
||||||
|
throw new Error(`${irMenuLink} is not a valid menu id link`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {number} menuId
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
|
export function buildIrMenuIdLink(menuId) {
|
||||||
|
return `${IR_MENU_ID_PREFIX}${menuId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param {string} url
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
export function isIrMenuXmlUrl(url) {
|
||||||
|
return url.startsWith(IR_MENU_XML_ID_PREFIX);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param {string} irMenuUrl
|
||||||
|
* @returns {string} ir.ui.menu record id
|
||||||
|
*/
|
||||||
|
export function parseIrMenuXmlUrl(irMenuUrl) {
|
||||||
|
if (irMenuUrl.startsWith(IR_MENU_XML_ID_PREFIX)) {
|
||||||
|
return irMenuUrl.substr(IR_MENU_XML_ID_PREFIX.length);
|
||||||
|
}
|
||||||
|
throw new Error(`${irMenuUrl} is not a valid menu xml link`);
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* @param {number} menuXmlId
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
|
export function buildIrMenuXmlLink(menuXmlId) {
|
||||||
|
return `${IR_MENU_XML_ID_PREFIX}${menuXmlId}`;
|
||||||
|
}
|
67
static/src/list/index.js
Normal file
67
static/src/list/index.js
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
/** @odoo-module */
|
||||||
|
import { _t } from "@web/core/l10n/translation";
|
||||||
|
|
||||||
|
import * as spreadsheet from "@odoo/o-spreadsheet";
|
||||||
|
|
||||||
|
import "./list_functions";
|
||||||
|
|
||||||
|
import { ListCorePlugin } from "@spreadsheet/list/plugins/list_core_plugin";
|
||||||
|
import { ListUIPlugin } from "@spreadsheet/list/plugins/list_ui_plugin";
|
||||||
|
|
||||||
|
import { SEE_RECORD_LIST, SEE_RECORD_LIST_VISIBLE } from "./list_actions";
|
||||||
|
const { inverseCommandRegistry } = spreadsheet.registries;
|
||||||
|
|
||||||
|
function identity(cmd) {
|
||||||
|
return [cmd];
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
coreTypes,
|
||||||
|
invalidateEvaluationCommands,
|
||||||
|
invalidateCFEvaluationCommands,
|
||||||
|
invalidateDependenciesCommands,
|
||||||
|
} = spreadsheet;
|
||||||
|
|
||||||
|
const { cellMenuRegistry } = spreadsheet.registries;
|
||||||
|
|
||||||
|
coreTypes.add("INSERT_ODOO_LIST");
|
||||||
|
coreTypes.add("RENAME_ODOO_LIST");
|
||||||
|
coreTypes.add("REMOVE_ODOO_LIST");
|
||||||
|
coreTypes.add("RE_INSERT_ODOO_LIST");
|
||||||
|
coreTypes.add("UPDATE_ODOO_LIST_DOMAIN");
|
||||||
|
coreTypes.add("ADD_LIST_DOMAIN");
|
||||||
|
|
||||||
|
invalidateEvaluationCommands.add("UPDATE_ODOO_LIST_DOMAIN");
|
||||||
|
invalidateEvaluationCommands.add("INSERT_ODOO_LIST");
|
||||||
|
invalidateEvaluationCommands.add("REMOVE_ODOO_LIST");
|
||||||
|
|
||||||
|
invalidateDependenciesCommands.add("UPDATE_ODOO_LIST_DOMAIN");
|
||||||
|
invalidateDependenciesCommands.add("INSERT_ODOO_LIST");
|
||||||
|
invalidateDependenciesCommands.add("REMOVE_ODOO_LIST");
|
||||||
|
|
||||||
|
invalidateCFEvaluationCommands.add("UPDATE_ODOO_LIST_DOMAIN");
|
||||||
|
invalidateCFEvaluationCommands.add("INSERT_ODOO_LIST");
|
||||||
|
invalidateCFEvaluationCommands.add("REMOVE_ODOO_LIST");
|
||||||
|
|
||||||
|
cellMenuRegistry.add("list_see_record", {
|
||||||
|
name: _t("See record"),
|
||||||
|
sequence: 200,
|
||||||
|
execute: async (env) => {
|
||||||
|
const position = env.model.getters.getActivePosition();
|
||||||
|
await SEE_RECORD_LIST(position, env);
|
||||||
|
},
|
||||||
|
isVisible: (env) => {
|
||||||
|
const position = env.model.getters.getActivePosition();
|
||||||
|
return SEE_RECORD_LIST_VISIBLE(position, env);
|
||||||
|
},
|
||||||
|
icon: "o-spreadsheet-Icon.SEE_RECORDS",
|
||||||
|
});
|
||||||
|
|
||||||
|
inverseCommandRegistry
|
||||||
|
.add("INSERT_ODOO_LIST", identity)
|
||||||
|
.add("UPDATE_ODOO_LIST_DOMAIN", identity)
|
||||||
|
.add("RE_INSERT_ODOO_LIST", identity)
|
||||||
|
.add("RENAME_ODOO_LIST", identity)
|
||||||
|
.add("REMOVE_ODOO_LIST", identity);
|
||||||
|
|
||||||
|
export { ListCorePlugin, ListUIPlugin };
|
43
static/src/list/list_actions.js
Normal file
43
static/src/list/list_actions.js
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
/** @odoo-module */
|
||||||
|
|
||||||
|
import { astToFormula } from "@odoo/o-spreadsheet";
|
||||||
|
import { getFirstListFunction, getNumberOfListFormulas } from "./list_helpers";
|
||||||
|
|
||||||
|
export const SEE_RECORD_LIST = async (position, env) => {
|
||||||
|
const cell = env.model.getters.getCell(position);
|
||||||
|
const sheetId = position.sheetId;
|
||||||
|
if (!cell) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { args } = getFirstListFunction(cell.compiledFormula.tokens);
|
||||||
|
const evaluatedArgs = args
|
||||||
|
.map(astToFormula)
|
||||||
|
.map((arg) => env.model.getters.evaluateFormula(sheetId, arg));
|
||||||
|
const listId = env.model.getters.getListIdFromPosition(position);
|
||||||
|
const { model } = env.model.getters.getListDefinition(listId);
|
||||||
|
const dataSource = await env.model.getters.getAsyncListDataSource(listId);
|
||||||
|
const recordId = dataSource.getIdFromPosition(evaluatedArgs[1] - 1);
|
||||||
|
if (!recordId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await env.services.action.doAction({
|
||||||
|
type: "ir.actions.act_window",
|
||||||
|
res_model: model,
|
||||||
|
res_id: recordId,
|
||||||
|
views: [[false, "form"]],
|
||||||
|
view_mode: "form",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SEE_RECORD_LIST_VISIBLE = (position, env) => {
|
||||||
|
const evaluatedCell = env.model.getters.getEvaluatedCell(position);
|
||||||
|
const cell = env.model.getters.getCell(position);
|
||||||
|
return (
|
||||||
|
evaluatedCell.type !== "empty" &&
|
||||||
|
evaluatedCell.type !== "error" &&
|
||||||
|
cell &&
|
||||||
|
cell.isFormula &&
|
||||||
|
getNumberOfListFormulas(cell.compiledFormula.tokens) === 1 &&
|
||||||
|
getFirstListFunction(cell.compiledFormula.tokens).functionName === "ODOO.LIST"
|
||||||
|
);
|
||||||
|
};
|
217
static/src/list/list_data_source.js
Normal file
217
static/src/list/list_data_source.js
Normal file
@ -0,0 +1,217 @@
|
|||||||
|
/** @odoo-module */
|
||||||
|
|
||||||
|
import { OdooViewsDataSource } from "@spreadsheet/data_sources/odoo_views_data_source";
|
||||||
|
import { LoadingDataError } from "@spreadsheet/o_spreadsheet/errors";
|
||||||
|
import { _t } from "@web/core/l10n/translation";
|
||||||
|
import { sprintf } from "@web/core/utils/strings";
|
||||||
|
import {
|
||||||
|
formatDateTime,
|
||||||
|
deserializeDateTime,
|
||||||
|
formatDate,
|
||||||
|
deserializeDate,
|
||||||
|
} from "@web/core/l10n/dates";
|
||||||
|
import { orderByToString } from "@web/search/utils/order_by";
|
||||||
|
|
||||||
|
import * as spreadsheet from "@odoo/o-spreadsheet";
|
||||||
|
|
||||||
|
const { toNumber } = spreadsheet.helpers;
|
||||||
|
const { DEFAULT_LOCALE } = spreadsheet.constants;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {import("@spreadsheet/data_sources/metadata_repository").Field} Field
|
||||||
|
*
|
||||||
|
* @typedef {Object} ListMetaData
|
||||||
|
* @property {Array<string>} columns
|
||||||
|
* @property {string} resModel
|
||||||
|
* @property {Record<string, Field>} fields
|
||||||
|
*
|
||||||
|
* @typedef {Object} ListSearchParams
|
||||||
|
* @property {Array<string>} orderBy
|
||||||
|
* @property {Object} domain
|
||||||
|
* @property {Object} context
|
||||||
|
*/
|
||||||
|
|
||||||
|
export class ListDataSource extends OdooViewsDataSource {
|
||||||
|
/**
|
||||||
|
* @override
|
||||||
|
* @param {Object} services Services (see DataSource)
|
||||||
|
* @param {Object} params
|
||||||
|
* @param {ListMetaData} params.metaData
|
||||||
|
* @param {ListSearchParams} params.searchParams
|
||||||
|
* @param {number} params.limit
|
||||||
|
*/
|
||||||
|
constructor(services, params) {
|
||||||
|
super(services, params);
|
||||||
|
this.maxPosition = params.limit;
|
||||||
|
this.maxPositionFetched = 0;
|
||||||
|
this.data = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Increase the max position of the list
|
||||||
|
* @param {number} position
|
||||||
|
*/
|
||||||
|
increaseMaxPosition(position) {
|
||||||
|
this.maxPosition = Math.max(this.maxPosition, position);
|
||||||
|
}
|
||||||
|
|
||||||
|
async _load() {
|
||||||
|
await super._load();
|
||||||
|
if (this.maxPosition === 0) {
|
||||||
|
this.data = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { domain, orderBy, context } = this._searchParams;
|
||||||
|
this.data = await this._orm.searchRead(
|
||||||
|
this._metaData.resModel,
|
||||||
|
domain,
|
||||||
|
this._getFieldsToFetch(),
|
||||||
|
{
|
||||||
|
order: orderByToString(orderBy),
|
||||||
|
limit: this.maxPosition,
|
||||||
|
context,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
this.maxPositionFetched = this.maxPosition;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the fields to fetch from the server.
|
||||||
|
* Automatically add the currency field if the field is a monetary field.
|
||||||
|
*/
|
||||||
|
_getFieldsToFetch() {
|
||||||
|
const fields = this._metaData.columns.filter((f) => this.getField(f));
|
||||||
|
for (const field of fields) {
|
||||||
|
if (this.getField(field).type === "monetary") {
|
||||||
|
fields.push(this.getField(field).currency_field);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fields;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {number} position
|
||||||
|
* @returns {number}
|
||||||
|
*/
|
||||||
|
getIdFromPosition(position) {
|
||||||
|
this._assertDataIsLoaded();
|
||||||
|
const record = this.data[position];
|
||||||
|
return record ? record.id : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} fieldName
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
getListHeaderValue(fieldName) {
|
||||||
|
this._assertDataIsLoaded();
|
||||||
|
const field = this.getField(fieldName);
|
||||||
|
return field ? field.string : fieldName;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {number} position
|
||||||
|
* @param {string} fieldName
|
||||||
|
* @returns {string|number|undefined}
|
||||||
|
*/
|
||||||
|
getListCellValue(position, fieldName) {
|
||||||
|
this._assertDataIsLoaded();
|
||||||
|
if (position >= this.maxPositionFetched) {
|
||||||
|
this.increaseMaxPosition(position + 1);
|
||||||
|
// A reload is needed because the asked position is not already loaded.
|
||||||
|
this._triggerFetching();
|
||||||
|
throw new LoadingDataError();
|
||||||
|
}
|
||||||
|
const record = this.data[position];
|
||||||
|
if (!record) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
const field = this.getField(fieldName);
|
||||||
|
if (!field) {
|
||||||
|
throw new Error(
|
||||||
|
sprintf(
|
||||||
|
_t("The field %s does not exist or you do not have access to that field"),
|
||||||
|
fieldName
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!(fieldName in record)) {
|
||||||
|
this._metaData.columns.push(fieldName);
|
||||||
|
this._metaData.columns = [...new Set(this._metaData.columns)]; //Remove duplicates
|
||||||
|
this._triggerFetching();
|
||||||
|
throw new LoadingDataError();
|
||||||
|
}
|
||||||
|
switch (field.type) {
|
||||||
|
case "many2one":
|
||||||
|
return record[fieldName].length === 2 ? record[fieldName][1] : "";
|
||||||
|
case "one2many":
|
||||||
|
case "many2many": {
|
||||||
|
const labels = record[fieldName]
|
||||||
|
.map((id) => this._metadataRepository.getRecordDisplayName(field.relation, id))
|
||||||
|
.filter((value) => value !== undefined);
|
||||||
|
return labels.join(", ");
|
||||||
|
}
|
||||||
|
case "selection": {
|
||||||
|
const key = record[fieldName];
|
||||||
|
const value = field.selection.find((array) => array[0] === key);
|
||||||
|
return value ? value[1] : "";
|
||||||
|
}
|
||||||
|
case "boolean":
|
||||||
|
return record[fieldName] ? "TRUE" : "FALSE";
|
||||||
|
case "date":
|
||||||
|
return record[fieldName]
|
||||||
|
? toNumber(this._formatDate(record[fieldName]), DEFAULT_LOCALE)
|
||||||
|
: "";
|
||||||
|
case "datetime":
|
||||||
|
return record[fieldName]
|
||||||
|
? toNumber(this._formatDateTime(record[fieldName]), DEFAULT_LOCALE)
|
||||||
|
: "";
|
||||||
|
case "properties": {
|
||||||
|
const properties = record[fieldName] || [];
|
||||||
|
return properties.map((property) => property.string).join(", ");
|
||||||
|
}
|
||||||
|
case "json":
|
||||||
|
throw new Error(sprintf(_t('Fields of type "%s" are not supported'), "json"));
|
||||||
|
default:
|
||||||
|
return record[fieldName] || "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
//--------------------------------------------------------------------------
|
||||||
|
// Private
|
||||||
|
//--------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_formatDateTime(dateValue) {
|
||||||
|
const date = deserializeDateTime(dateValue);
|
||||||
|
return formatDateTime(date, {
|
||||||
|
format: "yyyy-MM-dd HH:mm:ss",
|
||||||
|
numberingSystem: "latn",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
_formatDate(dateValue) {
|
||||||
|
const date = deserializeDate(dateValue);
|
||||||
|
return formatDate(date, {
|
||||||
|
format: "yyyy-MM-dd",
|
||||||
|
numberingSystem: "latn",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ask the parent data source to force a reload of this data source in the
|
||||||
|
* next clock cycle. It's necessary when this.limit was updated and new
|
||||||
|
* records have to be fetched.
|
||||||
|
*/
|
||||||
|
_triggerFetching() {
|
||||||
|
if (this._fetchingPromise) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this._fetchingPromise = Promise.resolve().then(() => {
|
||||||
|
new Promise((resolve) => {
|
||||||
|
this.load({ reload: true });
|
||||||
|
this._fetchingPromise = undefined;
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user