19.0 vanilla

This commit is contained in:
Ernad Husremovic 2026-03-09 09:32:02 +01:00
parent 62d197ac8b
commit 184bb0e321
667 changed files with 691406 additions and 239886 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 557 B

View file

@ -0,0 +1 @@
<svg width="50" height="50" viewBox="0 0 50 50" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M46 42C46 44.2091 44.2091 46 42 46L4 46L4 8C4 5.79086 5.79086 4 8 4L46 4L46 42Z" fill="#1A6F66"/><path d="M18 18L46 18L46 42C46 44.2091 44.2091 46 42 46L18 46L18 18Z" fill="#1AD3BB"/><path d="M18 18L4 18L4 8C4 5.79086 5.79086 4 8 4L18 4L18 18Z" fill="#1AD3BB"/></svg>

After

Width:  |  Height:  |  Size: 372 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 3 KiB

File diff suppressed because it is too large Load diff

View file

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

View file

@ -0,0 +1,157 @@
import { FieldMatching } from "./global_filter.d";
import {
CorePlugin,
UIPlugin,
DispatchResult,
CommandResult,
AddPivotCommand,
UpdatePivotCommand,
CancelledReason,
} from "@odoo/o-spreadsheet";
import * as OdooCancelledReason from "@spreadsheet/o_spreadsheet/cancelled_reason";
type CoreDispatch = CorePlugin["dispatch"];
type UIDispatch = UIPlugin["dispatch"];
type CoreCommand = Parameters<CorePlugin["allowDispatch"]>[0];
type Command = Parameters<UIPlugin["allowDispatch"]>[0];
// TODO look for a way to remove this and use the real import * as OdooCancelledReason
type OdooCancelledReason = string;
declare module "@spreadsheet" {
interface OdooCommandDispatcher {
dispatch<T extends OdooCommandTypes, C extends Extract<OdooCommand, { type: T }>>(
type: {} extends Omit<C, "type"> ? T : never
): OdooDispatchResult;
dispatch<T extends OdooCommandTypes, C extends Extract<OdooCommand, { type: T }>>(
type: T,
r: Omit<C, "type">
): OdooDispatchResult;
}
interface OdooCoreCommandDispatcher {
dispatch<T extends OdooCoreCommandTypes, C extends Extract<OdooCoreCommand, { type: T }>>(
type: {} extends Omit<C, "type"> ? T : never
): OdooDispatchResult;
dispatch<T extends OdooCoreCommandTypes, C extends Extract<OdooCoreCommand, { type: T }>>(
type: T,
r: Omit<C, "type">
): OdooDispatchResult;
}
interface OdooDispatchResult extends DispatchResult {
readonly reasons: (CancelledReason | OdooCancelledReason)[];
isCancelledBecause(reason: CancelledReason | OdooCancelledReason): boolean;
}
type OdooCommandTypes = OdooCommand["type"];
type OdooCoreCommandTypes = OdooCoreCommand["type"];
type OdooDispatch = UIDispatch & OdooCommandDispatcher["dispatch"];
type OdooCoreDispatch = CoreDispatch & OdooCoreCommandDispatcher["dispatch"];
// CORE
export interface ExtendedAddPivotCommand extends AddPivotCommand {
pivot: ExtendedPivotCoreDefinition;
}
export interface ExtendedUpdatePivotCommand extends UpdatePivotCommand {
pivot: ExtendedPivotCoreDefinition;
}
export interface AddThreadCommand {
type: "ADD_COMMENT_THREAD";
threadId: number;
sheetId: string;
col: number;
row: number;
}
export interface EditThreadCommand {
type: "EDIT_COMMENT_THREAD";
threadId: number;
sheetId: string;
col: number;
row: number;
isResolved: boolean;
}
export interface DeleteThreadCommand {
type: "DELETE_COMMENT_THREAD";
threadId: number;
sheetId: string;
col: number;
row: number;
}
// this command is deprecated. use UPDATE_PIVOT instead
export interface UpdatePivotDomainCommand {
type: "UPDATE_ODOO_PIVOT_DOMAIN";
pivotId: string;
domain: Array;
}
export interface AddGlobalFilterCommand {
type: "ADD_GLOBAL_FILTER";
filter: CmdGlobalFilter;
[string]: any; // Fields matching
}
export interface EditGlobalFilterCommand {
type: "EDIT_GLOBAL_FILTER";
filter: CmdGlobalFilter;
[string]: any; // Fields matching
}
export interface RemoveGlobalFilterCommand {
type: "REMOVE_GLOBAL_FILTER";
id: string;
}
export interface MoveGlobalFilterCommand {
type: "MOVE_GLOBAL_FILTER";
id: string;
delta: number;
}
// UI
export interface RefreshAllDataSourcesCommand {
type: "REFRESH_ALL_DATA_SOURCES";
}
export interface SetGlobalFilterValueCommand {
type: "SET_GLOBAL_FILTER_VALUE";
id: string;
value: any;
}
export interface SetManyGlobalFilterValueCommand {
type: "SET_MANY_GLOBAL_FILTER_VALUE";
filters: { filterId: string; value: any }[];
}
type OdooCoreCommand =
| ExtendedAddPivotCommand
| ExtendedUpdatePivotCommand
| UpdatePivotDomainCommand
| AddThreadCommand
| DeleteThreadCommand
| EditThreadCommand
| AddGlobalFilterCommand
| EditGlobalFilterCommand
| RemoveGlobalFilterCommand
| MoveGlobalFilterCommand;
export type AllCoreCommand = OdooCoreCommand | CoreCommand;
type OdooLocalCommand =
| RefreshAllDataSourcesCommand
| SetGlobalFilterValueCommand
| SetManyGlobalFilterValueCommand;
type OdooCommand = OdooCoreCommand | OdooLocalCommand;
export type AllCommand = OdooCommand | Command;
}

View file

@ -0,0 +1,11 @@
import { SpreadsheetChildEnv as SSChildEnv } from "@odoo/o-spreadsheet";
import { Services } from "services";
declare module "@spreadsheet" {
import { Model } from "@odoo/o-spreadsheet";
export interface SpreadsheetChildEnv extends SSChildEnv {
model: OdooSpreadsheetModel;
services: Services;
}
}

View file

@ -0,0 +1,11 @@
declare module "@spreadsheet" {
import { AddFunctionDescription, Arg, EvalContext } from "@odoo/o-spreadsheet";
export interface CustomFunctionDescription extends AddFunctionDescription {
compute: (this: ExtendedEvalContext, ...args: Arg[]) => any;
}
interface ExtendedEvalContext extends EvalContext {
getters: OdooGetters;
}
}

View file

@ -0,0 +1,74 @@
import { CorePlugin, Model, UID } from "@odoo/o-spreadsheet";
import { ChartOdooMenuPlugin, OdooChartCorePlugin, OdooChartCoreViewPlugin } from "@spreadsheet/chart";
import { CurrencyPlugin } from "@spreadsheet/currency/plugins/currency";
import { AccountingPlugin } from "addons/spreadsheet_account/static/src/plugins/accounting_plugin";
import { GlobalFiltersCorePlugin, GlobalFiltersCoreViewPlugin } from "@spreadsheet/global_filters";
import { ListCorePlugin, ListCoreViewPlugin } from "@spreadsheet/list";
import { IrMenuPlugin } from "@spreadsheet/ir_ui_menu/ir_ui_menu_plugin";
import { PivotOdooCorePlugin } from "@spreadsheet/pivot";
import { PivotCoreGlobalFilterPlugin } from "@spreadsheet/pivot/plugins/pivot_core_global_filter_plugin";
type Getters = Model["getters"];
type CoreGetters = CorePlugin["getters"];
/**
* Union of all getter names of a plugin.
*
* e.g. With the following plugin
* @example
* class MyPlugin {
* static getters = [
* "getCell",
* "getCellValue",
* ] as const;
* getCell() { ... }
* getCellValue() { ... }
* }
* type Names = GetterNames<typeof MyPlugin>
* // is equivalent to "getCell" | "getCellValue"
*/
type GetterNames<Plugin extends { getters: readonly string[] }> = Plugin["getters"][number];
/**
* Extract getter methods from a plugin, based on its `getters` static array.
* @example
* class MyPlugin {
* static getters = [
* "getCell",
* "getCellValue",
* ] as const;
* getCell() { ... }
* getCellValue() { ... }
* }
* type MyPluginGetters = PluginGetters<typeof MyPlugin>;
* // MyPluginGetters is equivalent to:
* // {
* // getCell: () => ...,
* // getCellValue: () => ...,
* // }
*/
type PluginGetters<Plugin extends { new (...args: unknown[]): any; getters: readonly string[] }> =
Pick<InstanceType<Plugin>, GetterNames<Plugin>>;
declare module "@spreadsheet" {
/**
* Add getters from custom plugins defined in odoo
*/
interface OdooCoreGetters extends CoreGetters {}
interface OdooCoreGetters extends PluginGetters<typeof GlobalFiltersCorePlugin> {}
interface OdooCoreGetters extends PluginGetters<typeof ListCorePlugin> {}
interface OdooCoreGetters extends PluginGetters<typeof OdooChartCorePlugin> {}
interface OdooCoreGetters extends PluginGetters<typeof ChartOdooMenuPlugin> {}
interface OdooCoreGetters extends PluginGetters<typeof IrMenuPlugin> {}
interface OdooCoreGetters extends PluginGetters<typeof PivotOdooCorePlugin> {}
interface OdooCoreGetters extends PluginGetters<typeof PivotCoreGlobalFilterPlugin> {}
interface OdooGetters extends Getters {}
interface OdooGetters extends OdooCoreGetters {}
interface OdooGetters extends PluginGetters<typeof GlobalFiltersCoreViewPlugin> {}
interface OdooGetters extends PluginGetters<typeof ListCoreViewPlugin> {}
interface OdooGetters extends PluginGetters<typeof OdooChartCoreViewPlugin> {}
interface OdooGetters extends PluginGetters<typeof CurrencyPlugin> {}
interface OdooGetters extends PluginGetters<typeof AccountingPlugin> {}
}

View file

@ -0,0 +1,175 @@
import { Range, RangeData } from "@odoo/o-spreadsheet";
import { DomainListRepr } from "@web/core/domain";
declare module "@spreadsheet" {
export type DateDefaultValue =
| "today"
| "yesterday"
| "last_7_days"
| "last_30_days"
| "last_90_days"
| "month_to_date"
| "last_month"
| "this_month"
| "this_quarter"
| "last_12_months"
| "this_year"
| "year_to_date";
export interface MonthDateValue {
type: "month";
year: number;
month: number; // 1-12
}
export interface QuarterDateValue {
type: "quarter";
year: number;
quarter: number; // 1-4
}
export interface YearDateValue {
type: "year";
year: number;
}
export interface RelativeDateValue {
type: "relative";
period:
| "today"
| "yesterday"
| "last_7_days"
| "last_30_days"
| "last_90_days"
| "month_to_date"
| "last_month"
| "last_12_months"
| "year_to_date";
}
export interface DateRangeValue {
type: "range";
from?: string;
to?: string;
}
export type DateValue =
| MonthDateValue
| QuarterDateValue
| YearDateValue
| RelativeDateValue
| DateRangeValue;
interface SetValue {
operator: "set" | "not set";
}
interface RelationIdsValue {
operator: "in" | "not in" | "child_of";
ids: number[];
}
interface RelationContainsValue {
operator: "ilike" | "not ilike" | "starts with";
strings: string[];
}
interface CurrentUser {
operator: "in" | "not in";
ids: "current_user";
}
export type RelationValue = RelationIdsValue | SetValue | RelationContainsValue;
type RelationDefaultValue = RelationValue | CurrentUser;
interface NumericUnaryValue {
operator: "=" | "!=" | ">" | "<";
targetValue: number;
}
interface NumericRangeValue {
operator: "between";
minimumValue: number;
maximumValue: number;
}
export type NumericValue = NumericUnaryValue | NumericRangeValue;
interface TextInValue {
operator: "in" | "not in";
strings: string[];
}
interface TextContainsValue {
operator: "ilike" | "not ilike" | "starts with";
text: string;
}
export type TextValue = TextInValue | TextContainsValue | SetValue;
interface SelectionInValue {
operator: "in" | "not in";
selectionValues: string[];
}
export interface FieldMatching {
chain: string;
type: string;
offset?: number;
}
export interface TextGlobalFilter {
type: "text";
id: string;
label: string;
rangesOfAllowedValues?: Range[];
defaultValue?: TextValue;
}
export interface SelectionGlobalFilter {
type: "selection";
id: string;
label: string;
resModel: string;
selectionField: string;
defaultValue?: SelectionInValue;
}
export interface CmdTextGlobalFilter extends TextGlobalFilter {
rangesOfAllowedValues?: RangeData[];
}
export interface DateGlobalFilter {
type: "date";
id: string;
label: string;
defaultValue?: DateDefaultValue;
}
export interface RelationalGlobalFilter {
type: "relation";
id: string;
label: string;
modelName: string;
includeChildren: boolean;
defaultValue?: RelationDefaultValue;
domainOfAllowedValues?: DomainListRepr | string;
}
export interface NumericGlobalFilter {
type: "numeric";
id: string;
label: string;
defaultValue?: NumericValue;
}
export interface BooleanGlobalFilter {
type: "boolean";
id: string;
label: string;
defaultValue?: SetValue;
}
export type GlobalFilter = TextGlobalFilter | DateGlobalFilter | RelationalGlobalFilter | BooleanGlobalFilter | SelectionGlobalFilter | NumericGlobalFilter;
export type CmdGlobalFilter = CmdTextGlobalFilter | DateGlobalFilter | RelationalGlobalFilter | BooleanGlobalFilter | SelectionGlobalFilter | NumericGlobalFilter;
}

View file

@ -0,0 +1,10 @@
declare module "@spreadsheet" {
export interface Zone {
bottom: number;
left: number;
right: number;
top: number;
}
export interface LazyTranslatedString extends String {}
}

View file

@ -0,0 +1,16 @@
declare module "@spreadsheet" {
import { Model } from "@odoo/o-spreadsheet";
export interface OdooSpreadsheetModel extends Model {
getters: OdooGetters;
dispatch: OdooDispatch;
}
export interface OdooSpreadsheetModelConstructor {
new (
data: object,
config: Partial<Model["config"]>,
revisions: object[]
): OdooSpreadsheetModel;
}
}

View file

@ -0,0 +1,75 @@
import { OdooPivotRuntimeDefinition } from "@spreadsheet/pivot/pivot_runtime";
import { ORM } from "@web/core/orm_service";
import { PivotMeasure } from "@spreadsheet/pivot/pivot_runtime";
import { ServerData } from "@spreadsheet/data_sources/server_data";
import { Pivot, CommonPivotCoreDefinition, PivotCoreDefinition } from "@odoo/o-spreadsheet";
declare module "@spreadsheet" {
export interface OdooPivotCoreDefinition extends CommonPivotCoreDefinition {
type: "ODOO";
model: string;
domain: Array;
context: Object;
actionXmlId: string;
}
export type ExtendedPivotCoreDefinition = PivotCoreDefinition | OdooPivotCoreDefinition;
interface OdooPivot<T> extends Pivot<T> {
type: ExtendedPivotCoreDefinition["type"];
}
export interface GFLocalPivot {
id: string;
fieldMatching: Record<string, any>;
}
export interface OdooField {
name: string;
type: string;
string: string;
relation?: string;
searchable?: boolean;
aggregator?: string;
store?: boolean;
}
export type OdooFields = Record<string, Field | undefined>;
export interface PivotMetaData {
colGroupBys: string[];
rowGroupBys: string[];
activeMeasures: string[];
resModel: string;
fields?: Record<string, Field | undefined>;
modelLabel?: string;
fieldAttrs: any;
}
export interface PivotSearchParams {
groupBy: string[];
orderBy: string[];
domain: Array;
context: Object;
}
/* Params used for the odoo pivot model */
export interface WebPivotModelParams {
metaData: PivotMetaData;
searchParams: PivotSearchParams;
}
export interface OdooPivotModelParams {
fields: OdooFields;
definition: OdooPivotRuntimeDefinition;
searchParams: {
domain: Array;
context: Object;
};
}
export interface PivotModelServices {
serverData: ServerData;
orm: ORM;
getters: OdooGetters;
}
}

View file

@ -0,0 +1,29 @@
declare module "@spreadsheet" {
import { CommandResult, CorePlugin, UIPlugin } from "@odoo/o-spreadsheet";
import { CommandResult as CR } from "@spreadsheet/o_spreadsheet/cancelled_reason";
type OdooCommandResult = CommandResult | typeof CR;
export interface OdooCorePlugin extends CorePlugin {
getters: OdooCoreGetters;
dispatch: OdooCoreDispatch;
allowDispatch(command: AllCoreCommand): string | string[];
beforeHandle(command: AllCoreCommand): void;
handle(command: AllCoreCommand): void;
}
export interface OdooCorePluginConstructor {
new (config: unknown): OdooCorePlugin;
}
export interface OdooUIPlugin extends UIPlugin {
getters: OdooGetters;
dispatch: OdooDispatch;
allowDispatch(command: AllCommand): string | string[];
beforeHandle(command: AllCommand): void;
handle(command: AllCommand): void;
}
export interface OdooUIPluginConstructor {
new (config: unknown): OdooUIPlugin;
}
}

View file

@ -0,0 +1,55 @@
/**
* @typedef {import("@web/webclient/actions/action_service").ActionOptions} ActionOptions
*/
/**
* @param {*} env
* @param {string} actionXmlId
* @param {Object} actionDescription
* @param {ActionOptions} options
*/
export async function navigateTo(env, actionXmlId, actionDescription, options) {
const actionService = env.services.action;
let navigateActionDescription;
const { views, view_mode, domain, context, name, res_model, res_id } = actionDescription;
try {
navigateActionDescription = await actionService.loadAction(actionXmlId, context);
const filteredViews = views.map(
([v, viewType]) =>
navigateActionDescription.views.find(([, type]) => viewType === type) || [
v,
viewType,
]
);
navigateActionDescription = {
...navigateActionDescription,
context,
domain,
name,
res_model,
res_id,
view_mode,
target: "current",
views: filteredViews,
};
} catch {
navigateActionDescription = {
type: "ir.actions.act_window",
name,
res_model,
res_id,
views,
target: "current",
domain,
context,
view_mode,
};
} finally {
await actionService.doAction(
// clear empty keys
JSON.parse(JSON.stringify(navigateActionDescription)),
options
);
}
}

View file

@ -0,0 +1,23 @@
import { useSpreadsheetNotificationStore } from "@spreadsheet/hooks";
import { Spreadsheet, Model } from "@odoo/o-spreadsheet";
import { Component } from "@odoo/owl";
/**
* Component wrapping the <Spreadsheet> component from o-spreadsheet
* to add user interactions extensions from odoo such as notifications,
* error dialogs, etc.
*/
export class SpreadsheetComponent extends Component {
static template = "spreadsheet.SpreadsheetComponent";
static components = { Spreadsheet };
static props = {
model: Model,
};
get model() {
return this.props.model;
}
setup() {
useSpreadsheetNotificationStore();
}
}

View file

@ -0,0 +1,5 @@
.o_spreadsheet_container {
flex: 1 1 auto;
overflow: auto;
height: 100%;
}

View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<templates>
<div t-name="spreadsheet.SpreadsheetComponent" class="o_spreadsheet_container o_field_highlight">
<Spreadsheet model="model"/>
</div>
</templates>

View file

@ -1,66 +1,46 @@
/** @odoo-module */
import { DataSources } from "@spreadsheet/data_sources/data_sources";
import { migrate } from "@spreadsheet/o_spreadsheet/migration";
import { download } from "@web/core/network/download";
import { registry } from "@web/core/registry";
import spreadsheet from "../o_spreadsheet/o_spreadsheet_extended";
import { createSpreadsheetModel, waitForDataLoaded } from "@spreadsheet/helpers/model";
import { user } from "@web/core/user";
import { _t } from "@web/core/l10n/translation";
const { Model } = spreadsheet;
/**
* @param {import("@web/env").OdooEnv} env
* @param {object} action
*/
async function downloadSpreadsheet(env, action) {
let { orm, name, data, stateUpdateMessages, xlsxData } = action.params;
const canExport = await user.hasGroup("base.group_allow_export");
if (!canExport) {
env.services.notification.add(
_t("You don't have the rights to export data. Please contact an Administrator."),
{
title: _t("Access Error"),
type: "danger",
}
);
return;
}
let { name, data, sources, stateUpdateMessages, xlsxData } = action.params;
if (!xlsxData) {
const dataSources = new DataSources(orm);
const model = new Model(migrate(data), { dataSources }, stateUpdateMessages);
const model = await createSpreadsheetModel({ env, data, revisions: stateUpdateMessages });
await waitForDataLoaded(model);
sources = model.getters.getLoadedDataSources();
xlsxData = model.exportXLSX();
}
await download({
url: "/spreadsheet/xlsx",
data: {
zip_name: `${name}.xlsx`,
files: new Blob([JSON.stringify(xlsxData.files)], { type: "application/json" }),
files: new Blob([JSON.stringify(xlsxData.files)], {
type: "application/json",
}),
datasources: new Blob([JSON.stringify(sources)], {
type: "application/json",
}),
},
});
}
/**
* Ensure that the spreadsheet does not contains cells that are in loading state
* @param {Model} model
* @returns {Promise<void>}
*/
export async function waitForDataLoaded(model) {
const dataSources = model.config.dataSources;
return new Promise((resolve, reject) => {
function check() {
model.dispatch("EVALUATE_CELLS");
if (isLoaded(model)) {
dataSources.removeEventListener("data-source-updated", check);
resolve();
}
}
dataSources.addEventListener("data-source-updated", check);
check();
});
}
function isLoaded(model) {
for (const sheetId of model.getters.getSheetIds()) {
for (const cell of Object.values(model.getters.getCells(sheetId))) {
if (
cell.evaluated &&
cell.evaluated.type === "error" &&
cell.evaluated.error.message === _t("Data is loading")
) {
return false;
}
}
}
return true;
}
registry
.category("actions")
.add("action_download_spreadsheet", downloadSpreadsheet, { force: true });

View file

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

View file

@ -1,48 +1,49 @@
/** @odoo-module **/
import { _t } from "@web/core/l10n/translation";
import { registry } from "@web/core/registry";
import { getBundle, loadBundle } from "@web/core/assets";
import { sprintf } from "@web/core/utils/strings";
import { loadBundle } from "@web/core/assets";
const actionRegistry = registry.category("actions");
/**
*
* @param {object} env
* Add a new function client action which loads the spreadsheet bundle, then
* launch the actual action.
* The action should be redefine in the bundle with `{ force: true }`
* and the actual action component or function
* @param {string} actionName
* @param {function} actionLazyLoader
* @param {string} [path]
* @param {string} [displayName]
*/
export async function loadSpreadsheetAction(env, actionName, actionLazyLoader) {
const desc = await getBundle("spreadsheet.o_spreadsheet");
await loadBundle(desc);
export function addSpreadsheetActionLazyLoader(actionName, path, displayName) {
const actionLazyLoader = async (env, action) => {
// load the bundle which should redefine the action in the registry
await loadBundle("spreadsheet.o_spreadsheet");
if (actionRegistry.get(actionName) === actionLazyLoader) {
// At this point, the real spreadsheet client action should be loaded and have
// replaced this function in the action registry. If it's not the case,
// it probably means that there was a crash in the bundle (e.g. syntax
// error). In this case, this action will remain in the registry, which
// will lead to an infinite loop. To prevent that, we push another action
// in the registry.
actionRegistry.add(
actionName,
() => {
const msg = sprintf(env._t("%s couldn't be loaded"), actionName);
env.services.notification.add(msg, { type: "danger" });
},
{ force: true }
);
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 = _t("%s couldn't be loaded", actionName);
env.services.notification.add(msg, { type: "danger" });
},
{ force: true }
);
}
// then do the action again, with the actual definition registered
return action;
};
if (path) {
actionLazyLoader.path = path;
}
if (displayName) {
actionLazyLoader.displayName = displayName;
}
actionRegistry.add(actionName, actionLazyLoader);
}
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);
addSpreadsheetActionLazyLoader("action_download_spreadsheet");

View file

@ -0,0 +1,19 @@
import { registry } from "@web/core/registry";
import { BinaryField, binaryField } from "@web/views/fields/binary/binary_field";
export class SpreadsheetBinaryField extends BinaryField {
static template = "spreadsheet.SpreadsheetBinaryField";
setup() {
super.setup();
}
async onFileDownload() {}
}
export const spreadsheetBinaryField = {
...binaryField,
component: SpreadsheetBinaryField,
};
registry.category("fields").add("binary_spreadsheet", spreadsheetBinaryField);

View file

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-name="spreadsheet.SpreadsheetBinaryField" t-inherit="web.BinaryField" t-inherit-mode="primary">
<xpath expr="//t[@name='download']" position="replace"></xpath>
<xpath expr="//span[hasclass('fa-download')]" position="replace">
<span class="fa fa-file-text-o me-2" />
</xpath>
</t>
</templates>

View file

