Skip to content

Commit

Permalink
[IMP] chart: add custom tooltip for charts
Browse files Browse the repository at this point in the history
This commit replaces the chartJS default tooltip by a custom
tooltip that is prettier (and matches the Odoo style).

closes #5246

Task: 4351271
Signed-off-by: Rémi Rahir (rar) <[email protected]>
  • Loading branch information
hokolomopo authored and rrahir committed Jan 6, 2025
1 parent c6fc50f commit df4cb38
Show file tree
Hide file tree
Showing 6 changed files with 208 additions and 2 deletions.
15 changes: 15 additions & 0 deletions src/components/figures/chart/chartJs/chartjs.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { Component, onMounted, onWillUnmount, useEffect, useRef } from "@odoo/owl";
import { Chart, ChartConfiguration } from "chart.js/auto";
import { ComponentsImportance } from "../../../../constants";
import { deepCopy } from "../../../../helpers";
import { Figure, SpreadsheetChildEnv } from "../../../../types";
import { ChartJSRuntime } from "../../../../types/chart/chart";
import { css } from "../../../helpers";
import { chartShowValuesPlugin } from "./chartjs_show_values_plugin";
import { waterfallLinesPlugin } from "./chartjs_waterfall_plugin";

Expand All @@ -13,6 +15,19 @@ interface Props {
window.Chart?.register(waterfallLinesPlugin);
window.Chart?.register(chartShowValuesPlugin);

css/* scss */ `
.o-spreadsheet {
.o-chart-custom-tooltip {
font-size: 12px;
background-color: #fff;
z-index: ${ComponentsImportance.FigureTooltip};
table td span {
box-sizing: border-box;
}
}
}
`;

export class ChartJsComponent extends Component<Props, SpreadsheetChildEnv> {
static template = "o-spreadsheet-ChartJsComponent";
static props = {
Expand Down
1 change: 1 addition & 0 deletions src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,7 @@ export enum ComponentsImportance {
Popover = 35,
FigureAnchor = 1000,
FigureSnapLine = 1001,
FigureTooltip = 1002,
}
let DEFAULT_SHEETVIEW_SIZE = 0;

Expand Down
65 changes: 65 additions & 0 deletions src/helpers/figures/charts/runtime/chart_custom_tooltip.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { App, Component, blockDom } from "@odoo/owl";
import { _t } from "../../../../translation";

/**
* Custom tooltip for the charts. Mostly copied from Odoo's custom tooltip, with some slight changes to make it work
* with o-spreadsheet chart data and CSS.
*
* https://github.com/odoo/odoo/blob/18.0/addons/web/static/src/views/graph/graph_renderer.xml
*/
const templates = /* xml */ `
<templates>
<t t-name="o-spreadsheet-CustomTooltip">
<div
class="o-chart-custom-tooltip border rounded px-2 py-1 pe-none mw-100 position-absolute text-nowrap shadow opacity-100">
<table class="overflow-hidden m-0">
<thead>
<tr>
<th class="o-tooltip-title align-baseline border-0 text-truncate" t-esc="title" t-attf-style="max-width: {{ labelsMaxWidth }}"/>
</tr>
</thead>
<tbody>
<tr t-foreach="tooltipItems" t-as="tooltipItem" t-key="tooltipItem_index">
<td>
<span
class="badge ps-2 py-2 rounded-0 align-middle"
t-attf-style="background-color: {{ tooltipItem.boxColor }}"
> </span>
<small
t-if="tooltipItem.label"
class="o-tooltip-label d-inline-block text-truncate align-middle smaller ms-2"
t-esc="tooltipItem.label"
t-attf-style="max-width: {{ labelsMaxWidth }}"
/>
</td>
<td class="o-tooltip-value ps-2 fw-bolder text-end">
<small class="smaller d-inline-block text-truncate align-middle" t-attf-style="max-width: {{ valuesMaxWidth }}">
<t t-esc="tooltipItem.value"/>
<t t-if="tooltipItem.percentage">
(
<t t-esc="tooltipItem.percentage"/>
%)
</t>
</small>
</td>
</tr>
</tbody>
</table>
</div>
</t>
</templates>
`;

const app = new App(Component, { templates, translateFn: _t });

export function renderToString(templateName: string, context: any = {}) {
return render(templateName, context).innerHTML;
}

function render(templateName: string, context: any = {}) {
const templateFn = app.getTemplate(templateName);
const bdom = templateFn(context, {});
const div = document.createElement("div");
blockDom.mount(bdom, div);
return div;
}
68 changes: 66 additions & 2 deletions src/helpers/figures/charts/runtime/chartjs_tooltip.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { BubbleDataPoint, Point, TooltipItem, TooltipOptions } from "chart.js";
import { BubbleDataPoint, Chart, Point, TooltipItem, TooltipModel, TooltipOptions } from "chart.js";
import { _DeepPartialObject } from "chart.js/dist/types/utils";
import { toNumber } from "../../../../functions/helpers";
import { CellValue } from "../../../../types";
Expand All @@ -13,17 +13,22 @@ import {
} from "../../../../types/chart";
import { GeoChartDefinition } from "../../../../types/chart/geo_chart";
import { RadarChartDefinition } from "../../../../types/chart/radar_chart";
import { setColorAlpha } from "../../../color";
import { formatValue } from "../../../format/format";
import { isNumber } from "../../../numbers";
import { TREND_LINE_XAXIS_ID, formatChartDatasetValue } from "../chart_common";
import { renderToString } from "./chart_custom_tooltip";

type ChartTooltip = _DeepPartialObject<TooltipOptions<any>>;
type ChartContext = { chart: Chart; tooltip: TooltipModel<any> };

export function getBarChartTooltip(
definition: GenericDefinition<BarChartDefinition>,
args: ChartRuntimeGenerationArgs
): ChartTooltip {
return {
enabled: false,
external: customTooltipHandler,
callbacks: {
title: function (tooltipItems) {
return tooltipItems.some((item) => item.dataset.xAxisID !== TREND_LINE_XAXIS_ID)
Expand Down Expand Up @@ -53,7 +58,11 @@ export function getLineChartTooltip(
const { axisType, locale, axisFormats } = args;
const labelFormat = axisFormats?.x;

const tooltip: ChartTooltip = { callbacks: {} };
const tooltip: ChartTooltip = {
enabled: false,
external: customTooltipHandler,
callbacks: {},
};

if (axisType === "linear") {
tooltip.callbacks!.label = (tooltipItem) => {
Expand Down Expand Up @@ -102,6 +111,8 @@ export function getPieChartTooltip(
const { locale, axisFormats } = args;
const format = axisFormats?.y || axisFormats?.y1;
return {
enabled: false,
external: customTooltipHandler,
callbacks: {
title: function (tooltipItems) {
return tooltipItems[0].dataset.label;
Expand Down Expand Up @@ -132,6 +143,8 @@ export function getWaterfallChartTooltip(
const format = axisFormats?.y || axisFormats?.y1;
const dataSeriesLabels = dataSetsValues.map((dataSet) => dataSet.label);
return {
enabled: false,
external: customTooltipHandler,
callbacks: {
label: function (tooltipItem) {
const [lastValue, currentValue] = tooltipItem.raw as [number, number];
Expand Down Expand Up @@ -171,6 +184,8 @@ export function getRadarChartTooltip(
): ChartTooltip {
const { locale, axisFormats } = args;
return {
enabled: false,
external: customTooltipHandler,
callbacks: {
label: function (tooltipItem) {
const xLabel = tooltipItem.dataset?.label || tooltipItem.label;
Expand Down Expand Up @@ -219,3 +234,52 @@ function calculatePercentage(

return percentage.toFixed(2);
}

function customTooltipHandler({ chart, tooltip }: ChartContext) {
chart.canvas.parentNode!.querySelector("div.o-chart-custom-tooltip")?.remove();
if (tooltip.opacity === 0 || tooltip.dataPoints.length === 0) {
return;
}

const tooltipItems = tooltip.body.map((body, index) => {
let [label, value] = body.lines[0].split(":").map((str) => str.trim());
if (!value) {
value = label;
label = "";
}

const color = tooltip.labelColors[index].backgroundColor;
return {
label,
value,
boxColor: typeof color === "string" ? setColorAlpha(color, 1) : color,
};
});

const innerHTML = renderToString("o-spreadsheet-CustomTooltip", {
labelsMaxWidth: Math.floor(chart.canvas.clientWidth * 0.5) + "px",
valuesMaxWidth: Math.floor(chart.canvas.clientWidth * 0.25) + "px",
title: tooltip.title[0],
tooltipItems,
});
const template = Object.assign(document.createElement("template"), { innerHTML });
const newTooltipEl = template.content.firstChild as HTMLElement;

chart.canvas.parentNode?.appendChild(newTooltipEl);

Object.assign(newTooltipEl.style, {
left: getTooltipLeftPosition(chart, tooltip, newTooltipEl.clientWidth) + "px",
top: Math.floor(tooltip.caretY - newTooltipEl.clientHeight / 2) + "px",
});
}

/**
* Get the left position for the tooltip, making sure it doesn't go out of the chart area.
*/
function getTooltipLeftPosition(chart: Chart, tooltip: TooltipModel<any>, tooltipWidth: number) {
const x = tooltip.caretX;
if (x + tooltipWidth > chart.chartArea.right) {
return Math.max(0, x - tooltipWidth);
}
return x;
}
26 changes: 26 additions & 0 deletions tests/figures/chart/__snapshots__/chart_plugin.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,8 @@ exports[`Linear/Time charts font color is white with a dark background color 1`]
"label": [Function],
"title": [Function],
},
"enabled": false,
"external": [Function],
},
},
"responsive": true,
Expand Down Expand Up @@ -228,6 +230,8 @@ exports[`Linear/Time charts snapshot test of chartJS configuration for date char
"label": [Function],
"title": [Function],
},
"enabled": false,
"external": [Function],
},
},
"responsive": true,
Expand Down Expand Up @@ -371,6 +375,8 @@ exports[`Linear/Time charts snapshot test of chartJS configuration for linear ch
"label": [Function],
"title": [Function],
},
"enabled": false,
"external": [Function],
},
},
"responsive": true,
Expand Down Expand Up @@ -488,6 +494,8 @@ exports[`datasource tests create a chart with stacked bar 1`] = `
"label": [Function],
"title": [Function],
},
"enabled": false,
"external": [Function],
},
},
"responsive": true,
Expand Down Expand Up @@ -604,6 +612,8 @@ exports[`datasource tests create chart with a dataset of one cell (no title) 1`]
"label": [Function],
"title": [Function],
},
"enabled": false,
"external": [Function],
},
},
"responsive": true,
Expand Down Expand Up @@ -738,6 +748,8 @@ exports[`datasource tests create chart with column datasets 1`] = `
"label": [Function],
"title": [Function],
},
"enabled": false,
"external": [Function],
},
},
"responsive": true,
Expand Down Expand Up @@ -872,6 +884,8 @@ exports[`datasource tests create chart with column datasets with category title
"label": [Function],
"title": [Function],
},
"enabled": false,
"external": [Function],
},
},
"responsive": true,
Expand Down Expand Up @@ -1006,6 +1020,8 @@ exports[`datasource tests create chart with column datasets without series title
"label": [Function],
"title": [Function],
},
"enabled": false,
"external": [Function],
},
},
"responsive": true,
Expand Down Expand Up @@ -1111,6 +1127,8 @@ exports[`datasource tests create chart with only the dataset title (no data) 1`]
"label": [Function],
"title": [Function],
},
"enabled": false,
"external": [Function],
},
},
"responsive": true,
Expand Down Expand Up @@ -1245,6 +1263,8 @@ exports[`datasource tests create chart with rectangle dataset 1`] = `
"label": [Function],
"title": [Function],
},
"enabled": false,
"external": [Function],
},
},
"responsive": true,
Expand Down Expand Up @@ -1379,6 +1399,8 @@ exports[`datasource tests create chart with row datasets 1`] = `
"label": [Function],
"title": [Function],
},
"enabled": false,
"external": [Function],
},
},
"responsive": true,
Expand Down Expand Up @@ -1513,6 +1535,8 @@ exports[`datasource tests create chart with row datasets with category title 1`]
"label": [Function],
"title": [Function],
},
"enabled": false,
"external": [Function],
},
},
"responsive": true,
Expand Down Expand Up @@ -1647,6 +1671,8 @@ exports[`datasource tests create chart with row datasets without series title 1`
"label": [Function],
"title": [Function],
},
"enabled": false,
"external": [Function],
},
},
"responsive": true,
Expand Down
35 changes: 35 additions & 0 deletions tests/figures/chart/chart_plugin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3536,3 +3536,38 @@ describe("Chart labels truncation", () => {
}
);
});

