@automattic/charts 1.8.1 → 1.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +25 -0
- package/dist/index.cjs +615 -58
- package/dist/index.cjs.map +1 -1
- package/dist/index.css +124 -36
- package/dist/index.d.cts +104 -4
- package/dist/index.d.ts +104 -4
- package/dist/index.js +615 -63
- package/dist/index.js.map +1 -1
- package/package.json +10 -10
- package/src/charts/area-chart/area-chart.module.scss +3 -1
- package/src/charts/area-chart/area-chart.tsx +5 -2
- package/src/charts/bar-chart/bar-chart.module.scss +6 -2
- package/src/charts/bar-chart/private/comparison-bars.tsx +6 -0
- package/src/charts/conversion-funnel-chart/conversion-funnel-chart.module.scss +15 -12
- package/src/charts/geo-chart/geo-chart.tsx +153 -3
- package/src/charts/geo-chart/index.ts +1 -1
- package/src/charts/geo-chart/test/geo-chart.test.tsx +132 -2
- package/src/charts/geo-chart/types.ts +11 -0
- package/src/charts/heatmap-chart/heatmap-chart.module.scss +103 -0
- package/src/charts/heatmap-chart/heatmap-chart.tsx +422 -0
- package/src/charts/heatmap-chart/index.ts +4 -0
- package/src/charts/heatmap-chart/private/build-calendar-data.ts +81 -0
- package/src/charts/heatmap-chart/private/heatmap-legend.tsx +53 -0
- package/src/charts/heatmap-chart/private/index.ts +5 -0
- package/src/charts/heatmap-chart/private/use-heatmap-colors.ts +45 -0
- package/src/charts/heatmap-chart/test/build-calendar-data.test.ts +88 -0
- package/src/charts/heatmap-chart/test/heatmap-chart.test.tsx +301 -0
- package/src/charts/heatmap-chart/test/use-heatmap-colors.test.ts +34 -0
- package/src/charts/heatmap-chart/types.ts +42 -0
- package/src/charts/index.ts +1 -0
- package/src/charts/leaderboard-chart/leaderboard-chart.module.scss +20 -8
- package/src/charts/line-chart/line-chart.module.scss +6 -4
- package/src/charts/line-chart/line-chart.tsx +6 -2
- package/src/charts/line-chart/private/line-chart-annotation.tsx +16 -3
- package/src/charts/private/grid-control/grid-control.module.scss +1 -4
- package/src/charts/private/svg-empty-state/svg-empty-state.module.scss +1 -1
- package/src/charts/private/with-responsive/test/with-responsive.test.tsx +14 -0
- package/src/charts/private/with-responsive/with-responsive.tsx +12 -0
- package/src/charts/private/x-zoom.module.scss +6 -3
- package/src/components/legend/private/base-legend.module.scss +3 -1
- package/src/components/tooltip/base-tooltip.module.scss +4 -1
- package/src/components/trend-indicator/trend-indicator.module.scss +5 -3
- package/src/hooks/use-xychart-theme.ts +24 -0
- package/src/index.ts +13 -1
- package/src/providers/chart-context/themes.ts +32 -16
- package/src/types.ts +19 -2
- package/src/utils/color-utils.ts +36 -0
- package/src/utils/test/color-utils.test.ts +33 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@automattic/charts",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.10.0",
|
|
4
4
|
"description": "Display charts within Automattic products.",
|
|
5
5
|
"homepage": "https://github.com/Automattic/jetpack/tree/HEAD/projects/js-packages/charts/#readme",
|
|
6
6
|
"bugs": {
|
|
@@ -83,9 +83,9 @@
|
|
|
83
83
|
"@visx/vendor": "^4.0.0",
|
|
84
84
|
"@visx/xychart": "^4.0.0",
|
|
85
85
|
"@wordpress/i18n": "^6.0.0",
|
|
86
|
-
"@wordpress/icons": "^
|
|
87
|
-
"@wordpress/theme": "0.
|
|
88
|
-
"@wordpress/ui": "0.
|
|
86
|
+
"@wordpress/icons": "^15.0.0",
|
|
87
|
+
"@wordpress/theme": "0.17.0",
|
|
88
|
+
"@wordpress/ui": "0.17.0",
|
|
89
89
|
"clsx": "2.1.1",
|
|
90
90
|
"date-fns": "^4.1.0",
|
|
91
91
|
"deepmerge": "4.3.1",
|
|
@@ -97,8 +97,8 @@
|
|
|
97
97
|
"devDependencies": {
|
|
98
98
|
"@automattic/jetpack-webpack-config": "workspace:*",
|
|
99
99
|
"@babel/core": "^7.29.7",
|
|
100
|
-
"@storybook/addon-docs": "10.
|
|
101
|
-
"@storybook/react": "10.
|
|
100
|
+
"@storybook/addon-docs": "10.4.6",
|
|
101
|
+
"@storybook/react": "10.4.6",
|
|
102
102
|
"@testing-library/dom": "^10.0.0",
|
|
103
103
|
"@testing-library/jest-dom": "^6.0.0",
|
|
104
104
|
"@testing-library/react": "^16.0.0",
|
|
@@ -109,9 +109,9 @@
|
|
|
109
109
|
"@types/react-dom": "18.3.7",
|
|
110
110
|
"@typescript/native-preview": "7.0.0-dev.20260225.1",
|
|
111
111
|
"@visx/glyph": "4.0.0",
|
|
112
|
-
"@wordpress/components": "
|
|
113
|
-
"@wordpress/element": "8.0
|
|
114
|
-
"@wordpress/private-apis": "1.
|
|
112
|
+
"@wordpress/components": "36.1.0",
|
|
113
|
+
"@wordpress/element": "8.2.0",
|
|
114
|
+
"@wordpress/private-apis": "1.50.0",
|
|
115
115
|
"babel-jest": "30.4.1",
|
|
116
116
|
"identity-obj-proxy": "^3.0.0",
|
|
117
117
|
"jest": "30.4.2",
|
|
@@ -120,7 +120,7 @@
|
|
|
120
120
|
"react": "18.3.1",
|
|
121
121
|
"react-dom": "18.3.1",
|
|
122
122
|
"sass-embedded": "1.97.3",
|
|
123
|
-
"storybook": "10.
|
|
123
|
+
"storybook": "10.4.6",
|
|
124
124
|
"tsdown": "0.22.2",
|
|
125
125
|
"typescript": "5.9.3"
|
|
126
126
|
},
|
|
@@ -27,7 +27,7 @@ import {
|
|
|
27
27
|
useGlobalChartsContext,
|
|
28
28
|
useGlobalChartsTheme,
|
|
29
29
|
} from '../../providers';
|
|
30
|
-
import { attachSubComponents } from '../../utils';
|
|
30
|
+
import { attachSubComponents, resolveCssVariable } from '../../utils';
|
|
31
31
|
import { renderDefaultTooltip } from '../line-chart';
|
|
32
32
|
import { useChartChildren } from '../private/chart-composition';
|
|
33
33
|
import { ChartLayout } from '../private/chart-layout';
|
|
@@ -461,7 +461,10 @@ const AreaChartInternal = forwardRef< SingleChartRef, AreaChartProps >(
|
|
|
461
461
|
stacked={ stacked }
|
|
462
462
|
stackOffset={ stackOffset }
|
|
463
463
|
getElementStyles={ getElementStyles }
|
|
464
|
-
strokeColor={
|
|
464
|
+
strokeColor={
|
|
465
|
+
resolveCssVariable( providerTheme.backgroundColor ) ??
|
|
466
|
+
providerTheme.backgroundColor
|
|
467
|
+
}
|
|
465
468
|
/>
|
|
466
469
|
</>
|
|
467
470
|
) }
|
|
@@ -8,7 +8,9 @@
|
|
|
8
8
|
transform-origin: bottom;
|
|
9
9
|
transform-box: fill-box;
|
|
10
10
|
transform: scaleY(0);
|
|
11
|
-
animation:
|
|
11
|
+
animation:
|
|
12
|
+
rise var(--wpds-motion-duration-xl, 400ms)
|
|
13
|
+
var(--wpds-motion-easing-expressive, cubic-bezier(0.25, 0, 0, 1)) forwards;
|
|
12
14
|
}
|
|
13
15
|
|
|
14
16
|
@keyframes rise {
|
|
@@ -22,7 +24,9 @@
|
|
|
22
24
|
transform-origin: left;
|
|
23
25
|
transform-box: fill-box;
|
|
24
26
|
transform: scaleX(0);
|
|
25
|
-
animation:
|
|
27
|
+
animation:
|
|
28
|
+
stretch var(--wpds-motion-duration-xl, 400ms)
|
|
29
|
+
var(--wpds-motion-easing-expressive, cubic-bezier(0.25, 0, 0, 1)) forwards;
|
|
26
30
|
}
|
|
27
31
|
|
|
28
32
|
@keyframes stretch {
|
|
@@ -10,6 +10,12 @@ import type { ElementStyles, GetElementStylesParams } from '../../../providers';
|
|
|
10
10
|
import type { DataPointDate, SeriesData } from '../../../types';
|
|
11
11
|
import type { FC, ReactNode } from 'react';
|
|
12
12
|
|
|
13
|
+
/*
|
|
14
|
+
* `process.env.NODE_ENV` is replaced by the bundler at build time. Declare a
|
|
15
|
+
* minimal `process` locally so this file type-checks as source under `jetpack:src`.
|
|
16
|
+
*/
|
|
17
|
+
declare const process: { env: Record< string, string | undefined > };
|
|
18
|
+
|
|
13
19
|
export type ComparisonSeriesEntry = {
|
|
14
20
|
series: SeriesData;
|
|
15
21
|
index: number;
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
|
|
13
13
|
.main-rate {
|
|
14
14
|
overflow: hidden;
|
|
15
|
-
color: #1e1e1e;
|
|
15
|
+
color: var(--wpds-color-foreground-content-neutral, #1e1e1e);
|
|
16
16
|
text-overflow: ellipsis;
|
|
17
17
|
font-size: var(--wpds-typography-font-size-xl, 18px);
|
|
18
18
|
font-style: normal;
|
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
|
|
34
34
|
.funnel-container {
|
|
35
35
|
flex: 1;
|
|
36
|
-
min-height:
|
|
36
|
+
min-height: 0;
|
|
37
37
|
width: 100%;
|
|
38
38
|
}
|
|
39
39
|
|
|
@@ -43,7 +43,9 @@
|
|
|
43
43
|
height: 100%;
|
|
44
44
|
|
|
45
45
|
&--animated {
|
|
46
|
-
transition:
|
|
46
|
+
transition:
|
|
47
|
+
opacity var(--wpds-motion-duration-lg, 300ms)
|
|
48
|
+
var(--wpds-motion-easing-subtle, cubic-bezier(0.15, 0, 0.15, 1));
|
|
47
49
|
}
|
|
48
50
|
|
|
49
51
|
&--blurred {
|
|
@@ -52,7 +54,7 @@
|
|
|
52
54
|
}
|
|
53
55
|
|
|
54
56
|
.step-label {
|
|
55
|
-
color: #
|
|
57
|
+
color: var(--wpds-color-foreground-content-neutral-weak, #707070);
|
|
56
58
|
font-size: var(--wpds-typography-font-size-sm, 12px);
|
|
57
59
|
font-weight: var(--wpds-typography-font-weight-regular, 400);
|
|
58
60
|
line-height: var(--wpds-typography-line-height-xs, 16px);
|
|
@@ -62,7 +64,7 @@
|
|
|
62
64
|
}
|
|
63
65
|
|
|
64
66
|
.step-rate {
|
|
65
|
-
color: #1e1e1e;
|
|
67
|
+
color: var(--wpds-color-foreground-content-neutral, #1e1e1e);
|
|
66
68
|
font-size: var(--wpds-typography-font-size-md, 13px);
|
|
67
69
|
font-weight: var(--wpds-typography-font-weight-medium, 499);
|
|
68
70
|
line-height: var(--wpds-typography-line-height-xs, 16px);
|
|
@@ -84,7 +86,9 @@
|
|
|
84
86
|
transform-origin: bottom;
|
|
85
87
|
transform-box: fill-box;
|
|
86
88
|
transform: scaleY(0);
|
|
87
|
-
animation:
|
|
89
|
+
animation:
|
|
90
|
+
stretch var(--wpds-motion-duration-xl, 400ms)
|
|
91
|
+
var(--wpds-motion-easing-expressive, cubic-bezier(0.25, 0, 0, 1)) forwards;
|
|
88
92
|
}
|
|
89
93
|
|
|
90
94
|
@keyframes stretch {
|
|
@@ -96,18 +100,17 @@
|
|
|
96
100
|
}
|
|
97
101
|
|
|
98
102
|
.tooltip-wrapper {
|
|
99
|
-
|
|
100
|
-
background: var(--black-white-white, #fff);
|
|
103
|
+
background: var(--wpds-color-background-surface-neutral-strong, #fff);
|
|
101
104
|
|
|
102
105
|
// Override .visx-tooltip inline styles.
|
|
103
106
|
border-radius: var(--wpds-border-radius-md, 4px) !important;
|
|
104
107
|
padding: var(--wpds-dimension-padding-md, 12px) !important;
|
|
105
|
-
box-shadow: 0 1px
|
|
108
|
+
box-shadow: var(--wpds-elevation-sm, 0 1px 2px 0 #0000000d, 0 2px 3px 0 #0000000a, 0 6px 6px 0 #00000008, 0 8px 8px 0 #00000005) !important;
|
|
106
109
|
|
|
107
110
|
}
|
|
108
111
|
|
|
109
112
|
.tooltip-title {
|
|
110
|
-
color: #1e1e1e;
|
|
113
|
+
color: var(--wpds-color-foreground-content-neutral, #1e1e1e);
|
|
111
114
|
font-size: var(--wpds-typography-font-size-sm, 12px);
|
|
112
115
|
font-style: normal;
|
|
113
116
|
font-weight: var(--wpds-typography-font-weight-regular, 400);
|
|
@@ -115,7 +118,7 @@
|
|
|
115
118
|
}
|
|
116
119
|
|
|
117
120
|
.tooltip-content {
|
|
118
|
-
color: #1e1e1e;
|
|
121
|
+
color: var(--wpds-color-foreground-content-neutral, #1e1e1e);
|
|
119
122
|
font-size: var(--wpds-typography-font-size-md, 13px);
|
|
120
123
|
font-style: normal;
|
|
121
124
|
font-weight: var(--wpds-typography-font-weight-medium, 499);
|
|
@@ -124,6 +127,6 @@
|
|
|
124
127
|
|
|
125
128
|
.empty-state {
|
|
126
129
|
text-align: center;
|
|
127
|
-
color: #
|
|
130
|
+
color: var(--wpds-color-foreground-content-neutral-weak, #707070);
|
|
128
131
|
font-size: var(--wpds-typography-font-size-lg, 16px);
|
|
129
132
|
}
|
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
*/
|
|
4
4
|
import { __ } from '@wordpress/i18n';
|
|
5
5
|
import clsx from 'clsx';
|
|
6
|
-
import { FC, useContext, useMemo } from 'react';
|
|
7
|
-
import { Chart } from 'react-google-charts';
|
|
6
|
+
import { FC, useContext, useEffect, useMemo, useRef } from 'react';
|
|
7
|
+
import { Chart, type GoogleChartPackages, type ReactGoogleChartEvent } from 'react-google-charts';
|
|
8
8
|
/**
|
|
9
9
|
* Internal dependencies
|
|
10
10
|
*/
|
|
@@ -15,12 +15,99 @@ import { sanitizeHtml } from '../../utils/sanitize-html';
|
|
|
15
15
|
import { Center } from '../private/center';
|
|
16
16
|
import { withResponsive } from '../private/with-responsive';
|
|
17
17
|
import styles from './geo-chart.module.scss';
|
|
18
|
-
import { GeoChartProps } from './types';
|
|
18
|
+
import type { GeoChartError, GeoChartProps } from './types';
|
|
19
19
|
|
|
20
20
|
const DEFAULT_FEATURE_FILL_COLOR = '#ffffff';
|
|
21
21
|
const DEFAULT_BACKGROUND_COLOR = '#ffffff';
|
|
22
|
+
// `chartPackages` replaces (not extends) react-google-charts' default `[ 'corechart', 'controls' ]`,
|
|
23
|
+
// so we restate the defaults and add `geochart`. Without it the loader backfills the geochart package
|
|
24
|
+
// late, which can clash with another Google Charts version already loaded on the page.
|
|
25
|
+
const GEO_CHART_PACKAGES: GoogleChartPackages[] = [ 'corechart', 'controls', 'geochart' ];
|
|
22
26
|
|
|
23
27
|
type GoogleChartOptions = Record< string, unknown >;
|
|
28
|
+
type GoogleChartErrorPayload = {
|
|
29
|
+
id?: unknown;
|
|
30
|
+
message?: unknown;
|
|
31
|
+
detailedMessage?: unknown;
|
|
32
|
+
options?: unknown;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
// Google Charts renders draw errors as DOM elements injected into the chart
|
|
36
|
+
// container: a wrapper `<div id="google-visualization-errors-all-N">` holding
|
|
37
|
+
// one `<span id="google-visualization-errors-N">` per error. The span id is the
|
|
38
|
+
// error id accepted by `google.visualization.errors.removeError()`.
|
|
39
|
+
const GOOGLE_CHARTS_ERROR_ID_PREFIX = 'google-visualization-errors-';
|
|
40
|
+
const GOOGLE_CHARTS_ERROR_WRAPPER_INFIX = '-all-';
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Collects Google Charts error elements rendered inside a chart container.
|
|
44
|
+
*
|
|
45
|
+
* @param container - The chart container element to scan.
|
|
46
|
+
* @return Errors found in the container, one per error span.
|
|
47
|
+
*/
|
|
48
|
+
function collectRenderedGeoChartErrors(
|
|
49
|
+
container: HTMLElement
|
|
50
|
+
): Required< Pick< GeoChartError, 'id' | 'message' > >[] {
|
|
51
|
+
const elements = container.querySelectorAll< HTMLElement >(
|
|
52
|
+
`[id^="${ GOOGLE_CHARTS_ERROR_ID_PREFIX }"]`
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
return Array.from( elements )
|
|
56
|
+
.filter( element => ! element.id.includes( GOOGLE_CHARTS_ERROR_WRAPPER_INFIX ) )
|
|
57
|
+
.map( element => ( {
|
|
58
|
+
id: element.id,
|
|
59
|
+
message: element.textContent?.trim() ?? '',
|
|
60
|
+
} ) )
|
|
61
|
+
.filter( error => error.message.length > 0 );
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Whether a node added to the chart container is — or contains — a Google
|
|
66
|
+
* Charts error element. Also matches text appended into an existing error
|
|
67
|
+
* span, in case Google fills the message after inserting the element.
|
|
68
|
+
*
|
|
69
|
+
* @param node - The added DOM node to inspect.
|
|
70
|
+
* @return Whether the node involves a Google Charts error element.
|
|
71
|
+
*/
|
|
72
|
+
function involvesGeoChartErrorElement( node: Node ): boolean {
|
|
73
|
+
if ( node.nodeType === Node.TEXT_NODE ) {
|
|
74
|
+
return !! node.parentElement?.id.startsWith( GOOGLE_CHARTS_ERROR_ID_PREFIX );
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if ( ! ( node instanceof HTMLElement ) ) {
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return (
|
|
82
|
+
node.id.startsWith( GOOGLE_CHARTS_ERROR_ID_PREFIX ) ||
|
|
83
|
+
node.querySelector( `[id^="${ GOOGLE_CHARTS_ERROR_ID_PREFIX }"]` ) !== null
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Normalizes the raw Google Charts error event into the GeoChart error shape.
|
|
89
|
+
*
|
|
90
|
+
* @param eventArgs - Error event payload from react-google-charts.
|
|
91
|
+
* @return Normalized GeoChart error.
|
|
92
|
+
*/
|
|
93
|
+
function normalizeGeoChartError( eventArgs: unknown ): GeoChartError {
|
|
94
|
+
const payload = Array.isArray( eventArgs ) ? eventArgs[ 0 ] : eventArgs;
|
|
95
|
+
|
|
96
|
+
if ( ! payload || typeof payload !== 'object' ) {
|
|
97
|
+
return {};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const { id, message, detailedMessage, options } = payload as GoogleChartErrorPayload;
|
|
101
|
+
|
|
102
|
+
return {
|
|
103
|
+
...( typeof id === 'string' && { id } ),
|
|
104
|
+
...( typeof message === 'string' && { message } ),
|
|
105
|
+
...( typeof detailedMessage === 'string' && { detailedMessage } ),
|
|
106
|
+
...( options &&
|
|
107
|
+
typeof options === 'object' &&
|
|
108
|
+
! Array.isArray( options ) && { options: options as Record< string, unknown > } ),
|
|
109
|
+
};
|
|
110
|
+
}
|
|
24
111
|
|
|
25
112
|
/**
|
|
26
113
|
* Renders a geographical chart using Google Charts GeoChart to visualize data.
|
|
@@ -37,6 +124,7 @@ type GoogleChartOptions = Record< string, unknown >;
|
|
|
37
124
|
* @param props.height - Height of the chart in pixels
|
|
38
125
|
* @param props.region - Region to display ('world', 'US', or ISO 3166-1 alpha-2 code)
|
|
39
126
|
* @param props.resolution - Resolution level ('countries', 'provinces', or 'metros')
|
|
127
|
+
* @param props.onError - Optional callback for Google Charts errors
|
|
40
128
|
* @param props.className - Additional CSS class name for the chart container
|
|
41
129
|
* @param props.renderPlaceholder - Optional render function for the loading placeholder
|
|
42
130
|
* @return A React component displaying an interactive map with data visualization
|
|
@@ -48,6 +136,7 @@ const GeoChartInternal: FC< GeoChartProps > = ( {
|
|
|
48
136
|
height,
|
|
49
137
|
region = 'world',
|
|
50
138
|
resolution = 'countries',
|
|
139
|
+
onError,
|
|
51
140
|
renderPlaceholder,
|
|
52
141
|
} ) => {
|
|
53
142
|
const {
|
|
@@ -57,6 +146,49 @@ const GeoChartInternal: FC< GeoChartProps > = ( {
|
|
|
57
146
|
backgroundColor,
|
|
58
147
|
},
|
|
59
148
|
} = useGlobalChartsContext();
|
|
149
|
+
const containerRef = useRef< HTMLDivElement >( null );
|
|
150
|
+
const reportedErrorIdsRef = useRef< Set< string > >( new Set() );
|
|
151
|
+
|
|
152
|
+
// The ChartWrapper `error` event does not fire for every draw failure —
|
|
153
|
+
// notably not when GeoChart's asynchronous map-file load fails (e.g.
|
|
154
|
+
// `resolution: 'provinces'` for a country without a provinces map). Those
|
|
155
|
+
// errors only surface as DOM elements Google injects into the container, so
|
|
156
|
+
// watch the container and report them through the same `onError` callback.
|
|
157
|
+
useEffect( () => {
|
|
158
|
+
const container = containerRef.current;
|
|
159
|
+
|
|
160
|
+
if ( ! onError || ! container || typeof MutationObserver === 'undefined' ) {
|
|
161
|
+
return undefined;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const reportRenderedErrors = () => {
|
|
165
|
+
for ( const error of collectRenderedGeoChartErrors( container ) ) {
|
|
166
|
+
if ( reportedErrorIdsRef.current.has( error.id ) ) {
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
reportedErrorIdsRef.current.add( error.id );
|
|
171
|
+
onError( error );
|
|
172
|
+
}
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
// GeoChart mutates the container heavily while drawing and resizing;
|
|
176
|
+
// only rescan when an added node involves a Google error element.
|
|
177
|
+
const observer = new MutationObserver( records => {
|
|
178
|
+
const hasErrorNodes = records.some( record =>
|
|
179
|
+
Array.from( record.addedNodes ).some( involvesGeoChartErrorElement )
|
|
180
|
+
);
|
|
181
|
+
|
|
182
|
+
if ( hasErrorNodes ) {
|
|
183
|
+
reportRenderedErrors();
|
|
184
|
+
}
|
|
185
|
+
} );
|
|
186
|
+
observer.observe( container, { childList: true, subtree: true } );
|
|
187
|
+
// Report errors already rendered before the observer attached.
|
|
188
|
+
reportRenderedErrors();
|
|
189
|
+
|
|
190
|
+
return () => observer.disconnect();
|
|
191
|
+
}, [ onError ] );
|
|
60
192
|
|
|
61
193
|
// Render loading placeholder
|
|
62
194
|
const loadingPlaceholder = (
|
|
@@ -146,18 +278,36 @@ const GeoChartInternal: FC< GeoChartProps > = ( {
|
|
|
146
278
|
]
|
|
147
279
|
);
|
|
148
280
|
|
|
281
|
+
const chartEvents = useMemo< ReactGoogleChartEvent[] | undefined >( () => {
|
|
282
|
+
if ( ! onError ) {
|
|
283
|
+
return undefined;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
return [
|
|
287
|
+
{
|
|
288
|
+
eventName: 'error',
|
|
289
|
+
callback: ( { eventArgs } ) => {
|
|
290
|
+
onError( normalizeGeoChartError( eventArgs ) );
|
|
291
|
+
},
|
|
292
|
+
},
|
|
293
|
+
];
|
|
294
|
+
}, [ onError ] );
|
|
295
|
+
|
|
149
296
|
return (
|
|
150
297
|
<Center
|
|
298
|
+
ref={ containerRef }
|
|
151
299
|
className={ clsx( 'geo-chart', styles.container, className ) }
|
|
152
300
|
data-testid="geo-chart"
|
|
153
301
|
style={ { width, height, backgroundColor } }
|
|
154
302
|
>
|
|
155
303
|
<Chart
|
|
156
304
|
chartType="GeoChart"
|
|
305
|
+
chartPackages={ GEO_CHART_PACKAGES }
|
|
157
306
|
width={ width }
|
|
158
307
|
height={ height }
|
|
159
308
|
data={ sanitizedData.data }
|
|
160
309
|
options={ options }
|
|
310
|
+
chartEvents={ chartEvents }
|
|
161
311
|
loader={ loadingPlaceholder }
|
|
162
312
|
/>
|
|
163
313
|
</Center>
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
export { default as GeoChart, GeoChartUnresponsive } from './geo-chart';
|
|
2
|
-
export type { GeoChartProps, GeoRegion, GeoResolution } from './types';
|
|
2
|
+
export type { GeoChartProps, GeoRegion, GeoResolution, GeoChartError } from './types';
|
|
@@ -1,12 +1,17 @@
|
|
|
1
|
-
import { render, screen } from '@testing-library/react';
|
|
1
|
+
import { render, screen, waitFor } from '@testing-library/react';
|
|
2
|
+
import { Chart } from 'react-google-charts';
|
|
2
3
|
import { GlobalChartsProvider } from '../../../providers';
|
|
3
4
|
import GeoChart, { GeoChartUnresponsive } from '../geo-chart';
|
|
4
5
|
|
|
5
6
|
// Mock react-google-charts
|
|
6
7
|
jest.mock( 'react-google-charts', () => ( {
|
|
7
|
-
Chart: jest.fn( ( { data, options, width, height } ) => {
|
|
8
|
+
Chart: jest.fn( ( { chartEvents, chartPackages, data, options, width, height } ) => {
|
|
8
9
|
return (
|
|
9
10
|
<div data-testid="google-chart-mock" data-width={ width } data-height={ height }>
|
|
11
|
+
<div data-testid="chart-events">
|
|
12
|
+
{ JSON.stringify( chartEvents?.map( event => event.eventName ) ?? [] ) }
|
|
13
|
+
</div>
|
|
14
|
+
<div data-testid="chart-packages">{ JSON.stringify( chartPackages ) }</div>
|
|
10
15
|
<div data-testid="chart-data">{ JSON.stringify( data ) }</div>
|
|
11
16
|
<div data-testid="chart-options">{ JSON.stringify( options ) }</div>
|
|
12
17
|
</div>
|
|
@@ -15,6 +20,8 @@ jest.mock( 'react-google-charts', () => ( {
|
|
|
15
20
|
} ) );
|
|
16
21
|
|
|
17
22
|
describe( 'GeoChart', () => {
|
|
23
|
+
const ChartMock = Chart as jest.Mock;
|
|
24
|
+
|
|
18
25
|
const defaultProps = {
|
|
19
26
|
width: 800,
|
|
20
27
|
height: 400,
|
|
@@ -64,6 +71,15 @@ describe( 'GeoChart', () => {
|
|
|
64
71
|
expect( chart ).toHaveAttribute( 'data-width', '1200' );
|
|
65
72
|
expect( chart ).toHaveAttribute( 'data-height', '600' );
|
|
66
73
|
} );
|
|
74
|
+
|
|
75
|
+
test( 'loads the GeoChart package explicitly', () => {
|
|
76
|
+
renderWithTheme();
|
|
77
|
+
|
|
78
|
+
const chartPackages = screen.getByTestId( 'chart-packages' );
|
|
79
|
+
const packages = JSON.parse( chartPackages.textContent || '[]' );
|
|
80
|
+
|
|
81
|
+
expect( packages ).toEqual( [ 'corechart', 'controls', 'geochart' ] );
|
|
82
|
+
} );
|
|
67
83
|
} );
|
|
68
84
|
|
|
69
85
|
describe( 'Data Handling', () => {
|
|
@@ -248,6 +264,120 @@ describe( 'GeoChart', () => {
|
|
|
248
264
|
} );
|
|
249
265
|
} );
|
|
250
266
|
|
|
267
|
+
describe( 'Chart Events', () => {
|
|
268
|
+
test( 'does not pass chart events when onError is omitted', () => {
|
|
269
|
+
renderWithTheme();
|
|
270
|
+
|
|
271
|
+
const chartEvents = screen.getByTestId( 'chart-events' );
|
|
272
|
+
const events = JSON.parse( chartEvents.textContent || '[]' );
|
|
273
|
+
|
|
274
|
+
expect( events ).toEqual( [] );
|
|
275
|
+
} );
|
|
276
|
+
|
|
277
|
+
test( 'calls onError when Google Charts emits an error', () => {
|
|
278
|
+
const onError = jest.fn();
|
|
279
|
+
renderWithTheme( { onError } );
|
|
280
|
+
|
|
281
|
+
const chartProps = ChartMock.mock.calls[ ChartMock.mock.calls.length - 1 ]?.[ 0 ];
|
|
282
|
+
const errorEvent = chartProps.chartEvents.find(
|
|
283
|
+
( event: { eventName: string } ) => event.eventName === 'error'
|
|
284
|
+
);
|
|
285
|
+
|
|
286
|
+
errorEvent.callback( {
|
|
287
|
+
eventArgs: [
|
|
288
|
+
{
|
|
289
|
+
id: 'map-error',
|
|
290
|
+
message: 'Requested map does not exist.',
|
|
291
|
+
detailedMessage: 'The requested map is not available.',
|
|
292
|
+
options: { region: 'SG', resolution: 'provinces' },
|
|
293
|
+
},
|
|
294
|
+
],
|
|
295
|
+
} );
|
|
296
|
+
|
|
297
|
+
expect( onError ).toHaveBeenCalledWith( {
|
|
298
|
+
id: 'map-error',
|
|
299
|
+
message: 'Requested map does not exist.',
|
|
300
|
+
detailedMessage: 'The requested map is not available.',
|
|
301
|
+
options: { region: 'SG', resolution: 'provinces' },
|
|
302
|
+
} );
|
|
303
|
+
} );
|
|
304
|
+
|
|
305
|
+
test( 'reports error elements Google injects into the chart container', async () => {
|
|
306
|
+
const onError = jest.fn();
|
|
307
|
+
renderWithTheme( { onError } );
|
|
308
|
+
|
|
309
|
+
// Simulate Google Charts rendering a draw error into the container,
|
|
310
|
+
// as happens when an async map-file load fails without firing the
|
|
311
|
+
// ChartWrapper error event.
|
|
312
|
+
const container = screen.getByTestId( 'geo-chart' );
|
|
313
|
+
const errorWrapper = document.createElement( 'div' );
|
|
314
|
+
errorWrapper.id = 'google-visualization-errors-all-1';
|
|
315
|
+
const errorSpan = document.createElement( 'span' );
|
|
316
|
+
errorSpan.id = 'google-visualization-errors-1';
|
|
317
|
+
errorSpan.textContent = 'Requested map does not exist.';
|
|
318
|
+
errorWrapper.appendChild( errorSpan );
|
|
319
|
+
container.appendChild( errorWrapper );
|
|
320
|
+
|
|
321
|
+
await waitFor( () =>
|
|
322
|
+
expect( onError ).toHaveBeenCalledWith( {
|
|
323
|
+
id: 'google-visualization-errors-1',
|
|
324
|
+
message: 'Requested map does not exist.',
|
|
325
|
+
} )
|
|
326
|
+
);
|
|
327
|
+
} );
|
|
328
|
+
|
|
329
|
+
test( 'reports error elements nested inside an added plain node', async () => {
|
|
330
|
+
const onError = jest.fn();
|
|
331
|
+
renderWithTheme( { onError } );
|
|
332
|
+
|
|
333
|
+
// The observer filters mutation records by the added node; an error
|
|
334
|
+
// element arriving inside a plain wrapper must still be found.
|
|
335
|
+
const container = screen.getByTestId( 'geo-chart' );
|
|
336
|
+
const plainWrapper = document.createElement( 'div' );
|
|
337
|
+
const errorSpan = document.createElement( 'span' );
|
|
338
|
+
errorSpan.id = 'google-visualization-errors-9';
|
|
339
|
+
errorSpan.textContent = 'Requested map does not exist.';
|
|
340
|
+
plainWrapper.appendChild( errorSpan );
|
|
341
|
+
container.appendChild( plainWrapper );
|
|
342
|
+
|
|
343
|
+
await waitFor( () =>
|
|
344
|
+
expect( onError ).toHaveBeenCalledWith( {
|
|
345
|
+
id: 'google-visualization-errors-9',
|
|
346
|
+
message: 'Requested map does not exist.',
|
|
347
|
+
} )
|
|
348
|
+
);
|
|
349
|
+
} );
|
|
350
|
+
|
|
351
|
+
test( 'reports each rendered error element only once', async () => {
|
|
352
|
+
const onError = jest.fn();
|
|
353
|
+
renderWithTheme( { onError } );
|
|
354
|
+
|
|
355
|
+
const container = screen.getByTestId( 'geo-chart' );
|
|
356
|
+
const errorSpan = document.createElement( 'span' );
|
|
357
|
+
errorSpan.id = 'google-visualization-errors-2';
|
|
358
|
+
errorSpan.textContent = 'Requested map does not exist.';
|
|
359
|
+
container.appendChild( errorSpan );
|
|
360
|
+
|
|
361
|
+
await waitFor( () => expect( onError ).toHaveBeenCalledTimes( 1 ) );
|
|
362
|
+
|
|
363
|
+
// A later mutation makes the observer re-scan the container; the first
|
|
364
|
+
// error is seen again but must not be re-reported. The second error
|
|
365
|
+
// being reported proves the re-scan happened.
|
|
366
|
+
const secondErrorSpan = document.createElement( 'span' );
|
|
367
|
+
secondErrorSpan.id = 'google-visualization-errors-3';
|
|
368
|
+
secondErrorSpan.textContent = 'Requested map does not exist.';
|
|
369
|
+
container.appendChild( secondErrorSpan );
|
|
370
|
+
|
|
371
|
+
await waitFor( () =>
|
|
372
|
+
expect( onError ).toHaveBeenCalledWith( {
|
|
373
|
+
id: 'google-visualization-errors-3',
|
|
374
|
+
message: 'Requested map does not exist.',
|
|
375
|
+
} )
|
|
376
|
+
);
|
|
377
|
+
expect( onError ).toHaveBeenCalledTimes( 2 );
|
|
378
|
+
} );
|
|
379
|
+
} );
|
|
380
|
+
|
|
251
381
|
describe( 'Loading State', () => {
|
|
252
382
|
test( 'provides loading placeholder to Google Charts', () => {
|
|
253
383
|
// The loading placeholder is passed to the Chart component's loader prop
|
|
@@ -15,6 +15,13 @@ export type GeoRegion = 'world' | ( string & {} );
|
|
|
15
15
|
*/
|
|
16
16
|
export type GeoResolution = 'countries' | 'provinces' | 'metros';
|
|
17
17
|
|
|
18
|
+
export interface GeoChartError {
|
|
19
|
+
id?: string;
|
|
20
|
+
message?: string;
|
|
21
|
+
detailedMessage?: string;
|
|
22
|
+
options?: Record< string, unknown >;
|
|
23
|
+
}
|
|
24
|
+
|
|
18
25
|
export interface GeoChartProps
|
|
19
26
|
extends Pick< BaseChartProps, 'className' | 'chartId' | 'width' | 'height' > {
|
|
20
27
|
/**
|
|
@@ -39,6 +46,10 @@ export interface GeoChartProps
|
|
|
39
46
|
* @default 'countries'
|
|
40
47
|
*/
|
|
41
48
|
resolution?: GeoResolution;
|
|
49
|
+
/**
|
|
50
|
+
* Callback fired when Google Charts emits a chart error.
|
|
51
|
+
*/
|
|
52
|
+
onError?: ( error: GeoChartError ) => void;
|
|
42
53
|
/**
|
|
43
54
|
* Optional render function for the loading placeholder.
|
|
44
55
|
* Called while Google Charts is loading.
|