@ -1,10 +1,9 @@
/** @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";
import { GraphModel as ChartModel } from "@web/views/graph/graph_model";
import { Domain } from "@web/core/domain";
export default class ChartDataSource extends OdooViewsDataSource {
export class ChartDataSource extends OdooViewsDataSource {
/**
* @override
* @param {Object} services Services (see DataSource)
@ -32,6 +31,8 @@ export default class ChartDataSource extends OdooViewsDataSource {
}
);
await this._model.load(this._searchParams);
this._hierarchicalData = undefined;
this.labelToDomainMapping = undefined;
}
getData() {
@ -44,4 +45,90 @@ export default class ChartDataSource extends OdooViewsDataSource {
}
return this._model.data;
}
getHierarchicalData() {
if (!this.isReady()) {
this.load();
return { datasets: [], labels: [] };
}
if (!this._isValid) {
return { datasets: [], labels: [] };
}
return this._getHierarchicalData();
}
get source() {
this._assertMetadataIsLoaded();
const data = this._metaData;
return {
resModel: data.resModel,
type: "graph",
fields: [data.measure],
groupby: data.groupBy,
domain: this._searchParams.domain,
};
}
changeChartType(newMode) {
this._metaData.mode = newMode;
this._model?.updateMetaData({ mode: newMode });
}
_getHierarchicalData() {
if (this._hierarchicalData && this.labelToDomainMapping) {
return this._hierarchicalData;
}
const dataPoints = this._model.dataPoints;
const groupBy = this._metaData.groupBy;
const datasets = new Array(groupBy.length).fill().map(() => ({ data: [], domains: [] }));
const labels = new Array();
const domainMapping = {};
for (const gb of groupBy) {
domainMapping[gb] = {};
}
for (const point of dataPoints) {
labels.push(point.value.toString());
for (let i = 0; i < groupBy.length; i++) {
datasets[i].data.push(point.labels[i]);
const label = point.labels[i];
if (!domainMapping[groupBy[i]][label]) {
const gb = groupBy[i].split(":")[0];
domainMapping[groupBy[i]][label] = point.domain.filter((d) => d[0] === gb);
}
}
}
this._hierarchicalData = { datasets, labels };
this.labelToDomainMapping = domainMapping;
return this._hierarchicalData;
}
/**
* Build a domain from the labels of the values of the groupBys.
* Only works if getHierarchicalData was called before to build a mapping between groupBy labels and domains.
*/
buildDomainFromGroupByLabels(groupByValuesLabels) {
const domains = [this._searchParams.domain];
for (let i = 0; i < groupByValuesLabels.length; i++) {
const groupBy = this._metaData.groupBy[i];
const label = groupByValuesLabels[i];
if (this.labelToDomainMapping[groupBy]?.[label]) {
domains.push(this.labelToDomainMapping[groupBy][label]);
}
}
return Domain.and(domains).toList();
}
}
export function chartTypeToDataSourceMode(chartType) {
switch (chartType) {
case "odoo_bar":
case "odoo_line":
case "odoo_pie":
return chartType.replace("odoo_", "");
default:
return "bar";
}
}

View file

