@gooddata/react-components
Version:
GoodData.UI - A powerful JavaScript library for building analytical applications
949 lines (844 loc) • 34.2 kB
text/typescript
// (C) 2007-2020 GoodData Corporation
import get = require("lodash/get");
import set = require("lodash/set");
import noop = require("lodash/noop");
import {
escapeCategories,
formatOverlapping,
formatOverlappingForParentAttribute,
getCustomizedConfiguration,
percentageDataLabelFormatter,
getTooltipPositionInViewPort,
getTooltipPositionInChartContainer,
TOOLTIP_VIEWPORT_MARGIN_TOP,
TOOLTIP_PADDING,
} from "../customConfiguration";
import { ISeriesDataItem, IPointData, IChartOptions } from "../../../../../interfaces/Config";
import { VisualizationTypes } from "../../../../../constants/visualizationTypes";
import { immutableSet } from "../../../utils/common";
import {
supportedStackingAttributesChartTypes,
supportedTooltipFollowPointerChartTypes,
} from "../../chartOptionsBuilder";
import { AFM } from "@gooddata/typings";
import { IDrillConfig } from "../../../../../interfaces/DrillEvents";
function getData(dataValues: ISeriesDataItem[]) {
return {
series: [
{
color: "rgb(0, 0, 0)",
name: "<b>aaa</b>",
data: dataValues,
},
],
};
}
const chartOptions = {
type: VisualizationTypes.LINE,
yAxes: [{ title: "atitle" }],
xAxes: [{ title: "xtitle" }],
data: getData([
{
name: "<b>bbb</b>",
y: 10,
},
null,
]),
};
describe("getCustomizedConfiguration", () => {
it("should escape series names", () => {
const result = getCustomizedConfiguration(chartOptions);
expect(result.series[0].name).toEqual("<b>aaa</b>");
});
it("should escape data items in series", () => {
const result = getCustomizedConfiguration(chartOptions);
expect(result.series[0].data[0].name).toEqual("<b>bbb</b>");
});
it('should handle "%" format on axis and use label formatter', () => {
const chartOptionsWithFormat = immutableSet(chartOptions, "yAxes[0].format", "0.00 %");
const resultWithoutFormat = getCustomizedConfiguration(chartOptions);
const resultWithFormat = getCustomizedConfiguration(chartOptionsWithFormat);
expect(resultWithoutFormat.yAxis[0].labels.formatter).toBeUndefined();
expect(resultWithFormat.yAxis[0].labels.formatter).toBeDefined();
});
it("should set formatter for xAxis labels to prevent overlapping for bar chart with 90 rotation", () => {
const result = getCustomizedConfiguration({
...chartOptions,
type: "bar",
xAxisProps: {
rotation: "90",
},
});
expect(result.xAxis[0].labels.formatter).toBe(formatOverlapping);
});
it("should set formatter for xAxis labels to prevent overlapping for stacking bar chart with 90 rotation", () => {
const result = getCustomizedConfiguration({
...chartOptions,
isViewByTwoAttributes: true,
type: "bar",
xAxisProps: {
rotation: "90",
},
});
expect(result.xAxis[0].labels.formatter).toBe(formatOverlappingForParentAttribute);
});
it("shouldn't set formatter for xAxis by default", () => {
const result = getCustomizedConfiguration(chartOptions);
expect(result.xAxis[0].labels.formatter).toBeUndefined();
});
it("should set connectNulls for stacked Area chart", () => {
const result = getCustomizedConfiguration({
...chartOptions,
type: VisualizationTypes.AREA,
stacking: "normal",
});
expect(result.plotOptions.series.connectNulls).toBeTruthy();
});
it("should NOT set connectNulls for NON stacked Area chart", () => {
const result = getCustomizedConfiguration({
...chartOptions,
type: VisualizationTypes.AREA,
stacking: null,
});
expect(result.plotOptions.series).toEqual({});
});
it("should NOT set connectNulls for stacked Line chart", () => {
const result = getCustomizedConfiguration({
...chartOptions,
stacking: "normal",
});
expect(result.plotOptions.series.connectNulls).toBeUndefined();
});
describe("getAxesConfiguration", () => {
it("should set Y axis configuration from properties", () => {
const result = getCustomizedConfiguration({
...chartOptions,
yAxisProps: {
min: 20,
max: 30,
labelsEnabled: false,
visible: false,
},
});
const expectedResult = {
...result.yAxis[0],
min: 20,
max: 30,
labels: {
...result.yAxis[0].labels,
enabled: false,
},
title: {
...result.yAxis[0].title,
enabled: false,
text: "",
},
};
expect(result.yAxis[0]).toEqual(expectedResult);
});
it("should set X axis configurations from properties", () => {
const result = getCustomizedConfiguration({
...chartOptions,
xAxisProps: {
visible: false,
labelsEnabled: false,
rotation: "60",
},
});
const expectedResult = {
...result.xAxis[0],
title: {
...result.xAxis[0].title,
enabled: false,
text: "",
},
labels: {
...result.xAxis[0].labels,
enabled: false,
rotation: -60,
},
};
expect(result.xAxis[0]).toEqual(expectedResult);
});
it("should set X axis configurations with style", () => {
const result = getCustomizedConfiguration(chartOptions);
expect(result.xAxis[0].title.style).toEqual({
color: "#6d7680",
font: '14px Avenir, "Helvetica Neue", Arial, sans-serif',
textOverflow: "ellipsis",
});
});
it("should enable axis label for scatter plot when x and y are not set", () => {
const result = getCustomizedConfiguration({
...chartOptions,
type: VisualizationTypes.SCATTER,
});
const expectedXAxisResult = {
...result.xAxis[0],
labels: {
...result.xAxis[0].labels,
enabled: true,
},
};
const expectedYAxisResult = {
...result.yAxis[0],
labels: {
...result.yAxis[0].labels,
enabled: true,
},
};
expect(result.xAxis[0]).toEqual(expectedXAxisResult);
expect(result.yAxis[0]).toEqual(expectedYAxisResult);
});
it("should disable xAxis labels when x axis is disabled in scatter", () => {
const result = getCustomizedConfiguration({
...chartOptions,
xAxisProps: {
visible: false,
},
type: VisualizationTypes.SCATTER,
});
const expectedXAxisResult = {
...result.xAxis[0],
labels: {
...result.xAxis[0].labels,
enabled: false,
},
};
const expectedYAxisResult = {
...result.yAxis[0],
labels: {
...result.yAxis[0].labels,
enabled: true,
},
};
expect(result.xAxis[0]).toEqual(expectedXAxisResult);
expect(result.yAxis[0]).toEqual(expectedYAxisResult);
});
it("should disable labels when labels are disabled in bubble", () => {
const result = getCustomizedConfiguration({
...chartOptions,
xAxisProps: {
labelsEnabled: false,
},
type: VisualizationTypes.BUBBLE,
});
const expectedXAxisResult = {
...result.xAxis[0],
labels: {
...result.xAxis[0].labels,
enabled: false,
},
};
const expectedYAxisResult = {
...result.yAxis[0],
labels: {
...result.yAxis[0].labels,
enabled: true,
},
};
expect(result.xAxis[0]).toEqual(expectedXAxisResult);
expect(result.yAxis[0]).toEqual(expectedYAxisResult);
});
it("should enable labels for heatmap when categories are not empty", () => {
const result = getCustomizedConfiguration({
...chartOptions,
data: {
...chartOptions.data,
categories: ["c1", "c2"],
},
type: VisualizationTypes.HEATMAP,
});
const expectedXAxisResult = {
...result.xAxis[0],
labels: {
...result.xAxis[0].labels,
enabled: true,
},
};
const expectedYAxisResult = {
...result.yAxis[0],
labels: {
...result.yAxis[0].labels,
enabled: true,
},
};
expect(result.xAxis[0]).toEqual(expectedXAxisResult);
expect(result.yAxis[0]).toEqual(expectedYAxisResult);
});
it("should disable lables for heatmap when categories are empty", () => {
const result = getCustomizedConfiguration({
...chartOptions,
data: {
...chartOptions.data,
categories: [],
},
type: VisualizationTypes.HEATMAP,
});
const expectedXAxisResult = {
...result.xAxis[0],
labels: {
...result.xAxis[0].labels,
enabled: false,
},
};
const expectedYAxisResult = {
...result.yAxis[0],
labels: {
...result.yAxis[0].labels,
enabled: false,
},
};
expect(result.xAxis[0]).toEqual(expectedXAxisResult);
expect(result.yAxis[0]).toEqual(expectedYAxisResult);
});
it("should set extremes for y axis when x axis scale changed", () => {
const result = getCustomizedConfiguration({
...chartOptions,
xAxisProps: {
min: 20,
max: 30,
},
});
const expectedPlotOptions = {
getExtremesFromAll: true,
};
expect(result.plotOptions.series).toEqual(expectedPlotOptions);
});
it("should set axis line width to 1 in scatter plot when axis is enabled", () => {
const result = getCustomizedConfiguration({
...chartOptions,
yAxisProps: {
visible: true,
},
xAxisProps: {
visible: true,
},
type: VisualizationTypes.SCATTER,
});
expect(result.xAxis[0].lineWidth).toEqual(1);
expect(result.yAxis[0].lineWidth).toEqual(1);
});
it("should not set axis line when in column and axis is enabled", () => {
const result = getCustomizedConfiguration({
...chartOptions,
yAxisProps: {
visible: true,
},
xAxisProps: {
visible: true,
},
type: VisualizationTypes.COLUMN,
});
expect(result.xAxis[0].lineWidth).toBeUndefined();
expect(result.yAxis[0].lineWidth).toBeUndefined();
});
it("should set axis line width to 0 when axis is disabled", () => {
const result = getCustomizedConfiguration({
...chartOptions,
yAxisProps: {
visible: false,
},
xAxisProps: {
visible: false,
},
});
expect(result.xAxis[0].lineWidth).toEqual(0);
expect(result.yAxis[0].lineWidth).toEqual(0);
});
});
describe("gridline configuration", () => {
it("should set gridline width to 0 when grid is disabled", () => {
const result = getCustomizedConfiguration({ ...chartOptions, grid: { enabled: false } });
expect(result.yAxis[0].gridLineWidth).toEqual(0);
});
it("should set gridline width on xAxis on 1 for Scatterplot when enabled", () => {
const customConfig = { grid: { enabled: true }, type: VisualizationTypes.SCATTER };
const result = getCustomizedConfiguration({ ...chartOptions, ...customConfig });
expect(result.xAxis[0].gridLineWidth).toEqual(1);
});
it("should set gridline width on xAxis on 1 for Bubblechart when enabled", () => {
const customConfig = { grid: { enabled: true }, type: VisualizationTypes.BUBBLE };
const result = getCustomizedConfiguration({ ...chartOptions, ...customConfig });
expect(result.xAxis[0].gridLineWidth).toEqual(1);
});
it("should set gridline width on xAxis on 0 for Scatterplot when disabled", () => {
const customConfig = { grid: { enabled: false }, type: VisualizationTypes.SCATTER };
const result = getCustomizedConfiguration({ ...chartOptions, ...customConfig });
expect(result.xAxis[0].gridLineWidth).toEqual(0);
});
it("should set gridline width on xAxis on 0 for Bubblechart when disabled", () => {
const customConfig = { grid: { enabled: false }, type: VisualizationTypes.BUBBLE };
const result = getCustomizedConfiguration({ ...chartOptions, ...customConfig });
expect(result.xAxis[0].gridLineWidth).toEqual(0);
});
});
describe("labels configuration", () => {
it("should set two levels labels for multi-level treemap", () => {
const result = getCustomizedConfiguration({
...chartOptions,
type: VisualizationTypes.TREEMAP,
stacking: "normal",
});
const treemapConfig = result.plotOptions.treemap;
expect(treemapConfig.levels.length).toEqual(2);
});
it("should set one level labels for single-level treemap", () => {
const result = getCustomizedConfiguration({
...chartOptions,
type: VisualizationTypes.TREEMAP,
stacking: null,
});
const treemapConfig = result.plotOptions.treemap;
expect(treemapConfig.levels.length).toEqual(1);
});
it("should set global HCH dataLabels config according user config for treemap", () => {
const result = getCustomizedConfiguration(
{
...chartOptions,
type: VisualizationTypes.TREEMAP,
stacking: null,
},
{
dataLabels: {
visible: true,
},
},
);
const treemapConfig = result.plotOptions.treemap;
expect(treemapConfig.dataLabels).toEqual({
allowOverlap: true,
enabled: true,
});
});
describe("bubble dataLabels formatter", () => {
function setMinMax(
obj: any,
xAxisMin: number,
xAxisMax: number,
yAxisMin: number,
yAxisMax: number,
) {
set(obj, "series.xAxis.min", xAxisMin);
set(obj, "series.xAxis.max", xAxisMax);
set(obj, "series.yAxis.min", yAxisMin);
set(obj, "series.yAxis.max", yAxisMax);
}
function setPoint(obj: any, x: number, y: number, z: number) {
set(obj, "x", x);
set(obj, "y", y);
set(obj, "point.z", z);
}
it("should draw label when bubble is inside chart area", () => {
const result = getCustomizedConfiguration(
{
...chartOptions,
type: VisualizationTypes.BUBBLE,
},
{
dataLabels: {
visible: true,
},
},
);
setMinMax(result, 0, 10, 0, 10);
setPoint(result, 5, 10, 5);
const bubbleFormatter = get(result, "plotOptions.bubble.dataLabels.formatter", noop).bind(
result,
);
expect(bubbleFormatter()).toEqual("5");
});
it("should not draw dataLabel when label is outside chart area", () => {
const result = getCustomizedConfiguration(
{
...chartOptions,
type: VisualizationTypes.BUBBLE,
},
{
dataLabels: {
visible: true,
},
},
);
setMinMax(result, 0, 10, 0, 10);
setPoint(result, 5, 11, 5);
const bubbleFormatter = get(result, "plotOptions.bubble.dataLabels.formatter", noop).bind(
result,
);
expect(bubbleFormatter()).toEqual(null);
});
it("should show data label when min and max are not defined", () => {
const result = getCustomizedConfiguration(
{
...chartOptions,
type: VisualizationTypes.BUBBLE,
},
{
dataLabels: {
visible: true,
},
},
);
setPoint(result, 5, 11, 5);
const bubbleFormatter = get(result, "plotOptions.bubble.dataLabels.formatter", noop).bind(
result,
);
expect(bubbleFormatter()).toEqual("5");
});
});
});
describe("tooltip followPointer", () => {
// convert [bar, column, combo] to [ [bar] , [column] , [combo] ]
const CHART_TYPES = supportedTooltipFollowPointerChartTypes.map(
(chartType: string): string[] => [chartType],
);
it.each(CHART_TYPES)(
"should follow pointer for %s chart when data max is above axis max",
(chartType: string) => {
const result = getCustomizedConfiguration({
...chartOptions,
actions: { tooltip: true },
data: getData([{ y: 100 }, { y: 101 }]),
type: chartType,
yAxisProps: {
max: 50,
},
});
expect(result.tooltip.followPointer).toBeTruthy();
},
);
it.each(CHART_TYPES)(
"should not follow pointer for %s chart when data max is below axis max",
(chartType: string) => {
const result = getCustomizedConfiguration({
...chartOptions,
actions: { tooltip: true },
data: getData([{ y: 0 }, { y: 1 }]),
type: chartType,
yAxisProps: {
max: 50,
},
});
expect(result.tooltip.followPointer).toBeFalsy();
},
);
it("should follow pointer for pie chart should be false by default", () => {
const result = getCustomizedConfiguration({
...chartOptions,
actions: { tooltip: true },
data: getData([{ y: 100 }, { y: 101 }]),
type: VisualizationTypes.PIE,
yAxisProps: {
max: 50,
},
});
expect(result.tooltip.followPointer).toBeFalsy();
});
});
describe("tooltip position", () => {
it("should be positioned by absolute position", () => {
const highchartContext: any = {
chart: {
plotLeft: 0,
plotTop: 0,
container: {
getBoundingClientRect: jest.fn().mockReturnValue({
top: 10,
left: 20,
}),
},
},
};
const mockDataPoint: IPointData = {
negative: false,
plotX: 50,
plotY: 50,
h: 10,
};
// array: [chartType, stacking, labelWidth, labelHeight, point]
let mockChartParameters = ["bar", false, 10, 10, mockDataPoint];
// use .apply function here to mock for tooltip callback from highchart
let relativePosition = getTooltipPositionInChartContainer.apply(
highchartContext,
mockChartParameters,
);
expect(relativePosition).toEqual({ x: 45, y: 35 });
let position = getTooltipPositionInViewPort.apply(highchartContext, mockChartParameters);
// offset: pageOffset + containerPosition - highchart off set
// bar
expect(position).toEqual({
x: 15, // 0 + 20 - 5
y: 2 + relativePosition.y, // 0 + 10 -8 + 35
});
// line
mockChartParameters = ["line", false, 10, 10, mockDataPoint];
relativePosition = getTooltipPositionInChartContainer.apply(
highchartContext,
mockChartParameters,
);
expect(relativePosition).toEqual({ x: 45, y: 26 });
position = getTooltipPositionInViewPort.apply(highchartContext, mockChartParameters);
expect(position).toEqual({
x: 4, // 0 + 20 - 16
y: -6 + relativePosition.y, // 0 + 10 - 16 + 26
});
});
it("should limit top position of tooltip to keep tooltip in viewport", () => {
const highchartContext: any = {
chart: {
plotLeft: 0,
plotTop: 0,
container: {
getBoundingClientRect: jest.fn().mockReturnValue({
top: 0,
left: 0,
}),
},
},
};
const mockDataPoint: IPointData = {
negative: false,
plotX: 0,
plotY: 0,
h: 10,
};
// array: [chartType, stacking, labelWidth, labelHeight, point]
const mockChartParameters = ["bar", false, 100, 100, mockDataPoint];
// use .apply function here to mock for tooltip callback from highchart
const position = getTooltipPositionInViewPort.apply(highchartContext, mockChartParameters);
expect(position).toMatchObject({
y: TOOLTIP_VIEWPORT_MARGIN_TOP - TOOLTIP_PADDING,
});
});
});
describe("format data labels", () => {
const getDataLabelPoint = (opposite = false, axisNumber = 1) => ({
y: 1000,
percentage: 55.55,
series: {
chart: {
yAxis: Array(axisNumber).fill({}),
},
yAxis: {
opposite,
},
},
point: {
format: "#,##0.00",
},
});
it("should return number for not supported chart", () => {
const chartOptions = { type: VisualizationTypes.LINE, yAxes: [{}] };
const configuration = getCustomizedConfiguration(chartOptions);
const formatter = get(configuration, "plotOptions.bar.dataLabels.formatter", noop);
const dataLabelPoint = getDataLabelPoint();
const dataLabel = formatter.call(dataLabelPoint);
expect(dataLabel).toBe("1,000.00");
});
it.each([
["should return number for single axis chart without 'Stack to 100%'", 1],
["should return number for dual axis chart without 'Stack to 100%'", 2],
])("%s", (_description: string, axisNumber: number) => {
const chartOptions = { type: VisualizationTypes.COLUMN, yAxes: Array(axisNumber).fill({}) };
const configuration = getCustomizedConfiguration(chartOptions);
const formatter = get(configuration, "plotOptions.bar.dataLabels.formatter", noop);
const dataLabelPoint = getDataLabelPoint(false, axisNumber);
const dataLabel = formatter.call(dataLabelPoint);
expect(dataLabel).toBe("1,000.00");
});
it.each([
["should return percentage for left single axis chart with 'Stack to 100%'", false, 1, "55.55%"],
["should return percentage for right single axis chart with 'Stack to 100%'", true, 1, "55.55%"],
[
"should return percentage for primary axis for dual chart with 'Stack to 100%'",
false,
2,
"55.55%",
],
[
"should return number for secondary axis for dual chart with 'Stack to 100%'",
true,
2,
"1,000.00",
],
])("%s", (_description: string, opposite: boolean, axisNumber: number, expectation: string) => {
const chartOptions = { type: VisualizationTypes.COLUMN, yAxes: Array(axisNumber).fill({}) };
const config = { stackMeasuresToPercent: true };
const configuration = getCustomizedConfiguration(chartOptions, config);
const formatter = get(configuration, "plotOptions.bar.dataLabels.formatter", noop);
const dataLabelPoint = getDataLabelPoint(opposite, axisNumber);
const dataLabel = formatter.call(dataLabelPoint);
expect(dataLabel).toBe(expectation);
});
describe("percentage data label formatter", () => {
it("should return null with empty configuration", () => {
const result = percentageDataLabelFormatter.call({});
expect(result).toBeNull();
});
it("should return default data labels format with undefined percentage", () => {
const getDataLabelPoint = {
y: 1000,
series: {
chart: {
yAxis: [{}],
},
yAxis: {
opposite: true,
},
},
point: {
format: "#,##0.00",
},
};
const result = percentageDataLabelFormatter.call(getDataLabelPoint);
expect(result).toEqual("1,000.00");
});
it("should format data labels to percentage for single axis chart implicitly", () => {
const dataLabel = {
percentage: 55.55,
};
const result = percentageDataLabelFormatter.call(dataLabel);
expect(result).toBe("55.55%");
});
it.each([["left", false], ["right", true]])(
"should format data labels to percentage for %s single axis chart",
(_: string, opposite: boolean) => {
const dataLabel = {
percentage: 55.55,
series: {
chart: {
yAxis: [{}],
},
yAxis: {
opposite,
},
},
};
const result = percentageDataLabelFormatter.call(dataLabel);
expect(result).toBe("55.55%");
},
);
it.each([["", "primary", false, "55.55%"], [" not", "secondary", true, "123"]])(
"should %s format data labels to percentage for dual axis chart on %s axis",
(_negation: string, _axis: string, opposite: boolean, expected: string) => {
const dataLabel = {
percentage: 55.55,
y: 123,
series: {
chart: {
yAxis: [{}, {}],
},
yAxis: {
opposite,
},
},
};
const result = percentageDataLabelFormatter.call(dataLabel);
expect(result).toBe(expected);
},
);
});
});
describe("get X axis with drill config", () => {
const chartTypes = supportedStackingAttributesChartTypes.map((chartType: string) => [chartType]);
const afm: AFM.IAfm = {
attributes: [],
measures: [],
filters: [],
};
const drillConfig: IDrillConfig = {
afm,
onFiredDrillEvent: () => false,
};
it.each(chartTypes)('should set "drillConfig" to xAxis to %s chart', (chartType: string) => {
const result = getCustomizedConfiguration(
{ type: chartType, data: { series: [] } },
{},
drillConfig,
);
expect(result.xAxis[0].drillConfig).toEqual(drillConfig);
});
it('should not set "drillConfig" to unsupported chart type', () => {
const result = getCustomizedConfiguration({ type: VisualizationTypes.LINE }, {}, drillConfig);
expect(result.xAxis[0].drillConfig).toBeFalsy();
});
});
describe("get target cursor for bullet chart", () => {
const chartOptionsWithDrillableTarget: IChartOptions = {
type: "bullet",
data: {
series: [
{
data: [],
isDrillable: false,
},
{
data: [],
isDrillable: true,
},
{
type: "bullet",
data: [],
isDrillable: true,
},
],
},
};
const chartOptionsWithNonDrillableTarget: IChartOptions = {
type: "bullet",
data: {
series: [
{
data: [],
isDrillable: false,
},
{
data: [],
isDrillable: true,
},
{
type: "bullet",
data: [],
isDrillable: false,
},
],
},
};
it("should set the target cursor to pointer if the target is drillable", () => {
const result = getCustomizedConfiguration(chartOptionsWithDrillableTarget);
expect(result.plotOptions.bullet.cursor).toBe("pointer");
});
it("should not set the target cursor to pointer if the target is not drillable", () => {
const result = getCustomizedConfiguration(chartOptionsWithNonDrillableTarget);
expect(result.plotOptions.bullet).toBe(undefined);
});
});
});
describe("escapeCategories", () => {
it("should escape string categories", () => {
const categories = escapeCategories(["cat1", "<cat2/>", "<cat3></cat3>"]);
expect(categories).toEqual(["cat1", "<cat2/>", "<cat3></cat3>"]);
});
it("should escape object categories", () => {
const categories = escapeCategories([
{
name: "Status",
categories: ["cat1", "<cat2/>", "<cat3></cat3>"],
},
{
name: "<span>Sales</span>",
categories: ["<div>sale1</div>", "<sale2/>", "<sale3></sale3>"],
},
]);
expect(categories).toEqual([
{
name: "Status",
categories: ["cat1", "<cat2/>", "<cat3></cat3>"],
},
{
name: "<span>Sales</span>",
categories: ["<div>sale1</div>", "<sale2/>", "<sale3></sale3>"],
},
]);
});
});