mirror of
https://github.com/bringout/oca-ocb-report.git
synced 2026-04-21 17:02:06 +02:00
19.0 vanilla
This commit is contained in:
parent
62d197ac8b
commit
184bb0e321
667 changed files with 691406 additions and 239886 deletions
157
odoo-bringout-oca-ocb-spreadsheet/spreadsheet/static/src/@types/commands.d.ts
vendored
Normal file
157
odoo-bringout-oca-ocb-spreadsheet/spreadsheet/static/src/@types/commands.d.ts
vendored
Normal 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;
|
||||
}
|
||||
11
odoo-bringout-oca-ocb-spreadsheet/spreadsheet/static/src/@types/env.d.ts
vendored
Normal file
11
odoo-bringout-oca-ocb-spreadsheet/spreadsheet/static/src/@types/env.d.ts
vendored
Normal 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;
|
||||
}
|
||||
}
|
||||
11
odoo-bringout-oca-ocb-spreadsheet/spreadsheet/static/src/@types/functions.d.ts
vendored
Normal file
11
odoo-bringout-oca-ocb-spreadsheet/spreadsheet/static/src/@types/functions.d.ts
vendored
Normal 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;
|
||||
}
|
||||
}
|
||||
74
odoo-bringout-oca-ocb-spreadsheet/spreadsheet/static/src/@types/getters.d.ts
vendored
Normal file
74
odoo-bringout-oca-ocb-spreadsheet/spreadsheet/static/src/@types/getters.d.ts
vendored
Normal 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> {}
|
||||
}
|
||||
175
odoo-bringout-oca-ocb-spreadsheet/spreadsheet/static/src/@types/global_filter.d.ts
vendored
Normal file
175
odoo-bringout-oca-ocb-spreadsheet/spreadsheet/static/src/@types/global_filter.d.ts
vendored
Normal 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;
|
||||
}
|
||||
10
odoo-bringout-oca-ocb-spreadsheet/spreadsheet/static/src/@types/misc.d.ts
vendored
Normal file
10
odoo-bringout-oca-ocb-spreadsheet/spreadsheet/static/src/@types/misc.d.ts
vendored
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
declare module "@spreadsheet" {
|
||||
export interface Zone {
|
||||
bottom: number;
|
||||
left: number;
|
||||
right: number;
|
||||
top: number;
|
||||
}
|
||||
|
||||
export interface LazyTranslatedString extends String {}
|
||||
}
|
||||
16
odoo-bringout-oca-ocb-spreadsheet/spreadsheet/static/src/@types/models.d.ts
vendored
Normal file
16
odoo-bringout-oca-ocb-spreadsheet/spreadsheet/static/src/@types/models.d.ts
vendored
Normal 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;
|
||||
}
|
||||
}
|
||||
75
odoo-bringout-oca-ocb-spreadsheet/spreadsheet/static/src/@types/pivot.d.ts
vendored
Normal file
75
odoo-bringout-oca-ocb-spreadsheet/spreadsheet/static/src/@types/pivot.d.ts
vendored
Normal 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;
|
||||
}
|
||||
}
|
||||
29
odoo-bringout-oca-ocb-spreadsheet/spreadsheet/static/src/@types/plugins.d.ts
vendored
Normal file
29
odoo-bringout-oca-ocb-spreadsheet/spreadsheet/static/src/@types/plugins.d.ts
vendored
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
.o_spreadsheet_container {
|
||||
flex: 1 1 auto;
|
||||
overflow: auto;
|
||||
height: 100%;
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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") },
|
||||
];
|
||||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
@ -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>
|
||||
|
|
@ -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";
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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] || {});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
);
|
||||
}
|
||||
|
|
@ -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 };
|
||||
}
|
||||
|
|
@ -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 };
|
||||
}
|
||||
|
|
@ -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 };
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
@ -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 };
|
||||
}
|
||||
|
|
@ -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 };
|
||||
}
|
||||
|
|
@ -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 };
|
||||
}
|
||||
|
|
@ -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 };
|
||||
}
|
||||
|
|
@ -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 };
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
|
@ -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>
|
||||
|
|
@ -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();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
.o-chart-menu {
|
||||
.o-chart-menu-item {
|
||||
.o-figure-menu {
|
||||
.o-figure-menu-item {
|
||||
padding-left: 7px;
|
||||
}
|
||||
|
||||
|
|
@ -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>
|
||||
|
|
@ -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);
|
||||
},
|
||||
};
|
||||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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 = [];
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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"];
|
||||
},
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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"],
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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 },
|
||||
};
|
||||
|
|
@ -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>
|
||||
|
|
@ -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 = "";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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 },
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 && filterValue.period"
|
||||
yearOffset="filterValue && filterValue.yearOffset"
|
||||
type="filter.rangeType"
|
||||
onTimeRangeChanged="(value) => this.onDateInput(filter.id, value)" />
|
||||
<select t-if="filter.rangeType === 'relative'"
|
||||
t-on-change="(e) => this.onDateInput(filter.id, e.target.value || undefined)"
|
||||
class="date_filter_values o_input me-3 text-truncate"
|
||||
required="true">
|
||||
<option value="">Select period...</option>
|
||||
<t t-foreach="relativeDateRangesTypes"
|
||||
t-as="range"
|
||||
t-key="range.type">
|
||||
<option t-att-selected="range.type === filterValue"
|
||||
t-att-value="range.type">
|
||||
<t t-esc="range.description"/>
|
||||
</option>
|
||||
</t>
|
||||
</select>
|
||||
</div>
|
||||
<i t-if="getters.isGlobalFilterActive(filter.id)"
|
||||
class="fa fa-times btn btn-link text-muted o_side_panel_filter_icon ms-1 mt-1"
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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: [],
|
||||
};
|
||||
|
|
@ -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>
|
||||
|
|
@ -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]);
|
||||
},
|
||||
})),
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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,
|
||||
};
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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",
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
@ -1,6 +1,4 @@
|
|||
/** @odoo-module */
|
||||
|
||||
import { loadJS } from "@web/core/assets";
|
||||
// @ts-check
|
||||
|
||||
/**
|
||||
* Get the intersection of two arrays
|
||||
|
|
@ -15,30 +13,6 @@ export function intersect(a, b) {
|
|||
return a.filter((x) => b.includes(x));
|
||||
}
|
||||
|
||||
/**
|
||||
* Given an object of form {"1": {...}, "2": {...}, ...} get the maximum ID used
|
||||
* in this object
|
||||
* If the object has no keys, return 0
|
||||
*
|
||||
* @param {Object} o an object for which the keys are an ID
|
||||
*
|
||||
* @returns {number}
|
||||
*/
|
||||
export function getMaxObjectId(o) {
|
||||
const keys = Object.keys(o);
|
||||
if (!keys.length) {
|
||||
return 0;
|
||||
}
|
||||
const nums = keys.map((id) => parseInt(id, 10));
|
||||
const max = Math.max(...nums);
|
||||
return max;
|
||||
}
|
||||
|
||||
/** converts and orderBy Object to a string equivalent that can be processed by orm.call */
|
||||
export function orderByToString(orderBy) {
|
||||
return orderBy.map((o) => `${o.name} ${o.asc ? "ASC" : "DESC"}`).join(", ");
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a spreadsheet date representation to an odoo
|
||||
* server formatted date
|
||||
|
|
@ -58,6 +32,9 @@ export function sum(array) {
|
|||
return array.reduce((acc, n) => acc + n, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} word
|
||||
*/
|
||||
function camelToSnakeKey(word) {
|
||||
const result = word.replace(/(.){1}([A-Z])/g, "$1 $2");
|
||||
return result.split(" ").join("_").toLowerCase();
|
||||
|
|
@ -98,11 +75,11 @@ export function isEmpty(item) {
|
|||
}
|
||||
|
||||
/**
|
||||
* Load external libraries required for o-spreadsheet
|
||||
* @returns {Promise<void>}
|
||||
* @param {import("@odoo/o-spreadsheet").Cell} cell
|
||||
*/
|
||||
export async function loadSpreadsheetDependencies() {
|
||||
await loadJS("/web/static/lib/Chart/Chart.js");
|
||||
// chartjs-gauge should only be loaded when Chart.js is fully loaded !
|
||||
await loadJS("/spreadsheet/static/lib/chartjs-gauge/chartjs-gauge.js");
|
||||
export function containsReferences(cell) {
|
||||
if (!cell.isFormula) {
|
||||
return false;
|
||||
}
|
||||
return cell.compiledFormula.tokens.some((token) => token.type === "REFERENCE");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,314 @@
|
|||
// @ts-check
|
||||
|
||||
import { parse, helpers, iterateAstNodes } from "@odoo/o-spreadsheet";
|
||||
import { isLoadingError } from "@spreadsheet/o_spreadsheet/errors";
|
||||
import { OdooSpreadsheetModel } from "@spreadsheet/model";
|
||||
import { OdooDataProvider } from "@spreadsheet/data_sources/odoo_data_provider";
|
||||
|
||||
const { formatValue, isDefined, toCartesian, toXC } = helpers;
|
||||
import {
|
||||
isMarkdownViewUrl,
|
||||
isMarkdownIrMenuIdUrl,
|
||||
isIrMenuXmlUrl,
|
||||
} from "@spreadsheet/ir_ui_menu/odoo_menu_link_cell";
|
||||
|
||||
/**
|
||||
* @typedef {import("@spreadsheet").OdooSpreadsheetModel} OdooSpreadsheetModel
|
||||
*/
|
||||
|
||||
export async function fetchSpreadsheetModel(env, resModel, resId) {
|
||||
const { data, revisions } = await env.services.http.get(
|
||||
`/spreadsheet/data/${resModel}/${resId}`
|
||||
);
|
||||
return createSpreadsheetModel({ env, data, revisions });
|
||||
}
|
||||
|
||||
export function createSpreadsheetModel({ env, data, revisions }) {
|
||||
const odooDataProvider = new OdooDataProvider(env);
|
||||
const model = new OdooSpreadsheetModel(data, { custom: { env, odooDataProvider } }, revisions);
|
||||
return model;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {OdooSpreadsheetModel} model
|
||||
*/
|
||||
export async function waitForOdooSources(model) {
|
||||
const promises = model.getters
|
||||
.getOdooChartIds()
|
||||
.map((chartId) => model.getters.getChartDataSource(chartId).load());
|
||||
promises.push(
|
||||
...model.getters
|
||||
.getPivotIds()
|
||||
.filter((pivotId) => model.getters.getPivotCoreDefinition(pivotId).type === "ODOO")
|
||||
.map((pivotId) => model.getters.getPivot(pivotId))
|
||||
.map((pivot) => pivot.load())
|
||||
);
|
||||
promises.push(
|
||||
...model.getters
|
||||
.getListIds()
|
||||
.map((listId) => model.getters.getListDataSource(listId))
|
||||
.map((list) => list.load())
|
||||
);
|
||||
await Promise.all(promises);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure that the spreadsheet does not contains cells that are in loading state
|
||||
* @param {OdooSpreadsheetModel} model
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function waitForDataLoaded(model) {
|
||||
await waitForOdooSources(model);
|
||||
const odooDataProvider = model.config.custom.odooDataProvider;
|
||||
if (!odooDataProvider) {
|
||||
return;
|
||||
}
|
||||
await new Promise((resolve, reject) => {
|
||||
function check() {
|
||||
model.dispatch("EVALUATE_CELLS");
|
||||
if (isLoaded(model)) {
|
||||
odooDataProvider.removeEventListener("data-source-updated", check);
|
||||
resolve();
|
||||
}
|
||||
}
|
||||
odooDataProvider.addEventListener("data-source-updated", check);
|
||||
check();
|
||||
});
|
||||
}
|
||||
|
||||
function containsLinkToOdoo(link) {
|
||||
if (link && link.url) {
|
||||
return (
|
||||
isMarkdownViewUrl(link.url) ||
|
||||
isIrMenuXmlUrl(link.url) ||
|
||||
isMarkdownIrMenuIdUrl(link.url)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {OdooSpreadsheetModel} model
|
||||
* @returns {Promise<object>}
|
||||
*/
|
||||
export async function freezeOdooData(model) {
|
||||
await waitForDataLoaded(model);
|
||||
const data = model.exportData();
|
||||
for (const sheet of Object.values(data.sheets)) {
|
||||
sheet.formats ??= {};
|
||||
for (const [xc, content] of Object.entries(sheet.cells)) {
|
||||
const { col, row } = toCartesian(xc);
|
||||
const sheetId = sheet.id;
|
||||
const position = { sheetId, col, row };
|
||||
const evaluatedCell = model.getters.getEvaluatedCell(position);
|
||||
if (containsOdooFunction(content)) {
|
||||
const pivotId = model.getters.getPivotIdFromPosition(position);
|
||||
if (pivotId && model.getters.getPivotCoreDefinition(pivotId).type !== "ODOO") {
|
||||
continue;
|
||||
}
|
||||
sheet.cells[xc] = toFrozenContent(evaluatedCell);
|
||||
if (evaluatedCell.format) {
|
||||
sheet.formats[xc] = getItemId(evaluatedCell.format, data.formats);
|
||||
}
|
||||
const spreadZone = model.getters.getSpreadZone(position);
|
||||
if (spreadZone) {
|
||||
const { left, right, top, bottom } = spreadZone;
|
||||
for (let row = top; row <= bottom; row++) {
|
||||
for (let col = left; col <= right; col++) {
|
||||
const xc = toXC(col, row);
|
||||
const evaluatedCell = model.getters.getEvaluatedCell({
|
||||
sheetId,
|
||||
col,
|
||||
row,
|
||||
});
|
||||
sheet.cells[xc] = toFrozenContent(evaluatedCell);
|
||||
if (evaluatedCell.format) {
|
||||
sheet.formats[xc] = getItemId(evaluatedCell.format, data.formats);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (containsLinkToOdoo(evaluatedCell.link)) {
|
||||
sheet.cells[xc] = evaluatedCell.link.label;
|
||||
}
|
||||
}
|
||||
for (const figure of sheet.figures) {
|
||||
if (
|
||||
figure.tag === "chart" &&
|
||||
(figure.data.type.startsWith("odoo_") || figure.data.type === "geo")
|
||||
) {
|
||||
const img = odooChartToImage(model, figure, figure.data.chartId);
|
||||
figure.tag = "image";
|
||||
figure.data = {
|
||||
path: img,
|
||||
size: { width: figure.width, height: figure.height },
|
||||
};
|
||||
} else if (figure.tag === "carousel") {
|
||||
const hasImageChart = figure.data.items.some((item) => {
|
||||
if (item.type !== "chart") {
|
||||
return false;
|
||||
}
|
||||
const chartDefinition = model.getters.getChartDefinition(item.chartId);
|
||||
return (
|
||||
chartDefinition.type.startsWith("odoo_") || chartDefinition.type === "geo"
|
||||
);
|
||||
});
|
||||
if (hasImageChart) {
|
||||
const chartId = figure.data.items.find((item) => item.type === "chart").chartId;
|
||||
figure.tag = "image";
|
||||
figure.data = {
|
||||
path: odooChartToImage(model, figure, chartId),
|
||||
size: { width: figure.width, height: figure.height },
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (data.pivots) {
|
||||
data.pivots = Object.fromEntries(
|
||||
Object.entries(data.pivots).filter(([id, def]) => def.type !== "ODOO")
|
||||
);
|
||||
}
|
||||
data.lists = {};
|
||||
exportGlobalFiltersToSheet(model, data);
|
||||
return data;
|
||||
}
|
||||
|
||||
function toFrozenContent(evaluatedCell) {
|
||||
const value = evaluatedCell.value;
|
||||
if (value === "") {
|
||||
return '=""';
|
||||
}
|
||||
return value.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {OdooSpreadsheetModel} model
|
||||
* @returns {object}
|
||||
*/
|
||||
function exportGlobalFiltersToSheet(model, data) {
|
||||
model.getters.exportSheetWithActiveFilters(data);
|
||||
const locale = model.getters.getLocale();
|
||||
for (const filter of data.globalFilters) {
|
||||
const content = model.getters.getFilterDisplayValue(filter.label);
|
||||
filter["value"] = content
|
||||
.flat()
|
||||
.filter(isDefined)
|
||||
.map(({ value, format }) => formatValue(value, { format, locale }))
|
||||
.filter((formattedValue) => formattedValue !== "")
|
||||
.join(", ");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* copy-pasted from o-spreadsheet. Should be exported
|
||||
* Get the id of the given item (its key in the given dictionnary).
|
||||
* If the given item does not exist in the dictionary, it creates one with a new id.
|
||||
*/
|
||||
export function getItemId(item, itemsDic) {
|
||||
for (const [key, value] of Object.entries(itemsDic)) {
|
||||
if (value === item) {
|
||||
return parseInt(key, 10);
|
||||
}
|
||||
}
|
||||
|
||||
// Generate new Id if the item didn't exist in the dictionary
|
||||
const ids = Object.keys(itemsDic);
|
||||
const maxId = ids.length === 0 ? 0 : Math.max(...ids.map((id) => parseInt(id, 10)));
|
||||
|
||||
itemsDic[maxId + 1] = item;
|
||||
return maxId + 1;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {string | undefined} content
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function containsOdooFunction(content) {
|
||||
if (
|
||||
!content ||
|
||||
!content.startsWith("=") ||
|
||||
(!content.toUpperCase().includes("ODOO.") &&
|
||||
!content.toUpperCase().includes("_T") &&
|
||||
!content.toUpperCase().includes("PIVOT"))
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const ast = parse(content);
|
||||
return iterateAstNodes(ast).some(
|
||||
(ast) =>
|
||||
ast.type === "FUNCALL" &&
|
||||
(ast.value.toUpperCase().startsWith("ODOO.") ||
|
||||
ast.value.toUpperCase().startsWith("_T") ||
|
||||
ast.value.toUpperCase().startsWith("PIVOT"))
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {OdooSpreadsheetModel} model
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isLoaded(model) {
|
||||
for (const sheetId of model.getters.getSheetIds()) {
|
||||
for (const cell of Object.values(model.getters.getEvaluatedCells(sheetId))) {
|
||||
if (cell.type === "error" && isLoadingError(cell)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the chart figure as a base64 image.
|
||||
* "data:image/png;base64,iVBORw0KGg..."
|
||||
* @param {OdooSpreadsheetModel} model
|
||||
* @param {object} figure
|
||||
* @param {string} chartId
|
||||
* @returns {string}
|
||||
*/
|
||||
function odooChartToImage(model, figure, chartId) {
|
||||
const runtime = model.getters.getChartRuntime(chartId);
|
||||
// wrap the canvas in a div with a fixed size because chart.js would
|
||||
// fill the whole page otherwise
|
||||
const div = document.createElement("div");
|
||||
div.style.width = `${figure.width}px`;
|
||||
div.style.height = `${figure.height}px`;
|
||||
const canvas = document.createElement("canvas");
|
||||
div.append(canvas);
|
||||
canvas.setAttribute("width", figure.width);
|
||||
canvas.setAttribute("height", figure.height);
|
||||
// we have to add the canvas to the DOM otherwise it won't be rendered
|
||||
document.body.append(div);
|
||||
if (!("chartJsConfig" in runtime)) {
|
||||
return "";
|
||||
}
|
||||
runtime.chartJsConfig.plugins = [backgroundColorPlugin];
|
||||
// @ts-ignore
|
||||
const chart = new Chart(canvas, runtime.chartJsConfig);
|
||||
const img = chart.toBase64Image();
|
||||
chart.destroy();
|
||||
div.remove();
|
||||
return img;
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom chart.js plugin to set the background color of the canvas
|
||||
* https://github.com/chartjs/Chart.js/blob/8fdf76f8f02d31684d34704341a5d9217e977491/docs/configuration/canvas-background.md
|
||||
*/
|
||||
const backgroundColorPlugin = {
|
||||
id: "customCanvasBackgroundColor",
|
||||
beforeDraw: (chart, args, options) => {
|
||||
const { ctx } = chart;
|
||||
ctx.save();
|
||||
ctx.globalCompositeOperation = "destination-over";
|
||||
ctx.fillStyle = "#ffffff";
|
||||
ctx.fillRect(0, 0, chart.width, chart.height);
|
||||
ctx.restore();
|
||||
},
|
||||
};
|
||||
|
|
@ -1,70 +1,15 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import spreadsheet from "../o_spreadsheet/o_spreadsheet_extended";
|
||||
|
||||
const { parse } = spreadsheet;
|
||||
// @ts-check
|
||||
|
||||
/**
|
||||
* @typedef {Object} OdooFunctionDescription
|
||||
* @property {string} functionName Name of the function
|
||||
* @property {Array<string>} args Arguments of the function
|
||||
* @property {boolean} isMatched True if the function is matched by the matcher function
|
||||
* Extract the data source id (always the first argument) from the function
|
||||
* context of the given token.
|
||||
* @param {import("@odoo/o-spreadsheet").EnrichedToken} tokenAtCursor
|
||||
* @returns {string | undefined}
|
||||
*/
|
||||
|
||||
/**
|
||||
* This function is used to search for the functions which match the given matcher
|
||||
* from the given formula
|
||||
*
|
||||
* @param {string} formula
|
||||
* @param {string[]} functionNames e.g. ["ODOO.LIST", "ODOO.LIST.HEADER"]
|
||||
* @private
|
||||
* @returns {Array<OdooFunctionDescription>}
|
||||
*/
|
||||
export function getOdooFunctions(formula, functionNames) {
|
||||
const formulaUpperCased = formula.toUpperCase();
|
||||
// Parsing is an expensive operation, so we first check if the
|
||||
// formula contains one of the function names
|
||||
if (!functionNames.some((fn) => formulaUpperCased.includes(fn.toUpperCase()))) {
|
||||
return [];
|
||||
}
|
||||
let ast;
|
||||
try {
|
||||
ast = parse(formula);
|
||||
} catch (_) {
|
||||
return [];
|
||||
}
|
||||
return _getOdooFunctionsFromAST(ast, functionNames);
|
||||
}
|
||||
|
||||
/**
|
||||
* This function is used to search for the functions which match the given matcher
|
||||
* from the given AST
|
||||
*
|
||||
* @param {Object} ast (see o-spreadsheet)
|
||||
* @param {string[]} functionNames e.g. ["ODOO.LIST", "ODOO.LIST.HEADER"]
|
||||
*
|
||||
* @private
|
||||
* @returns {Array<OdooFunctionDescription>}
|
||||
*/
|
||||
function _getOdooFunctionsFromAST(ast, functionNames) {
|
||||
switch (ast.type) {
|
||||
case "UNARY_OPERATION":
|
||||
return _getOdooFunctionsFromAST(ast.operand, functionNames);
|
||||
case "BIN_OPERATION": {
|
||||
return _getOdooFunctionsFromAST(ast.left, functionNames).concat(
|
||||
_getOdooFunctionsFromAST(ast.right, functionNames)
|
||||
);
|
||||
}
|
||||
case "FUNCALL": {
|
||||
const functionName = ast.value.toUpperCase();
|
||||
|
||||
if (functionNames.includes(functionName)) {
|
||||
return [{ functionName, args: ast.args, isMatched: true }];
|
||||
} else {
|
||||
return ast.args.map((arg) => _getOdooFunctionsFromAST(arg, functionNames)).flat();
|
||||
}
|
||||
}
|
||||
default:
|
||||
return [];
|
||||
export function extractDataSourceId(tokenAtCursor) {
|
||||
const idAst = tokenAtCursor.functionContext?.args[0];
|
||||
if (!idAst || !["STRING", "NUMBER"].includes(idAst.type)) {
|
||||
return;
|
||||
}
|
||||
return idAst.value;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,187 @@
|
|||
import { _t } from "@web/core/l10n/translation";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
|
||||
import { ConfirmationDialog } from "@web/core/confirmation_dialog/confirmation_dialog";
|
||||
|
||||
import { stores } from "@odoo/o-spreadsheet";
|
||||
import { useEffect, useEnv, useExternalListener, useState } from "@odoo/owl";
|
||||
|
||||
import { loadBundle } from "@web/core/assets";
|
||||
|
||||
const { useStore, useStoreProvider, NotificationStore, GridRenderer } = stores;
|
||||
/**
|
||||
* Hook that will capture the 'Ctrl+p' press that corresponds to the user intent to print a spreadsheet.
|
||||
* It will prepare the spreadsheet for printing by:
|
||||
* - displaying it in dashboard mode.
|
||||
* - altering the spreadsheet dimensions to ensure we render the whole sheet.
|
||||
* The hook will also restore the spreadsheet dimensions to their original state after the print.
|
||||
*
|
||||
* The hook will return the print preparation function to be called manually in other contexts than pressing
|
||||
* the common keybind (through a menu for instance).
|
||||
*
|
||||
* @param {() => Model | undefined} model
|
||||
* @returns {() => Promise<void>} preparePrint
|
||||
*/
|
||||
export function useSpreadsheetPrint(model) {
|
||||
let frozenPrintState = undefined;
|
||||
const printState = useState({ active: false });
|
||||
const env = useEnv();
|
||||
|
||||
useExternalListener(
|
||||
window,
|
||||
"keydown",
|
||||
async (ev) => {
|
||||
const isMeta = ev.metaKey || ev.ctrlKey;
|
||||
if (ev.key === "p" && isMeta) {
|
||||
if (!model()) {
|
||||
return;
|
||||
}
|
||||
ev.preventDefault();
|
||||
ev.stopImmediatePropagation();
|
||||
await preparePrint();
|
||||
}
|
||||
},
|
||||
{ capture: true }
|
||||
);
|
||||
useExternalListener(window, "afterprint", afterPrint);
|
||||
|
||||
useEffect(
|
||||
() => {
|
||||
if (printState.active) {
|
||||
window.print();
|
||||
}
|
||||
},
|
||||
() => [printState.active]
|
||||
);
|
||||
|
||||
/**
|
||||
* Returns the DOM position & dimensions such that the whole spreadsheet content is visible.
|
||||
* @returns {Rect}
|
||||
*/
|
||||
function getPrintRect() {
|
||||
const sheetId = model().getters.getActiveSheetId();
|
||||
const { bottom, right } = model().getters.getSheetZone(sheetId);
|
||||
const { end: width } = model().getters.getColDimensions(sheetId, right);
|
||||
const { end: height } = model().getters.getRowDimensions(sheetId, bottom);
|
||||
return { x: 0, y: 0, width, height };
|
||||
}
|
||||
|
||||
/**
|
||||
* Will alter the spreadsheet dimensions to ensure we render the whole sheet.
|
||||
* invoking this function will ultimately trigger a print of the page after a patch.
|
||||
*/
|
||||
async function preparePrint() {
|
||||
if (!model()) {
|
||||
return;
|
||||
}
|
||||
await loadBundle("spreadsheet.assets_print");
|
||||
const { width, height } = model().getters.getSheetViewDimension();
|
||||
const { width: widthAndHeader, height: heightAndHeader } =
|
||||
model().getters.getSheetViewDimension();
|
||||
const viewRect = {
|
||||
x: widthAndHeader - width,
|
||||
y: heightAndHeader - height,
|
||||
width,
|
||||
height,
|
||||
};
|
||||
frozenPrintState = {
|
||||
viewRect,
|
||||
offset: model().getters.getActiveSheetScrollInfo(),
|
||||
mode: model().config.mode,
|
||||
};
|
||||
const startPrinting = () => {
|
||||
// reset the viewport to A1 visibility
|
||||
model().dispatch("SET_VIEWPORT_OFFSET", { offsetX: 0, offsetY: 0 });
|
||||
model().dispatch("RESIZE_SHEETVIEW", { ...getPrintRect() });
|
||||
printState.active = true;
|
||||
};
|
||||
if (model().getters.isDashboard()) {
|
||||
startPrinting();
|
||||
return;
|
||||
}
|
||||
// FIXME: updateMode is not meant fore production use,
|
||||
// we should render a specific component with limited interface instead
|
||||
model().updateMode("dashboard");
|
||||
// loaded here as the store provider might be empty (no Model store) when the hook is used
|
||||
const gridRendererStore = env.getStore(GridRenderer);
|
||||
const intervalId = setInterval(() => {
|
||||
if (!gridRendererStore.animations.size) {
|
||||
clearInterval(intervalId);
|
||||
startPrinting();
|
||||
}
|
||||
}, 50);
|
||||
}
|
||||
|
||||
function afterPrint() {
|
||||
if (!model()) {
|
||||
return;
|
||||
}
|
||||
if (frozenPrintState) {
|
||||
model().dispatch("RESIZE_SHEETVIEW", frozenPrintState.viewRect);
|
||||
const { scrollX: offsetX, scrollY: offsetY } = frozenPrintState.offset;
|
||||
model().dispatch("SET_VIEWPORT_OFFSET", { offsetX, offsetY });
|
||||
model().updateMode(frozenPrintState.mode);
|
||||
frozenPrintState = undefined;
|
||||
}
|
||||
printState.active = false;
|
||||
}
|
||||
|
||||
return preparePrint;
|
||||
}
|
||||
|
||||
export function useSpreadsheetNotificationStore() {
|
||||
/**
|
||||
* Open a dialog to ask a confirmation to the user.
|
||||
*
|
||||
* @param {string} body body content to display
|
||||
* @param {Function} confirm Callback if the user press 'Confirm'
|
||||
*/
|
||||
function askConfirmation(body, confirm) {
|
||||
dialog.add(ConfirmationDialog, {
|
||||
title: _t("Odoo Spreadsheet"),
|
||||
body,
|
||||
confirm,
|
||||
cancel: () => {}, // Must be defined to display the Cancel button
|
||||
confirmLabel: _t("Confirm"),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a notification to display to the user
|
||||
* @param {{text: string, type: string, sticky: boolean }} notification
|
||||
*/
|
||||
function notifyUser(notification) {
|
||||
notifications.add(notification.text, {
|
||||
type: notification.type,
|
||||
sticky: notification.sticky,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a dialog to display an error message to the user.
|
||||
*
|
||||
* @param {string} body Content to display
|
||||
* @param {function} callBack Callback function to be executed when the dialog is closed
|
||||
*/
|
||||
function raiseError(body, callBack) {
|
||||
dialog.add(
|
||||
ConfirmationDialog,
|
||||
{
|
||||
title: _t("Odoo Spreadsheet"),
|
||||
body,
|
||||
},
|
||||
{
|
||||
onClose: callBack,
|
||||
}
|
||||
);
|
||||
}
|
||||
const dialog = useService("dialog");
|
||||
const notifications = useService("notification");
|
||||
useStoreProvider();
|
||||
const notificationStore = useStore(NotificationStore);
|
||||
notificationStore.updateNotificationCallbacks({
|
||||
notifyUser: notifyUser,
|
||||
raiseError: raiseError,
|
||||
askConfirmation: askConfirmation,
|
||||
});
|
||||
}
|
||||
|
|
@ -1,5 +1,3 @@
|
|||
/** @odoo-module */
|
||||
|
||||
/**
|
||||
* This file is meant to load the different subparts of the module
|
||||
* to guarantee their plugins are loaded in the right order
|
||||
|
|
@ -16,25 +14,113 @@
|
|||
*/
|
||||
|
||||
/** TODO: Introduce a position parameter to the plugin registry in order to load them in a specific order */
|
||||
import spreadsheet from "@spreadsheet/o_spreadsheet/o_spreadsheet_extended";
|
||||
const { corePluginRegistry, uiPluginRegistry } = spreadsheet.registries;
|
||||
import * as spreadsheet from "@odoo/o-spreadsheet";
|
||||
import { _t } from "@web/core/l10n/translation";
|
||||
|
||||
import { GlobalFiltersCorePlugin, GlobalFiltersUIPlugin } from "@spreadsheet/global_filters/index";
|
||||
import { PivotCorePlugin, PivotUIPlugin } from "@spreadsheet/pivot/index"; // list depends on filter for its getters
|
||||
import { ListCorePlugin, ListUIPlugin } from "@spreadsheet/list/index"; // pivot depends on filter for its getters
|
||||
const { corePluginRegistry, coreViewsPluginRegistry, featurePluginRegistry } =
|
||||
spreadsheet.registries;
|
||||
|
||||
import {
|
||||
GlobalFiltersCorePlugin,
|
||||
GlobalFiltersUIPlugin,
|
||||
GlobalFiltersCoreViewPlugin,
|
||||
} from "@spreadsheet/global_filters/index";
|
||||
import {
|
||||
PivotOdooCorePlugin,
|
||||
PivotCoreViewGlobalFilterPlugin,
|
||||
PivotUIGlobalFilterPlugin,
|
||||
} from "@spreadsheet/pivot/index"; // list depends on filter for its getters
|
||||
import { ListCorePlugin, ListCoreViewPlugin, ListUIPlugin } from "@spreadsheet/list/index"; // pivot depends on filter for its getters
|
||||
import {
|
||||
ChartOdooMenuPlugin,
|
||||
OdooChartCorePlugin,
|
||||
OdooChartUIPlugin,
|
||||
OdooChartCoreViewPlugin,
|
||||
} from "@spreadsheet/chart/index"; // Odoochart depends on filter for its getters
|
||||
import { PivotCoreGlobalFilterPlugin } from "./pivot/plugins/pivot_core_global_filter_plugin";
|
||||
import { PivotOdooUIPlugin } from "./pivot/plugins/pivot_odoo_ui_plugin";
|
||||
import { ListCoreGlobalFilterPlugin } from "./list/plugins/list_core_global_filter_plugin";
|
||||
import { globalFieldMatchingRegistry } from "./global_filters/helpers";
|
||||
import { OdooChartFeaturePlugin } from "./chart/plugins/odoo_chart_feature_plugin";
|
||||
import { LoggingUIPlugin } from "@spreadsheet/logging/logging_ui_plugin";
|
||||
|
||||
globalFieldMatchingRegistry.add("pivot", {
|
||||
getIds: (getters) =>
|
||||
getters
|
||||
.getPivotIds()
|
||||
.filter(
|
||||
(id) =>
|
||||
getters.getPivotCoreDefinition(id).type === "ODOO" &&
|
||||
getters.getPivotFieldMatch(id)
|
||||
),
|
||||
getDisplayName: (getters, pivotId) => getters.getPivotName(pivotId),
|
||||
getTag: (getters, pivotId) =>
|
||||
_t("Pivot #%(pivot_id)s", { pivot_id: getters.getPivotFormulaId(pivotId) }),
|
||||
getFieldMatching: (getters, pivotId, filterId) =>
|
||||
getters.getPivotFieldMatching(pivotId, filterId),
|
||||
getModel: (getters, pivotId) => {
|
||||
const pivot = getters.getPivotCoreDefinition(pivotId);
|
||||
return pivot.type === "ODOO" && pivot.model;
|
||||
},
|
||||
waitForReady: (getters) =>
|
||||
getters
|
||||
.getPivotIds()
|
||||
.map((pivotId) => getters.getPivot(pivotId))
|
||||
.filter((pivot) => pivot.type === "ODOO")
|
||||
.map((pivot) => pivot.loadMetadata()),
|
||||
getFields: (getters, pivotId) => getters.getPivot(pivotId).getFields(),
|
||||
getActionXmlId: (getters, pivotId) => getters.getPivotCoreDefinition(pivotId).actionXmlId,
|
||||
});
|
||||
|
||||
globalFieldMatchingRegistry.add("list", {
|
||||
getIds: (getters) => getters.getListIds().filter((id) => getters.getListFieldMatch(id)),
|
||||
getDisplayName: (getters, listId) => getters.getListName(listId),
|
||||
getTag: (getters, listId) => _t(`List #%(list_id)s`, { list_id: listId }),
|
||||
getFieldMatching: (getters, listId, filterId) => getters.getListFieldMatching(listId, filterId),
|
||||
getModel: (getters, listId) => getters.getListDefinition(listId).model,
|
||||
waitForReady: (getters) =>
|
||||
getters.getListIds().map((listId) => getters.getListDataSource(listId).loadMetadata()),
|
||||
getFields: (getters, listId) => getters.getListDataSource(listId).getFields(),
|
||||
getActionXmlId: (getters, listId) => getters.getListDefinition(listId).actionXmlId,
|
||||
});
|
||||
|
||||
globalFieldMatchingRegistry.add("chart", {
|
||||
getIds: (getters) => getters.getOdooChartIds(),
|
||||
getDisplayName: (getters, chartId) => getters.getOdooChartDisplayName(chartId),
|
||||
getFieldMatching: (getters, chartId, filterId) =>
|
||||
getters.getOdooChartFieldMatching(chartId, filterId),
|
||||
getModel: (getters, chartId) =>
|
||||
getters.getChart(chartId).getDefinitionForDataSource().metaData.resModel,
|
||||
getTag: async (getters, chartId) => {
|
||||
const chartModel = await getters.getChartDataSource(chartId).getModelLabel();
|
||||
return _t("Chart - %(chart_model)s", { chart_model: chartModel });
|
||||
},
|
||||
waitForReady: (getters) =>
|
||||
getters
|
||||
.getOdooChartIds()
|
||||
.map((chartId) => getters.getChartDataSource(chartId).loadMetadata()),
|
||||
getFields: (getters, chartId) => getters.getChartDataSource(chartId).getFields(),
|
||||
getActionXmlId: (getters, chartId) => getters.getChartDefinition(chartId).actionXmlId,
|
||||
});
|
||||
|
||||
corePluginRegistry.add("OdooGlobalFiltersCorePlugin", GlobalFiltersCorePlugin);
|
||||
corePluginRegistry.add("OdooPivotCorePlugin", PivotCorePlugin);
|
||||
corePluginRegistry.add("PivotOdooCorePlugin", PivotOdooCorePlugin);
|
||||
corePluginRegistry.add("OdooPivotGlobalFiltersCorePlugin", PivotCoreGlobalFilterPlugin);
|
||||
corePluginRegistry.add("OdooListCorePlugin", ListCorePlugin);
|
||||
corePluginRegistry.add("OdooListCoreGlobalFilterPlugin", ListCoreGlobalFilterPlugin);
|
||||
corePluginRegistry.add("odooChartCorePlugin", OdooChartCorePlugin);
|
||||
corePluginRegistry.add("chartOdooMenuPlugin", ChartOdooMenuPlugin);
|
||||
|
||||
uiPluginRegistry.add("OdooGlobalFiltersUIPlugin", GlobalFiltersUIPlugin);
|
||||
uiPluginRegistry.add("OdooPivotUIPlugin", PivotUIPlugin);
|
||||
uiPluginRegistry.add("OdooListUIPlugin", ListUIPlugin);
|
||||
uiPluginRegistry.add("odooChartUIPlugin", OdooChartUIPlugin);
|
||||
coreViewsPluginRegistry.add("OdooGlobalFiltersCoreViewPlugin", GlobalFiltersCoreViewPlugin);
|
||||
coreViewsPluginRegistry.add(
|
||||
"OdooPivotGlobalFiltersCoreViewPlugin",
|
||||
PivotCoreViewGlobalFilterPlugin
|
||||
);
|
||||
coreViewsPluginRegistry.add("OdooListCoreViewPlugin", ListCoreViewPlugin);
|
||||
coreViewsPluginRegistry.add("OdooChartCoreViewPlugin", OdooChartCoreViewPlugin);
|
||||
coreViewsPluginRegistry.add("OdooLoggingUIPlugin", LoggingUIPlugin);
|
||||
|
||||
featurePluginRegistry.add("OdooPivotGlobalFilterUIPlugin", PivotUIGlobalFilterPlugin);
|
||||
featurePluginRegistry.add("OdooGlobalFiltersUIPlugin", GlobalFiltersUIPlugin);
|
||||
featurePluginRegistry.add("odooPivotUIPlugin", PivotOdooUIPlugin);
|
||||
featurePluginRegistry.add("odooListUIPlugin", ListUIPlugin);
|
||||
featurePluginRegistry.add("OdooChartFeaturePlugin", OdooChartFeaturePlugin);
|
||||
|
|
|
|||
|
|
@ -1,68 +1,130 @@
|
|||
/** @odoo-module */
|
||||
|
||||
import { registry } from "@web/core/registry";
|
||||
import spreadsheet from "@spreadsheet/o_spreadsheet/o_spreadsheet_extended";
|
||||
import * as spreadsheet from "@odoo/o-spreadsheet";
|
||||
|
||||
import IrMenuPlugin from "./ir_ui_menu_plugin";
|
||||
import { IrMenuPlugin } from "./ir_ui_menu_plugin";
|
||||
|
||||
import {
|
||||
isMarkdownIrMenuIdLink,
|
||||
isMarkdownIrMenuXmlLink,
|
||||
isMarkdownViewLink,
|
||||
parseIrMenuXmlLink,
|
||||
OdooViewLinkCell,
|
||||
OdooMenuLinkCell,
|
||||
isMarkdownIrMenuIdUrl,
|
||||
isIrMenuXmlUrl,
|
||||
isMarkdownViewUrl,
|
||||
parseIrMenuXmlUrl,
|
||||
parseViewLink,
|
||||
parseIrMenuIdLink,
|
||||
} from "./odoo_menu_link_cell";
|
||||
import { _t } from "@web/core/l10n/translation";
|
||||
import { navigateTo } from "../actions/helpers";
|
||||
|
||||
const { cellRegistry, corePluginRegistry } = spreadsheet.registries;
|
||||
const { parseMarkdownLink } = spreadsheet.helpers;
|
||||
const { urlRegistry, corePluginRegistry, errorTypes } = spreadsheet.registries;
|
||||
const { EvaluationError } = spreadsheet;
|
||||
|
||||
corePluginRegistry.add("ir_ui_menu_plugin", IrMenuPlugin);
|
||||
|
||||
const LINK_ERROR = "#LINK";
|
||||
errorTypes.add(LINK_ERROR);
|
||||
|
||||
class BadOdooLinkError extends EvaluationError {
|
||||
constructor(menuId) {
|
||||
super(
|
||||
_t("Menu %s not found. You may not have the required access rights.", menuId),
|
||||
LINK_ERROR
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const spreadsheetLinkMenuCellService = {
|
||||
dependencies: ["menu"],
|
||||
start(env) {
|
||||
function _getIrMenuByXmlId(xmlId) {
|
||||
const menu = env.services.menu.getAll().find((menu) => menu.xmlid === xmlId);
|
||||
if (!menu) {
|
||||
throw new Error(
|
||||
`Menu ${xmlId} not found. You may not have the required access rights.`
|
||||
);
|
||||
throw new BadOdooLinkError(xmlId);
|
||||
}
|
||||
return menu;
|
||||
}
|
||||
|
||||
cellRegistry
|
||||
.add("OdooMenuIdLink", {
|
||||
urlRegistry
|
||||
.replace("OdooMenuIdLink", {
|
||||
sequence: 65,
|
||||
match: isMarkdownIrMenuIdLink,
|
||||
createCell: (id, content, properties, sheetId, getters) => {
|
||||
const { url } = parseMarkdownLink(content);
|
||||
match: isMarkdownIrMenuIdUrl,
|
||||
createLink(url, label) {
|
||||
const menuId = parseIrMenuIdLink(url);
|
||||
const menuName = env.services.menu.getMenu(menuId).name;
|
||||
return new OdooMenuLinkCell(id, content, menuId, menuName, properties);
|
||||
const menu = env.services.menu.getMenu(menuId);
|
||||
if (!menu) {
|
||||
throw new BadOdooLinkError(menuId);
|
||||
}
|
||||
return {
|
||||
url,
|
||||
label,
|
||||
isExternal: false,
|
||||
isUrlEditable: false,
|
||||
};
|
||||
},
|
||||
urlRepresentation(url) {
|
||||
const menuId = parseIrMenuIdLink(url);
|
||||
return env.services.menu.getMenu(menuId).name;
|
||||
},
|
||||
open(url, env, newWindow) {
|
||||
const menuId = parseIrMenuIdLink(url);
|
||||
const menu = env.services.menu.getMenu(menuId);
|
||||
env.services.action.doAction(menu.actionID, { newWindow });
|
||||
},
|
||||
})
|
||||
.add("OdooMenuXmlLink", {
|
||||
.replace("OdooMenuXmlLink", {
|
||||
sequence: 66,
|
||||
match: isMarkdownIrMenuXmlLink,
|
||||
createCell: (id, content, properties, sheetId, getters) => {
|
||||
const { url } = parseMarkdownLink(content);
|
||||
const xmlId = parseIrMenuXmlLink(url);
|
||||
match: isIrMenuXmlUrl,
|
||||
createLink(url, label) {
|
||||
const xmlId = parseIrMenuXmlUrl(url);
|
||||
_getIrMenuByXmlId(xmlId); // Validate the XML ID exists
|
||||
return {
|
||||
url,
|
||||
label,
|
||||
isExternal: false,
|
||||
isUrlEditable: false,
|
||||
};
|
||||
},
|
||||
urlRepresentation(url) {
|
||||
const xmlId = parseIrMenuXmlUrl(url);
|
||||
const menuId = _getIrMenuByXmlId(xmlId).id;
|
||||
const menuName = _getIrMenuByXmlId(xmlId).name;
|
||||
return new OdooMenuLinkCell(id, content, menuId, menuName, properties);
|
||||
return env.services.menu.getMenu(menuId).name;
|
||||
},
|
||||
open(url, env, newWindow) {
|
||||
const xmlId = parseIrMenuXmlUrl(url);
|
||||
const menuId = _getIrMenuByXmlId(xmlId).id;
|
||||
const menu = env.services.menu.getMenu(menuId);
|
||||
env.services.action.doAction(menu.actionID, { newWindow });
|
||||
},
|
||||
})
|
||||
.add("OdooIrFilterLink", {
|
||||
.replace("OdooViewLink", {
|
||||
sequence: 67,
|
||||
match: isMarkdownViewLink,
|
||||
createCell: (id, content, properties, sheetId, getters) => {
|
||||
const { url } = parseMarkdownLink(content);
|
||||
match: isMarkdownViewUrl,
|
||||
createLink(url, label) {
|
||||
return {
|
||||
url,
|
||||
label,
|
||||
isExternal: false,
|
||||
isUrlEditable: false,
|
||||
};
|
||||
},
|
||||
urlRepresentation(url) {
|
||||
const actionDescription = parseViewLink(url);
|
||||
return new OdooViewLinkCell(id, content, actionDescription, properties);
|
||||
return actionDescription.name;
|
||||
},
|
||||
async open(url, env, newWindow) {
|
||||
const { viewType, action, name } = parseViewLink(url);
|
||||
await navigateTo(
|
||||
env,
|
||||
action.xmlId,
|
||||
{
|
||||
type: "ir.actions.act_window",
|
||||
name: name,
|
||||
res_model: action.modelName,
|
||||
views: action.views,
|
||||
target: "current",
|
||||
domain: action.domain,
|
||||
context: action.context,
|
||||
},
|
||||
{ viewType, newWindow }
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue