7 changed files with 10 additions and 1441 deletions
@ -1,32 +1,10 @@ |
|||
/** @odoo-module **/ |
|||
import { COLORS } from "@web/views/graph/colors"; |
|||
|
|||
export const COLORS = ["#556ee6", "#f1b44c", "#50a5f1", "#ffbb78", "#34c38f", "#98df8a", "#d62728", |
|||
var code_backend_color = ["#556ee6", "#f1b44c", "#50a5f1", "#ffbb78", "#34c38f", "#98df8a", "#d62728", |
|||
"#ff9896", "#9467bd", "#c5b0d5", "#8c564b", "#c49c94", "#e377c2", "#f7b6d2", |
|||
"#7f7f7f", "#c7c7c7", "#bcbd22", "#dbdb8d", "#17becf", "#9edae5"]; |
|||
|
|||
/** |
|||
* @param {number} index |
|||
* @returns {string} |
|||
*/ |
|||
export function getColor(index) { |
|||
return COLORS[index % COLORS.length]; |
|||
} |
|||
|
|||
export const DEFAULT_BG = "#d3d3d3"; |
|||
|
|||
export const BORDER_WHITE = "rgba(255,255,255,0.6)"; |
|||
|
|||
const RGB_REGEX = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i; |
|||
|
|||
/** |
|||
* @param {string} hex |
|||
* @param {number} opacity |
|||
* @returns {string} |
|||
*/ |
|||
export function hexToRGBA(hex, opacity) { |
|||
const rgb = RGB_REGEX.exec(hex) |
|||
.slice(1, 4) |
|||
.map((n) => parseInt(n, 16)) |
|||
.join(","); |
|||
return `rgba(${rgb},${opacity})`; |
|||
for (let i=0;i<=code_backend_color.length;i++){ |
|||
COLORS[i] = code_backend_color[i] |
|||
} |
|||
|
@ -1,82 +0,0 @@ |
|||
/** @odoo-module **/ |
|||
|
|||
import { evaluateExpr } from "@web/core/py_js/py"; |
|||
import { GROUPABLE_TYPES } from "@web/search/utils/misc"; |
|||
import { XMLParser } from "@web/core/utils/xml"; |
|||
import { archParseBoolean } from "@web/views/helpers/utils"; |
|||
|
|||
const MODES = ["bar", "line", "pie"]; |
|||
const ORDERS = ["ASC", "DESC", null]; |
|||
|
|||
export class GraphArchParser extends XMLParser { |
|||
parse(arch, fields = {}) { |
|||
const archInfo = { fields, fieldAttrs: {}, groupBy: [] }; |
|||
this.visitXML(arch, (node) => { |
|||
switch (node.tagName) { |
|||
case "graph": { |
|||
if (node.hasAttribute("disable_linking")) { |
|||
archInfo.disableLinking = archParseBoolean( |
|||
node.getAttribute("disable_linking") |
|||
); |
|||
} |
|||
if (node.hasAttribute("stacked")) { |
|||
archInfo.stacked = archParseBoolean(node.getAttribute("stacked")); |
|||
} |
|||
const mode = node.getAttribute("type"); |
|||
if (mode && MODES.includes(mode)) { |
|||
archInfo.mode = mode; |
|||
} |
|||
const order = node.getAttribute("order"); |
|||
if (order && ORDERS.includes(order)) { |
|||
archInfo.order = order; |
|||
} |
|||
const title = node.getAttribute("string"); |
|||
if (title) { |
|||
archInfo.title = title; |
|||
} |
|||
break; |
|||
} |
|||
case "field": { |
|||
let fieldName = node.getAttribute("name"); // exists (rng validation)
|
|||
if (fieldName === "id") { |
|||
break; |
|||
} |
|||
const string = node.getAttribute("string"); |
|||
if (string) { |
|||
if (!archInfo.fieldAttrs[fieldName]) { |
|||
archInfo.fieldAttrs[fieldName] = {}; |
|||
} |
|||
archInfo.fieldAttrs[fieldName].string = string; |
|||
} |
|||
const isInvisible = Boolean( |
|||
evaluateExpr(node.getAttribute("invisible") || "0") |
|||
); |
|||
if (isInvisible) { |
|||
if (!archInfo.fieldAttrs[fieldName]) { |
|||
archInfo.fieldAttrs[fieldName] = {}; |
|||
} |
|||
archInfo.fieldAttrs[fieldName].isInvisible = true; |
|||
break; |
|||
} |
|||
const isMeasure = node.getAttribute("type") === "measure"; |
|||
if (isMeasure) { |
|||
// the last field with type="measure" (if any) will be used as measure else __count
|
|||
archInfo.measure = fieldName; |
|||
} else { |
|||
const { type } = archInfo.fields[fieldName]; // exists (rng validation)
|
|||
if (GROUPABLE_TYPES.includes(type)) { |
|||
let groupBy = fieldName; |
|||
const interval = node.getAttribute("interval"); |
|||
if (interval) { |
|||
groupBy += `:${interval}`; |
|||
} |
|||
archInfo.groupBy.push(groupBy); |
|||
} |
|||
} |
|||
break; |
|||
} |
|||
} |
|||
}); |
|||
return archInfo; |
|||
} |
|||
} |
@ -1,537 +0,0 @@ |
|||
/** @odoo-module **/ |
|||
|
|||
import { sortBy } from "@web/core/utils/arrays"; |
|||
import { KeepLast, Race } from "@web/core/utils/concurrency"; |
|||
import { rankInterval } from "@web/search/utils/dates"; |
|||
import { getGroupBy } from "@web/search/utils/group_by"; |
|||
import { GROUPABLE_TYPES } from "@web/search/utils/misc"; |
|||
import { Model } from "@web/views/helpers/model"; |
|||
import { computeReportMeasures, processMeasure } from "@web/views/helpers/utils"; |
|||
|
|||
export const SEP = " / "; |
|||
|
|||
/** |
|||
* @typedef {import("@web/search/search_model").SearchParams} SearchParams |
|||
*/ |
|||
|
|||
class DateClasses { |
|||
// We view the param "array" as a matrix of values and undefined.
|
|||
// An equivalence class is formed of defined values of a column.
|
|||
// So nothing has to do with dates but we only use Dateclasses to manage
|
|||
// identification of dates.
|
|||
/** |
|||
* @param {(any[])[]} array |
|||
*/ |
|||
constructor(array) { |
|||
this.__referenceIndex = null; |
|||
this.__array = array; |
|||
for (let i = 0; i < this.__array.length; i++) { |
|||
const arr = this.__array[i]; |
|||
if (arr.length && this.__referenceIndex === null) { |
|||
this.__referenceIndex = i; |
|||
} |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* @param {number} index |
|||
* @param {any} o |
|||
* @returns {string} |
|||
*/ |
|||
classLabel(index, o) { |
|||
return `${this.__array[index].indexOf(o)}`; |
|||
} |
|||
|
|||
/** |
|||
* @param {string} classLabel |
|||
* @returns {any[]} |
|||
*/ |
|||
classMembers(classLabel) { |
|||
const classNumber = Number(classLabel); |
|||
const classMembers = new Set(); |
|||
for (const arr of this.__array) { |
|||
if (arr[classNumber] !== undefined) { |
|||
classMembers.add(arr[classNumber]); |
|||
} |
|||
} |
|||
return [...classMembers]; |
|||
} |
|||
|
|||
/** |
|||
* @param {string} classLabel |
|||
* @param {number} [index] |
|||
* @returns {any} |
|||
*/ |
|||
representative(classLabel, index) { |
|||
const classNumber = Number(classLabel); |
|||
const i = index === undefined ? this.__referenceIndex : index; |
|||
if (i === null) { |
|||
return null; |
|||
} |
|||
return this.__array[i][classNumber]; |
|||
} |
|||
|
|||
/** |
|||
* @param {number} index |
|||
* @returns {number} |
|||
*/ |
|||
arrayLength(index) { |
|||
return this.__array[index].length; |
|||
} |
|||
} |
|||
|
|||
export class GraphModel extends Model { |
|||
/** |
|||
* @override |
|||
*/ |
|||
setup(params) { |
|||
// concurrency management
|
|||
this.keepLast = new KeepLast(); |
|||
this.race = new Race(); |
|||
const _fetchDataPoints = this._fetchDataPoints.bind(this); |
|||
this._fetchDataPoints = (...args) => { |
|||
return this.race.add(_fetchDataPoints(...args)); |
|||
}; |
|||
|
|||
this.initialGroupBy = null; |
|||
|
|||
this.metaData = params; |
|||
this.data = null; |
|||
this.searchParams = null; |
|||
} |
|||
|
|||
//--------------------------------------------------------------------------
|
|||
// Public
|
|||
//--------------------------------------------------------------------------
|
|||
|
|||
/** |
|||
* @param {SearchParams} searchParams |
|||
*/ |
|||
async load(searchParams) { |
|||
this.searchParams = searchParams; |
|||
if (!this.initialGroupBy) { |
|||
this.initialGroupBy = searchParams.context.graph_groupbys || this.metaData.groupBy; // = arch groupBy --> change that
|
|||
} |
|||
const metaData = this._buildMetaData(); |
|||
return this._fetchDataPoints(metaData); |
|||
} |
|||
|
|||
/** |
|||
* @override |
|||
*/ |
|||
hasData() { |
|||
return this.dataPoints.length > 0; |
|||
} |
|||
|
|||
/** |
|||
* Only supposed to be called to change one or several parameters among |
|||
* "measure", "mode", "order", and "stacked". |
|||
* @param {Object} params |
|||
*/ |
|||
async updateMetaData(params) { |
|||
if ("measure" in params) { |
|||
const metaData = this._buildMetaData(params); |
|||
await this._fetchDataPoints(metaData); |
|||
} else { |
|||
await this.race.getCurrentProm(); |
|||
this.metaData = Object.assign({}, this.metaData, params); |
|||
this._prepareData(); |
|||
} |
|||
this.notify(); |
|||
} |
|||
|
|||
//--------------------------------------------------------------------------
|
|||
// Protected
|
|||
//--------------------------------------------------------------------------
|
|||
|
|||
/** |
|||
* @protected |
|||
* @param {Object} [params={}] |
|||
* @returns {Object} |
|||
*/ |
|||
_buildMetaData(params) { |
|||
const { comparison, domain, context, groupBy } = this.searchParams; |
|||
|
|||
const metaData = Object.assign({}, this.metaData, { context }); |
|||
if (comparison) { |
|||
metaData.domains = comparison.domains; |
|||
metaData.comparisonField = comparison.fieldName; |
|||
} else { |
|||
metaData.domains = [{ arrayRepr: domain, description: null }]; |
|||
} |
|||
metaData.measure = context.graph_measure || metaData.measure; |
|||
metaData.mode = context.graph_mode || metaData.mode; |
|||
metaData.groupBy = groupBy.length ? groupBy : this.initialGroupBy; |
|||
|
|||
this._normalize(metaData); |
|||
|
|||
metaData.measures = computeReportMeasures( |
|||
metaData.fields, |
|||
metaData.fieldAttrs, |
|||
[metaData.measure], |
|||
metaData.additionalMeasures |
|||
); |
|||
|
|||
return Object.assign(metaData, params); |
|||
} |
|||
|
|||
/** |
|||
* Fetch the data points determined by the metaData. This function has |
|||
* several side effects. It can alter this.metaData and set this.dataPoints. |
|||
* @protected |
|||
* @param {Object} metaData |
|||
*/ |
|||
async _fetchDataPoints(metaData) { |
|||
this.dataPoints = await this.keepLast.add(this._loadDataPoints(metaData)); |
|||
this.metaData = metaData; |
|||
this._prepareData(); |
|||
} |
|||
|
|||
/** |
|||
* Separates dataPoints coming from the read_group(s) into different |
|||
* datasets. This function returns the parameters data and labels used |
|||
* to produce the charts. |
|||
* @protected |
|||
* @param {Object[]} |
|||
* @returns {Object} |
|||
*/ |
|||
_getData(dataPoints) { |
|||
const { comparisonField, groupBy, mode } = this.metaData; |
|||
|
|||
let identify = false; |
|||
if (comparisonField && groupBy.length && groupBy[0].fieldName === comparisonField) { |
|||
identify = true; |
|||
} |
|||
const dateClasses = identify ? this._getDateClasses(dataPoints) : null; |
|||
|
|||
// dataPoints --> labels
|
|||
let labels = []; |
|||
const labelMap = {}; |
|||
for (const dataPt of dataPoints) { |
|||
const x = dataPt.labels.slice(0, mode === "pie" ? undefined : 1); |
|||
const trueLabel = x.length ? x.join(SEP) : this.env._t("Total"); |
|||
if (dateClasses) { |
|||
x[0] = dateClasses.classLabel(dataPt.originIndex, x[0]); |
|||
} |
|||
const key = JSON.stringify(x); |
|||
if (labelMap[key] === undefined) { |
|||
labelMap[key] = labels.length; |
|||
if (dateClasses) { |
|||
if (mode === "pie") { |
|||
x[0] = dateClasses.classMembers(x[0]).join(", "); |
|||
} else { |
|||
x[0] = dateClasses.representative(x[0]); |
|||
} |
|||
} |
|||
const label = x.length ? x.join(SEP) : this.env._t("Total"); |
|||
labels.push(label); |
|||
} |
|||
dataPt.labelIndex = labelMap[key]; |
|||
dataPt.trueLabel = trueLabel; |
|||
} |
|||
|
|||
// dataPoints + labels --> datasetsTmp --> datasets
|
|||
const datasetsTmp = {}; |
|||
for (const dataPt of dataPoints) { |
|||
const { domain, labelIndex, originIndex, trueLabel, value } = dataPt; |
|||
const datasetLabel = this._getDatasetLabel(dataPt); |
|||
if (!(datasetLabel in datasetsTmp)) { |
|||
let dataLength = labels.length; |
|||
if (mode !== "pie" && dateClasses) { |
|||
dataLength = dateClasses.arrayLength(originIndex); |
|||
} |
|||
datasetsTmp[datasetLabel] = { |
|||
data: new Array(dataLength).fill(0), |
|||
trueLabels: labels.slice(0, dataLength), // should be good // check this in case identify = true
|
|||
domains: new Array(dataLength).fill([]), |
|||
label: datasetLabel, |
|||
originIndex: originIndex, |
|||
}; |
|||
} |
|||
datasetsTmp[datasetLabel].data[labelIndex] = value; |
|||
datasetsTmp[datasetLabel].domains[labelIndex] = domain; |
|||
datasetsTmp[datasetLabel].trueLabels[labelIndex] = trueLabel; |
|||
} |
|||
// sort by origin
|
|||
let datasets = sortBy(Object.values(datasetsTmp), "originIndex"); |
|||
|
|||
if (mode === "pie") { |
|||
// We kinda have a matrix. We remove the zero columns and rows. This is a global operation.
|
|||
// That's why it cannot be done before.
|
|||
datasets = datasets.filter((dataset) => dataset.data.some((v) => Boolean(v))); |
|||
const labelsToKeepIndexes = {}; |
|||
labels.forEach((_, index) => { |
|||
if (datasets.some((dataset) => Boolean(dataset.data[index]))) { |
|||
labelsToKeepIndexes[index] = true; |
|||
} |
|||
}); |
|||
labels = labels.filter((_, index) => labelsToKeepIndexes[index]); |
|||
for (const dataset of datasets) { |
|||
dataset.data = dataset.data.filter((_, index) => labelsToKeepIndexes[index]); |
|||
dataset.domains = dataset.domains.filter((_, index) => labelsToKeepIndexes[index]); |
|||
dataset.trueLabels = dataset.trueLabels.filter( |
|||
(_, index) => labelsToKeepIndexes[index] |
|||
); |
|||
} |
|||
} |
|||
|
|||
return { datasets, labels }; |
|||
} |
|||
|
|||
/** |
|||
* Determines the dataset to which the data point belongs. |
|||
* @protected |
|||
* @param {Object} dataPoint |
|||
* @returns {string} |
|||
*/ |
|||
_getDatasetLabel(dataPoint) { |
|||
const { measure, measures, domains, mode } = this.metaData; |
|||
const { labels, originIndex } = dataPoint; |
|||
if (mode === "pie") { |
|||
return domains[originIndex].description || ""; |
|||
} |
|||
// ([origin] + second to last groupBys) or measure
|
|||
let datasetLabel = labels.slice(1).join(SEP); |
|||
if (domains.length > 1) { |
|||
datasetLabel = |
|||
domains[originIndex].description + (datasetLabel ? SEP + datasetLabel : ""); |
|||
} |
|||
datasetLabel = datasetLabel || measures[measure].string; |
|||
return datasetLabel; |
|||
} |
|||
|
|||
/** |
|||
* @protected |
|||
* @param {Object[]} dataPoints |
|||
* @returns {DateClasses} |
|||
*/ |
|||
_getDateClasses(dataPoints) { |
|||
const { domains } = this.metaData; |
|||
const dateSets = domains.map(() => new Set()); |
|||
for (const { labels, originIndex } of dataPoints) { |
|||
const date = labels[0]; |
|||
dateSets[originIndex].add(date); |
|||
} |
|||
const arrays = dateSets.map((dateSet) => [...dateSet]); |
|||
return new DateClasses(arrays); |
|||
} |
|||
|
|||
/** |
|||
* Eventually filters and sort data points. |
|||
* @protected |
|||
* @returns {Object[]} |
|||
*/ |
|||
_getProcessedDataPoints() { |
|||
const { domains, groupBy, mode, order } = this.metaData; |
|||
let processedDataPoints = []; |
|||
if (mode === "line") { |
|||
processedDataPoints = this.dataPoints.filter( |
|||
(dataPoint) => dataPoint.labels[0] !== this.env._t("Undefined") |
|||
); |
|||
} else { |
|||
processedDataPoints = this.dataPoints.filter((dataPoint) => dataPoint.count !== 0); |
|||
} |
|||
|
|||
if (order !== null && mode !== "pie" && domains.length === 1 && groupBy.length > 0) { |
|||
// group data by their x-axis value, and then sort datapoints
|
|||
// based on the sum of values by group in ascending/descending order
|
|||
const groupedDataPoints = {}; |
|||
for (const dataPt of processedDataPoints) { |
|||
const key = dataPt.labels[0]; // = x-axis value under the current assumptions
|
|||
if (!groupedDataPoints[key]) { |
|||
groupedDataPoints[key] = []; |
|||
} |
|||
groupedDataPoints[key].push(dataPt); |
|||
} |
|||
const groups = Object.values(groupedDataPoints); |
|||
const groupTotal = (group) => group.reduce((sum, dataPt) => sum + dataPt.value, 0); |
|||
processedDataPoints = sortBy(groups, groupTotal, order.toLowerCase()).flat(); |
|||
} |
|||
|
|||
return processedDataPoints; |
|||
} |
|||
|
|||
/** |
|||
* Determines whether the set of data points is good. If not, this.data will be (re)set to null |
|||
* @protected |
|||
* @param {Object[]} |
|||
* @returns {boolean} |
|||
*/ |
|||
_isValidData(dataPoints) { |
|||
const { mode } = this.metaData; |
|||
let somePositive = false; |
|||
let someNegative = false; |
|||
if (mode === "pie") { |
|||
for (const dataPt of dataPoints) { |
|||
if (dataPt.value > 0) { |
|||
somePositive = true; |
|||
} else if (dataPt.value < 0) { |
|||
someNegative = true; |
|||
} |
|||
} |
|||
if (someNegative && somePositive) { |
|||
return false; |
|||
} |
|||
} |
|||
return true; |
|||
} |
|||
|
|||
/** |
|||
* Fetch and process graph data. It is basically a(some) read_group(s) |
|||
* with correct fields for each domain. We have to do some light processing |
|||
* to separate date groups in the field list, because they can be defined |
|||
* with an aggregation function, such as my_date:week. |
|||
* @protected |
|||
* @param {Object} metaData |
|||
* @returns {Object[]} |
|||
*/ |
|||
async _loadDataPoints(metaData) { |
|||
const { measure, domains, fields, groupBy, resModel } = metaData; |
|||
|
|||
const measures = ["__count"]; |
|||
if (measure !== "__count") { |
|||
let { group_operator, type } = fields[measure]; |
|||
if (type === "many2one") { |
|||
group_operator = "count_distinct"; |
|||
} |
|||
if (group_operator === undefined) { |
|||
throw new Error( |
|||
`No aggregate function has been provided for the measure '${measure}'` |
|||
); |
|||
} |
|||
measures.push(`${measure}:${group_operator}`); |
|||
} |
|||
|
|||
const proms = []; |
|||
const numbering = {}; // used to avoid ambiguity with many2one with values with same labels:
|
|||
// for instance [1, "ABC"] [3, "ABC"] should be distinguished.
|
|||
domains.forEach((domain, originIndex) => { |
|||
proms.push( |
|||
this.orm |
|||
.webReadGroup( |
|||
resModel, |
|||
domain.arrayRepr, |
|||
measures, |
|||
groupBy.map((gb) => gb.spec), |
|||
{ lazy: false }, // what is this thing???
|
|||
{ fill_temporal: true, ...this.searchParams.context } |
|||
) |
|||
.then((data) => { |
|||
const dataPoints = []; |
|||
for (const group of data.groups) { |
|||
const { __domain, __count } = group; |
|||
const labels = []; |
|||
|
|||
for (const gb of groupBy) { |
|||
let label; |
|||
const val = group[gb.spec]; |
|||
const fieldName = gb.fieldName; |
|||
const { type } = fields[fieldName]; |
|||
if (type === "boolean") { |
|||
label = `${val}`; // toUpperCase?
|
|||
} else if (val === false) { |
|||
label = this.env._t("Undefined"); |
|||
} else if (type === "many2one") { |
|||
const [id, name] = val; |
|||
const key = JSON.stringify([fieldName, name]); |
|||
if (!numbering[key]) { |
|||
numbering[key] = {}; |
|||
} |
|||
const numbers = numbering[key]; |
|||
if (!numbers[id]) { |
|||
numbers[id] = Object.keys(numbers).length + 1; |
|||
} |
|||
const num = numbers[id]; |
|||
label = num === 1 ? name : `${name} (${num})`; |
|||
} else if (type === "selection") { |
|||
const selected = fields[fieldName].selection.find( |
|||
(s) => s[0] === val |
|||
); |
|||
label = selected[1]; |
|||
} else { |
|||
label = val; |
|||
} |
|||
labels.push(label); |
|||
} |
|||
|
|||
let value = group[measure]; |
|||
if (value instanceof Array) { |
|||
// case where measure is a many2one and is used as groupBy
|
|||
value = 1; |
|||
} |
|||
if (!Number.isInteger(value)) { |
|||
metaData.allIntegers = false; |
|||
} |
|||
dataPoints.push({ |
|||
count: __count, |
|||
domain: __domain, |
|||
value, |
|||
labels, |
|||
originIndex, |
|||
}); |
|||
} |
|||
return dataPoints; |
|||
}) |
|||
); |
|||
}); |
|||
const promResults = await Promise.all(proms); |
|||
return promResults.flat(); |
|||
} |
|||
|
|||
/** |
|||
* Process metaData.groupBy in order to keep only the finest interval option for |
|||
* elements based on date/datetime field (e.g. 'date:year'). This means that |
|||
* 'week' is prefered to 'month'. The field stays at the place of its first occurence. |
|||
* For instance, |
|||
* ['foo', 'date:month', 'bar', 'date:week'] becomes ['foo', 'date:week', 'bar']. |
|||
* @protected |
|||
* @param {Object} metaData |
|||
*/ |
|||
_normalize(metaData) { |
|||
const { fields } = metaData; |
|||
const groupBy = []; |
|||
for (const gb of metaData.groupBy) { |
|||
let ngb = gb; |
|||
if (typeof gb === "string") { |
|||
ngb = getGroupBy(gb, fields); |
|||
} |
|||
groupBy.push(ngb); |
|||
} |
|||
|
|||
const processedGroupBy = []; |
|||
for (const gb of groupBy) { |
|||
const { fieldName, interval } = gb; |
|||
const { store, type } = fields[fieldName]; |
|||
if ( |
|||
!store || |
|||
["id", "__count"].includes(fieldName) || |
|||
!GROUPABLE_TYPES.includes(type) |
|||
) { |
|||
continue; |
|||
} |
|||
const index = processedGroupBy.findIndex((gb) => gb.fieldName === fieldName); |
|||
if (index === -1) { |
|||
processedGroupBy.push(gb); |
|||
} else if (interval) { |
|||
const registeredInterval = processedGroupBy[index].interval; |
|||
if (rankInterval(registeredInterval) < rankInterval(interval)) { |
|||
processedGroupBy.splice(index, 1, gb); |
|||
} |
|||
} |
|||
} |
|||
metaData.groupBy = processedGroupBy; |
|||
|
|||
metaData.measure = processMeasure(metaData.measure); |
|||
} |
|||
|
|||
/** |
|||
* @protected |
|||
*/ |
|||
async _prepareData() { |
|||
const processedDataPoints = this._getProcessedDataPoints(); |
|||
this.data = null; |
|||
if (this._isValidData(processedDataPoints)) { |
|||
this.data = this._getData(processedDataPoints); |
|||
} |
|||
} |
|||
} |
@ -1,627 +0,0 @@ |
|||
/** @odoo-module **/ |
|||
|
|||
import { _lt } from "@web/core/l10n/translation"; |
|||
import { BORDER_WHITE, DEFAULT_BG, getColor, hexToRGBA } from "./colors"; |
|||
import { formatFloat } from "@web/fields/formatters"; |
|||
import { SEP } from "./graph_model"; |
|||
import { sortBy } from "@web/core/utils/arrays"; |
|||
import { useAssets } from "@web/core/assets"; |
|||
import { useEffect } from "@web/core/utils/hooks"; |
|||
|
|||
const { Component, hooks } = owl; |
|||
const { useRef } = hooks; |
|||
|
|||
const NO_DATA = _lt("No data"); |
|||
|
|||
/** |
|||
* @param {Object} chartArea |
|||
* @returns {string} |
|||
*/ |
|||
function getMaxWidth(chartArea) { |
|||
const { left, right } = chartArea; |
|||
return Math.floor((right - left) / 1.618) + "px"; |
|||
} |
|||
|
|||
/** |
|||
* Used to avoid too long legend items. |
|||
* @param {string|Strin} label |
|||
* @returns {string} shortened version of the input label |
|||
*/ |
|||
function shortenLabel(label) { |
|||
// string returned could be wrong if a groupby value contain a " / "!
|
|||
const groups = label.toString().split(SEP); |
|||
let shortLabel = groups.slice(0, 3).join(SEP); |
|||
if (shortLabel.length > 30) { |
|||
shortLabel = `${shortLabel.slice(0, 30)}...`; |
|||
} else if (groups.length > 3) { |
|||
shortLabel = `${shortLabel}${SEP}...`; |
|||
} |
|||
return shortLabel; |
|||
} |
|||
|
|||
export class GraphRenderer extends Component { |
|||
setup() { |
|||
this.model = this.props.model; |
|||
|
|||
this.canvasRef = useRef("canvas"); |
|||
this.containerRef = useRef("container"); |
|||
|
|||
this.chart = null; |
|||
this.tooltip = null; |
|||
this.legendTooltip = null; |
|||
|
|||
useAssets({ jsLibs: ["/web/static/lib/Chart/Chart.js"] }); |
|||
|
|||
useEffect(() => this.renderChart()); |
|||
} |
|||
|
|||
willUnmount() { |
|||
if (this.chart) { |
|||
this.chart.destroy(); |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* This function aims to remove a suitable number of lines from the |
|||
* tooltip in order to make it reasonably visible. A message indicating |
|||
* the number of lines is added if necessary. |
|||
* @param {HTMLElement} tooltip |
|||
* @param {number} maxTooltipHeight this the max height in pixels of the tooltip |
|||
*/ |
|||
adjustTooltipHeight(tooltip, maxTooltipHeight) { |
|||
const sizeOneLine = tooltip.querySelector("tbody tr").clientHeight; |
|||
const tbodySize = tooltip.querySelector("tbody").clientHeight; |
|||
const toKeep = Math.max( |
|||
0, |
|||
Math.floor((maxTooltipHeight - (tooltip.clientHeight - tbodySize)) / sizeOneLine) - 1 |
|||
); |
|||
const lines = tooltip.querySelectorAll("tbody tr"); |
|||
const toRemove = lines.length - toKeep; |
|||
if (toRemove > 0) { |
|||
for (let index = toKeep; index < lines.length; ++index) { |
|||
lines[index].remove(); |
|||
} |
|||
const tr = document.createElement("tr"); |
|||
const td = document.createElement("td"); |
|||
tr.classList.add("o_show_more"); |
|||
td.innerText = this.env._t("..."); |
|||
tr.appendChild(td); |
|||
tooltip.querySelector("tbody").appendChild(tr); |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* Creates a custom HTML tooltip. |
|||
* @param {Object} data |
|||
* @param {Object} metaData |
|||
* @param {Object} tooltipModel see chartjs documentation |
|||
*/ |
|||
customTooltip(data, metaData, tooltipModel) { |
|||
const { measure, measures, disableLinking, mode } = metaData; |
|||
this.el.style.cursor = ""; |
|||
this.removeTooltips(); |
|||
if (tooltipModel.opacity === 0 || tooltipModel.dataPoints.length === 0) { |
|||
return; |
|||
} |
|||
if (!disableLinking && mode !== "line") { |
|||
this.el.style.cursor = "pointer"; |
|||
} |
|||
const chartAreaTop = this.chart.chartArea.top; |
|||
const viewContentTop = this.el.getBoundingClientRect().top; |
|||
const innerHTML = this.env.qweb.renderToString("web.GraphRenderer.CustomTooltip", { |
|||
maxWidth: getMaxWidth(this.chart.chartArea), |
|||
measure: measures[measure].string, |
|||
tooltipItems: this.getTooltipItems(data, metaData, tooltipModel), |
|||
}); |
|||
const template = Object.assign(document.createElement("template"), { innerHTML }); |
|||
const tooltip = template.content.firstChild; |
|||
this.containerRef.el.prepend(tooltip); |
|||
|
|||
let top; |
|||
const tooltipHeight = tooltip.clientHeight; |
|||
const minTopAllowed = Math.floor(chartAreaTop); |
|||
const maxTopAllowed = Math.floor(window.innerHeight - (viewContentTop + tooltipHeight)) - 2; |
|||
const y = Math.floor(tooltipModel.y); |
|||
if (minTopAllowed <= maxTopAllowed) { |
|||
// Here we know that the full tooltip can fit in the screen.
|
|||
// We put it in the position where Chart.js would put it
|
|||
// if two conditions are respected:
|
|||
// 1: the tooltip is not cut (because we know it is possible to not cut it)
|
|||
// 2: the tooltip does not hide the legend.
|
|||
// If it is not possible to use the Chart.js proposition (y)
|
|||
// we use the best approximated value.
|
|||
if (y <= maxTopAllowed) { |
|||
if (y >= minTopAllowed) { |
|||
top = y; |
|||
} else { |
|||
top = minTopAllowed; |
|||
} |
|||
} else { |
|||
top = maxTopAllowed; |
|||
} |
|||
} else { |
|||
// Here we know that we cannot satisfy condition 1 above,
|
|||
// so we position the tooltip at the minimal position and
|
|||
// cut it the minimum possible.
|
|||
top = minTopAllowed; |
|||
const maxTooltipHeight = window.innerHeight - (viewContentTop + chartAreaTop) - 2; |
|||
this.adjustTooltipHeight(tooltip, maxTooltipHeight); |
|||
} |
|||
this.fixTooltipLeftPosition(tooltip, tooltipModel.x); |
|||
tooltip.style.top = Math.floor(top) + "px"; |
|||
|
|||
this.tooltip = tooltip; |
|||
} |
|||
|
|||
/** |
|||
* Sets best left position of a tooltip approaching the proposal x. |
|||
* @param {HTMLElement} tooltip |
|||
* @param {number} x |
|||
*/ |
|||
fixTooltipLeftPosition(tooltip, x) { |
|||
let left; |
|||
const tooltipWidth = tooltip.clientWidth; |
|||
const minLeftAllowed = Math.floor(this.chart.chartArea.left + 2); |
|||
const maxLeftAllowed = Math.floor(this.chart.chartArea.right - tooltipWidth - 2); |
|||
x = Math.floor(x); |
|||
if (x < minLeftAllowed) { |
|||
left = minLeftAllowed; |
|||
} else if (x > maxLeftAllowed) { |
|||
left = maxLeftAllowed; |
|||
} else { |
|||
left = x; |
|||
} |
|||
tooltip.style.left = `${left}px`; |
|||
} |
|||
|
|||
/** |
|||
* Used to format correctly the values in tooltips and yAxes. |
|||
* @param {number} value |
|||
* @param {boolean} [allIntegers=true] |
|||
* @returns {string} |
|||
*/ |
|||
formatValue(value, allIntegers = true) { |
|||
const largeNumber = Math.abs(value) >= 1000; |
|||
if (allIntegers && !largeNumber) { |
|||
return String(value); |
|||
} |
|||
if (largeNumber) { |
|||
return formatFloat(value, { humanReadable: true, decimals: 2, minDigits: 1 }); |
|||
} |
|||
return formatFloat(value); |
|||
} |
|||
|
|||
/** |
|||
* Returns the bar chart data |
|||
* @returns {Object} |
|||
*/ |
|||
getBarChartData() { |
|||
// style data
|
|||
const { domains, stacked } = this.model.metaData; |
|||
const data = this.model.data; |
|||
for (let index = 0; index < data.datasets.length; ++index) { |
|||
const dataset = data.datasets[index]; |
|||
// used when stacked
|
|||
if (stacked) { |
|||
dataset.stack = domains[dataset.originIndex].description || ""; |
|||
} |
|||
// set dataset color
|
|||
dataset.backgroundColor = getColor(index); |
|||
} |
|||
|
|||
return data; |
|||
} |
|||
|
|||
/** |
|||
* Returns the chart config. |
|||
* @returns {Object} |
|||
*/ |
|||
getChartConfig() { |
|||
const { mode } = this.model.metaData; |
|||
let data; |
|||
switch (mode) { |
|||
case "bar": |
|||
data = this.getBarChartData(); |
|||
break; |
|||
case "line": |
|||
data = this.getLineChartData(); |
|||
break; |
|||
case "pie": |
|||
data = this.getPieChartData(); |
|||
} |
|||
const options = this.prepareOptions(); |
|||
return { data, options, type: mode }; |
|||
} |
|||
|
|||
/** |
|||
* Returns an object used to style chart elements independently from |
|||
* the datasets. |
|||
* @returns {Object} |
|||
*/ |
|||
getElementOptions() { |
|||
const { mode } = this.model.metaData; |
|||
const elementOptions = {}; |
|||
if (mode === "bar") { |
|||
elementOptions.rectangle = { borderWidth: 1 }; |
|||
} else if (mode === "line") { |
|||
elementOptions.line = { fill: false, tension: 0 }; |
|||
} |
|||
return elementOptions; |
|||
} |
|||
|
|||
/** |
|||
* @returns {Object} |
|||
*/ |
|||
getLegendOptions() { |
|||
const { mode } = this.model.metaData; |
|||
const data = this.model.data; |
|||
const refLength = mode === "pie" ? data.labels.length : data.datasets.length; |
|||
const legendOptions = { |
|||
display: refLength <= 20, |
|||
position: "top", |
|||
onHover: this.onlegendHover.bind(this), |
|||
onLeave: this.onLegendLeave.bind(this), |
|||
}; |
|||
if (mode === "line") { |
|||
legendOptions.onClick = this.onLegendClick.bind(this); |
|||
} |
|||
if (mode === "pie") { |
|||
legendOptions.labels = { |
|||
generateLabels: (chart) => { |
|||
const { data } = chart; |
|||
const metaData = data.datasets.map( |
|||
(_, index) => chart.getDatasetMeta(index).data |
|||
); |
|||
const labels = data.labels.map((label, index) => { |
|||
const hidden = metaData.some((data) => data[index] && data[index].hidden); |
|||
const fullText = label; |
|||
const text = shortenLabel(fullText); |
|||
const fillStyle = label === NO_DATA ? DEFAULT_BG : getColor(index); |
|||
return { text, fullText, fillStyle, hidden, index }; |
|||
}); |
|||
return labels; |
|||
}, |
|||
}; |
|||
} else { |
|||
const referenceColor = mode === "bar" ? "backgroundColor" : "borderColor"; |
|||
legendOptions.labels = { |
|||
generateLabels: (chart) => { |
|||
const { data } = chart; |
|||
const labels = data.datasets.map((dataset, index) => { |
|||
return { |
|||
text: shortenLabel(dataset.label), |
|||
fullText: dataset.label, |
|||
fillStyle: dataset[referenceColor], |
|||
hidden: !chart.isDatasetVisible(index), |
|||
lineCap: dataset.borderCapStyle, |
|||
lineDash: dataset.borderDash, |
|||
lineDashOffset: dataset.borderDashOffset, |
|||
lineJoin: dataset.borderJoinStyle, |
|||
lineWidth: dataset.borderWidth, |
|||
strokeStyle: dataset[referenceColor], |
|||
pointStyle: dataset.pointStyle, |
|||
datasetIndex: index, |
|||
}; |
|||
}); |
|||
return labels; |
|||
}, |
|||
}; |
|||
} |
|||
return legendOptions; |
|||
} |
|||
|
|||
/** |
|||
* Returns line chart data. |
|||
* @returns {Object} |
|||
*/ |
|||
getLineChartData() { |
|||
const { groupBy, domains } = this.model.metaData; |
|||
const data = this.model.data; |
|||
for (let index = 0; index < data.datasets.length; ++index) { |
|||
const dataset = data.datasets[index]; |
|||
if (groupBy.length <= 1 && domains.length > 1) { |
|||
if (dataset.originIndex === 0) { |
|||
dataset.fill = "origin"; |
|||
dataset.backgroundColor = hexToRGBA(getColor(0), 0.4); |
|||
dataset.borderColor = getColor(0); |
|||
} else if (dataset.originIndex === 1) { |
|||
dataset.borderColor = getColor(1); |
|||
} else { |
|||
dataset.borderColor = getColor(index); |
|||
} |
|||
} else { |
|||
dataset.borderColor = getColor(index); |
|||
} |
|||
if (data.labels.length === 1) { |
|||
// shift of the real value to right. This is done to
|
|||
// center the points in the chart. See data.labels below in
|
|||
// Chart parameters
|
|||
dataset.data.unshift(undefined); |
|||
dataset.trueLabels.unshift(undefined); |
|||
dataset.domains.unshift(undefined); |
|||
} |
|||
dataset.pointBackgroundColor = dataset.borderColor; |
|||
dataset.pointBorderColor = "rgba(0,0,0,0.2)"; |
|||
} |
|||
if (data.datasets.length === 1) { |
|||
const dataset = data.datasets[0]; |
|||
dataset.fill = "origin"; |
|||
dataset.backgroundColor = hexToRGBA(getColor(0), 0.4); |
|||
} |
|||
// center the points in the chart (without that code they are put
|
|||
// on the left and the graph seems empty)
|
|||
data.labels = data.labels.length > 1 ? data.labels : ["", ...data.labels, ""]; |
|||
|
|||
return data; |
|||
} |
|||
|
|||
/** |
|||
* Returns pie chart data. |
|||
* @returns {Object} |
|||
*/ |
|||
getPieChartData() { |
|||
const { domains } = this.model.metaData; |
|||
const data = this.model.data; |
|||
// style/complete data
|
|||
// give same color to same groups from different origins
|
|||
const colors = data.labels.map((_, index) => getColor(index)); |
|||
for (const dataset of data.datasets) { |
|||
dataset.backgroundColor = colors; |
|||
dataset.borderColor = BORDER_WHITE; |
|||
} |
|||
// make sure there is a zone associated with every origin
|
|||
const representedOriginIndexes = new Set( |
|||
data.datasets.map((dataset) => dataset.originIndex) |
|||
); |
|||
let addNoDataToLegend = false; |
|||
const fakeData = new Array(data.labels.length + 1); |
|||
fakeData[data.labels.length] = 1; |
|||
const fakeTrueLabels = new Array(data.labels.length + 1); |
|||
fakeTrueLabels[data.labels.length] = NO_DATA; |
|||
for (let index = 0; index < domains.length; ++index) { |
|||
if (!representedOriginIndexes.has(index)) { |
|||
data.datasets.push({ |
|||
label: domains[index].description, |
|||
data: fakeData, |
|||
trueLabels: fakeTrueLabels, |
|||
backgroundColor: [...colors, DEFAULT_BG], |
|||
borderColor: BORDER_WHITE, |
|||
}); |
|||
addNoDataToLegend = true; |
|||
} |
|||
} |
|||
if (addNoDataToLegend) { |
|||
data.labels.push(NO_DATA); |
|||
} |
|||
|
|||
return data; |
|||
} |
|||
|
|||
/** |
|||
* Returns the options used to generate the chart axes. |
|||
* @returns {Object} |
|||
*/ |
|||
getScaleOptions() { |
|||
const { |
|||
allIntegers, |
|||
displayScaleLabels, |
|||
fields, |
|||
groupBy, |
|||
measure, |
|||
measures, |
|||
mode, |
|||
} = this.model.metaData; |
|||
if (mode === "pie") { |
|||
return {}; |
|||
} |
|||
const xAxe = { |
|||
type: "category", |
|||
scaleLabel: { |
|||
display: Boolean(groupBy.length && displayScaleLabels), |
|||
labelString: groupBy.length ? fields[groupBy[0].fieldName].string : "", |
|||
}, |
|||
}; |
|||
const yAxe = { |
|||
type: "linear", |
|||
scaleLabel: { |
|||
display: displayScaleLabels, |
|||
labelString: measures[measure].string, |
|||
}, |
|||
ticks: { |
|||
callback: (value) => this.formatValue(value, allIntegers), |
|||
suggestedMax: 0, |
|||
suggestedMin: 0, |
|||
}, |
|||
}; |
|||
return { xAxes: [xAxe], yAxes: [yAxe] }; |
|||
} |
|||
|
|||
/** |
|||
* This function extracts the information from the data points in |
|||
* tooltipModel.dataPoints (corresponding to datapoints over a given |
|||
* label determined by the mouse position) that will be displayed in a |
|||
* custom tooltip. |
|||
* @param {Object} data |
|||
* @param {Object} metaData |
|||
* @param {Object} tooltipModel see chartjs documentation |
|||
* @returns {Object[]} |
|||
*/ |
|||
getTooltipItems(data, metaData, tooltipModel) { |
|||
const { allIntegers, domains, mode, groupBy } = metaData; |
|||
const sortedDataPoints = sortBy(tooltipModel.dataPoints, "yLabel", "desc"); |
|||
const items = []; |
|||
for (const item of sortedDataPoints) { |
|||
const id = item.index; |
|||
const dataset = data.datasets[item.datasetIndex]; |
|||
let label = dataset.trueLabels[id]; |
|||
let value = this.formatValue(dataset.data[id], allIntegers); |
|||
let boxColor; |
|||
if (mode === "pie") { |
|||
if (label === NO_DATA) { |
|||
value = this.formatValue(0, allIntegers); |
|||
} |
|||
if (domains.length > 1) { |
|||
label = `${dataset.label} / ${label}`; |
|||
} |
|||
boxColor = dataset.backgroundColor[id]; |
|||
} else { |
|||
if (groupBy.length > 1 || domains.length > 1) { |
|||
label = `${label} / ${dataset.label}`; |
|||
} |
|||
boxColor = mode === "bar" ? dataset.backgroundColor : dataset.borderColor; |
|||
} |
|||
items.push({ id, label, value, boxColor }); |
|||
} |
|||
return items; |
|||
} |
|||
|
|||
/** |
|||
* Returns the options used to generate chart tooltips. |
|||
* @returns {Object} |
|||
*/ |
|||
getTooltipOptions() { |
|||
const { data, metaData } = this.model; |
|||
const { mode } = metaData; |
|||
const tooltipOptions = { |
|||
enabled: false, |
|||
custom: this.customTooltip.bind(this, data, metaData), |
|||
}; |
|||
if (mode === "line") { |
|||
tooltipOptions.mode = "index"; |
|||
tooltipOptions.intersect = false; |
|||
} |
|||
return tooltipOptions; |
|||
} |
|||
|
|||
/** |
|||
* If a group has been clicked on, display a view of its records. |
|||
* @param {MouseEvent} ev |
|||
*/ |
|||
onGraphClicked(ev) { |
|||
const [activeElement] = this.chart.getElementAtEvent(ev); |
|||
if (!activeElement) { |
|||
return; |
|||
} |
|||
const { _datasetIndex, _index } = activeElement; |
|||
const { domains } = this.chart.data.datasets[_datasetIndex]; |
|||
if (domains) { |
|||
this.props.onGraphClicked(domains[_index]); |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* Overrides the default legend 'onClick' behaviour. This is done to |
|||
* remove all existing tooltips right before updating the chart. |
|||
* @param {Event} ev |
|||
* @param {Object} legendItem |
|||
*/ |
|||
onLegendClick(ev, legendItem) { |
|||
this.removeTooltips(); |
|||
// Default 'onClick' fallback. See web/static/lib/Chart/Chart.js#15138
|
|||
const index = legendItem.datasetIndex; |
|||
const meta = this.chart.getDatasetMeta(index); |
|||
meta.hidden = meta.hidden === null ? !this.chart.data.datasets[index].hidden : null; |
|||
this.chart.update(); |
|||
} |
|||
|
|||
/** |
|||
* If the text of a legend item has been shortened and the user mouse |
|||
* hovers that item (actually the event type is mousemove), a tooltip |
|||
* with the item full text is displayed. |
|||
* @param {Event} ev |
|||
* @param {Object} legendItem |
|||
*/ |
|||
onlegendHover(ev, legendItem) { |
|||
this.canvasRef.el.style.cursor = "pointer"; |
|||
/** |
|||
* The string legendItem.text is an initial segment of legendItem.fullText. |
|||
* If the two coincide, no need to generate a tooltip. If a tooltip |
|||
* for the legend already exists, it is already good and does not |
|||
* need to be recreated. |
|||
*/ |
|||
const { fullText, text } = legendItem; |
|||
if (this.legendTooltip || text === fullText) { |
|||
return; |
|||
} |
|||
const viewContentTop = this.el.getBoundingClientRect().top; |
|||
const legendTooltip = Object.assign(document.createElement("div"), { |
|||
className: "o_tooltip_legend", |
|||
innerText: fullText, |
|||
}); |
|||
legendTooltip.style.top = `${ev.clientY - viewContentTop}px`; |
|||
legendTooltip.style.maxWidth = getMaxWidth(this.chart.chartArea); |
|||
this.containerRef.el.appendChild(legendTooltip); |
|||
this.fixTooltipLeftPosition(legendTooltip, ev.clientX); |
|||
this.legendTooltip = legendTooltip; |
|||
} |
|||
|
|||
/** |
|||
* If there's a legend tooltip and the user mouse out of the |
|||
* corresponding legend item, the tooltip is removed. |
|||
*/ |
|||
onLegendLeave() { |
|||
this.canvasRef.el.style.cursor = ""; |
|||
this.removeLegendTooltip(); |
|||
} |
|||
|
|||
/** |
|||
* Prepares options for the chart according to the current mode |
|||
* (= chart type). This function returns the parameter options used to |
|||
* instantiate the chart. |
|||
*/ |
|||
prepareOptions() { |
|||
const { disableLinking, mode } = this.model.metaData; |
|||
const options = { |
|||
maintainAspectRatio: false, |
|||
scales: this.getScaleOptions(), |
|||
legend: this.getLegendOptions(), |
|||
tooltips: this.getTooltipOptions(), |
|||
elements: this.getElementOptions(), |
|||
}; |
|||
if (!disableLinking && mode !== "line") { |
|||
options.onClick = this.onGraphClicked.bind(this); |
|||
} |
|||
return options; |
|||
} |
|||
|
|||
/** |
|||
* Removes the legend tooltip (if any). |
|||
*/ |
|||
removeLegendTooltip() { |
|||
if (this.legendTooltip) { |
|||
this.legendTooltip.remove(); |
|||
this.legendTooltip = null; |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* Removes all existing tooltips (if any). |
|||
*/ |
|||
removeTooltips() { |
|||
if (this.tooltip) { |
|||
this.tooltip.remove(); |
|||
this.tooltip = null; |
|||
} |
|||
this.removeLegendTooltip(); |
|||
} |
|||
|
|||
/** |
|||
* Instantiates a Chart (Chart.js lib) to render the graph according to |
|||
* the current config. |
|||
*/ |
|||
renderChart() { |
|||
if (this.chart) { |
|||
this.chart.destroy(); |
|||
} |
|||
const config = this.getChartConfig(); |
|||
this.chart = new Chart(this.canvasRef.el, config); |
|||
// To perform its animations, ChartJS will perform each animation
|
|||
// step in the next animation frame. The initial rendering itself
|
|||
// is delayed for consistency. We can avoid this by manually
|
|||
// advancing the animation service.
|
|||
Chart.animationService.advance(); |
|||
} |
|||
} |
|||
|
|||
GraphRenderer.template = "web.GraphRenderer"; |
|||
GraphRenderer.props = ["model", "onGraphClicked"]; |
@ -1,160 +0,0 @@ |
|||
/** @odoo-module **/ |
|||
|
|||
import { _lt } from "@web/core/l10n/translation"; |
|||
import { registry } from "@web/core/registry"; |
|||
import { useService } from "@web/core/utils/hooks"; |
|||
import { GroupByMenu } from "@web/search/group_by_menu/group_by_menu"; |
|||
import { standardViewProps } from "@web/views/helpers/standard_view_props"; |
|||
import { useSetupView } from "@web/views/helpers/view_hook"; |
|||
import { Layout } from "@web/views/layout"; |
|||
import { useModel } from "@web/views/helpers/model"; |
|||
import { GraphArchParser } from "./graph_arch_parser"; |
|||
import { GraphModel } from "./graph_model"; |
|||
import { GraphRenderer } from "./graph_renderer"; |
|||
|
|||
const viewRegistry = registry.category("views"); |
|||
|
|||
const { Component } = owl; |
|||
|
|||
export class GraphView extends Component { |
|||
setup() { |
|||
this.actionService = useService("action"); |
|||
|
|||
let modelParams; |
|||
if (this.props.state) { |
|||
modelParams = this.props.state.metaData; |
|||
} else { |
|||
const { arch, fields } = this.props; |
|||
const parser = new this.constructor.ArchParser(); |
|||
const archInfo = parser.parse(arch, fields); |
|||
modelParams = { |
|||
additionalMeasures: this.props.additionalMeasures, |
|||
disableLinking: Boolean(archInfo.disableLinking), |
|||
displayScaleLabels: this.props.displayScaleLabels, |
|||
fieldAttrs: archInfo.fieldAttrs, |
|||
fields: this.props.fields, |
|||
groupBy: archInfo.groupBy, |
|||
measure: archInfo.measure || "__count", |
|||
mode: archInfo.mode || "bar", |
|||
order: archInfo.order || null, |
|||
resModel: this.props.resModel, |
|||
stacked: "stacked" in archInfo ? archInfo.stacked : true, |
|||
title: archInfo.title || this.env._t("Untitled"), |
|||
}; |
|||
} |
|||
|
|||
this.model = useModel(this.constructor.Model, modelParams); |
|||
|
|||
useSetupView({ |
|||
getLocalState: () => { |
|||
return { metaData: this.model.metaData }; |
|||
}, |
|||
getContext: () => this.getContext(), |
|||
}); |
|||
} |
|||
|
|||
/** |
|||
* @returns {Object} |
|||
*/ |
|||
getContext() { |
|||
// expand context object? change keys?
|
|||
const { measure, groupBy, mode } = this.model.metaData; |
|||
return { |
|||
graph_measure: measure, |
|||
graph_mode: mode, |
|||
graph_groupbys: groupBy.map((gb) => gb.spec), |
|||
}; |
|||
} |
|||
|
|||
/** |
|||
* @param {string} domain the domain of the clicked area |
|||
*/ |
|||
onGraphClicked(domain) { |
|||
const { context, resModel, title } = this.model.metaData; |
|||
|
|||
const views = {}; |
|||
for (const [viewId, viewType] of this.env.config.views || []) { |
|||
views[viewType] = viewId; |
|||
} |
|||
function getView(viewType) { |
|||
return [views[viewType] || false, viewType]; |
|||
} |
|||
const actionViews = [getView("list"), getView("form")]; |
|||
|
|||
this.actionService.doAction( |
|||
{ |
|||
context, |
|||
domain, |
|||
name: title, |
|||
res_model: resModel, |
|||
target: "current", |
|||
type: "ir.actions.act_window", |
|||
views: actionViews, |
|||
}, |
|||
{ |
|||
viewType: "list", |
|||
} |
|||
); |
|||
} |
|||
|
|||
/** |
|||
* @param {CustomEvent} ev |
|||
*/ |
|||
onMeasureSelected(ev) { |
|||
const { measure } = ev.detail.payload; |
|||
this.model.updateMetaData({ measure }); |
|||
} |
|||
|
|||
/** |
|||
* @param {"bar"|"line"|"pie"} mode |
|||
*/ |
|||
onModeSelected(mode) { |
|||
this.model.updateMetaData({ mode }); |
|||
} |
|||
|
|||
/** |
|||
* @param {"ASC"|"DESC"} order |
|||
*/ |
|||
toggleOrder(order) { |
|||
const { order: currentOrder } = this.model.metaData; |
|||
const nextOrder = currentOrder === order ? null : order; |
|||
this.model.updateMetaData({ order: nextOrder }); |
|||
} |
|||
|
|||
toggleStacked() { |
|||
const { stacked } = this.model.metaData; |
|||
this.model.updateMetaData({ stacked: !stacked }); |
|||
} |
|||
} |
|||
|
|||
GraphView.template = "web.GraphView"; |
|||
GraphView.buttonTemplate = "web.GraphView.Buttons"; |
|||
|
|||
GraphView.components = { GroupByMenu, Renderer: GraphRenderer, Layout }; |
|||
|
|||
GraphView.defaultProps = { |
|||
additionalMeasures: [], |
|||
displayGroupByMenu: false, |
|||
displayScaleLabels: true, |
|||
}; |
|||
|
|||
GraphView.props = { |
|||
...standardViewProps, |
|||
additionalMeasures: { type: Array, elements: String, optional: true }, |
|||
displayGroupByMenu: { type: Boolean, optional: true }, |
|||
displayScaleLabels: { type: Boolean, optional: true }, |
|||
}; |
|||
|
|||
GraphView.type = "graph"; |
|||
|
|||
GraphView.display_name = _lt("Graph"); |
|||
GraphView.icon = "fa-bar-chart"; |
|||
GraphView.multiRecord = true; |
|||
|
|||
GraphView.Model = GraphModel; |
|||
|
|||
GraphView.ArchParser = GraphArchParser; |
|||
|
|||
GraphView.searchMenuTypes = ["filter", "groupBy", "comparison", "favorite"]; |
|||
|
|||
viewRegistry.add("graph", GraphView); |
Loading…
Reference in new issue