test.each(["line", "bar", "pyramid", "pie", "combo", "waterfall", "scatter"] as const)(
"Chart %s use custom tooltip instead of default ChartJS one",
(chartType) => {
createChart(model, { type: chartType }, "chartId");
const runtime = model.getters.getChartRuntime("chartId") as LineChartRuntime;
expect(runtime.chartJsConfig.options!.plugins!.tooltip!.external).toBeDefined();
expect(runtime.chartJsConfig.options!.plugins!.tooltip!.enabled).toBe(false);
}
);

test("Custom chart tooltip is correctly filled", () => {
createChart(model, { type: "line" }, "chartId");
const runtime = model.getters.getChartRuntime("chartId") as any;
const mockChartParent = document.createElement("div");
const mockChartCtx: any = {
chart: {
canvas: { parentNode: mockChartParent },
chartArea: { left: 0, top: 0, right: 100, bottom: 100, width: 100, height: 100 },
},
tooltip: {
title: ["Marc Demo"],
body: [{ lines: ["dataset title: 1"] }],
labelColors: [{ backgroundColor: "#F00" }],
dataPoints: [{}],
},
};
runtime.chartJsConfig.options!.plugins!.tooltip!.external?.(mockChartCtx);
expect(mockChartParent.querySelector(".o-tooltip-title")).toHaveText("Marc Demo");
expect(mockChartParent.querySelector(".o-tooltip-label")).toHaveText("dataset title");
expect(mockChartParent.querySelector(".o-tooltip-value")).toHaveText("1");
expect(
mockChartParent.querySelector<HTMLElement>(".badge")?.style["background-color"]
).toBeSameColorAs("#F00");
});

0 comments on commit df4cb38

Please sign in to comment.