@ -1,16 +1,205 @@
/** @odoo-module */
import * as spreadsheet from "@odoo/o-spreadsheet";
import { OdooChartCorePlugin } from "./plugins/odoo_chart_core_plugin";
import { ChartOdooMenuPlugin } from "./plugins/chart_odoo_menu_plugin";
import { OdooChartCoreViewPlugin } from "./plugins/odoo_chart_core_view_plugin";
import { _t } from "@web/core/l10n/translation";
import { chartOdooMenuPlugin } from "./odoo_menu/odoo_menu_chartjs_plugin";
import spreadsheet from "@spreadsheet/o_spreadsheet/o_spreadsheet_extended";
const { chartComponentRegistry, chartSubtypeRegistry, chartJsExtensionRegistry } =
spreadsheet.registries;
const { ChartJsComponent, ZoomableChartJsComponent } = spreadsheet.components;
const { chartComponentRegistry } = spreadsheet.registries;
const { ChartJsComponent } = spreadsheet.components;
chartComponentRegistry.add("odoo_bar", ChartJsComponent);
chartComponentRegistry.add("odoo_line", ChartJsComponent);
chartComponentRegistry.add("odoo_bar", ZoomableChartJsComponent);
chartComponentRegistry.add("odoo_line", ZoomableChartJsComponent);
chartComponentRegistry.add("odoo_pie", ChartJsComponent);
chartComponentRegistry.add("odoo_radar", ChartJsComponent);
chartComponentRegistry.add("odoo_sunburst", ChartJsComponent);
chartComponentRegistry.add("odoo_treemap", ChartJsComponent);
chartComponentRegistry.add("odoo_waterfall", ZoomableChartJsComponent);
chartComponentRegistry.add("odoo_pyramid", ChartJsComponent);
chartComponentRegistry.add("odoo_scatter", ChartJsComponent);
chartComponentRegistry.add("odoo_combo", ZoomableChartJsComponent);
chartComponentRegistry.add("odoo_geo", ChartJsComponent);
chartComponentRegistry.add("odoo_funnel", 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";
chartSubtypeRegistry.add("odoo_line", {
matcher: (definition) =>
definition.type === "odoo_line" && !definition.stacked && !definition.fillArea,
subtypeDefinition: { stacked: false, fillArea: false },
displayName: _t("Line"),
chartSubtype: "odoo_line",
chartType: "odoo_line",
category: "line",
preview: "o-spreadsheet-ChartPreview.LINE_CHART",
});
chartSubtypeRegistry.add("odoo_stacked_line", {
matcher: (definition) =>
definition.type === "odoo_line" && definition.stacked && !definition.fillArea,
subtypeDefinition: { stacked: true, fillArea: false },
displayName: _t("Stacked Line"),
chartSubtype: "odoo_stacked_line",
chartType: "odoo_line",
category: "line",
preview: "o-spreadsheet-ChartPreview.STACKED_LINE_CHART",
});
chartSubtypeRegistry.add("odoo_area", {
matcher: (definition) =>
definition.type === "odoo_line" && !definition.stacked && definition.fillArea,
subtypeDefinition: { stacked: false, fillArea: true },
displayName: _t("Area"),
chartSubtype: "odoo_area",
chartType: "odoo_line",
category: "area",
preview: "o-spreadsheet-ChartPreview.AREA_CHART",
});
chartSubtypeRegistry.add("odoo_stacked_area", {
matcher: (definition) =>
definition.type === "odoo_line" && definition.stacked && definition.fillArea,
subtypeDefinition: { stacked: true, fillArea: true },
displayName: _t("Stacked Area"),
chartSubtype: "odoo_stacked_area",
chartType: "odoo_line",
category: "area",
preview: "o-spreadsheet-ChartPreview.STACKED_AREA_CHART",
});
chartSubtypeRegistry.add("odoo_bar", {
matcher: (definition) =>
definition.type === "odoo_bar" && !definition.stacked && !definition.horizontal,
subtypeDefinition: { stacked: false, horizontal: false },
displayName: _t("Column"),
chartSubtype: "odoo_bar",
chartType: "odoo_bar",
category: "column",
preview: "o-spreadsheet-ChartPreview.COLUMN_CHART",
});
chartSubtypeRegistry.add("odoo_stacked_bar", {
matcher: (definition) =>
definition.type === "odoo_bar" && definition.stacked && !definition.horizontal,
subtypeDefinition: { stacked: true, horizontal: false },
displayName: _t("Stacked Column"),
chartSubtype: "odoo_stacked_bar",
chartType: "odoo_bar",
category: "column",
preview: "o-spreadsheet-ChartPreview.STACKED_COLUMN_CHART",
});
chartSubtypeRegistry.add("odoo_horizontal_bar", {
matcher: (definition) =>
definition.type === "odoo_bar" && !definition.stacked && definition.horizontal,
subtypeDefinition: { stacked: false, horizontal: true },
displayName: _t("Bar"),
chartSubtype: "odoo_horizontal_bar",
chartType: "odoo_bar",
category: "bar",
preview: "o-spreadsheet-ChartPreview.BAR_CHART",
});
chartSubtypeRegistry.add("odoo_horizontal_stacked_bar", {
matcher: (definition) =>
definition.type === "odoo_bar" && definition.stacked && definition.horizontal,
subtypeDefinition: { stacked: true, horizontal: true },
displayName: _t("Stacked Bar"),
chartSubtype: "odoo_horizontal_stacked_bar",
chartType: "odoo_bar",
category: "bar",
preview: "o-spreadsheet-ChartPreview.STACKED_BAR_CHART",
});
chartSubtypeRegistry.add("odoo_combo", {
displayName: _t("Combo"),
chartSubtype: "odoo_combo",
chartType: "odoo_combo",
category: "line",
preview: "o-spreadsheet-ChartPreview.COMBO_CHART",
});
chartSubtypeRegistry.add("odoo_pie", {
displayName: _t("Pie"),
matcher: (definition) => definition.type === "odoo_pie" && !definition.isDoughnut,
subtypeDefinition: { isDoughnut: false },
chartSubtype: "odoo_pie",
chartType: "odoo_pie",
category: "pie",
preview: "o-spreadsheet-ChartPreview.PIE_CHART",
});
chartSubtypeRegistry.add("odoo_doughnut", {
matcher: (definition) => definition.type === "odoo_pie" && definition.isDoughnut,
subtypeDefinition: { isDoughnut: true },
displayName: _t("Doughnut"),
chartSubtype: "odoo_doughnut",
chartType: "odoo_pie",
category: "pie",
preview: "o-spreadsheet-ChartPreview.DOUGHNUT_CHART",
});
chartSubtypeRegistry.add("odoo_scatter", {
displayName: _t("Scatter"),
chartType: "odoo_scatter",
chartSubtype: "odoo_scatter",
category: "misc",
preview: "o-spreadsheet-ChartPreview.SCATTER_CHART",
});
chartSubtypeRegistry.add("odoo_waterfall", {
displayName: _t("Waterfall"),
chartSubtype: "odoo_waterfall",
chartType: "odoo_waterfall",
category: "misc",
preview: "o-spreadsheet-ChartPreview.WATERFALL_CHART",
});
chartSubtypeRegistry.add("odoo_pyramid", {
displayName: _t("Population Pyramid"),
chartSubtype: "odoo_pyramid",
chartType: "odoo_pyramid",
category: "misc",
preview: "o-spreadsheet-ChartPreview.POPULATION_PYRAMID_CHART",
});
chartSubtypeRegistry.add("odoo_radar", {
matcher: (definition) => definition.type === "odoo_radar" && !definition.fillArea,
displayName: _t("Radar"),
chartSubtype: "odoo_radar",
chartType: "odoo_radar",
subtypeDefinition: { fillArea: false },
category: "misc",
preview: "o-spreadsheet-ChartPreview.RADAR_CHART",
});
chartSubtypeRegistry.add("odoo_filled_radar", {
matcher: (definition) => definition.type === "odoo_radar" && !!definition.fillArea,
displayName: _t("Filled Radar"),
chartType: "odoo_radar",
chartSubtype: "odoo_filled_radar",
subtypeDefinition: { fillArea: true },
category: "misc",
preview: "o-spreadsheet-ChartPreview.FILLED_RADAR_CHART",
});
chartSubtypeRegistry.add("odoo_geo", {
displayName: _t("Geo chart"),
chartType: "odoo_geo",
chartSubtype: "odoo_geo",
category: "misc",
preview: "o-spreadsheet-ChartPreview.GEO_CHART",
});
chartSubtypeRegistry.add("odoo_funnel", {
matcher: (definition) => definition.type === "odoo_funnel",
displayName: _t("Funnel"),
chartType: "odoo_funnel",
chartSubtype: "odoo_funnel",
subtypeDefinition: { cumulative: true },
category: "misc",
preview: "o-spreadsheet-ChartPreview.FUNNEL_CHART",
});
chartSubtypeRegistry.add("odoo_treemap", {
displayName: _t("Treemap"),
chartType: "odoo_treemap",
chartSubtype: "odoo_treemap",
category: "hierarchical",
preview: "o-spreadsheet-ChartPreview.TREE_MAP_CHART",
});
chartSubtypeRegistry.add("odoo_sunburst", {
displayName: _t("Sunburst"),
chartType: "odoo_sunburst",
chartSubtype: "odoo_sunburst",
category: "hierarchical",
preview: "o-spreadsheet-ChartPreview.SUNBURST_CHART",
});
export { OdooChartCorePlugin, ChartOdooMenuPlugin, OdooChartUIPlugin };
chartJsExtensionRegistry.add("chartOdooMenuPlugin", {
register: (Chart) => Chart.register(chartOdooMenuPlugin),
unregister: (Chart) => Chart.unregister(chartOdooMenuPlugin),
});
export { OdooChartCorePlugin, ChartOdooMenuPlugin, OdooChartCoreViewPlugin };

View file

@ -1,18 +1,31 @@
/** @odoo-module */
import spreadsheet from "@spreadsheet/o_spreadsheet/o_spreadsheet_extended";
import { registries, chartHelpers } from "@odoo/o-spreadsheet";
import { _t } from "@web/core/l10n/translation";
import { OdooChart } from "./odoo_chart";
import { onOdooChartItemClick, onOdooChartItemHover } from "./odoo_chart_helpers";
const { chartRegistry } = spreadsheet.registries;
const { chartRegistry } = registries;
const { getDefaultChartJsRuntime, chartFontColor, ChartColors } = spreadsheet.helpers;
const {
getBarChartDatasets,
CHART_COMMON_OPTIONS,
getChartLayout,
getBarChartScales,
getBarChartTooltip,
getChartTitle,
getBarChartLegend,
getChartShowValues,
getTrendDatasetForBarChart,
getTopPaddingForDashboard,
} = chartHelpers;
export class OdooBarChart extends OdooChart {
constructor(definition, sheetId, getters) {
super(definition, sheetId, getters);
this.verticalAxisPosition = definition.verticalAxisPosition;
this.stacked = definition.stacked;
this.axesDesign = definition.axesDesign;
this.horizontal = definition.horizontal;
this.zoomable = definition.zoomable;
}
getDefinition() {
@ -20,6 +33,10 @@ export class OdooBarChart extends OdooChart {
...super.getDefinition(),
verticalAxisPosition: this.verticalAxisPosition,
stacked: this.stacked,
axesDesign: this.axesDesign,
trend: this.trend,
horizontal: this.horizontal,
zoomable: this.zoomable,
};
}
}
@ -38,63 +55,44 @@ chartRegistry.add("odoo_bar", {
function createOdooChartRuntime(chart, getters) {
const background = chart.background || "#FFFFFF";
const { datasets, labels } = chart.dataSource.getData();
const chartJsConfig = getBarConfiguration(chart, labels);
const colors = new ChartColors();
for (const { label, data } of datasets) {
const color = colors.next();
const dataset = {
label,
data,
borderColor: color,
backgroundColor: color,
};
chartJsConfig.data.datasets.push(dataset);
}
const definition = chart.getDefinition();
return { background, chartJsConfig };
}
const trendDataSetsValues = datasets.map((dataset, index) => {
const trend = definition.dataSets[index]?.trend;
return !trend?.display || chart.horizontal
? undefined
: getTrendDatasetForBarChart(trend, dataset.data);
});
function getBarConfiguration(chart, labels) {
const fontColor = chartFontColor(chart.background);
const config = getDefaultChartJsRuntime(chart, labels, fontColor);
config.type = chart.type.replace("odoo_", "");
const legend = {
...config.options.legend,
display: chart.legendPosition !== "none",
labels: { fontColor },
const chartData = {
labels,
dataSetsValues: datasets.map((ds) => ({ data: ds.data, label: ds.label })),
locale: getters.getLocale(),
trendDataSetsValues,
topPadding: getTopPaddingForDashboard(definition, getters),
};
legend.position = chart.legendPosition;
config.options.legend = legend;
config.options.layout = {
padding: { left: 20, right: 20, top: chart.title ? 10 : 25, bottom: 10 },
};
config.options.scales = {
xAxes: [
{
ticks: {
// x axis configuration
maxRotation: 60,
minRotation: 15,
padding: 5,
labelOffset: 2,
fontColor,
},
const config = {
type: "bar",
data: {
labels: chartData.labels,
datasets: getBarChartDatasets(definition, chartData),
},
options: {
...CHART_COMMON_OPTIONS,
indexAxis: chart.horizontal ? "y" : "x",
layout: getChartLayout(definition, chartData),
scales: getBarChartScales(definition, chartData),
plugins: {
title: getChartTitle(definition, getters),
legend: getBarChartLegend(definition, chartData),
tooltip: getBarChartTooltip(definition, chartData),
chartShowValuesPlugin: getChartShowValues(definition, chartData),
},
],
yAxes: [
{
position: chart.verticalAxisPosition,
ticks: {
fontColor,
// y axis configuration
beginAtZero: true, // the origin of the y axis is always zero
},
},
],
onHover: onOdooChartItemHover(),
onClick: onOdooChartItemClick(getters, chart),
},
};
if (chart.stacked) {
config.options.scales.xAxes[0].stacked = true;
config.options.scales.yAxes[0].stacked = true;
}
return config;
return { background, chartJsConfig: config };
}

View file

@ -1,15 +1,11 @@
/** @odoo-module */
import spreadsheet from "@spreadsheet/o_spreadsheet/o_spreadsheet_extended";
import ChartDataSource from "../data_source/chart_data_source";
const { AbstractChart, CommandResult } = spreadsheet;
import { AbstractChart, CommandResult } from "@odoo/o-spreadsheet";
import { ChartDataSource, chartTypeToDataSourceMode } from "../data_source/chart_data_source";
/**
* @typedef {import("@web/search/search_model").SearchParams} SearchParams
*
* @typedef MetaData
* @property {Array<Object>} domains
* @property {Object} domain
* @property {Array<string>} groupBy
* @property {string} measure
* @property {string} mode
@ -24,6 +20,7 @@ const { AbstractChart, CommandResult } = spreadsheet;
* @property {string} title
* @property {string} background
* @property {string} legendPosition
* @property {boolean} cumulative
*
* @typedef OdooChartDefinitionDataSource
* @property {MetaData} metaData
@ -40,11 +37,20 @@ export class OdooChart extends AbstractChart {
constructor(definition, sheetId, getters) {
super(definition, sheetId, getters);
this.type = definition.type;
this.metaData = definition.metaData;
this.metaData = {
...definition.metaData,
mode: chartTypeToDataSourceMode(this.type),
cumulated: definition.cumulative,
cumulatedStart: definition.cumulatedStart,
};
this.searchParams = definition.searchParams;
this.legendPosition = definition.legendPosition;
this.background = definition.background;
this.dataSource = undefined;
this.actionXmlId = definition.actionXmlId;
this.showValues = definition.showValues;
this._dataSets = definition.dataSets || [];
this.humanize = definition.humanize ?? true;
}
static transformDefinition(definition) {
@ -64,10 +70,7 @@ export class OdooChart extends AbstractChart {
*/
getDefinitionForDataSource() {
return {
metaData: {
...this.metaData,
mode: this.type.replace("odoo_", ""),
},
metaData: this.metaData,
searchParams: this.searchParams,
};
}
@ -84,6 +87,11 @@ export class OdooChart extends AbstractChart {
metaData: this.metaData,
searchParams: this.searchParams,
type: this.type,
actionXmlId: this.actionXmlId,
showValues: this.showValues,
dataSets: this.dataSets,
datasetsConfig: this.datasetsConfig,
humanize: this.humanize,
};
}
@ -103,7 +111,7 @@ export class OdooChart extends AbstractChart {
/**
* @returns {OdooChart}
*/
copyForSheetId() {
duplicateInDuplicatedSheet() {
return this;
}
@ -125,9 +133,19 @@ export class OdooChart extends AbstractChart {
setDataSource(dataSource) {
if (dataSource instanceof ChartDataSource) {
this.dataSource = dataSource;
}
else {
} else {
throw new Error("Only ChartDataSources can be added.");
}
}
get dataSets() {
if (!this.dataSource) {
return this.datasetsConfig || [];
}
if (!this.dataSource.isReady()) {
return [];
}
const data = this.dataSource.getData();
return data.datasets.map((ds, index) => this._dataSets?.[index] || {});
}
}

View file

@ -0,0 +1,187 @@
import { navigateTo } from "@spreadsheet/actions/helpers";
import { Domain } from "@web/core/domain";
import { _t } from "@web/core/l10n/translation";
export function onOdooChartItemClick(getters, chart) {
return navigateInOdooMenuOnClick(getters, chart, (chartJsItem, chartData) => {
const { datasets, labels } = chartData;
const { datasetIndex, index } = chartJsItem;
const dataset = datasets[datasetIndex];
let name = labels[index];
if (dataset.label) {
name += ` / ${dataset.label}`;
}
return { name, domain: dataset.domains[index] };
});
}
export function onWaterfallOdooChartItemClick(getters, chart) {
return navigateInOdooMenuOnClick(getters, chart, (chartJsItem, chartData) => {
const showSubtotals = chart.showSubTotals;
const { datasets, labels } = chartData;
// DataSource datasets are all merged in a single dataset in waterfall charts (with possibly subtotals)
// We need to transform back the chartJS index to the DataSource index
let datasetIndex = 0;
let index = chartJsItem.index;
for (const dataset of datasets) {
const length = dataset.data.length + (showSubtotals ? 1 : 0);
if (index < length) {
break;
} else {
datasetIndex++;
index -= length;
}
}
const dataset = datasets[datasetIndex];
let name = labels[index];
if (dataset.label) {
name += ` / ${dataset.label}`;
}
let domain = dataset.domains[index];
// Subtotal domain
if (!domain) {
const datasetItemDomain = dataset.domains[0];
const firstGroupBy = chart.dataSource._metaData.groupBy[0];
domain = Domain.removeDomainLeaves(datasetItemDomain, [firstGroupBy]).toList();
}
return { name, domain };
});
}
export function onGeoOdooChartItemClick(getters, chart) {
return navigateInOdooMenuOnClick(getters, chart, (chartJsItem) => {
const label = chartJsItem.element.feature.properties.name;
const { datasets, labels } = chart.dataSource.getData();
const index = labels.indexOf(label);
if (index === -1) {
return {};
}
const dataset = datasets[0];
let name = labels[index];
if (dataset.label) {
name += ` / ${dataset.label}`;
}
return { name, domain: dataset.domains[index] };
});
}
export function onSunburstOdooChartItemClick(getters, chart) {
return navigateInOdooMenuOnClick(getters, chart, (chartJsItem, chartData, chartJSChart) => {
const { datasetIndex, index } = chartJsItem;
const rawItem = chartJSChart.data.datasets[datasetIndex].data[index];
const domain = chart.dataSource.buildDomainFromGroupByLabels(rawItem.groups);
return { name: rawItem.groups.join(" / "), domain: domain };
});
}
export function onTreemapOdooChartItemClick(getters, chart) {
return navigateInOdooMenuOnClick(getters, chart, (chartJsItem, chartData, chartJSChart) => {
const { datasetIndex, index } = chartJsItem;
const rawItem = chartJSChart.data.datasets[datasetIndex].data[index];
const depth = rawItem.l;
const groups = [];
for (let i = 0; i <= depth; i++) {
groups.push(rawItem._data[i]);
}
const domain = chart.dataSource.buildDomainFromGroupByLabels(groups);
return { name: groups.join(" / "), domain: domain };
});
}
function navigateInOdooMenuOnClick(getters, chart, getDomainFromChartItem) {
return async (event, items, chartJSChart) => {
const env = getters.getOdooEnv();
const { datasets, labels } = chart.dataSource.getData();
if (!items.length || !env || !datasets[items[0].datasetIndex]) {
return;
}
if (event.type === "click" || isChartJSMiddleClick(event)) {
event.native.preventDefault(); // Prevent other click actions
} else {
return;
}
const { name, domain } = getDomainFromChartItem(
items[0],
{ datasets, labels },
chartJSChart
);
if (!domain || !name) {
return;
}
await navigateTo(
env,
chart.actionXmlId,
{
name,
type: "ir.actions.act_window",
res_model: chart.metaData.resModel,
views: [
[false, "list"],
[false, "form"],
],
domain,
},
{ viewType: "list", newWindow: isChartJSMiddleClick(event) }
);
};
}
export function onOdooChartItemHover() {
return (event, items) => {
if (items.length > 0) {
event.native.target.style.cursor = "pointer";
} else {
event.native.target.style.cursor = "";
}
};
}
export function onGeoOdooChartItemHover() {
return (event, items) => {
if (!items.length) {
event.native.target.style.cursor = "";
return;
}
const item = items[0];
const data = event.chart.data.datasets?.[item.datasetIndex]?.data?.[item.index];
if (data?.value !== undefined) {
event.native.target.style.cursor = "pointer";
} else {
event.native.target.style.cursor = "";
}
};
}
export async function navigateToOdooMenu(menu, actionService, notificationService, newWindow) {
if (!menu) {
throw new Error(`Cannot find any menu associated with the chart`);
}
if (!menu.actionID) {
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 actionService.doAction(menu.actionID, { newWindow });
}
/**
* Check if the even is a middle mouse click or ctrl+click
*
* ChartJS doesn't receive a click event when the user middle clicks on a chart, so we use the mouseup event instead.
*
*/
export function isChartJSMiddleClick(event) {
return (
(event.type === "click" &&
event.native.button === 0 &&
(event.native.ctrlKey || event.native.metaKey)) ||
(event.type === "mouseup" && event.native.button === 1)
);
}

View file

@ -0,0 +1,101 @@
import { registries, chartHelpers } from "@odoo/o-spreadsheet";
import { _t } from "@web/core/l10n/translation";
import { OdooChart } from "./odoo_chart";
import { onOdooChartItemHover, onOdooChartItemClick } from "./odoo_chart_helpers";
const { chartRegistry } = registries;
const {
getComboChartDatasets,
CHART_COMMON_OPTIONS,
getChartLayout,
getBarChartScales,
getBarChartTooltip,
getChartTitle,
getComboChartLegend,
getChartShowValues,
getTrendDatasetForBarChart,
} = chartHelpers;
export class OdooComboChart extends OdooChart {
constructor(definition, sheetId, getters) {
super(definition, sheetId, getters);
this.axesDesign = definition.axesDesign;
this.hideDataMarkers = definition.hideDataMarkers;
this.zoomable = definition.zoomable;
}
getDefinition() {
return {
...super.getDefinition(),
axesDesign: this.axesDesign,
hideDataMarkers: this.hideDataMarkers,
zoomable: this.zoomable,
};
}
get dataSets() {
const dataSets = super.dataSets;
if (dataSets.every((ds) => !ds.type)) {
return dataSets.map((ds, index) => ({
...ds,
type: index === 0 ? "bar" : "line",
}));
}
return dataSets;
}
}
chartRegistry.add("odoo_combo", {
match: (type) => type === "odoo_combo",
createChart: (definition, sheetId, getters) => new OdooComboChart(definition, sheetId, getters),
getChartRuntime: createOdooChartRuntime,
validateChartDefinition: (validator, definition) =>
OdooComboChart.validateChartDefinition(validator, definition),
transformDefinition: (definition) => OdooComboChart.transformDefinition(definition),
getChartDefinitionFromContextCreation: () => OdooComboChart.getDefinitionFromContextCreation(),
name: _t("Combo"),
});
function createOdooChartRuntime(chart, getters) {
const background = chart.background || "#FFFFFF";
const { datasets, labels } = chart.dataSource.getData();
const definition = chart.getDefinition();
const trendDataSetsValues = datasets.map((dataset, index) => {
const trend = definition.dataSets[index]?.trend;
return !trend?.display || chart.horizontal
? undefined
: getTrendDatasetForBarChart(trend, dataset.data);
});
const chartData = {
labels,
dataSetsValues: datasets.map((ds) => ({ data: ds.data, label: ds.label })),
locale: getters.getLocale(),
trendDataSetsValues,
};
const config = {
type: "bar",
data: {
labels: chartData.labels,
datasets: getComboChartDatasets(definition, chartData),
},
options: {
...CHART_COMMON_OPTIONS,
layout: getChartLayout(definition, chartData),
scales: getBarChartScales(definition, chartData),
plugins: {
title: getChartTitle(definition, getters),
legend: getComboChartLegend(definition, chartData),
tooltip: getBarChartTooltip(definition, chartData),
chartShowValuesPlugin: getChartShowValues(definition, chartData),
},
onHover: onOdooChartItemHover(),
onClick: onOdooChartItemClick(getters, chart),
},
};
return { background, chartJsConfig: config };
}

View file

@ -0,0 +1,86 @@
import { registries, chartHelpers } from "@odoo/o-spreadsheet";
import { _t } from "@web/core/l10n/translation";
import { OdooChart } from "./odoo_chart";
import { onOdooChartItemHover, onOdooChartItemClick } from "./odoo_chart_helpers";
const { chartRegistry } = registries;
const {
getFunnelChartDatasets,
CHART_COMMON_OPTIONS,
getChartLayout,
getChartTitle,
getChartShowValues,
getFunnelChartScales,
getFunnelChartTooltip,
makeDatasetsCumulative,
} = chartHelpers;
export class OdooFunnelChart extends OdooChart {
constructor(definition, sheetId, getters) {
super(definition, sheetId, getters);
this.cumulative = definition.cumulative;
this.funnelColors = definition.funnelColors;
}
getDefinition() {
return {
...super.getDefinition(),
cumulative: this.cumulative,
funnelColors: this.funnelColors,
};
}
}
chartRegistry.add("odoo_funnel", {
match: (type) => type === "odoo_funnel",
createChart: (definition, sheetId, getters) =>
new OdooFunnelChart(definition, sheetId, getters),
getChartRuntime: createOdooChartRuntime,
validateChartDefinition: (validator, definition) =>
OdooFunnelChart.validateChartDefinition(validator, definition),
transformDefinition: (definition) => OdooFunnelChart.transformDefinition(definition),
getChartDefinitionFromContextCreation: () => OdooFunnelChart.getDefinitionFromContextCreation(),
name: _t("Funnel"),
});
function createOdooChartRuntime(chart, getters) {
const definition = chart.getDefinition();
const background = chart.background || "#FFFFFF";
let { datasets, labels } = chart.dataSource.getData();
if (definition.cumulative) {
datasets = makeDatasetsCumulative(datasets, "desc");
}
const locale = getters.getLocale();
const chartData = {
labels,
dataSetsValues: datasets.map((ds) => ({ data: ds.data, label: ds.label })),
locale,
};
const config = {
type: "funnel",
data: {
labels: chartData.labels,
datasets: getFunnelChartDatasets(definition, chartData),
},
options: {
...CHART_COMMON_OPTIONS,
indexAxis: "y",
layout: getChartLayout(definition, chartData),
scales: getFunnelChartScales(definition, chartData),
plugins: {
title: getChartTitle(definition, getters),
legend: { display: false },
tooltip: getFunnelChartTooltip(definition, chartData),
chartShowValuesPlugin: getChartShowValues(definition, chartData),
},
onHover: onOdooChartItemHover(),
onClick: onOdooChartItemClick(getters, chart),
},
};
return { background, chartJsConfig: config };
}

View file

@ -0,0 +1,82 @@
import { registries, chartHelpers } from "@odoo/o-spreadsheet";
import { _t } from "@web/core/l10n/translation";
import { OdooChart } from "./odoo_chart";
import { onGeoOdooChartItemHover, onGeoOdooChartItemClick } from "./odoo_chart_helpers";
const { chartRegistry } = registries;
const {
getGeoChartDatasets,
CHART_COMMON_OPTIONS,
getChartLayout,
getChartTitle,
getGeoChartScales,
getGeoChartTooltip,
} = chartHelpers;
export class OdooGeoChart extends OdooChart {
constructor(definition, sheetId, getters) {
super(definition, sheetId, getters);
this.colorScale = definition.colorScale;
this.missingValueColor = definition.missingValueColor;
this.region = definition.region;
}
getDefinition() {
return {
...super.getDefinition(),
colorScale: this.colorScale,
missingValueColor: this.missingValueColor,
region: this.region,
};
}
}
chartRegistry.add("odoo_geo", {
match: (type) => type === "odoo_geo",
createChart: (definition, sheetId, getters) => new OdooGeoChart(definition, sheetId, getters),
getChartRuntime: createOdooChartRuntime,
validateChartDefinition: (validator, definition) =>
OdooGeoChart.validateChartDefinition(validator, definition),
transformDefinition: (definition) => OdooGeoChart.transformDefinition(definition),
getChartDefinitionFromContextCreation: () => OdooGeoChart.getDefinitionFromContextCreation(),
name: _t("Geo"),
});
function createOdooChartRuntime(chart, getters) {
const background = chart.background || "#FFFFFF";
const { datasets, labels } = chart.dataSource.getData();
const definition = chart.getDefinition();
const locale = getters.getLocale();
const chartData = {
labels,
dataSetsValues: datasets.map((ds) => ({ data: ds.data, label: ds.label })),
locale,
availableRegions: getters.getGeoChartAvailableRegions(),
geoFeatureNameToId: getters.geoFeatureNameToId,
getGeoJsonFeatures: getters.getGeoJsonFeatures,
};
const config = {
type: "choropleth",
data: {
datasets: getGeoChartDatasets(definition, chartData),
},
options: {
...CHART_COMMON_OPTIONS,
layout: getChartLayout(definition, chartData),
scales: getGeoChartScales(definition, chartData),
plugins: {
title: getChartTitle(definition, getters),
tooltip: getGeoChartTooltip(definition, chartData),
legend: { display: false },
},
onHover: onGeoOdooChartItemHover(),
onClick: onGeoOdooChartItemClick(getters, chart),
},
};
return { background, chartJsConfig: config };
}

View file

@ -1,20 +1,22 @@
/** @odoo-module */
import spreadsheet from "@spreadsheet/o_spreadsheet/o_spreadsheet_extended";
import { registries, chartHelpers } 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";
import { onOdooChartItemClick, onOdooChartItemHover } from "./odoo_chart_helpers";
const { chartRegistry } = spreadsheet.registries;
const { chartRegistry } = registries;
const {
getDefaultChartJsRuntime,
chartFontColor,
ChartColors,
getFillingMode,
colorToRGBA,
rgbaToHex,
} = spreadsheet.helpers;
getLineChartDatasets,
CHART_COMMON_OPTIONS,
getChartLayout,
getLineChartScales,
getLineChartTooltip,
getChartTitle,
getLineChartLegend,
getChartShowValues,
getTrendDatasetForLineChart,
getTopPaddingForDashboard,
} = chartHelpers;
export class OdooLineChart extends OdooChart {
constructor(definition, sheetId, getters) {
@ -22,6 +24,12 @@ export class OdooLineChart extends OdooChart {
this.verticalAxisPosition = definition.verticalAxisPosition;
this.stacked = definition.stacked;
this.cumulative = definition.cumulative;
this.cumulatedStart = definition.cumulatedStart;
this.axesDesign = definition.axesDesign;
this.fillArea = definition.fillArea;
this.cumulatedStart = definition.cumulatedStart;
this.hideDataMarkers = definition.hideDataMarkers;
this.zoomable = definition.zoomable;
}
getDefinition() {
@ -30,6 +38,11 @@ export class OdooLineChart extends OdooChart {
verticalAxisPosition: this.verticalAxisPosition,
stacked: this.stacked,
cumulative: this.cumulative,
cumulatedStart: this.cumulatedStart,
axesDesign: this.axesDesign,
fillArea: this.fillArea,
hideDataMarkers: this.hideDataMarkers,
zoomable: this.zoomable,
};
}
}
@ -47,89 +60,66 @@ chartRegistry.add("odoo_line", {
function createOdooChartRuntime(chart, getters) {
const background = chart.background || "#FFFFFF";
const { datasets, labels } = chart.dataSource.getData();
const chartJsConfig = getLineConfiguration(chart, labels);
const colors = new ChartColors();
for (let [index, { label, data }] of datasets.entries()) {
const color = colors.next();
const backgroundRGBA = colorToRGBA(color);
if (chart.stacked) {
// use the transparency of Odoo to keep consistency
backgroundRGBA.a = LINE_FILL_TRANSPARENCY;
}
let { datasets, labels } = chart.dataSource.getData();
datasets = computeCumulatedDatasets(chart, datasets);
const definition = chart.getDefinition();
const locale = getters.getLocale();
const trendDataSetsValues = datasets.map((dataset, index) => {
const trend = definition.dataSets[index]?.trend;
return !trend?.display
? undefined
: getTrendDatasetForLineChart(trend, dataset.data, labels, "category", locale);
});
const chartData = {
labels,
dataSetsValues: datasets.map((ds) => ({ data: ds.data, label: ds.label })),
locale,
trendDataSetsValues,
topPadding: getTopPaddingForDashboard(definition, getters),
axisType: definition.axisType || "category",
};
const chartJsDatasets = getLineChartDatasets(definition, chartData);
const config = {
type: "line",
data: {
labels: chartData.labels,
datasets: chartJsDatasets,
},
options: {
...CHART_COMMON_OPTIONS,
layout: getChartLayout(definition, chartData),
scales: getLineChartScales(definition, chartData),
plugins: {
title: getChartTitle(definition, getters),
legend: getLineChartLegend(definition, chartData),
tooltip: getLineChartTooltip(definition, chartData),
chartShowValuesPlugin: getChartShowValues(definition, chartData),
},
onHover: onOdooChartItemHover(),
onClick: onOdooChartItemClick(getters, chart),
},
};
return { background, chartJsConfig: config };
}
function computeCumulatedDatasets(chart, datasets) {
const cumulatedDatasets = [];
for (const dataset of datasets) {
if (chart.cumulative) {
let accumulator = 0;
data = data.map((value) => {
let accumulator = dataset.cumulatedStart || 0;
const data = dataset.data.map((value) => {
accumulator += value;
return accumulator;
});
cumulatedDatasets.push({ ...dataset, data });
} else {
cumulatedDatasets.push(dataset);
}
const backgroundColor = rgbaToHex(backgroundRGBA);
const dataset = {
label,
data,
lineTension: 0,
borderColor: color,
backgroundColor,
pointBackgroundColor: color,
fill: chart.stacked ? getFillingMode(index) : false,
};
chartJsConfig.data.datasets.push(dataset);
}
return { background, chartJsConfig };
}
function getLineConfiguration(chart, labels) {
const fontColor = chartFontColor(chart.background);
const config = getDefaultChartJsRuntime(chart, labels, fontColor);
config.type = chart.type.replace("odoo_", "");
const legend = {
...config.options.legend,
display: chart.legendPosition !== "none",
labels: {
fontColor,
generateLabels(chart) {
const { data } = chart;
const labels = window.Chart.defaults.global.legend.labels.generateLabels(chart);
for (const [index, label] of labels.entries()) {
label.fillStyle = data.datasets[index].borderColor;
}
return labels;
},
},
};
legend.position = chart.legendPosition;
config.options.legend = legend;
config.options.layout = {
padding: { left: 20, right: 20, top: chart.title ? 10 : 25, bottom: 10 },
};
config.options.scales = {
xAxes: [
{
ticks: {
// x axis configuration
maxRotation: 60,
minRotation: 15,
padding: 5,
labelOffset: 2,
fontColor,
},
},
],
yAxes: [
{
position: chart.verticalAxisPosition,
ticks: {
fontColor,
// y axis configuration
beginAtZero: true, // the origin of the y axis is always zero
},
},
],
};
if (chart.stacked) {
config.options.scales.yAxes[0].stacked = true;
}
return config;
return cumulatedDatasets;
}

View file

@ -1,72 +1,78 @@
/** @odoo-module */
import spreadsheet from "@spreadsheet/o_spreadsheet/o_spreadsheet_extended";
import { registries, chartHelpers } from "@odoo/o-spreadsheet";
import { _t } from "@web/core/l10n/translation";
import { OdooChart } from "./odoo_chart";
import { onOdooChartItemHover, onOdooChartItemClick } from "./odoo_chart_helpers";
const { chartRegistry } = spreadsheet.registries;
const { chartRegistry } = registries;
const { getDefaultChartJsRuntime, chartFontColor, ChartColors } = spreadsheet.helpers;
const {
getPieChartDatasets,
CHART_COMMON_OPTIONS,
getChartLayout,
getPieChartTooltip,
getChartTitle,
getPieChartLegend,
getChartShowValues,
getTopPaddingForDashboard,
} = chartHelpers;
export class OdooPieChart extends OdooChart {
constructor(definition, sheetId, getters) {
super(definition, sheetId, getters);
this.isDoughnut = definition.isDoughnut;
}
getDefinition() {
return {
...super.getDefinition(),
isDoughnut: this.isDoughnut,
};
}
}
chartRegistry.add("odoo_pie", {
match: (type) => type === "odoo_pie",
createChart: (definition, sheetId, getters) => new OdooChart(definition, sheetId, getters),
createChart: (definition, sheetId, getters) => new OdooPieChart(definition, sheetId, getters),
getChartRuntime: createOdooChartRuntime,
validateChartDefinition: (validator, definition) =>
OdooChart.validateChartDefinition(validator, definition),
transformDefinition: (definition) => OdooChart.transformDefinition(definition),
getChartDefinitionFromContextCreation: () => OdooChart.getDefinitionFromContextCreation(),
OdooPieChart.validateChartDefinition(validator, definition),
transformDefinition: (definition) => OdooPieChart.transformDefinition(definition),
getChartDefinitionFromContextCreation: () => OdooPieChart.getDefinitionFromContextCreation(),
name: _t("Pie"),
});
function createOdooChartRuntime(chart, getters) {
const background = chart.background || "#FFFFFF";
const { datasets, labels } = chart.dataSource.getData();
const chartJsConfig = getPieConfiguration(chart, labels);
const colors = new ChartColors();
for (const { label, data } of datasets) {
const backgroundColor = getPieColors(colors, datasets);
const dataset = {
label,
data,
borderColor: "#FFFFFF",
backgroundColor,
};
chartJsConfig.data.datasets.push(dataset);
}
return { background, chartJsConfig };
}
const definition = chart.getDefinition();
definition.dataSets = datasets.map(() => ({ trend: definition.trend }));
function getPieConfiguration(chart, labels) {
const fontColor = chartFontColor(chart.background);
const config = getDefaultChartJsRuntime(chart, labels, fontColor);
config.type = chart.type.replace("odoo_", "");
const legend = {
...config.options.legend,
display: chart.legendPosition !== "none",
labels: { fontColor },
const chartData = {
labels,
dataSetsValues: datasets.map((ds) => ({ data: ds.data, label: ds.label })),
locale: getters.getLocale(),
topPadding: getTopPaddingForDashboard(definition, getters),
};
legend.position = chart.legendPosition;
config.options.legend = legend;
config.options.layout = {
padding: { left: 20, right: 20, top: chart.title ? 10 : 25, bottom: 10 },
};
config.options.tooltips = {
callbacks: {
title: function (tooltipItems, data) {
return data.datasets[tooltipItems[0].datasetIndex].label;
const config = {
type: definition.isDoughnut ? "doughnut" : "pie",
data: {
labels: chartData.labels,
datasets: getPieChartDatasets(definition, chartData),
},
options: {
...CHART_COMMON_OPTIONS,
layout: getChartLayout(definition, chartData),
plugins: {
title: getChartTitle(definition, getters),
legend: getPieChartLegend(definition, chartData),
tooltip: getPieChartTooltip(definition, chartData),
chartShowValuesPlugin: getChartShowValues(definition, chartData),
},
onHover: onOdooChartItemHover(),
onClick: onOdooChartItemClick(getters, chart),
},
};
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;
return { background, chartJsConfig: config };
}

View file

@ -0,0 +1,94 @@
import { registries, chartHelpers } from "@odoo/o-spreadsheet";
import { _t } from "@web/core/l10n/translation";
import { OdooChart } from "./odoo_chart";
import { onOdooChartItemHover, onOdooChartItemClick } from "./odoo_chart_helpers";
const { chartRegistry } = registries;
const {
CHART_COMMON_OPTIONS,
getBarChartDatasets,
getChartLayout,
getChartTitle,
getPyramidChartShowValues,
getPyramidChartScales,
getBarChartLegend,
getPyramidChartTooltip,
} = chartHelpers;
export class OdooPyramidChart extends OdooChart {
constructor(definition, sheetId, getters) {
super(definition, sheetId, getters);
this.axesDesign = definition.axesDesign;
}
getDefinition() {
return {
...super.getDefinition(),
axesDesign: this.axesDesign,
horizontal: true,
stacked: true,
};
}
}
chartRegistry.add("odoo_pyramid", {
match: (type) => type === "odoo_pyramid",
createChart: (definition, sheetId, getters) =>
new OdooPyramidChart(definition, sheetId, getters),
getChartRuntime: createOdooChartRuntime,
validateChartDefinition: (validator, definition) =>
OdooPyramidChart.validateChartDefinition(validator, definition),
transformDefinition: (definition) => OdooPyramidChart.transformDefinition(definition),
getChartDefinitionFromContextCreation: () =>
OdooPyramidChart.getDefinitionFromContextCreation(),
name: _t("Pyramid"),
});
function createOdooChartRuntime(chart, getters) {
const background = chart.background || "#FFFFFF";
const { datasets, labels } = chart.dataSource.getData();
const pyramidDatasets = [];
if (datasets[0]) {
const pyramidData = datasets[0].data.map((value) => (value > 0 ? value : 0));
pyramidDatasets.push({ ...datasets[0], data: pyramidData });
}
if (datasets[1]) {
const pyramidData = datasets[1].data.map((value) => (value > 0 ? -value : 0));
pyramidDatasets.push({ ...datasets[1], data: pyramidData });
}
const definition = chart.getDefinition();
const locale = getters.getLocale();
const chartData = {
labels,
dataSetsValues: pyramidDatasets.map((ds) => ({ data: ds.data, label: ds.label })),
locale,
};
const config = {
type: "bar",
data: {
labels: chartData.labels,
datasets: getBarChartDatasets(definition, chartData),
},
options: {
...CHART_COMMON_OPTIONS,
indexAxis: "y",
layout: getChartLayout(definition, chartData),
scales: getPyramidChartScales(definition, chartData),
plugins: {
title: getChartTitle(definition, getters),
legend: getBarChartLegend(definition, chartData),
tooltip: getPyramidChartTooltip(definition, chartData),
chartShowValuesPlugin: getPyramidChartShowValues(definition, chartData),
},
onHover: onOdooChartItemHover(),
onClick: onOdooChartItemClick(getters, chart),
},
};
return { background, chartJsConfig: config };
}

View file

@ -0,0 +1,81 @@
import { registries, chartHelpers } from "@odoo/o-spreadsheet";
import { _t } from "@web/core/l10n/translation";
import { OdooChart } from "./odoo_chart";
import { onOdooChartItemHover, onOdooChartItemClick } from "./odoo_chart_helpers";
const { chartRegistry } = registries;
const {
getRadarChartDatasets,
CHART_COMMON_OPTIONS,
getChartLayout,
getChartTitle,
getChartShowValues,
getRadarChartScales,
getRadarChartLegend,
getRadarChartTooltip,
} = chartHelpers;
export class OdooRadarChart extends OdooChart {
constructor(definition, sheetId, getters) {
super(definition, sheetId, getters);
this.fillArea = definition.fillArea;
this.hideDataMarkers = definition.hideDataMarkers;
}
getDefinition() {
return {
...super.getDefinition(),
fillArea: this.fillArea,
hideDataMarkers: this.hideDataMarkers,
};
}
}
chartRegistry.add("odoo_radar", {
match: (type) => type === "odoo_radar",
createChart: (definition, sheetId, getters) => new OdooRadarChart(definition, sheetId, getters),
getChartRuntime: createOdooChartRuntime,
validateChartDefinition: (validator, definition) =>
OdooRadarChart.validateChartDefinition(validator, definition),
transformDefinition: (definition) => OdooRadarChart.transformDefinition(definition),
getChartDefinitionFromContextCreation: () => OdooRadarChart.getDefinitionFromContextCreation(),
name: _t("Radar"),
});
function createOdooChartRuntime(chart, getters) {
const background = chart.background || "#FFFFFF";
const { datasets, labels } = chart.dataSource.getData();
const definition = chart.getDefinition();
const locale = getters.getLocale();
const chartData = {
labels,
dataSetsValues: datasets.map((ds) => ({ data: ds.data, label: ds.label })),
locale,
};
const config = {
type: "radar",
data: {
labels: chartData.labels,
datasets: getRadarChartDatasets(definition, chartData),
},
options: {
...CHART_COMMON_OPTIONS,
layout: getChartLayout(definition, chartData),
scales: getRadarChartScales(definition, chartData),
plugins: {
title: getChartTitle(definition, getters),
legend: getRadarChartLegend(definition, chartData),
tooltip: getRadarChartTooltip(definition, chartData),
chartShowValuesPlugin: getChartShowValues(definition, chartData),
},
onHover: onOdooChartItemHover(),
onClick: onOdooChartItemClick(getters, chart),
},
};
return { background, chartJsConfig: config };
}

View file

@ -0,0 +1,93 @@
import { registries, chartHelpers } from "@odoo/o-spreadsheet";
import { _t } from "@web/core/l10n/translation";
import { OdooChart } from "./odoo_chart";
import { onOdooChartItemHover, onOdooChartItemClick } from "./odoo_chart_helpers";
const { chartRegistry } = registries;
const {
getScatterChartDatasets,
CHART_COMMON_OPTIONS,
getChartLayout,
getScatterChartScales,
getLineChartTooltip,
getChartTitle,
getScatterChartLegend,
getChartShowValues,
getTrendDatasetForLineChart,
} = chartHelpers;
export class OdooScatterChart extends OdooChart {
constructor(definition, sheetId, getters) {
super(definition, sheetId, getters);
this.verticalAxisPosition = definition.verticalAxisPosition;
this.axesDesign = definition.axesDesign;
}
getDefinition() {
return {
...super.getDefinition(),
verticalAxisPosition: this.verticalAxisPosition,
axesDesign: this.axesDesign,
};
}
}
chartRegistry.add("odoo_scatter", {
match: (type) => type === "odoo_scatter",
createChart: (definition, sheetId, getters) =>
new OdooScatterChart(definition, sheetId, getters),
getChartRuntime: createOdooChartRuntime,
validateChartDefinition: (validator, definition) =>
OdooScatterChart.validateChartDefinition(validator, definition),
transformDefinition: (definition) => OdooScatterChart.transformDefinition(definition),
getChartDefinitionFromContextCreation: () =>
OdooScatterChart.getDefinitionFromContextCreation(),
name: _t("Scatter"),
});
function createOdooChartRuntime(chart, getters) {
const background = chart.background || "#FFFFFF";
const { datasets, labels } = chart.dataSource.getData();
const definition = chart.getDefinition();
const locale = getters.getLocale();
const trendDataSetsValues = datasets.map((dataset, index) => {
const trend = definition.dataSets[index]?.trend;
return !trend?.display
? undefined
: getTrendDatasetForLineChart(trend, dataset.data, labels, "category", locale);
});
const chartData = {
labels,
dataSetsValues: datasets.map((ds) => ({ data: ds.data, label: ds.label })),
locale,
trendDataSetsValues,
axisType: definition.axisType || "category",
};
const config = {
type: "line",
data: {
labels: chartData.labels,
datasets: getScatterChartDatasets(definition, chartData),
},
options: {
...CHART_COMMON_OPTIONS,
layout: getChartLayout(definition, chartData),
scales: getScatterChartScales(definition, chartData),
plugins: {
title: getChartTitle(definition, getters),
legend: getScatterChartLegend(definition, chartData),
tooltip: getLineChartTooltip(definition, chartData),
chartShowValuesPlugin: getChartShowValues(definition, chartData),
},
onHover: onOdooChartItemHover(),
onClick: onOdooChartItemClick(getters, chart),
},
};
return { background, chartJsConfig: config };
}

View file

@ -0,0 +1,87 @@
import { registries, chartHelpers } from "@odoo/o-spreadsheet";
import { _t } from "@web/core/l10n/translation";
import { OdooChart } from "./odoo_chart";
import { onOdooChartItemHover, onSunburstOdooChartItemClick } from "./odoo_chart_helpers";
const { chartRegistry } = registries;
const {
getSunburstChartDatasets,
CHART_COMMON_OPTIONS,
getChartLayout,
getChartTitle,
getSunburstShowValues,
getSunburstChartLegend,
getSunburstChartTooltip,
} = chartHelpers;
export class OdooSunburstChart extends OdooChart {
constructor(definition, sheetId, getters) {
super(definition, sheetId, getters);
this.showLabels = definition.showLabels;
this.valuesDesign = definition.valuesDesign;
this.groupColors = definition.groupColors;
this.pieHolePercentage = definition.pieHolePercentage;
}
getDefinition() {
return {
...super.getDefinition(),
pieHolePercentage: this.pieHolePercentage,
showLabels: this.showLabels,
valuesDesign: this.valuesDesign,
groupColors: this.groupColors,
};
}
}
chartRegistry.add("odoo_sunburst", {
match: (type) => type === "odoo_sunburst",
createChart: (definition, sheetId, getters) =>
new OdooSunburstChart(definition, sheetId, getters),
getChartRuntime: createOdooChartRuntime,
validateChartDefinition: (validator, definition) =>
OdooSunburstChart.validateChartDefinition(validator, definition),
transformDefinition: (definition) => OdooSunburstChart.transformDefinition(definition),
getChartDefinitionFromContextCreation: () =>
OdooSunburstChart.getDefinitionFromContextCreation(),
name: _t("Sunburst"),
});
function createOdooChartRuntime(chart, getters) {
const background = chart.background || "#FFFFFF";
const { datasets, labels } = chart.dataSource.getHierarchicalData();
const definition = chart.getDefinition();
const locale = getters.getLocale();
const chartData = {
labels,
dataSetsValues: datasets.map((ds) => ({ data: ds.data, label: ds.label })),
locale,
};
const config = {
type: "doughnut",
data: {
labels: chartData.labels,
datasets: getSunburstChartDatasets(definition, chartData),
},
options: {
...CHART_COMMON_OPTIONS,
cutout: chart.pieHolePercentage === undefined ? "25%" : `${chart.pieHolePercentage}%`,
layout: getChartLayout(definition, chartData),
plugins: {
title: getChartTitle(definition, getters),
legend: getSunburstChartLegend(definition, chartData),
tooltip: getSunburstChartTooltip(definition, chartData),
sunburstLabelsPlugin: getSunburstShowValues(definition, chartData),
sunburstHoverPlugin: { enabled: true },
},
onHover: onOdooChartItemHover(),
onClick: onSunburstOdooChartItemClick(getters, chart),
},
};
return { background, chartJsConfig: config };
}

View file

@ -0,0 +1,84 @@
import { registries, chartHelpers } from "@odoo/o-spreadsheet";
import { _t } from "@web/core/l10n/translation";
import { OdooChart } from "./odoo_chart";
import { onOdooChartItemHover, onTreemapOdooChartItemClick } from "./odoo_chart_helpers";
const { chartRegistry } = registries;
const {
getTreeMapChartDatasets,
CHART_COMMON_OPTIONS,
getChartLayout,
getChartTitle,
getTreeMapChartTooltip,
} = chartHelpers;
export class OdooTreemapChart extends OdooChart {
constructor(definition, sheetId, getters) {
super(definition, sheetId, getters);
this.showLabels = definition.showLabels;
this.valuesDesign = definition.valuesDesign;
this.coloringOptions = definition.coloringOptions;
this.headerDesign = definition.headerDesign;
this.showHeaders = definition.showHeaders;
}
getDefinition() {
return {
...super.getDefinition(),
showLabels: this.showLabels,
valuesDesign: this.valuesDesign,
coloringOptions: this.coloringOptions,
headerDesign: this.headerDesign,
showHeaders: this.showHeaders,
};
}
}
chartRegistry.add("odoo_treemap", {
match: (type) => type === "odoo_treemap",
createChart: (definition, sheetId, getters) =>
new OdooTreemapChart(definition, sheetId, getters),
getChartRuntime: createOdooChartRuntime,
validateChartDefinition: (validator, definition) =>
OdooTreemapChart.validateChartDefinition(validator, definition),
transformDefinition: (definition) => OdooTreemapChart.transformDefinition(definition),
getChartDefinitionFromContextCreation: () =>
OdooTreemapChart.getDefinitionFromContextCreation(),
name: _t("Treemap"),
});
function createOdooChartRuntime(chart, getters) {
const background = chart.background || "#FFFFFF";
const { datasets, labels } = chart.dataSource.getHierarchicalData();
const definition = chart.getDefinition();
const locale = getters.getLocale();
const chartData = {
labels,
dataSetsValues: datasets.map((ds) => ({ data: ds.data, label: ds.label })),
locale,
};
const config = {
type: "treemap",
data: {
labels: chartData.labels,
datasets: getTreeMapChartDatasets(definition, chartData),
},
options: {
...CHART_COMMON_OPTIONS,
layout: getChartLayout(definition, chartData),
plugins: {
title: getChartTitle(definition, getters),
legend: { display: false },
tooltip: getTreeMapChartTooltip(definition, chartData),
},
onHover: onOdooChartItemHover(),
onClick: onTreemapOdooChartItemClick(getters, chart),
},
};
return { background, chartJsConfig: config };
}

View file

@ -0,0 +1,97 @@
import { registries, chartHelpers } from "@odoo/o-spreadsheet";
import { _t } from "@web/core/l10n/translation";
import { OdooChart } from "./odoo_chart";
import { onOdooChartItemHover, onWaterfallOdooChartItemClick } from "./odoo_chart_helpers";
const { chartRegistry } = registries;
const {
CHART_COMMON_OPTIONS,
getChartLayout,
getChartTitle,
getWaterfallChartShowValues,
getWaterfallChartScales,
getWaterfallChartLegend,
getWaterfallChartTooltip,
getWaterfallDatasetAndLabels,
} = chartHelpers;
export class OdooWaterfallChart extends OdooChart {
constructor(definition, sheetId, getters) {
super(definition, sheetId, getters);
this.verticalAxisPosition = definition.verticalAxisPosition ?? "left";
this.showConnectorLines = definition.showConnectorLines ?? true;
this.positiveValuesColor = definition.positiveValuesColor;
this.negativeValuesColor = definition.negativeValuesColor;
this.subTotalValuesColor = definition.subTotalValuesColor;
this.firstValueAsSubtotal = definition.firstValueAsSubtotal ?? false;
this.showSubTotals = definition.showSubTotals ?? false;
this.axesDesign = definition.axesDesign;
this.zoomable = definition.zoomable ?? false;
}
getDefinition() {
return {
...super.getDefinition(),
verticalAxisPosition: this.verticalAxisPosition,
showConnectorLines: this.showConnectorLines,
firstValueAsSubtotal: this.firstValueAsSubtotal,
showSubTotals: this.showSubTotals,
positiveValuesColor: this.positiveValuesColor,
negativeValuesColor: this.negativeValuesColor,
subTotalValuesColor: this.subTotalValuesColor,
axesDesign: this.axesDesign,
zoomable: this.zoomable,
};
}
}
chartRegistry.add("odoo_waterfall", {
match: (type) => type === "odoo_waterfall",
createChart: (definition, sheetId, getters) =>
new OdooWaterfallChart(definition, sheetId, getters),
getChartRuntime: createOdooChartRuntime,
validateChartDefinition: (validator, definition) =>
OdooWaterfallChart.validateChartDefinition(validator, definition),
transformDefinition: (definition) => OdooWaterfallChart.transformDefinition(definition),
getChartDefinitionFromContextCreation: () =>
OdooWaterfallChart.getDefinitionFromContextCreation(),
name: _t("Waterfall"),
});
function createOdooChartRuntime(chart, getters) {
const background = chart.background || "#FFFFFF";
const { datasets, labels } = chart.dataSource.getData();
const definition = chart.getDefinition();
const locale = getters.getLocale();
const chartData = {
labels,
dataSetsValues: datasets.map((ds) => ({ data: ds.data, label: ds.label })),
locale,
};
const chartJSData = getWaterfallDatasetAndLabels(definition, chartData);
const config = {
type: "bar",
data: { labels: chartJSData.labels, datasets: chartJSData.datasets },
options: {
...CHART_COMMON_OPTIONS,
layout: getChartLayout(definition, chartData),
scales: getWaterfallChartScales(definition, chartData),
plugins: {
title: getChartTitle(definition, getters),
legend: getWaterfallChartLegend(definition, chartData),
tooltip: getWaterfallChartTooltip(definition, chartData),
chartShowValuesPlugin: getWaterfallChartShowValues(definition, chartData),
waterfallLinesPlugin: { showConnectorLines: definition.showConnectorLines },
},
onHover: onOdooChartItemHover(),
onClick: onWaterfallOdooChartItemClick(getters, chart),
},
};
return { background, chartJsConfig: config };
}

View file

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

View file

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

View file

@ -0,0 +1,65 @@
import { patch } from "@web/core/utils/patch";
import * as spreadsheet from "@odoo/o-spreadsheet";
import { useService } from "@web/core/utils/hooks";
import { navigateToOdooMenu } from "../odoo_chart/odoo_chart_helpers";
patch(spreadsheet.components.FigureComponent.prototype, {
setup() {
super.setup();
this.actionService = useService("action");
this.notificationService = useService("notification");
},
get chartId() {
if (this.props.figureUI.tag !== "chart" && this.props.figureUI.tag !== "carousel") {
return undefined;
}
return this.env.model.getters.getChartIdFromFigureId(this.props.figureUI.id);
},
async navigateToOdooMenu(newWindow) {
const menu = this.env.model.getters.getChartOdooMenu(this.chartId);
await navigateToOdooMenu(menu, this.actionService, this.notificationService, newWindow);
},
get hasOdooMenu() {
return this.chartId && this.env.model.getters.getChartOdooMenu(this.chartId) !== undefined;
},
});
patch(spreadsheet.components.ScorecardChart.prototype, {
setup() {
super.setup();
this.actionService = useService("action");
this.notificationService = useService("notification");
},
async navigateToOdooMenu(newWindow) {
const menu = this.env.model.getters.getChartOdooMenu(this.props.chartId);
await navigateToOdooMenu(menu, this.actionService, this.notificationService, newWindow);
},
get hasOdooMenu() {
return this.env.model.getters.getChartOdooMenu(this.props.chartId) !== undefined;
},
async onClick() {
if (this.env.isDashboard() && this.hasOdooMenu) {
await this.navigateToOdooMenu();
}
},
});
patch(spreadsheet.components.GaugeChartComponent.prototype, {
setup() {
super.setup();
this.actionService = useService("action");
this.notificationService = useService("notification");
},
async navigateToOdooMenu(newWindow) {
const menu = this.env.model.getters.getChartOdooMenu(this.props.chartId);
await navigateToOdooMenu(menu, this.actionService, this.notificationService, newWindow);
},
get hasOdooMenu() {
return this.env.model.getters.getChartOdooMenu(this.props.chartId) !== undefined;
},
async onClick() {
if (this.env.isDashboard() && this.hasOdooMenu) {
await this.navigateToOdooMenu();
}
},
});

View file

@ -1,5 +1,5 @@
.o-chart-menu {
.o-chart-menu-item {
.o-figure-menu {
.o-figure-menu-item {
padding-left: 7px;
}

View file

@ -0,0 +1,24 @@
<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-custom-click="(ev, isMiddleClick) => this.navigateToOdooMenu(isMiddleClick)">
<span class="fa fa-external-link" />
</div>
</xpath>
</div>
<div t-name="spreadsheet.ScorecardChart" t-inherit="o-spreadsheet-ScorecardChart" t-inherit-mode="extension">
<xpath expr="//canvas[1]" position="attributes">
<attribute name="t-on-click">() => this.onClick()</attribute>
<attribute name="t-att-role">env.isDashboard() and hasOdooMenu ? "button" : ""</attribute>
</xpath>
</div>
<div t-name="spreadsheet.GaugeChartComponent" t-inherit="o-spreadsheet-GaugeChartComponent" t-inherit-mode="extension">
<xpath expr="//canvas[1]" 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,23 @@
import {
navigateToOdooMenu,
isChartJSMiddleClick,
} from "@spreadsheet/chart/odoo_chart/odoo_chart_helpers";
export const chartOdooMenuPlugin = {
id: "chartOdooMenuPlugin",
afterEvent(chart, { event }, { env, menu }) {
const isDashboard = env?.model.getters.isDashboard();
event.native.target.style.cursor = menu && isDashboard ? "pointer" : "";
const middleClick = isChartJSMiddleClick(event);
if (
(event.type !== "click" && !middleClick) ||
!menu ||
!isDashboard ||
event.native.defaultPrevented
) {
return;
}
navigateToOdooMenu(menu, env.services.action, env.services.notification, middleClick);
},
};

View file

@ -1,14 +1,12 @@
/** @odoo-module */
import spreadsheet from "@spreadsheet/o_spreadsheet/o_spreadsheet_extended";
import { omit } from "@web/core/utils/objects";
const { coreTypes, helpers } = spreadsheet;
const { deepEquals } = helpers;
import { OdooCorePlugin } from "@spreadsheet/plugins";
import { coreTypes, constants } from "@odoo/o-spreadsheet";
const { FIGURE_ID_SPLITTER } = constants;
/** Plugin that link charts with Odoo menus. It can contain either the Id of the odoo menu, or its xml id. */
export default class ChartOdooMenuPlugin extends spreadsheet.CorePlugin {
constructor() {
super(...arguments);
export class ChartOdooMenuPlugin extends OdooCorePlugin {
static getters = /** @type {const} */ (["getChartOdooMenu"]);
constructor(config) {
super(config);
this.odooMenuReference = {};
}
@ -21,8 +19,8 @@ export default class ChartOdooMenuPlugin extends spreadsheet.CorePlugin {
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);
case "DELETE_CHART":
this.history.update("odooMenuReference", cmd.chartId, undefined);
break;
case "DUPLICATE_SHEET":
this.updateOnDuplicateSheet(cmd.sheetId, cmd.sheetIdTo);
@ -32,27 +30,13 @@ export default class ChartOdooMenuPlugin extends spreadsheet.CorePlugin {
updateOnDuplicateSheet(sheetIdFrom, sheetIdTo) {
for (const oldChartId of this.getters.getChartIds(sheetIdFrom)) {
if (!this.odooMenuReference[oldChartId]) {
const menu = this.odooMenuReference[oldChartId];
if (!menu) {
continue;
}
const oldChartDefinition = this.getters.getChartDefinition(oldChartId);
const oldFigure = this.getters.getFigure(sheetIdFrom, oldChartId);
const newChartId = this.getters.getChartIds(sheetIdTo).find((newChartId) => {
const newChartDefinition = this.getters.getChartDefinition(newChartId);
const newFigure = this.getters.getFigure(sheetIdTo, newChartId);
return (
deepEquals(oldChartDefinition, newChartDefinition) &&
deepEquals(omit(newFigure, "id"), omit(oldFigure, "id")) // compare size and position
);
});
if (newChartId) {
this.history.update(
"odooMenuReference",
newChartId,
this.odooMenuReference[oldChartId]
);
}
const chartIdBase = oldChartId.split(FIGURE_ID_SPLITTER).pop();
const newChartId = `${sheetIdTo}${FIGURE_ID_SPLITTER}${chartIdBase}`;
this.history.update("odooMenuReference", newChartId, menu);
}
}
@ -77,7 +61,5 @@ export default class ChartOdooMenuPlugin extends spreadsheet.CorePlugin {
data.chartOdooMenusReferences = this.odooMenuReference;
}
}
ChartOdooMenuPlugin.modes = ["normal", "headless"];
ChartOdooMenuPlugin.getters = ["getChartOdooMenu"];
coreTypes.add("LINK_ODOO_MENU_TO_CHART");

View file

@ -1,44 +1,45 @@
/** @odoo-module */
import spreadsheet from "../../o_spreadsheet/o_spreadsheet_extended";
import ChartDataSource from "../data_source/chart_data_source";
import { globalFiltersFieldMatchers } from "@spreadsheet/global_filters/plugins/global_filters_core_plugin";
import { sprintf } from "@web/core/utils/strings";
import { _t } from "@web/core/l10n/translation";
import { checkFilterFieldMatching } from "@spreadsheet/global_filters/helpers";
import CommandResult from "../../o_spreadsheet/cancelled_reason";
const { CorePlugin } = spreadsheet;
import { CommandResult } from "../../o_spreadsheet/cancelled_reason";
import { Domain } from "@web/core/domain";
import { OdooCorePlugin } from "@spreadsheet/plugins";
import { _t } from "@web/core/l10n/translation";
/**
* @typedef {Object} Chart
* @property {string} dataSourceId
* @property {Object} fieldMatching
*
* @typedef {import("@spreadsheet/global_filters/plugins/global_filters_core_plugin").FieldMatching} FieldMatching
* @typedef {import("@spreadsheet").FieldMatching} FieldMatching
*/
export default class OdooChartCorePlugin extends CorePlugin {
constructor(getters, history, range, dispatch, config, uuidGenerator) {
super(getters, history, range, dispatch, config, uuidGenerator);
this.dataSources = config.dataSources;
const CHART_PLACEHOLDER_DISPLAY_NAME = {
odoo_bar: _t("Odoo Bar Chart"),
odoo_line: _t("Odoo Line Chart"),
odoo_pie: _t("Odoo Pie Chart"),
odoo_radar: _t("Odoo Radar Chart"),
odoo_geo: _t("Odoo Geo Chart"),
odoo_treemap: _t("Odoo Treemap Chart"),
odoo_sunburst: _t("Odoo Sunburst Chart"),
odoo_waterfall: _t("Odoo Waterfall Chart"),
odoo_pyramid: _t("Odoo Pyramid Chart"),
odoo_scatter: _t("Odoo Scatter Chart"),
odoo_combo: _t("Odoo Combo Chart"),
odoo_funnel: _t("Odoo Funnel Chart"),
};
export class OdooChartCorePlugin extends OdooCorePlugin {
static getters = /** @type {const} */ ([
"getOdooChartIds",
"getChartFieldMatch",
"getOdooChartDisplayName",
"getOdooChartFieldMatching",
"getChartGranularity",
]);
constructor(config) {
super(config);
/** @type {Object.<string, Chart>} */
this.charts = {};
globalFiltersFieldMatchers["chart"] = {
geIds: () => this.getters.getOdooChartIds(),
getDisplayName: (chartId) => this.getters.getOdooChartDisplayName(chartId),
getTag: async (chartId) => {
const model = await this.getChartDataSource(chartId).getModelLabel();
return sprintf(_t("Chart - %s"), model);
},
getFieldMatching: (chartId, filterId) =>
this.getOdooChartFieldMatching(chartId, filterId),
waitForReady: () => this.getOdooChartsWaitForReady(),
getModel: (chartId) =>
this.getters.getChart(chartId).getDefinitionForDataSource().metaData.resModel,
getFields: (chartId) => this.getChartDataSource(chartId).getFields(),
};
}
allowDispatch(cmd) {
@ -60,28 +61,14 @@ export default class OdooChartCorePlugin extends CorePlugin {
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;
if (cmd.definition.type.startsWith("odoo_")) {
this._addOdooChart(cmd.chartId);
}
break;
}
case "UPDATE_CHART": {
switch (cmd.definition.type) {
case "odoo_pie":
case "odoo_bar":
case "odoo_line":
this._setChartDataSource(cmd.id);
break;
}
break;
}
case "DELETE_FIGURE": {
case "DELETE_CHART": {
const charts = { ...this.charts };
delete charts[cmd.id];
delete charts[cmd.chartId];
this.history.update("charts", charts);
break;
}
@ -106,15 +93,7 @@ export default class OdooChartCorePlugin extends CorePlugin {
* @returns {Array<string>}
*/
getOdooChartIds() {
const ids = [];
for (const sheetId of this.getters.getSheetIds()) {
ids.push(
...this.getters
.getChartIds(sheetId)
.filter((id) => this.getters.getChartType(id).startsWith("odoo_"))
);
}
return ids;
return Object.keys(this.charts);
}
/**
@ -125,26 +104,29 @@ export default class OdooChartCorePlugin extends CorePlugin {
return this.charts[chartId].fieldMatching;
}
/**
* @param {string} id
* @returns {ChartDataSource|undefined}
*/
getChartDataSource(id) {
const dataSourceId = this.charts[id].dataSourceId;
return this.dataSources.get(dataSourceId);
}
/**
*
* @param {string} chartId
* @returns {string}
*/
getOdooChartDisplayName(chartId) {
return this.getters.getChart(chartId).title;
const { title, type } = this.getters.getChart(chartId);
const name = title.text || CHART_PLACEHOLDER_DISPLAY_NAME[type];
return `(#${this.getOdooChartIds().indexOf(chartId) + 1}) ${name}`;
}
getChartGranularity(chartId) {
const definition = this.getters.getChartDefinition(chartId);
if (definition.type.startsWith("odoo_") && definition.metaData.groupBy.length) {
const horizontalAxis = definition.metaData.groupBy[0];
const [fieldName, granularity] = horizontalAxis.split(":");
return { fieldName, granularity };
}
return null;
}
/**
* Import the pivots
* Import the charts
*
* @param {Object} data
*/
@ -153,14 +135,21 @@ export default class OdooChartCorePlugin extends CorePlugin {
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);
this._addOdooChart(figure.data.chartId, figure.data.fieldMatching ?? {});
} else if (figure.tag === "carousel") {
for (const chartId in figure.data.chartDefinitions) {
const fieldMatching = figure.data.fieldMatching ?? {};
if (figure.data.chartDefinitions[chartId].type.startsWith("odoo_")) {
this._addOdooChart(chartId, fieldMatching[chartId]);
}
}
}
}
}
}
}
/**
* Export the pivots
* Export the chart
*
* @param {Object} data
*/
@ -169,7 +158,22 @@ export default class OdooChartCorePlugin extends CorePlugin {
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.fieldMatching = this.getChartFieldMatch(figure.data.chartId);
figure.data.searchParams.domain = new Domain(
figure.data.searchParams.domain
).toJson();
} else if (figure.tag === "carousel") {
figure.data.fieldMatching = {};
for (const chartId in figure.data.chartDefinitions) {
const chartDefinition = figure.data.chartDefinitions[chartId];
if (chartDefinition.type.startsWith("odoo_")) {
figure.data.fieldMatching[chartId] =
this.getChartFieldMatch(chartId);
chartDefinition.searchParams.domain = new Domain(
chartDefinition.searchParams.domain
).toJson();
}
}
}
}
}
@ -180,17 +184,7 @@ export default class OdooChartCorePlugin extends CorePlugin {
// -------------------------------------------------------------------------
/**
*
* @return {Promise[]}
*/
getOdooChartsWaitForReady() {
return this.getOdooChartIds().map((chartId) =>
this.getChartDataSource(chartId).loadMetadata()
);
}
/**
* Get the current pivotFieldMatching of a chart
* Get the current odooChartFieldMatching of a chart
*
* @param {string} chartId
* @param {string} filterId
@ -200,7 +194,7 @@ export default class OdooChartCorePlugin extends CorePlugin {
}
/**
* Sets the current pivotFieldMatching of a chart
* Sets the current odooChartFieldMatching of a chart
*
* @param {string} filterId
* @param {Record<string,FieldMatching>} chartFieldMatches
@ -222,37 +216,13 @@ export default class OdooChartCorePlugin extends CorePlugin {
/**
* @param {string} chartId
* @param {string} dataSourceId
* @param {Object} fieldMatching
*/
_addOdooChart(chartId, fieldMatching = {}) {
const dataSourceId = this.uuidGenerator.uuidv4();
const charts = { ...this.charts };
charts[chartId] = {
dataSourceId,
fieldMatching,
};
const definition = this.getters.getChart(chartId).getDefinitionForDataSource();
if (!this.dataSources.contains(dataSourceId)) {
this.dataSources.add(dataSourceId, ChartDataSource, definition);
}
this.history.update("charts", charts);
this._setChartDataSource(chartId);
}
/**
* Sets the catasource on the corresponding chart
* @param {string} chartId
*/
_setChartDataSource(chartId) {
const chart = this.getters.getChart(chartId);
chart.setDataSource(this.getters.getChartDataSource(chartId));
_addOdooChart(chartId, fieldMatching = undefined) {
const model = this.getters.getChartDefinition(chartId).metaData.resModel;
this.history.update("charts", chartId, {
chartId,
fieldMatching: fieldMatching || this.getters.getFieldMatchingForModel(model),
});
}
}
OdooChartCorePlugin.getters = [
"getChartDataSource",
"getOdooChartIds",
"getChartFieldMatch",
"getOdooChartDisplayName",
"getOdooChartFieldMatching",
];

View file

@ -0,0 +1,221 @@
import { Domain } from "@web/core/domain";
import { ChartDataSource, chartTypeToDataSourceMode } from "../data_source/chart_data_source";
import { OdooUIPlugin } from "@spreadsheet/plugins";
import { deepEqual } from "@web/core/utils/objects";
export class OdooChartCoreViewPlugin extends OdooUIPlugin {
static getters = /** @type {const} */ (["getChartDataSource", "getOdooEnv"]);
shouldChartUpdateReloadDataSource = false;
constructor(config) {
super(config);
this.custom = config.custom;
/** @type {Record<string, ChartDataSource>} */
this.charts = {};
}
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;
case "UPDATE_CHART": {
if (cmd.definition.type.startsWith("odoo_")) {
const chart = this.getters.getChart(cmd.chartId);
if (this._shouldReloadDataSource(cmd.chartId, cmd.definition)) {
this.shouldChartUpdateReloadDataSource = true;
} else if (cmd.definition.type !== chart.type) {
const dataSource = this.getChartDataSource(cmd.chartId);
dataSource.changeChartType(chartTypeToDataSourceMode(cmd.definition.type));
}
}
break;
}
}
}
/**
* Handle a spreadsheet command
*
* @param {Object} cmd Command
*/
handle(cmd) {
switch (cmd.type) {
case "CREATE_CHART": {
if (cmd.definition.type.startsWith("odoo_")) {
this._setupChartDataSource(cmd.chartId);
}
break;
}
case "UPDATE_CHART": {
if (cmd.definition.type.startsWith("odoo_")) {
if (this.shouldChartUpdateReloadDataSource) {
this._resetChartDataSource(cmd.chartId);
this.shouldChartUpdateReloadDataSource = false;
}
this._setChartDataSource(cmd.chartId);
}
break;
}
case "ADD_GLOBAL_FILTER":
case "EDIT_GLOBAL_FILTER":
case "REMOVE_GLOBAL_FILTER":
case "SET_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.chartId)) {
continue;
}
if (this._shouldReloadDataSource(cmd.chartId, cmd.definition)) {
this._resetChartDataSource(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.charts[dataSourceId];
}
getOdooEnv() {
return this.custom.env;
}
// -------------------------------------------------------------------------
// 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()) {
// Reset the data source to prevent eager loading
// of the data source when the domain is added
this._resetChartDataSource(chartId);
}
}
/**
* @param {string} chartId
* @param {string} dataSourceId
*/
_setupChartDataSource(chartId) {
const dataSourceId = this._getOdooChartDataSourceId(chartId);
if (!(dataSourceId in this.charts)) {
this._resetChartDataSource(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.charts[dataSourceId] = new ChartDataSource(this.custom, definition);
this._addDomain(chartId);
this._setChartDataSource(chartId);
}
/**
* Sets the datasource on the corresponding chart
* @param {string} chartId
*/
_setChartDataSource(chartId) {
const chart = this.getters.getChart(chartId);
chart.setDataSource(this.getChartDataSource(chartId));
}
_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);
}
}
_shouldReloadDataSource(chartId, definition) {
const chart = this.getters.getChart(chartId);
const dataSource = this.getChartDataSource(chartId);
return (
!deepEqual(chart.searchParams.groupBy, definition.searchParams.groupBy) ||
chart.metaData.cumulated !== definition.cumulative ||
chart.cumulatedStart !== definition.cumulatedStart ||
dataSource.getInitialDomainString() !==
new Domain(definition.searchParams.domain).toString()
);
}
}

View file

@ -0,0 +1,122 @@
import { _t } from "@web/core/l10n/translation";
import { OdooUIPlugin } from "@spreadsheet/plugins";
import { getBestGranularity, getValidGranularities } from "../../global_filters/helpers";
export class OdooChartFeaturePlugin extends OdooUIPlugin {
static getters = /** @type {const} */ (["getAvailableChartGranularities"]);
overwrittenGranularities = {};
granularityOptionsCache = {};
handle(cmd) {
switch (cmd.type) {
case "SET_GLOBAL_FILTER_VALUE": {
if (this.getters.isDashboard()) {
this._onGlobalFilterChange(cmd);
}
break;
}
case "UPDATE_CHART_GRANULARITY": {
this._updateChartGranularity(cmd.chartId, cmd.granularity);
this.overwrittenGranularities[cmd.chartId] = cmd.granularity;
break;
}
}
}
getAvailableChartGranularities(chartId) {
if (this.granularityOptionsCache[chartId]) {
return this.granularityOptionsCache[chartId];
}
const { granularity, fieldName } = this.getters.getChartGranularity(chartId);
if (!granularity) {
return [];
}
const allGranularities = [
{ value: "hour", label: _t("Hours") },
{ value: "day", label: _t("Days") },
{ value: "week", label: _t("Weeks") },
{ value: "month", label: _t("Months") },
{ value: "quarter", label: _t("Quarters") },
{ value: "year", label: _t("Years") },
];
const filterId = this._getChartHorizontalAxisFilter(chartId, fieldName);
const matching = this.getters.getOdooChartFieldMatching(chartId, filterId);
if (matching?.type !== "datetime") {
allGranularities.shift();
}
const currentFilterValue = filterId
? this.getters.getGlobalFilterValue(filterId)
: undefined;
const allowed = getValidGranularities(currentFilterValue);
const available = allGranularities.filter(({ value }) => allowed.includes(value));
this.granularityOptionsCache[chartId] = available;
return available;
}
_onGlobalFilterChange(cmd) {
const filterId = cmd.id;
const globalFilter = this.getters.getGlobalFilter(filterId);
if (globalFilter.type !== "date") {
return;
}
for (const chartId of this.getters.getOdooChartIds()) {
const { fieldName, granularity: currentGranularity } =
this.getters.getChartGranularity(chartId);
const fieldMatching = this.getters.getChartFieldMatch(chartId)[filterId];
const bestGranularity = getBestGranularity(cmd.value, fieldMatching);
const validGranularities = getValidGranularities(cmd.value);
const shouldAutoUpdate =
fieldMatching?.chain === fieldName &&
!validGranularities.includes(this.overwrittenGranularities[chartId]) &&
bestGranularity !== currentGranularity;
if (shouldAutoUpdate) {
this.dispatch("UPDATE_CHART_GRANULARITY", {
chartId,
granularity: bestGranularity,
});
this.overwrittenGranularities[chartId] = undefined;
}
if (currentGranularity) {
this.granularityOptionsCache[chartId] = undefined;
}
}
}
_updateChartGranularity(chartId, granularity) {
const definition = this.getters.getChartDefinition(chartId);
const { fieldName } = this.getters.getChartGranularity(chartId);
const newGroupBy = [
`${fieldName}:${granularity}`,
...definition.searchParams.groupBy.slice(1),
];
this.dispatch("UPDATE_CHART", {
chartId,
figureId: this.getters.getFigureIdFromChartId(chartId),
definition: {
...definition,
// I don't know why it's in both searchParams and metaData.
searchParams: {
...definition.searchParams,
groupBy: newGroupBy,
},
metaData: {
...definition.metaData,
groupBy: newGroupBy,
},
},
});
}
_getChartHorizontalAxisFilter(chartId, fieldName) {
for (const filter of this.getters.getGlobalFilters()) {
const matching = this.getters.getOdooChartFieldMatching(chartId, filter.id);
if (matching?.chain === fieldName) {
return filter.id;
}
}
return undefined;
}
}

View file

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

View file

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

View file

@ -0,0 +1,24 @@
import { Spreadsheet, components } from "@odoo/o-spreadsheet";
import { patch } from "@web/core/utils/patch";
import { useSpreadsheetCommandPalette } from "./command_provider";
const { Grid } = components;
patch(Spreadsheet.prototype, {
setup() {
super.setup();
if (this.env.isDashboard()) {
return;
}
useSpreadsheetCommandPalette();
},
});
patch(Grid.prototype, {
setup() {
super.setup();
// Remove the Ctrl+K hotkey (open a link) from the grid to avoid conflict
// with the command palette.
delete this.keyDownMapping["Ctrl+K"];
},
});

View file

@ -0,0 +1,79 @@
import { registries } from "@odoo/o-spreadsheet";
import { useEffect, useEnv } from "@odoo/owl";
import { registry } from "@web/core/registry";
const { topbarMenuRegistry } = registries;
const commandProviderRegistry = registry.category("command_provider");
const commandCategoryRegistry = registry.category("command_categories");
/**
* Activate the command palette for spreadsheet.
*/
export function useSpreadsheetCommandPalette() {
const env = useEnv();
useEffect(
() => {
setupSpreadsheetCategories(env);
setupSpreadsheetCommandProvider(env);
return () => commandProviderRegistry.remove("spreadsheet_provider");
},
() => []
);
}
function setupSpreadsheetCategories(spreadsheetEnv) {
let sequence = 5;
commandCategoryRegistry.add("spreadsheet_insert_link", {}, { sequence: 0, force: true });
for (const menu of topbarMenuRegistry.getMenuItems()) {
const category = `spreadsheet_${menu.name(spreadsheetEnv)}`;
commandCategoryRegistry.add(category, {}, { sequence, force: true });
sequence++;
}
}
function setupSpreadsheetCommandProvider(spreadsheetEnv) {
commandProviderRegistry.add("spreadsheet_provider", {
provide: (env, options) => {
const result = [];
for (const menu of topbarMenuRegistry.getMenuItems()) {
const name = menu.name(spreadsheetEnv);
const category = `spreadsheet_${name}`;
result.push(...registerCommand(spreadsheetEnv, menu, name, category));
}
return result;
},
});
}
function registerCommand(spreadsheetEnv, menu, parentName, category) {
const result = [];
if (menu.children) {
for (const subMenu of menu
.children(spreadsheetEnv)
.sort((a, b) => a.sequence - b.sequence)) {
if (!subMenu.isVisible(spreadsheetEnv) || !subMenu.isEnabled(spreadsheetEnv)) {
continue;
}
const subMenuName = `${subMenu.name(spreadsheetEnv)}`;
if (subMenu.execute) {
result.push({
action() {
subMenu.execute(spreadsheetEnv);
},
category: subMenu.id === "insert_link" ? "spreadsheet_insert_link" : category,
name: `${parentName} / ${subMenuName}`,
});
} else {
result.push(
...registerCommand(
spreadsheetEnv,
subMenu,
`${parentName} / ${subMenuName}`,
category
)
);
}
}
}
return result;
}

View file

@ -0,0 +1,77 @@
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/core/copy_button/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", this.props.togglerClass].join(" ");
}
async onOpened() {
const model = this.props.model;
await waitForDataLoaded(model);
const data = await freezeOdooData(model);
if (!this.isChanged(data)) {
return;
}
model.dispatch("LOG_DATASOURCE_EXPORT", { action: "freeze" });
const url = await this.props.onSpreadsheetShared(data, model.exportXLSX());
this.state.url = url;
setTimeout(async () => {
try {
await browser.navigator.clipboard.writeText(url);
} catch (error) {
browser.console.warn(error);
}
});
}
/**
* 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] !== newCells[key]) {
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,18 @@
.spreadsheet_share_dropdown {
width: 320px;
height: 100px;
}
.o-spreadsheet {
.o-dropdown.o_topbar_share_icon {
display: block;
}
}
.o_bottom_sheet .spreadsheet_share_dropdown {
width: 100%;
.o_loading_state {
height: 40px;
}
}

View file

@ -0,0 +1,39 @@
<?xml version="1.0" encoding="utf-8"?>
<templates>
<t t-name="spreadsheet.ShareButton">
<Dropdown
menuClass="'spreadsheet_share_dropdown d-flex flex-column h-auto'"
position="'bottom-end'"
onOpened.bind="onOpened"
disabled="!props.model"
>
<button t-att-class="togglerClass">
<i class="fa fa-share-alt"/>
Share
</button>
<t t-set-slot="content">
<t>
<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 t-if="state.url" 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" icon="'fa-clipboard'"/>
</div>
<div t-else="" class="o_loading_state d-flex align-items-center justify-content-center">
<i class="fa fa-circle-o-notch fa-spin px-2"/><span>Generating sharing link</span>
</div>
</div>
</t>
</t>
</Dropdown>
</t>
</templates>

View file

@ -1,69 +0,0 @@
/** @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

@ -1,24 +1,25 @@
/** @odoo-module **/
import { _t } from "@web/core/l10n/translation";
import spreadsheet from "../o_spreadsheet/o_spreadsheet_extended";
const { args, toString, toJsDate } = spreadsheet.helpers;
import * as spreadsheet from "@odoo/o-spreadsheet";
const { arg, toString, toJsDate, toNumber } = spreadsheet.helpers;
const { functionRegistry } = spreadsheet.registries;
functionRegistry.add("ODOO.CURRENCY.RATE", {
description: _t(
"This function takes in two currency codes as arguments, and returns the exchange rate from the first currency to the second as float."
),
compute: function (currencyFrom, currencyTo, date) {
category: "Odoo",
compute: function (currencyFrom, currencyTo, date, companyId) {
const from = toString(currencyFrom);
const to = toString(currencyTo);
const _date = date ? toJsDate(date) : undefined;
return this.getters.getCurrencyRate(from, to, _date);
const _date = date ? toJsDate(date, this.locale) : undefined;
const _companyId = companyId ? toNumber(companyId) : undefined;
return this.getters.getCurrencyRate(from, to, _date, _companyId);
},
args: args(`
currency_from (string) ${_t("First currency code.")}
currency_to (string) ${_t("Second currency code.")}
date (date, optional) ${_t("Date of the rate.")}
`),
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.")),
arg("company_id (number, optional)", _t("The company to take the exchange rate from.")),
],
returns: ["NUMBER"],
});

View file

@ -0,0 +1,17 @@
/**
* Return the currency cleaned from useless info and from the `code` field to be used to generate
* a default currency format.
*
* @param {object} currency
* @returns {object}
*/
export function createDefaultCurrency(currency) {
if (!currency) {
return undefined;
}
return {
symbol: currency.symbol,
position: currency.position,
decimalPlaces: currency.decimalPlaces,
};
}

View file

@ -1,22 +1,41 @@
/** @odoo-module */
import spreadsheet from "../../o_spreadsheet/o_spreadsheet_extended";
import { CurrencyDataSource } from "../currency_data_source";
const { uiPluginRegistry } = spreadsheet.registries;
const DATA_SOURCE_ID = "CURRENCIES";
import { EvaluationError, helpers, registries } from "@odoo/o-spreadsheet";
import { OdooUIPlugin } from "@spreadsheet/plugins";
import { toServerDateString } from "@spreadsheet/helpers/helpers";
import { _t } from "@web/core/l10n/translation";
const { featurePluginRegistry } = registries;
const { createCurrencyFormat } = helpers;
/**
* @typedef {import("../currency_data_source").Currency} Currency
* @typedef Currency
* @property {string} name
* @property {string} code
* @property {string} symbol
* @property {number} decimalPlaces
* @property {"before" | "after"} position
*/
class CurrencyPlugin extends spreadsheet.UIPlugin {
constructor(getters, history, dispatch, config) {
super(getters, history, dispatch, config);
this.dataSources = config.dataSources;
if (this.dataSources) {
this.dataSources.add(DATA_SOURCE_ID, CurrencyDataSource);
export class CurrencyPlugin extends OdooUIPlugin {
static getters = /** @type {const} */ ([
"getCurrencyRate",
"computeFormatFromCurrency",
"getCompanyCurrencyFormat",
]);
constructor(config) {
super(config);
/** @type {string | undefined} */
this.currentCompanyCurrency = config.defaultCurrency;
/** @type {import("@spreadsheet/data_sources/server_data").ServerData} */
this._serverData = config.custom.odooDataProvider?.serverData;
}
get serverData() {
if (!this._serverData) {
throw new Error(
"'serverData' is not defined, please make sure a 'OdooDataProvider' instance is provided to the model."
);
}
return this._serverData;
}
// -------------------------------------------------------------------------
@ -27,63 +46,58 @@ class CurrencyPlugin extends spreadsheet.UIPlugin {
* Get the currency rate between the two given currencies
* @param {string} from Currency from
* @param {string} to Currency to
* @param {string} date
* @param {string | undefined} date
* @param {number | undefined} companyId
* @returns {number|string}
*/
getCurrencyRate(from, to, date) {
return (
this.dataSources && this.dataSources.get(DATA_SOURCE_ID).getCurrencyRate(from, to, date)
);
getCurrencyRate(from, to, date, companyId) {
const data = this.serverData.batch.get("res.currency.rate", "get_rates_for_spreadsheet", {
from,
to,
date: date ? toServerDateString(date) : undefined,
company_id: companyId,
});
const rate = data !== undefined ? data.rate : undefined;
if (rate === false) {
throw new EvaluationError(_t("Currency rate unavailable."));
}
return rate;
}
/**
*
* @param {Currency | undefined} currency
* @private
*
* @returns {string | undefined}
*/
computeFormatFromCurrency(currency) {
if (!currency) {
return undefined;
}
const decimalFormatPart = currency.decimalPlaces
? "." + "0".repeat(currency.decimalPlaces)
: "";
const numberFormat = "#,##0" + decimalFormatPart;
const symbolFormatPart = "[$" + currency.symbol + "]";
return currency.position === "after"
? numberFormat + symbolFormatPart
: symbolFormatPart + numberFormat;
}
/**
* Returns the default display format of a given currency
* @param {string} currencyName
* @returns {string | undefined}
*/
getCurrencyFormat(currencyName) {
const currency =
currencyName &&
this.dataSources &&
this.dataSources.get(DATA_SOURCE_ID).getCurrency(currencyName);
return this.computeFormatFromCurrency(currency);
return createCurrencyFormat({
symbol: currency.symbol,
position: currency.position,
decimalPlaces: currency.decimalPlaces,
});
}
/**
* Returns the default display format of a the company currency
* @param {number|undefined} companyId
* @param {number} [companyId]
* @returns {string | undefined}
*/
getCompanyCurrencyFormat(companyId) {
const currency =
this.dataSources &&
this.dataSources.get(DATA_SOURCE_ID).getCompanyCurrencyFormat(companyId);
if (!companyId && this.currentCompanyCurrency) {
return this.computeFormatFromCurrency(this.currentCompanyCurrency);
}
const currency = this.serverData.get(
"res.currency",
"get_company_currency_for_spreadsheet",
[companyId]
);
if (currency === false) {
throw new EvaluationError(_t("Currency not available for this company."));
}
return this.computeFormatFromCurrency(currency);
}
}
CurrencyPlugin.modes = ["normal", "headless"];
CurrencyPlugin.getters = ["getCurrencyRate", "getCurrencyFormat", "getCompanyCurrencyFormat"];
uiPluginRegistry.add("odooCurrency", CurrencyPlugin);
featurePluginRegistry.add("odooCurrency", CurrencyPlugin);

View file

@ -1,8 +1,15 @@
/** @odoo-module */
// @ts-check
import { LoadingDataError } from "@spreadsheet/o_spreadsheet/errors";
import { RPCError } from "@web/core/network/rpc_service";
import { RPCError } from "@web/core/network/rpc";
import { KeepLast } from "@web/core/utils/concurrency";
import { CellErrorType, EvaluationError } from "@odoo/o-spreadsheet";
import { _t } from "@web/core/l10n/translation";
/**
* @typedef {import("./odoo_data_provider").OdooDataProvider} OdooDataProvider
* @typedef {import("./server_data").ServerData} ServerData
*/
/**
* DataSource is an abstract class that contains the logic of fetching and
@ -15,11 +22,13 @@ import { KeepLast } from "@web/core/utils/concurrency";
* particular data.
*/
export class LoadableDataSource {
constructor(params) {
this._orm = params.orm;
this._metadataRepository = params.metadataRepository;
this._notifyWhenPromiseResolves = params.notifyWhenPromiseResolves;
this._cancelPromise = params.cancelPromise;
/**
* @param {Object} param0
* @param {OdooDataProvider} param0.odooDataProvider
*/
constructor({ odooDataProvider }) {
/** @protected */
this.odooDataProvider = odooDataProvider;
/**
* Last time that this dataSource has been updated
@ -33,7 +42,16 @@ export class LoadableDataSource {
this._loadPromise = undefined;
this._isFullyLoaded = false;
this._isValid = true;
this._loadErrorMessage = "";
this._loadError = undefined;
this._isModelValid = true;
}
get _orm() {
return this.odooDataProvider.orm;
}
get serverData() {
return this.odooDataProvider.serverData;
}
/**
@ -45,24 +63,39 @@ export class LoadableDataSource {
*/
async load(params) {
if (params && params.reload) {
this._cancelPromise(this._loadPromise);
this.odooDataProvider.cancelPromise(this._loadPromise);
this._loadPromise = undefined;
}
if (!this._loadPromise) {
this._isFullyLoaded = false;
this._isValid = true;
this._loadErrorMessage = "";
this._loadError = undefined;
this._loadPromise = this._concurrency
.add(this._load())
.catch((e) => {
this._isValid = false;
this._loadErrorMessage = e instanceof RPCError ? e.data.message : e.message;
if (e instanceof ModelNotFoundError) {
this._isModelValid = false;
this._loadError = Object.assign(
new EvaluationError(
_t(`The model "%(model)s" does not exist.`, { model: e.message })
),
{
cause: e,
}
);
return;
}
this._loadError = Object.assign(
new EvaluationError(e instanceof RPCError ? e.data.message : e.message),
{ cause: e }
);
})
.finally(() => {
this._lastUpdate = Date.now();
this._isFullyLoaded = true;
});
await this._notifyWhenPromiseResolves(this._loadPromise);
await this.odooDataProvider.notifyWhenPromiseResolves(this._loadPromise);
}
return this._loadPromise;
}
@ -78,16 +111,31 @@ export class LoadableDataSource {
return this._isFullyLoaded;
}
/**
* @protected
*/
_assertDataIsLoaded() {
isLoading() {
return !!this._loadPromise && !this.isReady();
}
isValid() {
return this.isReady() && this._isValid;
}
isModelValid() {
return this.isReady() && this._isModelValid;
}
assertIsValid({ throwOnError } = { throwOnError: true }) {
if (!this._isFullyLoaded) {
this.load();
throw LOADING_ERROR;
if (throwOnError) {
throw LOADING_ERROR;
}
return LOADING_ERROR;
}
if (!this._isValid) {
throw new Error(this._loadErrorMessage);
if (throwOnError) {
throw this._loadError;
}
return { value: CellErrorType.GenericError, message: this._loadError.message };
}
}
@ -100,4 +148,25 @@ export class LoadableDataSource {
async _load() {}
}
const LOADING_ERROR = new LoadingDataError();
export const LOADING_ERROR = new LoadingDataError();
export class ModelNotFoundError extends Error {}
/**
* Return the fields of a given model.
* If the model is not found, a `ModelNotFoundError` is thrown.
*
* @param {object} fieldService
* @param {string} model
* @returns {Promise<import("@spreadsheet").OdooFields>}
*/
export async function getFields(fieldService, model) {
try {
return await fieldService.loadFields(model);
} catch (e) {
if (e instanceof RPCError && e.code === 404) {
throw new ModelNotFoundError(model);
}
throw e;
}
}

View file

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

View file

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

View file

@ -1,54 +0,0 @@
/** @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

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

View file

@ -0,0 +1,55 @@
import { EventBus } from "@odoo/owl";
import { ServerData } from "./server_data";
export class OdooDataProvider extends EventBus {
constructor(env) {
super();
this.orm = env.services.orm.silent;
this.fieldService = env.services.field;
this.serverData = new ServerData(this.orm, {
whenDataStartLoading: (promise) => this.notifyWhenPromiseResolves(promise),
});
this.pendingPromises = new Set();
}
cancelPromise(promise) {
this.pendingPromises.delete(promise);
}
/**
* @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");
}
}

View file

@ -1,18 +1,24 @@
/** @odoo-module */
// @ts-check
import { LoadableDataSource } from "./data_source";
import { Domain } from "@web/core/domain";
import { LoadingDataError } from "@spreadsheet/o_spreadsheet/errors";
import { LOADING_ERROR, LoadableDataSource, getFields } from "./data_source";
import { Domain } from "@web/core/domain";
import { user } from "@web/core/user";
import { omit } from "@web/core/utils/objects";
/**
* @typedef {import("@spreadsheet/data_sources/metadata_repository").Field} Field
* @typedef {import("@spreadsheet").OdooField} OdooField
* @typedef {import("@spreadsheet").OdooFields} OdooFields
*/
/**
* @typedef {Object} OdooModelMetaData
* @property {string} resModel
* @property {Array<Object>|undefined} fields
* @property {OdooFields} [fields]
*
* @typedef {Object} OdooModelSearchParams
* @property {Object} context
* @property {Array<string>} domain
*/
export class OdooViewsDataSource extends LoadableDataSource {
@ -25,15 +31,18 @@ export class OdooViewsDataSource extends LoadableDataSource {
*/
constructor(services, params) {
super(services);
/** @type {OdooModelMetaData} */
this._metaData = JSON.parse(JSON.stringify(params.metaData));
/** @protected */
this._initialSearchParams = JSON.parse(JSON.stringify(params.searchParams));
const userContext = user.context;
this._initialSearchParams.context = omit(
this._initialSearchParams.context || {},
...Object.keys(this._orm.user.context)
...Object.keys(userContext)
);
/** @private */
this._customDomain = this._initialSearchParams.domain;
this._metaDataLoaded = false;
}
/**
@ -42,34 +51,47 @@ export class OdooViewsDataSource extends LoadableDataSource {
get _searchParams() {
return {
...this._initialSearchParams,
domain: this._customDomain,
domain: this.getComputedDomain(),
};
}
async loadMetadata() {
if (!this._metaData.fields) {
this._metaData.fields = await this._metadataRepository.fieldsGet(
this._metaData.fields = await getFields(
this.odooDataProvider.fieldService,
this._metaData.resModel
);
}
this._metaDataLoaded = true;
}
/**
* Ensure that the metadata are loaded. If not, throw an error
*/
_assertMetaDataLoaded() {
if (!this._isModelValid) {
throw this._loadError;
}
if (!this._metaDataLoaded) {
this.loadMetadata();
throw LOADING_ERROR;
}
}
/**
* @returns {Record<string, Field>} List of fields
* @returns {OdooFields} List of fields
*/
getFields() {
if (this._metaData.fields === undefined) {
this.loadMetadata();
throw new LoadingDataError();
}
this._assertMetaDataLoaded();
return this._metaData.fields;
}
/**
* @param {string} field Field name
* @returns {Field | undefined} Field
* @returns {OdooField | undefined} Field
*/
getField(field) {
this._assertMetaDataLoaded();
return this._metaData.fields[field];
}
@ -84,20 +106,43 @@ export class OdooViewsDataSource extends LoadableDataSource {
return this._metaData.fields !== undefined;
}
_assertMetadataIsLoaded() {
if (this._metaData.fields === undefined) {
this.loadMetadata();
throw new LoadingDataError();
}
}
/**
* Get the computed domain of this source
* @returns {Array}
*/
getComputedDomain() {
return this._customDomain;
const userContext = 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]);
const newDomain = Domain.and([this._initialSearchParams.domain, domain]).toString();
if (newDomain.toString() === new Domain(this._customDomain).toString()) {
return;
}
this._customDomain = newDomain.toList();
this._customDomain = newDomain;
if (this._loadPromise === undefined) {
// if the data source has never been loaded, there's no point
// at reloading it now.
@ -109,7 +154,14 @@ export class OdooViewsDataSource extends LoadableDataSource {
/**
* @returns {Promise<string>} Display name of the model
*/
getModelLabel() {
return this._metadataRepository.modelDisplayName(this._metaData.resModel);
async getModelLabel() {
const result = await this._orm
.cache({ type: "disk" })
.call("ir.model", "display_name_for", [[this._metaData.resModel]]);
return result[0]?.display_name || "";
}
get source() {
return {};
}
}

View file

@ -1,5 +1,7 @@
/** @odoo-module */
import { LoadingDataError } from "../o_spreadsheet/errors";
// @ts-check
import { EvaluationError } from "@odoo/o-spreadsheet";
import { LoadingDataError, isLoadingError } from "../o_spreadsheet/errors";
/**
* @param {T[]} array
@ -85,17 +87,17 @@ class ListRequestBatch {
export class ServerData {
/**
* @param {any} orm
* @param {import("@web/core/orm_service").ORM} orm
* @param {object} params
* @param {function} params.whenDataIsFetched
* @param {(promise: Promise<any>) => void} [params.whenDataStartLoading]
*/
constructor(orm, { whenDataIsFetched }) {
constructor(orm, { whenDataStartLoading }) {
/** @type {import("@web/core/orm_service").ORM} */
this.orm = orm;
this.dataFetchedCallback = whenDataIsFetched;
/** @type {(promise: Promise<any>) => void} */
this.startLoadingCallback = whenDataStartLoading ?? (() => {});
/** @type {Record<string, unknown>}*/
this.cache = {};
/** @type {Record<string, Promise<unknown>>}*/
this.asyncCache = {};
this.batchEndpoints = {};
}
@ -135,31 +137,21 @@ export class ServerData {
if (!(request.key in this.cache)) {
const error = new LoadingDataError();
this.cache[request.key] = error;
this.orm
const promise = this.orm
.call(resModel, method, args)
.then((result) => (this.cache[request.key] = result))
.catch((error) => (this.cache[request.key] = error))
.finally(() => this.dataFetchedCallback());
.catch(
(error) =>
(this.cache[request.key] = new EvaluationError(
error.data?.message || error.message
))
);
this.startLoadingCallback(promise);
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
@ -177,7 +169,11 @@ export class ServerData {
*/
_getOrThrowCachedResponse(request) {
const data = this.cache[request.key];
if (data instanceof Error) {
if (
data instanceof Error ||
data instanceof EvaluationError ||
isLoadingError({ value: data })
) {
throw data;
}
return data;
@ -205,9 +201,14 @@ export class ServerData {
*/
_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),
whenDataStartLoading: (promise) => this.startLoadingCallback(promise),
successCallback: (request, result) => {
this.cache[request.key] = result;
},
failureCallback: (request, error) =>
(this.cache[request.key] = new EvaluationError(
error.data?.message || error.message
)),
});
}
}
@ -215,7 +216,7 @@ export class ServerData {
/**
* Collect multiple requests into a single batch.
*/
export default class BatchEndpoint {
export class BatchEndpoint {
/**
* @param {object} orm
* @param {string} resModel
@ -223,15 +224,15 @@ export default class BatchEndpoint {
* @param {object} callbacks
* @param {function} callbacks.successCallback
* @param {function} callbacks.failureCallback
* @param {function} callbacks.whenDataIsFetched
* @param {(promise: Promise<any>) => void} callbacks.whenDataStartLoading
*/
constructor(orm, resModel, method, { successCallback, failureCallback, whenDataIsFetched }) {
constructor(orm, resModel, method, { successCallback, failureCallback, whenDataStartLoading }) {
this.orm = orm;
this.resModel = resModel;
this.method = method;
this.successCallback = successCallback;
this.failureCallback = failureCallback;
this.batchedFetchedCallback = whenDataIsFetched;
this.batchStartsLoadingCallback = whenDataStartLoading;
this._isScheduled = false;
this._pendingBatch = new ListRequestBatch(resModel, method);
@ -268,19 +269,16 @@ export default class BatchEndpoint {
}
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();
}
this._isScheduled = false;
const batch = this._pendingBatch;
const { resModel, method } = batch;
this._pendingBatch = new ListRequestBatch(resModel, method);
const promise = this.orm
.call(resModel, method, batch.payload)
.then((result) => batch.splitResponse(result))
.catch(() => this._retryOneByOne(batch))
.then((batchResults) => this._notifyResults(batchResults));
this.batchStartsLoadingCallback(promise);
});
}

View file

@ -0,0 +1,248 @@
import { Component, onWillUpdateProps } from "@odoo/owl";
import {
dateFilterValueToString,
getDateRange,
getNextDateFilterValue,
getPreviousDateFilterValue,
RELATIVE_PERIODS,
} from "@spreadsheet/global_filters/helpers";
import { DateTimeInput } from "@web/core/datetime/datetime_input";
import { DropdownItem } from "@web/core/dropdown/dropdown_item";
import { _t } from "@web/core/l10n/translation";
const { DateTime } = luxon;
const DATE_OPTIONS = [
{
id: "today",
type: "relative",
label: RELATIVE_PERIODS["today"],
},
{
id: "yesterday",
type: "relative",
label: RELATIVE_PERIODS["yesterday"],
separator: true,
},
{
id: "last_7_days",
type: "relative",
label: RELATIVE_PERIODS["last_7_days"],
},
{
id: "last_30_days",
type: "relative",
label: RELATIVE_PERIODS["last_30_days"],
},
{
id: "last_90_days",
type: "relative",
label: RELATIVE_PERIODS["last_90_days"],
separator: true,
},
{
id: "month_to_date",
type: "relative",
label: RELATIVE_PERIODS["month_to_date"],
},
{
id: "last_month",
type: "relative",
label: RELATIVE_PERIODS["last_month"],
},
{
id: "month",
type: "month",
label: _t("Month"),
},
{
id: "quarter",
type: "quarter",
label: _t("Quarter"),
separator: true,
},
{
id: "year_to_date",
type: "relative",
label: RELATIVE_PERIODS["year_to_date"],
},
{
id: "last_12_months",
type: "relative",
label: RELATIVE_PERIODS["last_12_months"],
},
{
id: "year",
type: "year",
label: _t("Year"),
separator: true,
},
{
id: undefined,
type: undefined,
label: _t("All time"),
},
{
id: "range",
type: "range",
label: _t("Custom Range"),
},
];
/**
* This component is used to select a date filter value.
* It allows the user to select a month, quarter, year, or a custom date range.
* It also provides options for relative periods like "last 7 days".
*/
export class DateFilterDropdown extends Component {
static template = "spreadsheet.DateFilterDropdown";
static components = { DropdownItem, DateTimeInput };
static props = {
value: { type: Object, optional: true },
update: Function,
};
setup() {
this._computeDefaultSelectedValues();
this._applyCurrentValueToSelectedValues(this.props.value);
onWillUpdateProps((nextProps) => this._applyCurrentValueToSelectedValues(nextProps.value));
}
/**
* Computes the default selected values based on the current date.
*/
_computeDefaultSelectedValues() {
const now = DateTime.local();
this.selectedValues = {
month: { month: now.month, year: now.year, type: "month" },
quarter: { quarter: Math.ceil(now.month / 3), year: now.year, type: "quarter" },
year: { year: now.year, type: "year" },
range: { from: "", to: "", type: "range" },
};
}
/**
* Updates the default selected values based on the current value.
* This method is called whenever the component's props are updated.
*/
_applyCurrentValueToSelectedValues(value) {
this._setRangeToCurrentValue(value);
switch (value?.type) {
case "month":
this.selectedValues.month = {
type: "month",
month: value.month,
year: value.year,
};
break;
case "quarter":
this.selectedValues.quarter = {
type: "quarter",
quarter: value.quarter,
year: value.year,
};
break;
case "year":
this.selectedValues.year = { type: "year", year: value.year };
break;
case "range":
this.selectedValues.range = {
type: "range",
from: value.from,
to: value.to,
};
break;
}
}
_setRangeToCurrentValue(value) {
const { from, to } = getDateRange(value);
const now = DateTime.local();
this.selectedValues.range = {
type: "range",
from: from ? from.toISODate() : now.startOf("month").toISODate(),
to: to ? to.toISODate() : now.endOf("month").toISODate(),
};
}
get dateOptions() {
return DATE_OPTIONS;
}
isMonthQuarterYear(value) {
return ["month", "quarter", "year"].includes(value?.type);
}
isSelected(value) {
if (!this.props.value) {
return value.id === undefined;
}
if (this.props.value.type === "relative") {
return this.props.value.period === value.id;
}
return this.props.value.type === value.type;
}
update(value) {
switch (value.type) {
case "relative":
this.props.update({ type: "relative", period: value.id });
break;
case "month":
this.props.update(this.selectedValues.month);
break;
case "quarter":
this.props.update(this.selectedValues.quarter);
break;
case "year":
this.props.update(this.selectedValues.year);
break;
case "range": {
const { from, to } = this.selectedValues.range;
if (from && to) {
// Ensure 'to' is after 'from'
if (DateTime.fromISO(from) > DateTime.fromISO(to)) {
this.selectedValues.range.to = from;
this.selectedValues.range.from = to;
}
}
this.props.update(this.selectedValues.range);
break;
}
default:
this.props.update(undefined);
}
}
dateFrom() {
return this.selectedValues.range.from
? DateTime.fromISO(this.selectedValues.range.from)
: undefined;
}
dateTo() {
return this.selectedValues.range.to
? DateTime.fromISO(this.selectedValues.range.to)
: undefined;
}
setDateFrom(date) {
this.selectedValues.range.from = date ? date.toISODate() : "";
this.update(this.selectedValues.range);
}
setDateTo(date) {
this.selectedValues.range.to = date ? date.toISODate() : "";
this.update(this.selectedValues.range);
}
getDescription(type) {
return dateFilterValueToString(this.selectedValues[type]);
}
selectPrevious(type) {
this.selectedValues[type] = getPreviousDateFilterValue(this.selectedValues[type]);
}
selectNext(type) {
this.selectedValues[type] = getNextDateFilterValue(this.selectedValues[type]);
}
}

View file

@ -0,0 +1,30 @@
<?xml version="1.0" encoding="utf-8"?>
<templates>
<t t-name="spreadsheet.DateFilterDropdown">
<t t-foreach="dateOptions" t-as="option" t-key="option.id">
<DropdownItem tag="'div'"
class="{ 'selected': isSelected(option), 'd-flex justify-content-between o-date-filter-dropdown': true }"
closingMode="'none'"
attrs="{ 'data-id': option.id }"
onSelected="() => this.update(option)">
<div class="o-date-option-label" t-esc="option.label"/>
<t t-if="option.type === 'range'">
<div class="d-flex flex-row" t-on-click.stop="">
<DateTimeInput type="'date'" value="dateFrom()" onChange="(dateFrom) => this.setDateFrom(dateFrom)" />
<span class="d-flex align-items-center">to</span>
<DateTimeInput type="'date'" value="dateTo()" onChange="(dateTo) => this.setDateTo(dateTo)" />
</div>
</t>
<t t-elif="isMonthQuarterYear(option)">
<div class="d-flex justify-content-between">
<button class="btn-previous fa fa-caret-left" t-on-click="() => this.selectPrevious(option.type)" />
<input class="o_input" type="text" t-att-value="getDescription(option.type)" readonly="true"/>
<button class="btn-next fa fa-caret-right" t-on-click="() => this.selectNext(option.type)" />
</div>
</t>
</DropdownItem>
<div class="dropdown-divider" t-if="option.separator"/>
</t>
</t>
</templates>

View file

@ -0,0 +1,31 @@
import { Component } from "@odoo/owl";
import { Dropdown } from "@web/core/dropdown/dropdown";
import { DateFilterDropdown } from "../date_filter_dropdown/date_filter_dropdown";
import { dateFilterValueToString } from "@spreadsheet/global_filters/helpers";
import { useDropdownState } from "@web/core/dropdown/dropdown_hooks";
/**
* This component is used to select a date filter value.
* It allows the user to select a month, quarter, year, or a custom date range.
* It also provides options for relative periods like "last 7 days".
*/
export class DateFilterValue extends Component {
static template = "spreadsheet.DateFilterValue";
static components = { Dropdown, DateFilterDropdown };
static props = {
value: { type: Object, optional: true },
update: Function,
};
setup() {
this.dropdownState = useDropdownState();
}
onInputClick() {
this.dropdownState.open();
}
get inputValue() {
return dateFilterValueToString(this.props.value);
}
}

View file

@ -0,0 +1,52 @@
.o-date-filter-dropdown:hover .btn-previous,
.o-date-filter-dropdown:hover .btn-next,
.o-date-filter-dropdown.selected .btn-previous,
.o-date-filter-dropdown.selected .btn-next {
color: $o-gray-600;
}
.o-date-filter-dropdown {
.o-date-option-label {
min-width: 10ch;
padding: 1px 0;
border: var(--border-width) solid transparent;
border-width: 0 0 var(--border-width) 0;
margin-right: 16px;
}
.btn-previous, .btn-next {
background: inherit;
border: var(--border-width) solid transparent;
border-width: 0 0 var(--border-width) 0;
color: $o-gray-300;
padding: 1px 0;
text-align: center;
width: 20px;
}
.o_input {
text-align: center;
border-color: transparent;
cursor: pointer;
&:focus {
outline: none;
}
}
.o_datetime_input {
width: 10ch;
margin: 0 8px;
text-align: center;
}
}
.o-date-filter-input {
cursor: pointer;
&:focus, &:hover {
outline: none;
}
}
.o_bottom_sheet .o-date-filter-dropdown :last-child {
/* Make space for the check icon of the active item */
margin-right: 12px;
}

View file

@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<templates>
<t t-name="spreadsheet.DateFilterValue">
<Dropdown
menuClass="'mt-1'"
navigationOptions="{ 'shouldFocusChildInput': false }"
state="dropdownState"
manual="true"
position="'bottom-start'">
<input class="o_input w-100 o-date-filter-input" readonly="true" t-att-value="inputValue" t-on-click="onInputClick"/>
<t t-set-slot="content">
<DateFilterDropdown
value="props.value"
update.bind="props.update"
/>
</t>
</Dropdown>
</t>
</templates>

View file

@ -0,0 +1,88 @@
import { Component } from "@odoo/owl";
import { RELATIVE_PERIODS } from "@spreadsheet/global_filters/helpers";
import { Dropdown } from "@web/core/dropdown/dropdown";
import { DropdownItem } from "@web/core/dropdown/dropdown_item";
import { _t } from "@web/core/l10n/translation";
const DATE_OPTIONS = [
{
id: "today",
label: RELATIVE_PERIODS["today"],
},
{
id: "yesterday",
label: RELATIVE_PERIODS["yesterday"],
separator: true,
},
{
id: "last_7_days",
label: RELATIVE_PERIODS["last_7_days"],
},
{
id: "last_30_days",
label: RELATIVE_PERIODS["last_30_days"],
},
{
id: "last_90_days",
label: RELATIVE_PERIODS["last_90_days"],
separator: true,
},
{
id: "month_to_date",
label: RELATIVE_PERIODS["month_to_date"],
},
{
id: "last_month",
label: RELATIVE_PERIODS["last_month"],
},
{
id: "this_month",
label: _t("Current Month"),
},
{
id: "this_quarter",
label: _t("Current Quarter"),
separator: true,
},
{
id: "year_to_date",
label: RELATIVE_PERIODS["year_to_date"],
},
{
id: "last_12_months",
label: RELATIVE_PERIODS["last_12_months"],
},
{
id: "this_year",
label: _t("Current Year"),
separator: true,
},
{
id: undefined,
label: _t("All time"),
},
];
/**
* This component is used to select a default value for a date filter.
* It displays a dropdown with predefined date options.
*/
export class DefaultDateValue extends Component {
static template = "spreadsheet.DefaultDateValue";
static components = { Dropdown, DropdownItem };
static props = {
value: { type: String, optional: true },
update: Function,
};
get currentFormattedValue() {
return (
this.dateOptions.find((option) => option.id === this.props.value)?.label ||
_t("All time")
);
}
get dateOptions() {
return DATE_OPTIONS;
}
}

View file

@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<templates>
<t t-name="spreadsheet.DefaultDateValue">
<Dropdown>
<input class="o_input w-100 o-date-filter-input" readonly="true" t-att-value="currentFormattedValue"/>
<t t-set-slot="content">
<t t-foreach="dateOptions" t-as="option" t-key="option.id">
<DropdownItem class="{ 'selected': option.id === props.value }"
attrs="{ 'data-id': option.id }"
onSelected="() => props.update(option.id)">
<span t-esc="option.label"/>
</DropdownItem>
<div class="dropdown-divider" t-if="option.separator"/>
</t>
</t>
</Dropdown>
</t>
</templates>

View file

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

View file

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

View file

@ -0,0 +1,83 @@
/** @ts-check */
import { Component, useEffect } from "@odoo/owl";
import { useChildRef } from "@web/core/utils/hooks";
import { TagsList } from "@web/core/tags_list/tags_list";
import { AutoComplete } from "@web/core/autocomplete/autocomplete";
export class TextFilterValue extends Component {
static template = "spreadsheet.TextFilterValue";
static components = {
TagsList,
AutoComplete,
};
static props = {
onValueChanged: Function,
value: { type: Array, optional: true },
options: {
type: Array,
element: {
type: Object,
shape: { value: String, formattedValue: String },
optional: true,
},
},
};
static defaultProps = {
value: [],
};
setup() {
this.inputRef = useChildRef();
useEffect(
() => {
if (this.props.options.length && this.inputRef.el) {
// if there are options restricting the possible values,
// we prevent the user from typing free-text by setting the maxlength to 0
this.inputRef.el.setAttribute("maxlength", 0);
} else {
this.inputRef.el.removeAttribute("maxlength");
}
},
() => [this.props.options.length, this.inputRef.el]
);
}
get tags() {
return this.props.value.map((value) => ({
id: value,
text:
this.props.options.find((option) => option.value === value)?.formattedValue ??
value,
onDelete: () => {
this.props.onValueChanged(this.props.value.filter((v) => v !== value));
},
}));
}
get sources() {
const alreadySelected = new Set(this.props.value);
return [
{
options: this.props.options
.filter((option) => !alreadySelected.has(option.value))
.map((option) => ({
label: option.formattedValue,
onSelect: () =>
this.props.onValueChanged([...this.props.value, option.value]),
})),
},
];
}
onInputChange({ inputValue }) {
const value = inputValue.trim();
if (value) {
if (!this.props.value?.includes(value)) {
this.props.onValueChanged([...this.props.value, value]);
}
this.inputRef.el.value = "";
}
}
}

View file

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<templates>
<t t-name="spreadsheet.TextFilterValue">
<div class="o_input o-global-filter-text-value d-flex flex-wrap gap-1">
<TagsList tags="tags"/>
<AutoComplete
sources="sources"
input="inputRef"
onChange.bind="onInputChange"
inputDebounceDelay="0"
/>
</div>
</t>
</templates>

View file

@ -1,50 +1,186 @@
/** @odoo-module */
/** @ts-check */
import { RecordsSelector } from "../records_selector/records_selector";
import { RELATIVE_DATE_RANGE_TYPES } from "@spreadsheet/helpers/constants";
import { DateFilterValue } from "../filter_date_value/filter_date_value";
import { MultiRecordSelector } from "@web/core/record_selectors/multi_record_selector";
import { DateFilterValue } from "../date_filter_value/date_filter_value";
import { Component, onWillStart } from "@odoo/owl";
import { components } from "@odoo/o-spreadsheet";
import { _t } from "@web/core/l10n/translation";
import { useService } from "@web/core/utils/hooks";
import { Domain } from "@web/core/domain";
import { user } from "@web/core/user";
import { TextFilterValue } from "../filter_text_value/filter_text_value";
import { getFields, ModelNotFoundError } from "@spreadsheet/data_sources/data_source";
import { SelectionFilterValue } from "../selection_filter_value/selection_filter_value";
import {
isTextualOperator,
isSetOperator,
getDefaultValue,
} from "@spreadsheet/global_filters/helpers";
import { NumericFilterValue } from "../numeric_filter_value/numeric_filter_value";
const { Component } = owl;
const { ValidationMessages } = components;
export class FilterValue extends Component {
static template = "spreadsheet.FilterValue";
static components = {
TextFilterValue,
DateFilterValue,
MultiRecordSelector,
SelectionFilterValue,
ValidationMessages,
NumericFilterValue,
};
static props = {
filter: Object,
model: Object,
setGlobalFilterValue: Function,
globalFilterValue: { optional: true },
showTitle: { type: Boolean, optional: true },
showClear: { type: Boolean, optional: true },
};
setup() {
this.getters = this.props.model.getters;
this.relativeDateRangesTypes = RELATIVE_DATE_RANGE_TYPES;
this.orm = useService("orm");
}
onDateInput(id, value) {
this.props.model.dispatch("SET_GLOBAL_FILTER_VALUE", { id, value });
}
onTextInput(id, value) {
this.props.model.dispatch("SET_GLOBAL_FILTER_VALUE", { id, value });
}
async onTagSelected(id, values) {
let records = values;
if (values.some((record) => record.display_name === undefined)) {
({ records } = await this.orm.webSearchRead(
this.props.filter.modelName,
[["id", "in", values.map((record) => record.id)]],
["display_name"]
));
}
this.props.model.dispatch("SET_GLOBAL_FILTER_VALUE", {
id,
value: records.map((record) => record.id),
displayNames: records.map((record) => record.display_name),
this.fieldService = useService("field");
this.isValid = false;
onWillStart(async () => {
if (this.filter.type !== "relation") {
this.isValid = true;
return;
}
try {
await getFields(this.fieldService, this.filter.modelName);
this.isValid = true;
} catch (e) {
if (e instanceof ModelNotFoundError) {
this.isValid = false;
} else {
throw e;
}
}
});
}
onClear(id) {
this.props.model.dispatch("CLEAR_GLOBAL_FILTER_VALUE", { id });
get isTextualOperator() {
return isTextualOperator(this.filterValue?.operator);
}
get isSetOperator() {
return isSetOperator(this.filterValue?.operator);
}
get filter() {
return this.props.filter;
}
get filterValue() {
return this.props.globalFilterValue;
}
get textAllowedValues() {
return this.getters.getTextFilterOptions(this.filter.id);
}
get relationalAllowedDomain() {
const domain = this.props.filter.domainOfAllowedValues;
if (domain) {
return new Domain(domain).toList(user.context);
}
return [];
}
get invalidModel() {
const model = this.filter.modelName;
return _t(
"The model (%(model)s) of this global filter is not valid (it may have been renamed/deleted).",
{
model,
}
);
}
getDefaultOperator() {
return getDefaultValue(this.filter.type).operator;
}
onDateInput(id, value) {
this.props.setGlobalFilterValue(id, value);
}
onTextInput(id, value) {
if (Array.isArray(value) && value.length === 0) {
this.clear(id);
return;
}
const operator = this.filterValue?.operator ?? this.getDefaultOperator();
this.props.setGlobalFilterValue(id, { operator, strings: value });
}
onTargetValueNumericInput(id, value) {
const operator = this.filterValue?.operator ?? this.getDefaultOperator();
const newFilterValue = {
operator,
targetValue: value,
};
this.props.setGlobalFilterValue(id, newFilterValue);
}
reorderValues(min, max) {
if (min > max) {
const tmp = min;
min = max;
max = tmp;
}
return { minimumValue: min, maximumValue: max };
}
onMinimumValueNumericInput(id, value) {
const operator = this.filterValue?.operator ?? this.getDefaultOperator();
const newFilterValue = {
operator,
...this.reorderValues(value, this.filterValue?.maximumValue),
};
this.props.setGlobalFilterValue(id, newFilterValue);
}
onMaximumValueNumericInput(id, value) {
const operator = this.filterValue?.operator ?? this.getDefaultOperator();
const newFilterValue = {
operator,
...this.reorderValues(this.filterValue?.minimumValue, value),
};
this.props.setGlobalFilterValue(id, newFilterValue);
}
onBooleanInput(id, value) {
if (Array.isArray(value) && value.length === 0) {
this.clear(id);
return;
}
this.props.setGlobalFilterValue(id, value);
}
onSelectionInput(id, value) {
if (Array.isArray(value) && value.length === 0) {
this.clear(id);
return;
}
const operator = this.filterValue?.operator ?? this.getDefaultOperator();
this.props.setGlobalFilterValue(id, { operator, selectionValues: value });
}
async onTagSelected(id, resIds) {
if (!resIds.length) {
// force clear, even automatic default values
this.clear(id);
} else {
const operator = this.filterValue?.operator ?? this.getDefaultOperator();
this.props.setGlobalFilterValue(id, { operator, ids: resIds });
}
}
clear(id) {
this.props.setGlobalFilterValue(id);
}
}
FilterValue.template = "spreadsheet_edition.FilterValue";
FilterValue.components = { RecordsSelector, DateFilterValue };
FilterValue.props = {
filter: Object,
model: Object,
showTitle: { type: Boolean, optional: true },
};

View file

@ -1,17 +1,12 @@
.o-filter-value {
.o-text-filter-input {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.o_datepicker {
width: 100%;
}
.o_datepicker_input {
color: $o-main-text-color;
}
.date_filter_values {
display: flex;
gap: map-get($spacers, 2);
align-items: baseline;
}
select:has(option[value="empty"]:checked),
select:has(option[value=""]:checked) {
@ -21,4 +16,12 @@
select option {
color: $o-gray-700;
}
.o_multi_record_selector {
.o_record_autocomplete_with_caret {
flex: 1 0 50px;
min-width: auto;
width: 100%;
}
}
}

View file

@ -1,49 +1,62 @@
<?xml version="1.0" encoding="utf-8"?>
<templates>
<t t-name="spreadsheet_edition.FilterValue"
owl="1">
<t t-set="filter" t-value="props.filter"/>
<t t-name="spreadsheet.FilterValue">
<div class="o-filter-value d-flex align-items-start w-100" t-att-title="props.showTitle and filter.label">
<t t-set="filterValue"
t-value="getters.getGlobalFilterValue(filter.id)"/>
<div t-if="filter.type === 'text'" class="w-100">
<input type="text"
class="o_input o-text-filter-input"
t-att-placeholder="env._t(filter.label)"
t-att-value="filterValue"
t-on-change="(e) => this.onTextInput(filter.id, e.target.value)"/>
<div t-if="isSetOperator" class="w-100"></div>
<div t-elif="isTextualOperator || filter.type === 'text'" class="w-100">
<TextFilterValue
value="filterValue?.strings"
options="textAllowedValues"
onValueChanged="(value) => this.onTextInput(filter.id, value)"
/>
</div>
<span t-if="filter.type === 'relation'" class="w-100">
<RecordsSelector placeholder="' ' + env._t(filter.label)"
<div t-elif="filter.type === 'selection'" class="w-100">
<SelectionFilterValue
resModel="filter.resModel"
field="filter.selectionField"
value="filterValue?.selectionValues ?? []"
onValueChanged="(value) => this.onSelectionInput(filter.id, value)"
/>
</div>
<div t-elif="filter.type === 'date'" class="w-100">
<DateFilterValue value="filterValue"
update.bind="(value) => this.onDateInput(filter.id, value)"/>
</div>
<div t-elif="filter.type === 'numeric'" class="w-100 d-flex align-items-center"
t-att-class="filterValue?.operator === 'between' ? 'justify-content-between' : ''">
<t t-if="filterValue?.operator === 'between'">
<NumericFilterValue
value="filterValue.minimumValue"
onValueChanged="(value) => this.onMinimumValueNumericInput(filter.id, value)"
/>
<i class="fa fa-long-arrow-right mx-2"/>
<NumericFilterValue
value="filterValue.maximumValue"
onValueChanged="(value) => this.onMaximumValueNumericInput(filter.id, value)"
/>
</t>
<t t-else="">
<NumericFilterValue
value="filterValue.targetValue"
onValueChanged="(value) => this.onTargetValueNumericInput(filter.id, value)"
/>
</t>
</div>
<span t-elif="filter.type === 'relation'" class="w-100">
<MultiRecordSelector
t-if="isValid"
resModel="filter.modelName"
resIds="filterValue"
onValueChanged="(value) => this.onTagSelected(filter.id, value)" />
resIds="filterValue?.ids ?? []"
domain="relationalAllowedDomain"
update="(resIds) => this.onTagSelected(filter.id, resIds)" />
<ValidationMessages t-else="1"
messages="[invalidModel]"
msgType="'error'"/>
</span>
<div t-if="filter.type === 'date'" class="w-100">
<DateFilterValue t-if="filter.rangeType !== 'relative'"
period="filterValue &amp;&amp; filterValue.period"
yearOffset="filterValue &amp;&amp; filterValue.yearOffset"
type="filter.rangeType"
onTimeRangeChanged="(value) => this.onDateInput(filter.id, value)" />
<select t-if="filter.rangeType === 'relative'"
t-on-change="(e) => this.onDateInput(filter.id, e.target.value || undefined)"
class="date_filter_values o_input me-3 text-truncate"
required="true">
<option value="">Select period...</option>
<t t-foreach="relativeDateRangesTypes"
t-as="range"
t-key="range.type">
<option t-att-selected="range.type === filterValue"
t-att-value="range.type">
<t t-esc="range.description"/>
</option>
</t>
</select>
</div>
<i t-if="getters.isGlobalFilterActive(filter.id)"
class="fa fa-times btn btn-link text-muted o_side_panel_filter_icon ms-1 mt-1"
<i t-if="isValid and props.showClear and getters.isGlobalFilterActive(filter.id)"
class="fa fa-times btn btn-link text-muted ms-1 mt-1"
title="Clear"
t-on-click="() => this.onClear(filter.id)"/>
t-on-click="() => this.clear(filter.id)"/>
</div>
</t>
</templates>

View file

@ -0,0 +1,137 @@
import { Component, onWillStart, useState } from "@odoo/owl";
import { FilterValue } from "@spreadsheet/global_filters/components/filter_value/filter_value";
import { _t } from "@web/core/l10n/translation";
import { getOperatorLabel } from "@web/core/tree_editor/tree_editor_operator_editor";
import {
getDefaultValue,
getEmptyFilterValue,
getFilterTypeOperators,
} from "@spreadsheet/global_filters/helpers";
import { useService } from "@web/core/utils/hooks";
import { isEmptyFilterValue } from "../../helpers";
import { deepEqual } from "@web/core/utils/objects";
/**
* This component is used to display a list of all the global filters of a
* spreadsheet/dashboard. It allows the user to select the values of the filters
* and confirm or discard the changes.
*/
export class FilterValuesList extends Component {
static template = "spreadsheet_dashboard.FilterValuesList";
static components = { FilterValue };
static props = {
close: Function,
model: Object,
openFiltersEditor: { type: Function, optional: true },
};
setup() {
this.orm = useService("orm");
this.state = useState({
filtersAndValues: this.globalFilters.map((globalFilter) => {
const value = this.props.model.getters.getGlobalFilterValue(globalFilter.id);
return {
globalFilter,
value: value ? { ...value } : getDefaultValue(globalFilter.type),
};
}),
});
onWillStart(async () => {
this.searchableParentRelations = await this.fetchSearchableParentRelation();
});
}
get globalFilters() {
return this.props.model.getters.getGlobalFilters();
}
setGlobalFilterValue(node, value) {
if (value == undefined && node.globalFilter.type !== "date") {
// preserve the operator.
node.value = {
...node.value,
...getEmptyFilterValue(node.globalFilter, node.value.operator),
};
} else {
node.value = value;
}
}
getTranslatedFilterLabel(filter) {
return _t(filter.label); // Label is extracted from the spreadsheet json file
}
getOperators(filter) {
const operators = getFilterTypeOperators(filter.type);
if (filter.type === "relation" && !this.searchableParentRelations[filter.modelName]) {
return operators.filter((op) => op !== "child_of");
}
return filter.type === "boolean" ? [undefined, ...operators] : operators;
}
filterHasClearButton(node) {
return !isEmptyFilterValue(node.globalFilter, node.value);
}
getOperatorLabel(operator) {
return operator ? getOperatorLabel(operator) : "";
}
updateOperator(node, operator) {
if (!operator) {
node.value = undefined;
return;
}
if (!node.value) {
node.value = {};
}
node.value.operator = operator;
const defaultValue = getEmptyFilterValue(node.globalFilter, operator);
for (const key of Object.keys(defaultValue ?? {})) {
if (!(key in node.value)) {
node.value[key] = defaultValue[key];
}
}
}
clearFilter(filterId) {
const node = this.state.filtersAndValues.find((node) => node.globalFilter.id === filterId);
if (node && node.value) {
const emptyValue = getEmptyFilterValue(node.globalFilter, node.value.operator);
node.value =
typeof emptyValue === "object"
? { ...emptyValue, operator: node.value.operator }
: emptyValue;
}
}
onConfirm() {
for (const node of this.state.filtersAndValues) {
const { globalFilter, value } = node;
const originalValue = this.props.model.getters.getGlobalFilterValue(globalFilter.id);
if (deepEqual(originalValue, value)) {
continue;
}
this.props.model.dispatch("SET_GLOBAL_FILTER_VALUE", {
id: globalFilter.id,
value: isEmptyFilterValue(globalFilter, value) ? undefined : value,
});
}
this.props.close();
}
onDiscard() {
this.props.close();
}
fetchSearchableParentRelation() {
const models = this.globalFilters
.filter((filter) => filter.type === "relation")
.map((filter) => filter.modelName);
return this.orm
.cache({ type: "disk" })
.call("ir.model", "has_searchable_parent_relation", [models]);
}
}

View file

@ -0,0 +1,16 @@
.o-filter-values{
min-width: 600px;
.o-filter-clear {
width: 8%;
height: 36px;
}
}
.o_bottom_sheet .o-filter-values {
min-width: unset;
.o-filter-item .o-autocomplete .dropdown-item {
--dropdown-item-padding-y: 3px;
font-weight: 400;
}
}

View file

@ -0,0 +1,46 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="spreadsheet_dashboard.FilterValuesList">
<div class="d-flex flex-column o-filter-values">
<div t-foreach="state.filtersAndValues"
t-as="item"
t-key="item_index"
class="d-flex o-filter-item align-items-center"
t-att-class="env.isSmall ? 'dropdown-item' : ''"
t-att-data-id="item.globalFilter.id">
<div class="me-1 w-25">
<t t-esc="getTranslatedFilterLabel(item.globalFilter)"/>
</div>
<t t-set="operators" t-value="getOperators(item.globalFilter)"/>
<div t-if="!operators?.length" class="w-25 me-3"/>
<select t-else="" class="o_input w-25 pe-3 me-3 text-truncate" t-on-change="(ev) => this.updateOperator(item, ev.target.value)">
<t t-foreach="operators" t-as="operator" t-key="operator">
<option class="text-black" t-att-value="operator" t-att-selected="item.value?.operator === operator" t-esc="getOperatorLabel(operator)" />
</t>
</select>
<FilterValue filter="item.globalFilter"
model="props.model"
setGlobalFilterValue="(id, value) => this.setGlobalFilterValue(item, value)"
globalFilterValue="item.value"/>
<div class="o-filter-clear">
<button t-if="filterHasClearButton(item)"
class="btn btn-link px-2 text-danger fs-4"
t-on-click="() => this.clearFilter(item.globalFilter.id)">
<i class="fa fa-times"></i>
</button>
</div>
</div>
</div>
<div class="o-filter-values-footer d-flex mt-3 pt-3 border-top gap-1">
<button class="btn btn-primary" t-on-click="onConfirm">Filter</button>
<button t-if="props.openFiltersEditor"
class="btn btn-secondary o-edit-global-filters"
t-on-click="props.openFiltersEditor">
Edit
</button>
<button class="btn btn-secondary" t-on-click="onDiscard">Discard</button>
</div>
</t>
</templates>

View file

@ -0,0 +1,41 @@
/** @ts-check */
import { Component, useRef } from "@odoo/owl";
import { useNumpadDecimal } from "@web/views/fields/numpad_decimal_hook";
import { parseFloat } from "@web/views/fields/parsers";
export class NumericFilterValue extends Component {
static template = "spreadsheet.NumericFilterValue";
static props = {
onValueChanged: Function,
value: { type: [Number, String], optional: true },
};
setup() {
useNumpadDecimal();
this.inputRef = useRef("numpadDecimal");
}
onChange(value) {
let numericValue;
if (value === undefined || value === "") {
numericValue = undefined;
} else {
try {
numericValue = parseFloat(value);
// eslint-disable-next-line no-unused-vars
} catch (e) {
numericValue = 0;
}
}
this.props.onValueChanged(numericValue);
// If the user enters a non-numeric string, we default the value to 0.
// However, if the same invalid input is entered again, the component
// doesn't re-render because the prop value hasn't changed. To ensure
// the input reflects the correct state, we manually set the input
// element's value to 0.
if (numericValue === 0 && this.inputRef?.el) {
this.inputRef.el.value = 0;
}
}
}

View file

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<templates>
<t t-name="spreadsheet.NumericFilterValue">
<input
type="text"
t-ref="numpadDecimal"
class="o_input o-global-filter-numeric-value text-truncate"
t-att-value="props.value ?? ''"
t-on-change="(e) => this.onChange(e.target.value)"
inputmode="decimal"
/>
</t>
</templates>

View file

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

View file

@ -1,19 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<templates>
<div t-name="spreadsheet.RecordsSelector" class="o_field_widget o_field_many2many_tags" owl="1">
<div class="o_field_tags d-inline-flex flex-wrap mw-100 o_tags_input o_input">
<TagsList tags="tags"/>
<div class="o_field_many2many_selection d-inline-flex w-100">
<Many2XAutocomplete
placeholder="props.placeholder"
resModel="props.resModel"
fieldString="props.placeholder"
activeActions="{}"
update.bind="update"
getDomain.bind="searchDomain"
isToMany="true"
/>
</div>
</div>
</div>
</templates>

View file

@ -0,0 +1,71 @@
/** @ts-check */
import { Component, onWillStart, onWillUpdateProps, useEffect } from "@odoo/owl";
import { useChildRef, useService } from "@web/core/utils/hooks";
import { TagsList } from "@web/core/tags_list/tags_list";
import { AutoComplete } from "@web/core/autocomplete/autocomplete";
export class SelectionFilterValue extends Component {
static template = "spreadsheet.SelectionFilterValue";
static components = {
TagsList,
AutoComplete,
};
static props = {
resModel: String,
field: String,
value: { type: Array, optional: true },
onValueChanged: Function,
};
static defaultProps = {
value: [],
};
setup() {
this.inputRef = useChildRef();
useEffect(
() => {
if (this.inputRef.el) {
// Prevent the user from typing free-text by setting the maxlength to 0
this.inputRef.el.setAttribute("maxlength", 0);
}
},
() => [this.inputRef.el]
);
this.tags = [];
this.sources = [];
this.fields = useService("field");
onWillStart(() => this._computeTagsAndSources(this.props));
onWillUpdateProps((nextProps) => this._computeTagsAndSources(nextProps));
}
async _computeTagsAndSources(props) {
const fields = await this.fields.loadFields(props.resModel);
const field = fields[props.field];
if (!field) {
throw new Error(`Field "${props.field}" not found in model "${props.resModel}"`);
}
const selection = field.selection;
this.tags = props.value.map((value) => ({
id: value,
text: selection.find((option) => option[0] === value)?.[1] ?? value,
onDelete: () => {
props.onValueChanged(props.value.filter((v) => v !== value));
},
}));
const alreadySelected = new Set(props.value);
this.sources = [
{
options: selection
.filter((option) => !alreadySelected.has(option[0]))
.map(([value, formattedValue]) => ({
label: formattedValue,
onSelect: () => {
props.onValueChanged([...props.value, value]);
},
})),
},
];
}
}

View file

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<templates>
<t t-name="spreadsheet.SelectionFilterValue">
<div class="o_input o-global-filter-selection-value d-flex flex-wrap gap-1">
<TagsList tags="tags"/>
<AutoComplete input="inputRef" sources="sources"/>
</div>
</t>
</templates>

View file

@ -1,47 +0,0 @@
/** @odoo-module */
import { DatePicker } from "@web/core/datepicker/datepicker";
const { DateTime } = luxon;
export class YearPicker extends DatePicker {
/**
* @override
*/
initFormat() {
super.initFormat();
// moment.js format
this.defaultFormat = "yyyy";
}
/**
* @override
*/
bootstrapDateTimePicker(commandOrParams, ...commandArgs) {
if (typeof commandOrParams === "object") {
const widgetParent = window.$(this.rootRef.el);
commandOrParams = { ...commandOrParams, widgetParent };
}
super.bootstrapDateTimePicker(commandOrParams, ...commandArgs);
}
/**
* @override
*/
setDateAndFormat({ date, locale, format }) {
super.setDateAndFormat({ date, locale, format });
this.staticFormat = "yyyy";
}
}
const props = {
...DatePicker.props,
date: { type: DateTime, optional: true },
};
delete props["format"];
YearPicker.props = props;
YearPicker.defaultProps = {
...DatePicker.defaultProps,
};

View file

@ -1,9 +1,8 @@
/** @odoo-module */
import * as spreadsheet from "@odoo/o-spreadsheet";
import spreadsheet from "@spreadsheet/o_spreadsheet/o_spreadsheet_extended";
import GlobalFiltersUIPlugin from "./plugins/global_filters_ui_plugin";
import { GlobalFiltersUIPlugin } from "./plugins/global_filters_ui_plugin";
import { GlobalFiltersCorePlugin } from "./plugins/global_filters_core_plugin";
import { GlobalFiltersCoreViewPlugin } from "./plugins/global_filters_core_view_plugin";
const { inverseCommandRegistry } = spreadsheet.registries;
function identity(cmd) {
@ -15,36 +14,39 @@ const { coreTypes, invalidateEvaluationCommands, readonlyAllowedCommands } = spr
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");
readonlyAllowedCommands.add("SET_GLOBAL_FILTER_VALUE");
readonlyAllowedCommands.add("SET_MANY_GLOBAL_FILTER_VALUE");
readonlyAllowedCommands.add("CLEAR_GLOBAL_FILTER_VALUE");
readonlyAllowedCommands.add("UPDATE_OBJECT_DOMAINS");
readonlyAllowedCommands.add("UPDATE_CHART_GRANULARITY");
inverseCommandRegistry
.add("EDIT_GLOBAL_FILTER", identity)
.add("ADD_GLOBAL_FILTER", (cmd) => {
return [
{
type: "REMOVE_GLOBAL_FILTER",
id: cmd.id,
},
];
})
.add("REMOVE_GLOBAL_FILTER", (cmd) => {
return [
{
type: "ADD_GLOBAL_FILTER",
id: cmd.id,
filter: {},
},
];
});
.add("ADD_GLOBAL_FILTER", (cmd) => [
{
type: "REMOVE_GLOBAL_FILTER",
id: cmd.filter.id,
},
])
.add("REMOVE_GLOBAL_FILTER", (cmd) => [
{
type: "ADD_GLOBAL_FILTER",
filter: {},
},
])
.add("MOVE_GLOBAL_FILTER", (cmd) => [
{
type: "MOVE_GLOBAL_FILTER",
id: cmd.id,
delta: cmd.delta * -1,
},
]);
export { GlobalFiltersCorePlugin, GlobalFiltersUIPlugin };
export { GlobalFiltersCorePlugin, GlobalFiltersCoreViewPlugin, GlobalFiltersUIPlugin };

View file

@ -1,63 +1,77 @@
/** @odoo-module */
/** @ts-check */
import { CommandResult } from "@spreadsheet/o_spreadsheet/cancelled_reason";
import {
checkFilterDefaultValueIsValid,
globalFieldMatchingRegistry,
} from "@spreadsheet/global_filters/helpers";
import { escapeRegExp } from "@web/core/utils/strings";
import { OdooCorePlugin } from "@spreadsheet/plugins";
/**
* @typedef {"year"|"month"|"quarter"|"relative"} RangeType
*
/**
* @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 {Object} GlobalFilter
* @property {string} id
* @property {string} label
* @property {string} type "text" | "date" | "relation"
* @property {RangeType} [rangeType]
* @property {boolean} [defaultsToCurrentPeriod]
* @property {string|Array<string>|Object} defaultValue Default Value
* @property {number} [modelID] ID of the related model
* @property {string} [modelName] Name of the related model
* @typedef {import("@spreadsheet").GlobalFilter} GlobalFilter
* @typedef {import("@spreadsheet").CmdGlobalFilter} CmdGlobalFilter
* @typedef {import("@spreadsheet").FieldMatching} FieldMatching
*/
export const globalFiltersFieldMatchers = {};
import spreadsheet from "@spreadsheet/o_spreadsheet/o_spreadsheet_extended";
import CommandResult from "@spreadsheet/o_spreadsheet/cancelled_reason";
import { checkFiltersTypeValueCombination } from "@spreadsheet/global_filters/helpers";
import { _t } from "@web/core/l10n/translation";
export class GlobalFiltersCorePlugin extends spreadsheet.CorePlugin {
constructor() {
super(...arguments);
/** @type {Object.<string, GlobalFilter>} */
this.globalFilters = {};
export class GlobalFiltersCorePlugin extends OdooCorePlugin {
static getters = /** @type {const} */ ([
"getGlobalFilter",
"getGlobalFilters",
"getGlobalFilterDefaultValue",
"getFieldMatchingForModel",
]);
constructor(config) {
super(config);
/** @type {Array.<GlobalFilter>} */
this.globalFilters = [];
}
/**
* Check if the given command can be dispatched
*
* @param {Object} cmd Command
* @param {import("@spreadsheet").AllCoreCommand} cmd Command
*/
allowDispatch(cmd) {
switch (cmd.type) {
case "EDIT_GLOBAL_FILTER":
if (!this.getGlobalFilter(cmd.id)) {
if (!this.getGlobalFilter(cmd.filter.id)) {
return CommandResult.FilterNotFound;
} else if (this._isDuplicatedLabel(cmd.id, cmd.filter.label)) {
} else if (!cmd.filter.label) {
return CommandResult.InvalidFilterLabel;
} else if (this._isDuplicatedLabel(cmd.filter.id, cmd.filter.label)) {
return CommandResult.DuplicatedFilterLabel;
}
return checkFiltersTypeValueCombination(cmd.filter.type, cmd.filter.defaultValue);
if (!checkFilterDefaultValueIsValid(cmd.filter, cmd.filter.defaultValue)) {
return CommandResult.InvalidValueTypeCombination;
}
break;
case "REMOVE_GLOBAL_FILTER":
if (!this.getGlobalFilter(cmd.id)) {
return CommandResult.FilterNotFound;
}
break;
case "ADD_GLOBAL_FILTER":
if (this._isDuplicatedLabel(cmd.id, cmd.filter.label)) {
if (!cmd.filter.label) {
return CommandResult.InvalidFilterLabel;
} else if (this._isDuplicatedLabel(cmd.filter.id, cmd.filter.label)) {
return CommandResult.DuplicatedFilterLabel;
}
return checkFiltersTypeValueCombination(cmd.filter.type, cmd.filter.defaultValue);
if (!checkFilterDefaultValueIsValid(cmd.filter, cmd.filter.defaultValue)) {
return CommandResult.InvalidValueTypeCombination;
}
break;
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;
}
@ -69,15 +83,54 @@ export class GlobalFiltersCorePlugin extends spreadsheet.CorePlugin {
*/
handle(cmd) {
switch (cmd.type) {
case "ADD_GLOBAL_FILTER":
this._addGlobalFilter(cmd.filter);
case "ADD_GLOBAL_FILTER": {
const filter = { ...cmd.filter };
if (filter.type === "text" && filter.rangesOfAllowedValues?.length) {
filter.rangesOfAllowedValues = filter.rangesOfAllowedValues.map((rangeData) =>
this.getters.getRangeFromRangeData(rangeData)
);
}
this.history.update("globalFilters", [...this.globalFilters, filter]);
break;
case "EDIT_GLOBAL_FILTER":
this._editGlobalFilter(cmd.id, cmd.filter);
}
case "EDIT_GLOBAL_FILTER": {
this._editGlobalFilter(cmd.filter);
break;
case "REMOVE_GLOBAL_FILTER":
this._removeGlobalFilter(cmd.id);
}
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.rangesOfAllowedValues) {
const ranges = filter.rangesOfAllowedValues
.map((range) => {
const change = applyChange(range);
switch (change.changeType) {
case "RESIZE":
case "MOVE":
case "CHANGE": {
return change.range;
}
}
})
.filter(Boolean);
this.history.update(
"globalFilters",
filterIndex,
"rangesOfAllowedValues",
ranges.length ? ranges : undefined
);
}
}
}
@ -92,18 +145,7 @@ export class GlobalFiltersCorePlugin extends spreadsheet.CorePlugin {
* @returns {GlobalFilter|undefined} Global filter
*/
getGlobalFilter(id) {
return this.globalFilters[id];
}
/**
* Get the global filter with the given name
*
* @param {string} label Label
*
* @returns {GlobalFilter|undefined}
*/
getGlobalFilterLabel(label) {
return this.getGlobalFilters().find((filter) => _t(filter.label) === _t(label));
return this.globalFilters.find((filter) => filter.id === id);
}
/**
@ -112,7 +154,7 @@ export class GlobalFiltersCorePlugin extends spreadsheet.CorePlugin {
* @returns {Array<GlobalFilter>} Array of Global filters
*/
getGlobalFilters() {
return Object.values(this.globalFilters);
return [...this.globalFilters];
}
/**
@ -126,42 +168,69 @@ export class GlobalFiltersCorePlugin extends spreadsheet.CorePlugin {
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.
*
* @returns {Record<string, FieldMatching> | {}}
*/
getFieldMatchingForModel(newModel) {
const globalFilters = this.getGlobalFilters();
if (globalFilters.length === 0) {
return {};
}
for (const matcher of globalFieldMatchingRegistry.getAll()) {
for (const dataSourceId of matcher.getIds(this.getters)) {
const model = matcher.getModel(this.getters, dataSourceId);
if (model === newModel) {
const fieldMatching = {};
for (const filter of globalFilters) {
const matchedField = matcher.getFieldMatching(
this.getters,
dataSourceId,
filter.id
);
if (matchedField) {
fieldMatching[filter.id] = {
chain: matchedField.chain,
type: matchedField.type,
};
}
}
return fieldMatching;
}
}
}
return {};
}
// ---------------------------------------------------------------------
// Handlers
// ---------------------------------------------------------------------
/**
* Add a global filter
*
* @param {GlobalFilter} filter
*/
_addGlobalFilter(filter) {
const globalFilters = { ...this.globalFilters };
globalFilters[filter.id] = filter;
this.history.update("globalFilters", globalFilters);
}
/**
* Remove a global filter
*
* @param {number} id Id of the filter to remove
*/
_removeGlobalFilter(id) {
const globalFilters = { ...this.globalFilters };
delete globalFilters[id];
this.history.update("globalFilters", globalFilters);
}
/**
* Edit a global filter
*
* @param {number} id Id of the filter to update
* @param {GlobalFilter} newFilter
* @param {CmdGlobalFilter} cmdFilter
*/
_editGlobalFilter(id, newFilter) {
_editGlobalFilter(cmdFilter) {
const rangesOfAllowedValues =
cmdFilter.type === "text" && cmdFilter.rangesOfAllowedValues?.length
? cmdFilter.rangesOfAllowedValues.map((rangeData) =>
this.getters.getRangeFromRangeData(rangeData)
)
: undefined;
/** @type {GlobalFilter} */
const newFilter =
cmdFilter.type === "text" ? { ...cmdFilter, rangesOfAllowedValues } : { ...cmdFilter };
const id = newFilter.id;
const currentLabel = this.getGlobalFilter(id).label;
const globalFilters = { ...this.globalFilters };
newFilter.id = id;
globalFilters[id] = newFilter;
this.history.update("globalFilters", globalFilters);
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);
@ -179,7 +248,19 @@ export class GlobalFiltersCorePlugin extends spreadsheet.CorePlugin {
*/
import(data) {
for (const globalFilter of data.globalFilters || []) {
this.globalFilters[globalFilter.id] = globalFilter;
if (globalFilter.type === "text" && globalFilter.rangesOfAllowedValues?.length) {
globalFilter.rangesOfAllowedValues = globalFilter.rangesOfAllowedValues.map((xc) =>
this.getters.getRangeFromSheetXC(
// The default sheet id doesn't matter here, the exported range string
// is fully qualified and contains the sheet name.
// The getter expects a valid sheet id though, let's give it the
// first sheet id.
data.sheets[0].id,
xc
)
);
}
this.globalFilters.push(globalFilter);
}
}
/**
@ -188,9 +269,19 @@ export class GlobalFiltersCorePlugin extends spreadsheet.CorePlugin {
* @param {Object} data
*/
export(data) {
data.globalFilters = this.getGlobalFilters().map((filter) => ({
...filter,
}));
data.globalFilters = this.globalFilters.map((filter) => {
/** @type {Object} */
const filterData = { ...filter };
if (filter.type === "text" && filter.rangesOfAllowedValues?.length) {
filterData.rangesOfAllowedValues = filter.rangesOfAllowedValues.map((range) =>
this.getters.getRangeString(
range,
"" // force the range string to be fully qualified (with the sheet name)
)
);
}
return filterData;
});
}
// ---------------------------------------------------------------------
@ -206,10 +297,10 @@ export class GlobalFiltersCorePlugin extends spreadsheet.CorePlugin {
*/
_updateFilterLabelInFormulas(currentLabel, newLabel) {
const sheetIds = this.getters.getSheetIds();
currentLabel = currentLabel.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
currentLabel = escapeRegExp(currentLabel);
for (const sheetId of sheetIds) {
for (const cell of Object.values(this.getters.getCells(sheetId))) {
if (cell.isFormula()) {
if (cell.isFormula) {
const newContent = cell.content.replace(
new RegExp(`FILTER\\.VALUE\\(\\s*"${currentLabel}"\\s*\\)`, "g"),
`FILTER.VALUE("${newLabel}")`
@ -237,16 +328,21 @@ export class GlobalFiltersCorePlugin extends spreadsheet.CorePlugin {
*/
_isDuplicatedLabel(filterId, label) {
return (
this.getGlobalFilters().findIndex(
this.globalFilters.findIndex(
(filter) => (!filterId || filter.id !== filterId) && filter.label === label
) > -1
);
}
}
GlobalFiltersCorePlugin.getters = [
"getGlobalFilter",
"getGlobalFilters",
"getGlobalFilterDefaultValue",
"getGlobalFilterLabel",
];
_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);
}
}

View file

@ -0,0 +1,443 @@
/** @ts-check */
/**
* @typedef {import("@spreadsheet").GlobalFilter} GlobalFilter
* @typedef {import("@spreadsheet").FieldMatching} FieldMatching
* @typedef {import("@spreadsheet").DateGlobalFilter} DateGlobalFilter
* @typedef {import("@spreadsheet").RelationalGlobalFilter} RelationalGlobalFilter
* @typedef {import("@spreadsheet").DateValue} DateValue
* @typedef {import("@spreadsheet").DateDefaultValue} DateDefaultValue
*/
import { _t } from "@web/core/l10n/translation";
import { Domain } from "@web/core/domain";
import { user } from "@web/core/user";
import { EvaluationError, helpers } from "@odoo/o-spreadsheet";
import { CommandResult } from "@spreadsheet/o_spreadsheet/cancelled_reason";
import {
checkFilterValueIsValid,
getDateDomain,
getDateRange,
} from "@spreadsheet/global_filters/helpers";
import { OdooCoreViewPlugin } from "@spreadsheet/plugins";
import { getItemId } from "../../helpers/model";
import { serializeDate } from "@web/core/l10n/dates";
import { getFilterCellValue, getFilterValueDomain } from "../helpers";
import { deepEqual } from "@web/core/utils/objects";
const { DateTime } = luxon;
const { UuidGenerator, createEmptyExcelSheet, createEmptySheet, toXC, toNumber } = helpers;
const uuidGenerator = new UuidGenerator();
export class GlobalFiltersCoreViewPlugin extends OdooCoreViewPlugin {
static getters = /** @type {const} */ ([
"exportSheetWithActiveFilters",
"getFilterDisplayValue",
"getGlobalFilterByName",
"getGlobalFilterDomain",
"getGlobalFilterValue",
"getActiveFilterCount",
"isGlobalFilterActive",
"getTextFilterOptions",
"getTextFilterOptionsFromRanges",
]);
constructor(config) {
super(config);
this.values = {};
}
/**
* Check if the given command can be dispatched
*
* @param {import("@spreadsheet").AllCommand} cmd Command
*/
allowDispatch(cmd) {
switch (cmd.type) {
case "SET_GLOBAL_FILTER_VALUE": {
const filter = this.getters.getGlobalFilter(cmd.id);
if (!filter) {
return CommandResult.FilterNotFound;
}
if (!checkFilterValueIsValid(filter, cmd.value)) {
return CommandResult.InvalidValueTypeCombination;
}
const currentFilterValue = this.getters.getGlobalFilterValue(cmd.id);
if (deepEqual(currentFilterValue, cmd.value)) {
return CommandResult.NoChanges;
}
break;
}
}
return CommandResult.Success;
}
/**
* Handle a spreadsheet command
*
* @param {import("@spreadsheet").AllCommand} cmd
*/
handle(cmd) {
switch (cmd.type) {
case "SET_GLOBAL_FILTER_VALUE":
if (cmd.value === undefined) {
this._clearGlobalFilterValue(cmd.id);
} else {
this._setGlobalFilterValue(cmd.id, cmd.value);
}
break;
case "REMOVE_GLOBAL_FILTER":
delete this.values[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();
}
const value = this.getGlobalFilterValue(filter.id);
const field = fieldMatching.chain;
if (!field || !value) {
return new Domain();
} else if (filter.type === "date") {
return this._getDateDomain(filter, fieldMatching);
} else {
return getFilterValueDomain(filter, value, field);
}
}
/**
* 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;
if (value !== undefined) {
return value;
}
const preventDefaultValue = this.values[filterId]?.preventDefaultValue;
if (preventDefaultValue || filter.defaultValue === undefined) {
return undefined;
}
switch (filter.type) {
case "date":
return this._getDateValueFromDefaultValue(filter.defaultValue);
case "relation":
if (filter.defaultValue.ids === "current_user") {
return { ...filter.defaultValue, ids: [user.userId] };
}
return filter.defaultValue;
default:
return filter.defaultValue;
}
}
/**
* @param {string} id Id of the filter
*
* @returns { boolean } true if the given filter is active
*/
isGlobalFilterActive(id) {
return this.getGlobalFilterValue(id) !== undefined;
}
/**
* 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.getGlobalFilterByName(filterName);
if (!filter) {
throw new EvaluationError(
_t(`Filter "%(filter_name)s" not found`, { filter_name: filterName })
);
}
switch (filter.type) {
case "date":
return this._getDateFilterDisplayValue(filter);
default: {
const value = this.getGlobalFilterValue(filter.id);
if (!value) {
return [[{ value: "" }]];
}
return getFilterCellValue(this.getters, filter, value);
}
}
}
/**
* 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);
if (filter.type !== "text" || !filter.rangesOfAllowedValues) {
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)?.strings ?? []),
...(filter.defaultValue?.strings ?? []),
];
const options = this.getTextFilterOptionsFromRanges(
filter.rangesOfAllowedValues,
additionOptions
);
return options;
}
/**
* Returns the possible values a text global filter can take from a range
* or any addition raw string value. Removes duplicates and empty string values.
* @param {object[]} ranges
* @param {string[]} additionalOptionValues
*/
getTextFilterOptionsFromRanges(ranges, additionalOptionValues = []) {
const cells = ranges.flatMap((range) =>
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) && cell.value !== "")
.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] = {
preventDefaultValue: false,
value,
};
}
/**
* Set the current value to empty values which functionally deactivate the filter
*
* @param {string} id Id of the filter
*/
_clearGlobalFilterValue(id) {
this.values[id] = {
preventDefaultValue: true,
value: undefined,
};
}
// -------------------------------------------------------------------------
// Private
// -------------------------------------------------------------------------
/**
* Get the global filter with the given name
*
* @param {string} label Label
* @returns {GlobalFilter|undefined}
*/
getGlobalFilterByName(label) {
return this.getters
.getGlobalFilters()
.find(
(filter) =>
this.getters.dynamicTranslate(filter.label) ===
this.getters.dynamicTranslate(label)
);
}
_getDateFilterDisplayValue(filter) {
const { from, to } = getDateRange(this.getGlobalFilterValue(filter.id));
const locale = this.getters.getLocale();
const _from = {
value: from ? toNumber(serializeDate(from), locale) : "",
format: locale.dateFormat,
};
const _to = {
value: to ? toNumber(serializeDate(to), locale) : "",
format: locale.dateFormat,
};
return [[_from], [_to]];
}
/**
* Get the value derived from the default value of a date filter.
* e.g. if the default value is "this_year", it returns the actual current
* year. If it's a relative period, it returns the period as value.
*
* @param {DateDefaultValue} defaultValue
* @returns {DateValue|undefined}
*/
_getDateValueFromDefaultValue(defaultValue) {
const year = DateTime.local().year;
switch (defaultValue) {
case "this_year":
return { type: "year", year };
case "this_month": {
const month = DateTime.local().month;
return { type: "month", year, month };
}
case "this_quarter": {
const quarter = Math.floor(new Date().getMonth() / 3) + 1;
return { type: "quarter", year, quarter };
}
case "today":
case "yesterday":
case "last_7_days":
case "last_30_days":
case "last_90_days":
case "month_to_date":
case "last_month":
case "last_12_months":
case "year_to_date":
return {
type: "relative",
period: defaultValue,
};
}
return undefined;
}
/**
* Get the domain relative to a date field
*
* @private
*
* @param {DateGlobalFilter} filter
* @param {FieldMatching} fieldMatching
*
* @returns {Domain}
*/
_getDateDomain(filter, fieldMatching) {
if (!fieldMatching.chain) {
return new Domain();
}
const field = fieldMatching.chain;
const type = /** @type {"date" | "datetime"} */ (fieldMatching.type);
const offset = fieldMatching.offset || 0;
const { from, to } = getDateRange(this.getGlobalFilterValue(filter.id), offset);
return getDateDomain(from, to, field, type);
}
/**
* 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.smallUuid(), _t("Active Filters")),
...data.sheets.at(-1),
};
}
exportSheetWithActiveFilters(data) {
if (this.getters.getGlobalFilters().length === 0) {
return;
}
const cells = {
A1: "Filter",
B1: "Value",
};
const formats = {};
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}`] = 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];
if (cell.value === undefined) {
continue;
}
const xc = toXC(Number(colIndex) + 1, Number(rowIndex) + filterRowIndex);
cells[xc] = cell.value.toString();
if (cell.format) {
const formatId = getItemId(cell.format, data.formats);
formats[xc] = formatId;
}
}
}
filterRowIndex += result[0].length;
}
const styleId = getItemId({ bold: true }, data.styles);
const sheet = {
...createEmptySheet(uuidGenerator.smallUuid(), _t("Active Filters")),
cells,
formats,
styles: {
A1: styleId,
B1: styleId,
},
colNumber: numberOfCols,
rowNumber: filterRowIndex,
};
data.sheets.push(sheet);
}
}

View file

@ -1,460 +1,53 @@
/** @odoo-module */
/** @ts-check */
/**
* @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
* @typedef {import("@spreadsheet").GlobalFilter} GlobalFilter
* @typedef {import("@spreadsheet").FieldMatching} FieldMatching
* @typedef {import("@spreadsheet").DateGlobalFilter} DateGlobalFilter
* @typedef {import("@spreadsheet").RelationalGlobalFilter} RelationalGlobalFilter
*/
import { _t } from "@web/core/l10n/translation";
import { sprintf } from "@web/core/utils/strings";
import { Domain } from "@web/core/domain";
import { constructDateRange, getPeriodOptions, QUARTER_OPTIONS } from "@web/search/utils/dates";
import spreadsheet from "@spreadsheet/o_spreadsheet/o_spreadsheet_extended";
import CommandResult from "@spreadsheet/o_spreadsheet/cancelled_reason";
import { isEmpty } from "@spreadsheet/helpers/helpers";
import { FILTER_DATE_OPTION } from "@spreadsheet/assets_backend/constants";
import {
checkFiltersTypeValueCombination,
getRelativeDateDomain,
} from "@spreadsheet/global_filters/helpers";
import { RELATIVE_DATE_RANGE_TYPES } from "@spreadsheet/helpers/constants";
const { DateTime } = luxon;
const MONTHS = {
january: { value: 1, granularity: "month" },
february: { value: 2, granularity: "month" },
march: { value: 3, granularity: "month" },
april: { value: 4, granularity: "month" },
may: { value: 5, granularity: "month" },
june: { value: 6, granularity: "month" },
july: { value: 7, granularity: "month" },
august: { value: 8, granularity: "month" },
september: { value: 9, granularity: "month" },
october: { value: 10, granularity: "month" },
november: { value: 11, granularity: "month" },
december: { value: 12, granularity: "month" },
};
const { UuidGenerator, createEmptyExcelSheet } = spreadsheet.helpers;
const uuidGenerator = new UuidGenerator();
export default class GlobalFiltersUIPlugin extends spreadsheet.UIPlugin {
constructor(getters, history, dispatch, config) {
super(getters, history, dispatch, config);
this.orm = config.evalContext.env ? config.evalContext.env.services.orm : undefined;
/**
* Cache record display names for relation filters.
* For each filter, contains a promise resolving to
* the list of display names.
*/
this.recordsDisplayName = {};
/** @type {Object.<string, string|Array<string>|Object>} */
this.values = {};
}
/**
* Check if the given command can be dispatched
*
* @param {Object} cmd Command
*/
allowDispatch(cmd) {
switch (cmd.type) {
case "SET_GLOBAL_FILTER_VALUE": {
const filter = this.getters.getGlobalFilter(cmd.id);
if (!filter) {
return CommandResult.FilterNotFound;
}
return checkFiltersTypeValueCombination(filter.type, cmd.value);
}
}
return CommandResult.Success;
}
import { OdooUIPlugin } from "@spreadsheet/plugins";
import { globalFieldMatchingRegistry } from "../helpers";
export class GlobalFiltersUIPlugin extends OdooUIPlugin {
/**
* Handle a spreadsheet command
*
* @param {Object} cmd Command
* @param {import("@spreadsheet").AllCommand} cmd
*/
handle(cmd) {
switch (cmd.type) {
case "ADD_GLOBAL_FILTER":
this.recordsDisplayName[cmd.filter.id] = cmd.filter.defaultValueDisplayNames;
break;
case "EDIT_GLOBAL_FILTER":
if (this.values[cmd.id] && this.values[cmd.id].rangeType !== cmd.filter.rangeType) {
delete this.values[cmd.id];
}
this.recordsDisplayName[cmd.filter.id] = cmd.filter.defaultValueDisplayNames;
break;
case "SET_GLOBAL_FILTER_VALUE":
this.recordsDisplayName[cmd.id] = cmd.displayNames;
this._setGlobalFilterValue(cmd.id, cmd.value);
break;
case "SET_MANY_GLOBAL_FILTER_VALUE":
for (const filter of cmd.filters) {
if (filter.value !== undefined) {
this.dispatch("SET_GLOBAL_FILTER_VALUE", {
id: filter.filterId,
value: filter.value,
});
} else {
this.dispatch("CLEAR_GLOBAL_FILTER_VALUE", { id: filter.filterId });
}
}
break;
case "REMOVE_GLOBAL_FILTER":
delete this.recordsDisplayName[cmd.id];
delete this.values[cmd.id];
break;
case "CLEAR_GLOBAL_FILTER_VALUE":
this.recordsDisplayName[cmd.id] = [];
this._clearGlobalFilterValue(cmd.id);
break;
}
}
// -------------------------------------------------------------------------
// 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 : filter.defaultValue;
const preventAutomaticValue =
this.values[filterId] &&
this.values[filterId].value &&
this.values[filterId].value.preventAutomaticValue;
const defaultsToCurrentPeriod = !preventAutomaticValue && filter.defaultsToCurrentPeriod;
if (filter.type === "date" && isEmpty(value) && defaultsToCurrentPeriod) {
return this._getValueOfCurrentPeriod(filterId);
}
return value;
}
/**
* @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)
);
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 || "";
case "date": {
if (value && typeof value === "string") {
const type = RELATIVE_DATE_RANGE_TYPES.find((type) => type.type === value);
if (!type) {
return "";
}
return type.description.toString();
}
if (!value || value.yearOffset === undefined) {
return "";
}
const periodOptions = getPeriodOptions(DateTime.local());
const year = String(DateTime.local().year + value.yearOffset);
const period = periodOptions.find(({ id }) => value.period === id);
let periodStr = period && period.description;
// Named months aren't in getPeriodOptions
if (!period) {
periodStr =
MONTHS[value.period] && String(MONTHS[value.period].value).padStart(2, "0");
}
return periodStr ? periodStr + "/" + year : year;
}
case "relation":
if (!value || !this.orm) {
return "";
}
if (!this.recordsDisplayName[filter.id]) {
this.orm.call(filter.modelName, "name_get", [value]).then((result) => {
const names = result.map(([, name]) => name);
this.recordsDisplayName[filter.id] = names;
this.dispatch("EVALUATE_CELLS", {
sheetId: this.getters.getActiveSheetId(),
});
this.dispatch("SET_GLOBAL_FILTER_VALUE", {
id: filter.filterId,
value: filter.value,
});
}
break;
case "SET_DATASOURCE_FIELD_MATCHING": {
const matcher = globalFieldMatchingRegistry.get(cmd.dataSourceType);
/**
* cmd.fieldMatchings looks like { [filterId]: { chain, type } }
*/
for (const filterId in cmd.fieldMatchings) {
const filterFieldMatching = {};
for (const dataSourceId of matcher.getIds(this.getters)) {
if (dataSourceId === cmd.dataSourceId) {
filterFieldMatching[dataSourceId] = cmd.fieldMatchings[filterId];
} else {
filterFieldMatching[dataSourceId] =
matcher.getFieldMatching(this.getters, dataSourceId, filterId) ||
{};
}
}
this.dispatch("EDIT_GLOBAL_FILTER", {
filter: this.getters.getGlobalFilter(filterId),
[cmd.dataSourceType]: filterFieldMatching,
});
return "";
}
return this.recordsDisplayName[filter.id].join(", ");
}
}
// -------------------------------------------------------------------------
// Handlers
// -------------------------------------------------------------------------
/**
* Set the current value of a global filter
*
* @param {string} id Id of the filter
* @param {string|Array<string>|Object} value Current value to set
*/
_setGlobalFilterValue(id, value) {
this.values[id] = { value: value, rangeType: this.getters.getGlobalFilter(id).rangeType };
}
/**
* Get the filter value corresponding to the current period, depending of the type of range of the filter.
* For example if rangeType === "month", the value will be the current month of the current year.
*
* @param {string} filterId a global filter
* @return {Object} filter value
*/
_getValueOfCurrentPeriod(filterId) {
const filter = this.getters.getGlobalFilter(filterId);
const rangeType = filter.rangeType;
switch (rangeType) {
case "year":
return { yearOffset: 0 };
case "month": {
const month = new Date().getMonth() + 1;
const period = Object.entries(MONTHS).find((item) => item[1].value === month)[0];
return { yearOffset: 0, period };
}
case "quarter": {
const quarter = Math.floor(new Date().getMonth() / 3);
const period = FILTER_DATE_OPTION.quarter[quarter];
return { yearOffset: 0, period };
}
}
return {};
}
/**
* Set the current value to empty values which functionally deactivate the filter
*
* @param {string} id Id of the filter
*/
_clearGlobalFilterValue(id) {
const { type, rangeType } = this.getters.getGlobalFilter(id);
let value;
switch (type) {
case "text":
value = "";
break;
case "date":
value = { yearOffset: undefined, preventAutomaticValue: true };
break;
case "relation":
value = [];
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 === "relative") {
return getRelativeDateDomain(now, offset, value, field, type);
}
if (value.yearOffset === undefined) {
return new Domain();
}
const setParam = { year: now.year };
const yearOffset = value.yearOffset || 0;
const plusParam = {
years: filter.rangeType === "year" ? yearOffset + offset : yearOffset,
};
if (!value.period || value.period === "empty") {
granularity = "year";
} else {
switch (filter.rangeType) {
case "month":
granularity = "month";
setParam.month = MONTHS[value.period].value;
plusParam.month = offset;
break;
case "quarter":
granularity = "quarter";
setParam.quarter = QUARTER_OPTIONS[value.period].setParam.quarter;
plusParam.quarter = offset;
break;
}
}
return constructDateRange({
referenceMoment: now,
fieldName: field,
fieldType: type,
granularity,
setParam,
plusParam,
}).domain;
}
/**
* Get the domain relative to a text field
*
* @private
*
* @param {GlobalFilter} filter
* @param {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;
}
const styles = Object.entries(data.styles);
let titleStyleId =
styles.findIndex((el) => JSON.stringify(el[1]) === JSON.stringify({ bold: true })) + 1;
if (titleStyleId <= 0) {
titleStyleId = styles.length + 1;
data.styles[styles.length + 1] = { bold: true };
}
const cells = {};
cells["A1"] = { content: "Filter", style: titleStyleId };
cells["B1"] = { content: "Value", style: titleStyleId };
let row = 2;
for (const filter of this.getters.getGlobalFilters()) {
const content = this.getFilterDisplayValue(filter.label);
cells[`A${row}`] = { content: filter.label };
cells[`B${row}`] = { content };
row++;
}
data.sheets.push({
...createEmptyExcelSheet(uuidGenerator.uuidv4(), _t("Active Filters")),
cells,
colNumber: 2,
rowNumber: this.getters.getGlobalFilters().length + 1,
});
}
}
GlobalFiltersUIPlugin.getters = [
"getFilterDisplayValue",
"getGlobalFilterDomain",
"getGlobalFilterValue",
"getActiveFilterCount",
"isGlobalFilterActive",
];

View file

@ -1,28 +1,7 @@
/** @odoo-module */
// @ts-check
import { _lt } from "@web/core/l10n/translation";
import { _t } from "@web/core/l10n/translation";
export const DEFAULT_LINES_NUMBER = 20;
export const FORMATS = {
day: { out: "MM/DD/YYYY", display: "DD MMM YYYY", interval: "d" },
week: { out: "WW/YYYY", display: "[W]W YYYY", interval: "w" },
month: { out: "MM/YYYY", display: "MMMM YYYY", interval: "M" },
quarter: { out: "Q/YYYY", display: "[Q]Q YYYY", interval: "Q" },
year: { out: "YYYY", display: "YYYY", interval: "y" },
};
export const HEADER_STYLE = { fillColor: "#f2f2f2" };
export const TOP_LEVEL_STYLE = { bold: true, fillColor: "#f2f2f2" };
export const MEASURE_STYLE = { fillColor: "#f2f2f2", textColor: "#756f6f" };
export const UNTITLED_SPREADSHEET_NAME = _lt("Untitled spreadsheet");
export const RELATIVE_DATE_RANGE_TYPES = [
{ type: "last_week", description: _lt("Last 7 Days") },
{ type: "last_month", description: _lt("Last 30 Days") },
{ type: "last_three_months", description: _lt("Last 90 Days") },
{ type: "last_six_months", description: _lt("Last 180 Days") },
{ type: "last_year", description: _lt("Last 365 Days") },
{ type: "last_three_years", description: _lt("Last 3 Years") },
];
export const UNTITLED_SPREADSHEET_NAME = _t("Untitled spreadsheet");

View file

@ -0,0 +1,150 @@
import { _t } from "@web/core/l10n/translation";
import { registry } from "@web/core/registry";
const diacriticalMarksRegex = /[\u0300-\u036f]/g;
/** Put the feature name in lowercase and remove the accents */
function normalizeFeatureName(name) {
return name.normalize("NFD").replace(diacriticalMarksRegex, "").toLowerCase();
}
export const geoJsonService = {
dependencies: ["orm"],
start(env, { orm }) {
const geoJsonPromises = new Map();
const geoJsonCache = new Map();
let usaStatesMapping = undefined;
let countriesMapping = undefined;
let countriesMappingPromise = undefined;
let usStatesMappingPromise = undefined;
async function getRegionAndFetchMapping(region) {
const featurePromise = fetchJsonFromServer(
`/spreadsheet/static/topojson/${region}.topo.json`
);
const mappingPromise = region === "usa" ? getUsaStatesMapping() : getCountriesMapping();
return Promise.all([featurePromise, mappingPromise]);
}
async function getUsaStatesMapping() {
if (usaStatesMapping) {
return usaStatesMapping;
}
if (usStatesMappingPromise) {
return usStatesMappingPromise;
}
usStatesMappingPromise = orm
.searchRead("res.country.state", [["country_id.code", "=", "US"]], ["name", "code"])
.then((usStates) => {
const mapping = {};
for (const state of usStates) {
mapping[normalizeFeatureName(state.name)] = state.code;
mapping[normalizeFeatureName(state.code)] = state.code;
}
usaStatesMapping = mapping;
usStatesMappingPromise = undefined;
return mapping;
})
.catch((e) => {
console.error(e);
usaStatesMapping = {};
usStatesMappingPromise = undefined;
return {};
});
return usStatesMappingPromise;
}
async function getCountriesMapping() {
if (countriesMapping) {
return countriesMapping;
}
if (countriesMappingPromise) {
return countriesMappingPromise;
}
countriesMappingPromise = orm
.searchRead("res.country", [], ["name", "code"])
.then((resCountries) => {
const mapping = {};
for (const country of resCountries) {
mapping[normalizeFeatureName(country.name)] = country.code;
mapping[normalizeFeatureName(country.code)] = country.code;
}
countriesMapping = mapping;
countriesMappingPromise = undefined;
return mapping;
})
.catch((e) => {
console.error(e);
countriesMapping = {};
countriesMappingPromise = undefined;
return {};
});
return countriesMappingPromise;
}
async function fetchJsonFromServer(url) {
if (geoJsonCache.has(url)) {
return geoJsonCache.get(url);
}
if (geoJsonPromises.has(url)) {
return geoJsonPromises.get(url);
}
const promise = fetch(url, { method: "GET" })
.then((res) => res.json())
.then((geoJson) => {
geoJsonCache.set(url, geoJson);
geoJsonPromises.delete(url);
return geoJson;
})
.catch((e) => {
console.error(e);
geoJsonCache.set(url, { type: "FeatureCollection", features: [] });
geoJsonPromises.delete(url);
return geoJsonCache.get(url);
});
geoJsonPromises.set(url, promise);
return promise;
}
const stateNameRegex = /(.*?)(\(.*\))?$/;
return {
getAvailableRegions: () => [
{ id: "world", label: _t("World"), defaultProjection: "mercator" },
{ id: "africa", label: _t("Africa"), defaultProjection: "mercator" },
{ id: "asia", label: _t("Asia"), defaultProjection: "mercator" },
{ id: "europe", label: _t("Europe"), defaultProjection: "mercator" },
{
id: "north_america",
label: _t("North America"),
defaultProjection: "conicConformal",
},
{ id: "usa", label: _t("United States"), defaultProjection: "albersUsa" },
{ id: "south_america", label: _t("South America"), defaultProjection: "mercator" },
],
getTopoJson: async function (region) {
const [topoJson] = await getRegionAndFetchMapping(region);
return topoJson;
},
geoFeatureNameToId: function (region, name) {
if (region === "usa") {
// State display names are appended with the country in odoo (e.g. "California (US)").
const match = name.match(stateNameRegex);
if (match) {
name = match[1].trim();
}
}
name = normalizeFeatureName(name);
const mapping = region === "usa" ? usaStatesMapping : countriesMapping;
return mapping?.[name];
},
};
},
};
registry.category("services").add("geo_json_service", geoJsonService);

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