Начальное наполнение

This commit is contained in:
parent e138446add
commit 18852beb75
174 changed files with 501289 additions and 0 deletions

3
__init__.py Normal file
View 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
View 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

File diff suppressed because it is too large Load Diff

11472
i18n/bg.po Normal file

File diff suppressed because it is too large Load Diff

11622
i18n/ca.po Normal file

File diff suppressed because it is too large Load Diff

11500
i18n/cs.po Normal file

File diff suppressed because it is too large Load Diff

11496
i18n/da.po Normal file

File diff suppressed because it is too large Load Diff

12092
i18n/de.po Normal file

File diff suppressed because it is too large Load Diff

11968
i18n/es.po Normal file

File diff suppressed because it is too large Load Diff

11964
i18n/es_419.po Normal file

File diff suppressed because it is too large Load Diff

11477
i18n/et.po Normal file

File diff suppressed because it is too large Load Diff

11482
i18n/fa.po Normal file

File diff suppressed because it is too large Load Diff

11921
i18n/fi.po Normal file

File diff suppressed because it is too large Load Diff

12040
i18n/fr.po Normal file

File diff suppressed because it is too large Load Diff

11485
i18n/he.po Normal file

File diff suppressed because it is too large Load Diff

11483
i18n/hu.po Normal file

File diff suppressed because it is too large Load Diff

11913
i18n/id.po Normal file

File diff suppressed because it is too large Load Diff

12008
i18n/it.po Normal file

File diff suppressed because it is too large Load Diff

11503
i18n/ja.po Normal file

File diff suppressed because it is too large Load Diff

11514
i18n/ko.po Normal file

File diff suppressed because it is too large Load Diff

11483
i18n/lt.po Normal file

File diff suppressed because it is too large Load Diff

11467
i18n/lv.po Normal file

File diff suppressed because it is too large Load Diff

12044
i18n/nl.po Normal file

File diff suppressed because it is too large Load Diff

11795
i18n/pl.po Normal file

File diff suppressed because it is too large Load Diff

11463
i18n/pt.po Normal file

File diff suppressed because it is too large Load Diff

11979
i18n/pt_BR.po Normal file

File diff suppressed because it is too large Load Diff

11988
i18n/ru.po Normal file

File diff suppressed because it is too large Load Diff

11460
i18n/sk.po Normal file

File diff suppressed because it is too large Load Diff

11469
i18n/sl.po Normal file

File diff suppressed because it is too large Load Diff

11456
i18n/spreadsheet.pot Normal file

File diff suppressed because it is too large Load Diff

11565
i18n/sr.po Normal file

File diff suppressed because it is too large Load Diff

11473
i18n/sv.po Normal file

File diff suppressed because it is too large Load Diff

11692
i18n/th.po Normal file

File diff suppressed because it is too large Load Diff

11538
i18n/tr.po Normal file

File diff suppressed because it is too large Load Diff

11567
i18n/uk.po Normal file

File diff suppressed because it is too large Load Diff

11761
i18n/vi.po Normal file

File diff suppressed because it is too large Load Diff

11488
i18n/zh_CN.po Normal file

File diff suppressed because it is too large Load Diff

11487
i18n/zh_TW.po Normal file

File diff suppressed because it is too large Load Diff

6
models/__init__.py Normal file
View 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
View File

@ -0,0 +1,54 @@
from odoo import api, models
class ResCurrency(models.Model):
_inherit = "res.currency"
@api.model
def get_currencies_for_spreadsheet(self, currency_names):
"""
Returns the currency structure of provided currency names.
This function is meant to be called by the spreadsheet js lib,
hence the formatting of the result.
:currency_names list(str): list of currency names (e.g. ["EUR", "USD", "CAD"])
:return: list of dicts of the form `{ "code": str, "symbol": str, "decimalPlaces": int, "position":str }`
"""
currencies = self.with_context(active_test=False).search(
[("name", "in", currency_names)],
)
result = []
for currency_name in currency_names:
currency = next(filter(lambda curr: curr.name == currency_name, currencies), None)
if currency:
currency_data = {
"code": currency.name,
"symbol": currency.symbol,
"decimalPlaces": currency.decimal_places,
"position": currency.position,
}
else:
currency_data = None
result.append(currency_data)
return result
@api.model
def get_company_currency_for_spreadsheet(self, company_id=None):
"""
Returns the currency structure for the currency of the company.
This function is meant to be called by the spreadsheet js lib,
hence the formatting of the result.
:company_id int: Id of the company
:return: dict of the form `{ "code": str, "symbol": str, "decimalPlaces": int, "position":str }`
"""
company = self.env["res.company"].browse(company_id) if company_id else self.env.company
if not company.exists():
return False
currency = company.currency_id
return {
"code": currency.name,
"symbol": currency.symbol,
"decimalPlaces": currency.decimal_places,
"position": currency.position,
}

View File

@ -0,0 +1,29 @@
from odoo import api, fields, models
class ResCurrencyRate(models.Model):
_inherit = "res.currency.rate"
@api.model
def _get_rate_for_spreadsheet(self, currency_from_code, currency_to_code, date=None):
if not currency_from_code or not currency_to_code:
return False
Currency = self.env["res.currency"].with_context({"active_test": False})
currency_from = Currency.search([("name", "=", currency_from_code)])
currency_to = Currency.search([("name", "=", currency_to_code)])
if not currency_from or not currency_to:
return False
company = self.env.company
date = fields.Date.from_string(date) if date else fields.Date.context_today(self)
return Currency._get_conversion_rate(currency_from, currency_to, company, date)
@api.model
def get_rates_for_spreadsheet(self, requests):
result = []
for request in requests:
record = request.copy()
record.update({
"rate": self._get_rate_for_spreadsheet(request["from"], request["to"], request.get("date")),
})
result.append(record)
return result

38
models/res_lang.py Normal file
View 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
View 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()

View File

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

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@ -0,0 +1,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 });

View 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") },
];

View 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");
}

View 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);

View File

@ -0,0 +1,47 @@
/** @odoo-module */
import { OdooViewsDataSource } from "@spreadsheet/data_sources/odoo_views_data_source";
import { _t } from "@web/core/l10n/translation";
import { GraphModel as ChartModel } from "@web/views/graph/graph_model";
export 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
View 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 };

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

View 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.");
}
}
}

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

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

View 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();
}
},
});

View File

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

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

View 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");

View 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",
];

View 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"];

View 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);

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

View File

@ -0,0 +1,4 @@
.spreadsheet_share_dropdown {
width: 320px;
height: 100px;
}

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

View File

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

View File

@ -0,0 +1,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"],
});

View 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,
});
}

View 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);

View File

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

View File

@ -0,0 +1,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()
)
);
}
}

View 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();
}
}

View File

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

View File

@ -0,0 +1,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));
}
}
}

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

View File

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

View File

@ -0,0 +1,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);
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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]]);
}

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

View 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",
];

View 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",
];

View 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") },
];

View 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
View 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();
},
};

View 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
View 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
View File

@ -0,0 +1,40 @@
/** @odoo-module */
/**
* This file is meant to load the different subparts of the module
* to guarantee their plugins are loaded in the right order
*
* dependency:
* other plugins
* |
* ...
* |
* filters
* /\ \
* / \ \
* pivot list Odoo chart
*/
/** TODO: Introduce a position parameter to the plugin registry in order to load them in a specific order */
import * 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);

View 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);

View 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"];

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

View 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"
);
};

View 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