diff --git a/projects/js-packages/charts/changelog/add-geo-chart-error-reporting b/projects/js-packages/charts/changelog/add-geo-chart-error-reporting new file mode 100644 index 000000000000..6394caf17f9f --- /dev/null +++ b/projects/js-packages/charts/changelog/add-geo-chart-error-reporting @@ -0,0 +1,4 @@ +Significance: minor +Type: added + +Charts: Add GeoChart error reporting. diff --git a/projects/js-packages/charts/src/charts/geo-chart/geo-chart.tsx b/projects/js-packages/charts/src/charts/geo-chart/geo-chart.tsx index f3b761b98611..cfc84f677923 100644 --- a/projects/js-packages/charts/src/charts/geo-chart/geo-chart.tsx +++ b/projects/js-packages/charts/src/charts/geo-chart/geo-chart.tsx @@ -3,8 +3,8 @@ */ import { __ } from '@wordpress/i18n'; import clsx from 'clsx'; -import { FC, useContext, useMemo } from 'react'; -import { Chart, type GoogleChartPackages } from 'react-google-charts'; +import { FC, useContext, useEffect, useMemo, useRef } from 'react'; +import { Chart, type GoogleChartPackages, type ReactGoogleChartEvent } from 'react-google-charts'; /** * Internal dependencies */ @@ -15,7 +15,7 @@ import { sanitizeHtml } from '../../utils/sanitize-html'; import { Center } from '../private/center'; import { withResponsive } from '../private/with-responsive'; import styles from './geo-chart.module.scss'; -import { GeoChartProps } from './types'; +import type { GeoChartError, GeoChartProps } from './types'; const DEFAULT_FEATURE_FILL_COLOR = '#ffffff'; const DEFAULT_BACKGROUND_COLOR = '#ffffff'; @@ -25,6 +25,89 @@ const DEFAULT_BACKGROUND_COLOR = '#ffffff'; const GEO_CHART_PACKAGES: GoogleChartPackages[] = [ 'corechart', 'controls', 'geochart' ]; type GoogleChartOptions = Record< string, unknown >; +type GoogleChartErrorPayload = { + id?: unknown; + message?: unknown; + detailedMessage?: unknown; + options?: unknown; +}; + +// Google Charts renders draw errors as DOM elements injected into the chart +// container: a wrapper `
` holding +// one `` per error. The span id is the +// error id accepted by `google.visualization.errors.removeError()`. +const GOOGLE_CHARTS_ERROR_ID_PREFIX = 'google-visualization-errors-'; +const GOOGLE_CHARTS_ERROR_WRAPPER_INFIX = '-all-'; + +/** + * Collects Google Charts error elements rendered inside a chart container. + * + * @param container - The chart container element to scan. + * @return Errors found in the container, one per error span. + */ +function collectRenderedGeoChartErrors( + container: HTMLElement +): Required< Pick< GeoChartError, 'id' | 'message' > >[] { + const elements = container.querySelectorAll< HTMLElement >( + `[id^="${ GOOGLE_CHARTS_ERROR_ID_PREFIX }"]` + ); + + return Array.from( elements ) + .filter( element => ! element.id.includes( GOOGLE_CHARTS_ERROR_WRAPPER_INFIX ) ) + .map( element => ( { + id: element.id, + message: element.textContent?.trim() ?? '', + } ) ) + .filter( error => error.message.length > 0 ); +} + +/** + * Whether a node added to the chart container is — or contains — a Google + * Charts error element. Also matches text appended into an existing error + * span, in case Google fills the message after inserting the element. + * + * @param node - The added DOM node to inspect. + * @return Whether the node involves a Google Charts error element. + */ +function involvesGeoChartErrorElement( node: Node ): boolean { + if ( node.nodeType === Node.TEXT_NODE ) { + return !! node.parentElement?.id.startsWith( GOOGLE_CHARTS_ERROR_ID_PREFIX ); + } + + if ( ! ( node instanceof HTMLElement ) ) { + return false; + } + + return ( + node.id.startsWith( GOOGLE_CHARTS_ERROR_ID_PREFIX ) || + node.querySelector( `[id^="${ GOOGLE_CHARTS_ERROR_ID_PREFIX }"]` ) !== null + ); +} + +/** + * Normalizes the raw Google Charts error event into the GeoChart error shape. + * + * @param eventArgs - Error event payload from react-google-charts. + * @return Normalized GeoChart error. + */ +function normalizeGeoChartError( eventArgs: unknown ): GeoChartError { + const payload = Array.isArray( eventArgs ) ? eventArgs[ 0 ] : eventArgs; + + if ( ! payload || typeof payload !== 'object' ) { + return {}; + } + + const { id, message, detailedMessage, options } = payload as GoogleChartErrorPayload; + + return { + ...( typeof id === 'string' && { id } ), + ...( typeof message === 'string' && { message } ), + ...( typeof detailedMessage === 'string' && { detailedMessage } ), + ...( options && + typeof options === 'object' && + ! Array.isArray( options ) && { options: options as Record< string, unknown > } ), + }; +} /** * Renders a geographical chart using Google Charts GeoChart to visualize data. @@ -41,6 +124,7 @@ type GoogleChartOptions = Record< string, unknown >; * @param props.height - Height of the chart in pixels * @param props.region - Region to display ('world', 'US', or ISO 3166-1 alpha-2 code) * @param props.resolution - Resolution level ('countries', 'provinces', or 'metros') + * @param props.onError - Optional callback for Google Charts errors * @param props.className - Additional CSS class name for the chart container * @param props.renderPlaceholder - Optional render function for the loading placeholder * @return A React component displaying an interactive map with data visualization @@ -52,6 +136,7 @@ const GeoChartInternal: FC< GeoChartProps > = ( { height, region = 'world', resolution = 'countries', + onError, renderPlaceholder, } ) => { const { @@ -61,6 +146,49 @@ const GeoChartInternal: FC< GeoChartProps > = ( { backgroundColor, }, } = useGlobalChartsContext(); + const containerRef = useRef< HTMLDivElement >( null ); + const reportedErrorIdsRef = useRef< Set< string > >( new Set() ); + + // The ChartWrapper `error` event does not fire for every draw failure — + // notably not when GeoChart's asynchronous map-file load fails (e.g. + // `resolution: 'provinces'` for a country without a provinces map). Those + // errors only surface as DOM elements Google injects into the container, so + // watch the container and report them through the same `onError` callback. + useEffect( () => { + const container = containerRef.current; + + if ( ! onError || ! container || typeof MutationObserver === 'undefined' ) { + return undefined; + } + + const reportRenderedErrors = () => { + for ( const error of collectRenderedGeoChartErrors( container ) ) { + if ( reportedErrorIdsRef.current.has( error.id ) ) { + continue; + } + + reportedErrorIdsRef.current.add( error.id ); + onError( error ); + } + }; + + // GeoChart mutates the container heavily while drawing and resizing; + // only rescan when an added node involves a Google error element. + const observer = new MutationObserver( records => { + const hasErrorNodes = records.some( record => + Array.from( record.addedNodes ).some( involvesGeoChartErrorElement ) + ); + + if ( hasErrorNodes ) { + reportRenderedErrors(); + } + } ); + observer.observe( container, { childList: true, subtree: true } ); + // Report errors already rendered before the observer attached. + reportRenderedErrors(); + + return () => observer.disconnect(); + }, [ onError ] ); // Render loading placeholder const loadingPlaceholder = ( @@ -150,8 +278,24 @@ const GeoChartInternal: FC< GeoChartProps > = ( { ] ); + const chartEvents = useMemo< ReactGoogleChartEvent[] | undefined >( () => { + if ( ! onError ) { + return undefined; + } + + return [ + { + eventName: 'error', + callback: ( { eventArgs } ) => { + onError( normalizeGeoChartError( eventArgs ) ); + }, + }, + ]; + }, [ onError ] ); + return (
= ( { height={ height } data={ sanitizedData.data } options={ options } + chartEvents={ chartEvents } loader={ loadingPlaceholder } />
diff --git a/projects/js-packages/charts/src/charts/geo-chart/index.ts b/projects/js-packages/charts/src/charts/geo-chart/index.ts index 7aab420b2ce0..ec3c923b76ab 100644 --- a/projects/js-packages/charts/src/charts/geo-chart/index.ts +++ b/projects/js-packages/charts/src/charts/geo-chart/index.ts @@ -1,2 +1,2 @@ export { default as GeoChart, GeoChartUnresponsive } from './geo-chart'; -export type { GeoChartProps, GeoRegion, GeoResolution } from './types'; +export type { GeoChartProps, GeoRegion, GeoResolution, GeoChartError } from './types'; diff --git a/projects/js-packages/charts/src/charts/geo-chart/stories/index.api.mdx b/projects/js-packages/charts/src/charts/geo-chart/stories/index.api.mdx index 2e7eff555528..06999d180ac4 100644 --- a/projects/js-packages/charts/src/charts/geo-chart/stories/index.api.mdx +++ b/projects/js-packages/charts/src/charts/geo-chart/stories/index.api.mdx @@ -15,6 +15,9 @@ Main component for rendering geographical data on an interactive world map using | `data` | `GeoData` | - | **Required.** Data in Google Charts format. First row contains column headers, subsequent rows contain data. Countries can be identified by full name (e.g., 'United States') or ISO codes (e.g., 'US'). Full names are recommended for better readability in tooltips. | | `width` | `number` | - | **Required.** Width of the chart in pixels | | `height` | `number` | - | **Required.** Height of the chart in pixels | +| `region` | `GeoRegion` | `'world'` | Region to display. Use `'world'` for a global view or any ISO 3166-1 alpha-2 country code (e.g., `'US'`) | +| `resolution` | `GeoResolution` | `'countries'` | Map resolution: `'countries'`, `'provinces'` (state/province level, use with a specific region), or `'metros'` (US only) | +| `onError` | `(error: GeoChartError) => void` | - | Callback fired when Google Charts emits a chart error, including draw errors Google renders into the chart container (e.g. a missing map file) | | `className` | `string` | - | Additional CSS class name for the chart container | | `chartId` | `string` | - | Custom chart identifier for accessibility | | `renderPlaceholder` | `() => React.ReactNode` | - | Optional render function for custom loading placeholder. Called while Google Charts is loading | @@ -25,6 +28,9 @@ Main component for rendering geographical data on an interactive world map using interface GeoChartProps extends Pick { data: GeoData; + region?: GeoRegion; + resolution?: GeoResolution; + onError?: (error: GeoChartError) => void; renderPlaceholder?: () => React.ReactNode; } ``` @@ -62,6 +68,19 @@ const data: GeoData = [ ]; ``` +## GeoChartError Type + +```typescript +interface GeoChartError { + id?: string; // error id accepted by google.visualization.errors.removeError() + message?: string; + detailedMessage?: string; + options?: Record; +} +``` + +Errors reach `onError` from two paths: the Google ChartWrapper `error` event, and draw errors Google renders directly into the chart container without firing that event (observed via a `MutationObserver`). The second path covers async failures such as a missing map file for an unsupported `provinces` region. + ## Theme Properties The following properties can be customized via the theme system: diff --git a/projects/js-packages/charts/src/charts/geo-chart/stories/index.docs.mdx b/projects/js-packages/charts/src/charts/geo-chart/stories/index.docs.mdx index 0a7ac345a46a..92b9b6351986 100644 --- a/projects/js-packages/charts/src/charts/geo-chart/stories/index.docs.mdx +++ b/projects/js-packages/charts/src/charts/geo-chart/stories/index.docs.mdx @@ -73,10 +73,17 @@ The simplest geo chart requires data, width, and height. The `data` prop uses Go - **`maxWidth`**: Maximum width constraint for responsive charts (default: `1200`) - **`resizeDebounceTime`**: Debounce delay for resize events in ms (default: `300`) +**Map Scope:** +- **`region`**: Region to display — `'world'` (default) or an ISO 3166-1 alpha-2 country code (e.g., `'US'`) +- **`resolution`**: Map resolution — `'countries'` (default), `'provinces'` (use with a specific region), or `'metros'` (US only) + **Styling & Customization:** - **`className`**: Additional CSS class name for custom styling - **`renderPlaceholder`**: Custom render function for the loading placeholder +**Error Handling:** +- **`onError`**: Callback fired when Google Charts emits a chart error, including draw errors rendered into the chart container (e.g. a missing map file) + For detailed prop information and type definitions, see the [Geo Chart API Reference](./?path=/docs/js-packages-charts-library-charts-geo-chart-api-reference--docs). ## Advanced Features diff --git a/projects/js-packages/charts/src/charts/geo-chart/test/geo-chart.test.tsx b/projects/js-packages/charts/src/charts/geo-chart/test/geo-chart.test.tsx index 0dd5d02825f7..e2f2248e1fed 100644 --- a/projects/js-packages/charts/src/charts/geo-chart/test/geo-chart.test.tsx +++ b/projects/js-packages/charts/src/charts/geo-chart/test/geo-chart.test.tsx @@ -1,12 +1,16 @@ -import { render, screen } from '@testing-library/react'; +import { render, screen, waitFor } from '@testing-library/react'; +import { Chart } from 'react-google-charts'; import { GlobalChartsProvider } from '../../../providers'; import GeoChart, { GeoChartUnresponsive } from '../geo-chart'; // Mock react-google-charts jest.mock( 'react-google-charts', () => ( { - Chart: jest.fn( ( { chartPackages, data, options, width, height } ) => { + Chart: jest.fn( ( { chartEvents, chartPackages, data, options, width, height } ) => { return (
+
+ { JSON.stringify( chartEvents?.map( event => event.eventName ) ?? [] ) } +
{ JSON.stringify( chartPackages ) }
{ JSON.stringify( data ) }
{ JSON.stringify( options ) }
@@ -16,6 +20,8 @@ jest.mock( 'react-google-charts', () => ( { } ) ); describe( 'GeoChart', () => { + const ChartMock = Chart as jest.Mock; + const defaultProps = { width: 800, height: 400, @@ -258,6 +264,120 @@ describe( 'GeoChart', () => { } ); } ); + describe( 'Chart Events', () => { + test( 'does not pass chart events when onError is omitted', () => { + renderWithTheme(); + + const chartEvents = screen.getByTestId( 'chart-events' ); + const events = JSON.parse( chartEvents.textContent || '[]' ); + + expect( events ).toEqual( [] ); + } ); + + test( 'calls onError when Google Charts emits an error', () => { + const onError = jest.fn(); + renderWithTheme( { onError } ); + + const chartProps = ChartMock.mock.calls[ ChartMock.mock.calls.length - 1 ]?.[ 0 ]; + const errorEvent = chartProps.chartEvents.find( + ( event: { eventName: string } ) => event.eventName === 'error' + ); + + errorEvent.callback( { + eventArgs: [ + { + id: 'map-error', + message: 'Requested map does not exist.', + detailedMessage: 'The requested map is not available.', + options: { region: 'SG', resolution: 'provinces' }, + }, + ], + } ); + + expect( onError ).toHaveBeenCalledWith( { + id: 'map-error', + message: 'Requested map does not exist.', + detailedMessage: 'The requested map is not available.', + options: { region: 'SG', resolution: 'provinces' }, + } ); + } ); + + test( 'reports error elements Google injects into the chart container', async () => { + const onError = jest.fn(); + renderWithTheme( { onError } ); + + // Simulate Google Charts rendering a draw error into the container, + // as happens when an async map-file load fails without firing the + // ChartWrapper error event. + const container = screen.getByTestId( 'geo-chart' ); + const errorWrapper = document.createElement( 'div' ); + errorWrapper.id = 'google-visualization-errors-all-1'; + const errorSpan = document.createElement( 'span' ); + errorSpan.id = 'google-visualization-errors-1'; + errorSpan.textContent = 'Requested map does not exist.'; + errorWrapper.appendChild( errorSpan ); + container.appendChild( errorWrapper ); + + await waitFor( () => + expect( onError ).toHaveBeenCalledWith( { + id: 'google-visualization-errors-1', + message: 'Requested map does not exist.', + } ) + ); + } ); + + test( 'reports error elements nested inside an added plain node', async () => { + const onError = jest.fn(); + renderWithTheme( { onError } ); + + // The observer filters mutation records by the added node; an error + // element arriving inside a plain wrapper must still be found. + const container = screen.getByTestId( 'geo-chart' ); + const plainWrapper = document.createElement( 'div' ); + const errorSpan = document.createElement( 'span' ); + errorSpan.id = 'google-visualization-errors-9'; + errorSpan.textContent = 'Requested map does not exist.'; + plainWrapper.appendChild( errorSpan ); + container.appendChild( plainWrapper ); + + await waitFor( () => + expect( onError ).toHaveBeenCalledWith( { + id: 'google-visualization-errors-9', + message: 'Requested map does not exist.', + } ) + ); + } ); + + test( 'reports each rendered error element only once', async () => { + const onError = jest.fn(); + renderWithTheme( { onError } ); + + const container = screen.getByTestId( 'geo-chart' ); + const errorSpan = document.createElement( 'span' ); + errorSpan.id = 'google-visualization-errors-2'; + errorSpan.textContent = 'Requested map does not exist.'; + container.appendChild( errorSpan ); + + await waitFor( () => expect( onError ).toHaveBeenCalledTimes( 1 ) ); + + // A later mutation makes the observer re-scan the container; the first + // error is seen again but must not be re-reported. The second error + // being reported proves the re-scan happened. + const secondErrorSpan = document.createElement( 'span' ); + secondErrorSpan.id = 'google-visualization-errors-3'; + secondErrorSpan.textContent = 'Requested map does not exist.'; + container.appendChild( secondErrorSpan ); + + await waitFor( () => + expect( onError ).toHaveBeenCalledWith( { + id: 'google-visualization-errors-3', + message: 'Requested map does not exist.', + } ) + ); + expect( onError ).toHaveBeenCalledTimes( 2 ); + } ); + } ); + describe( 'Loading State', () => { test( 'provides loading placeholder to Google Charts', () => { // The loading placeholder is passed to the Chart component's loader prop diff --git a/projects/js-packages/charts/src/charts/geo-chart/types.ts b/projects/js-packages/charts/src/charts/geo-chart/types.ts index 78b4dd8c02d2..e3e88219195d 100644 --- a/projects/js-packages/charts/src/charts/geo-chart/types.ts +++ b/projects/js-packages/charts/src/charts/geo-chart/types.ts @@ -15,6 +15,13 @@ export type GeoRegion = 'world' | ( string & {} ); */ export type GeoResolution = 'countries' | 'provinces' | 'metros'; +export interface GeoChartError { + id?: string; + message?: string; + detailedMessage?: string; + options?: Record< string, unknown >; +} + export interface GeoChartProps extends Pick< BaseChartProps, 'className' | 'chartId' | 'width' | 'height' > { /** @@ -39,6 +46,10 @@ export interface GeoChartProps * @default 'countries' */ resolution?: GeoResolution; + /** + * Callback fired when Google Charts emits a chart error. + */ + onError?: ( error: GeoChartError ) => void; /** * Optional render function for the loading placeholder. * Called while Google Charts is loading. diff --git a/projects/js-packages/charts/src/index.ts b/projects/js-packages/charts/src/index.ts index 2491ce5f1e9a..812bbbe65934 100644 --- a/projects/js-packages/charts/src/index.ts +++ b/projects/js-packages/charts/src/index.ts @@ -72,7 +72,7 @@ export type { PieSemiCircleChartProps, PieSemiCircleChartRenderTooltipParams, } from './charts/pie-semi-circle-chart'; -export type { GeoChartProps, GeoRegion, GeoResolution } from './charts/geo-chart'; +export type { GeoChartProps, GeoRegion, GeoResolution, GeoChartError } from './charts/geo-chart'; export type { LegendValueDisplay, BaseLegendItem } from './components/legend'; export type { TrendIndicatorProps, TrendDirection } from './components/trend-indicator'; export type { LineStyles, GridStyles, EventHandlerParams } from '@visx/xychart'; diff --git a/projects/packages/premium-analytics/changelog/locations-inline-geo-mode b/projects/packages/premium-analytics/changelog/locations-inline-geo-mode new file mode 100644 index 000000000000..1602962e3754 --- /dev/null +++ b/projects/packages/premium-analytics/changelog/locations-inline-geo-mode @@ -0,0 +1,4 @@ +Significance: minor +Type: added + +Locations widget: Add inline country/city switching and map fallbacks. diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/index.ts b/projects/packages/premium-analytics/packages/widgets-toolkit/src/index.ts index acfd48d9488e..8e671ee56d29 100644 --- a/projects/packages/premium-analytics/packages/widgets-toolkit/src/index.ts +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/index.ts @@ -135,6 +135,7 @@ export { HeatmapChart, buildCalendarHeatmapData, type DataPointDate, + type GeoChartError, type GeoData, type GoogleDataTableColumn, type GoogleDataTableRow, diff --git a/projects/packages/premium-analytics/widgets/locations/package.json b/projects/packages/premium-analytics/widgets/locations/package.json index a4b9b38f2978..ed4a9463bcf9 100644 --- a/projects/packages/premium-analytics/widgets/locations/package.json +++ b/projects/packages/premium-analytics/widgets/locations/package.json @@ -10,7 +10,6 @@ "@wordpress/i18n": "^6.9.0", "@wordpress/icons": "^15.0.0", "@wordpress/ui": "0.17.0", - "@wordpress/widget-primitives": "0.2.0", - "clsx": "^2.1.1" + "@wordpress/widget-primitives": "0.2.0" } } diff --git a/projects/packages/premium-analytics/widgets/locations/render.tsx b/projects/packages/premium-analytics/widgets/locations/render.tsx index 8600ae8895eb..75e792d84737 100644 --- a/projects/packages/premium-analytics/widgets/locations/render.tsx +++ b/projects/packages/premium-analytics/widgets/locations/render.tsx @@ -12,16 +12,16 @@ import { flagUrl, useWidgetDrillDown, useWidgetRootContext, + type GeoChartError, type GeoData, type GoogleDataTableColumn, type GoogleDataTableRow, type LeaderboardChartData, type ReportParamsFieldAttributes, } from '@jetpack-premium-analytics/widgets-toolkit'; -import { useEffect, useMemo } from '@wordpress/element'; +import { useCallback, useEffect, useMemo, useState } from '@wordpress/element'; import { __, sprintf } from '@wordpress/i18n'; import { Stack, Text } from '@wordpress/ui'; -import clsx from 'clsx'; /** * Internal dependencies */ @@ -35,52 +35,230 @@ import type { WidgetRenderProps } from '@wordpress/widget-primitives'; type LocationsRenderAttributes = LocationsAttributes & Partial< ReportParamsFieldAttributes >; type LocationsWidgetProps = WidgetRenderProps< LocationsRenderAttributes >; +type DrillDownCountry = { code: string; name: string }; +type RenderLocationState = { + geoMode: GeoMode; + selectedCountry?: DrillDownCountry; +}; +type GoogleChartsWindow = Window & { + google?: { + visualization?: { + errors?: { + removeError?: ( errorId: string ) => void; + }; + }; + }; +}; + +const MISSING_MAP_ERROR_MESSAGE = 'Requested map does not exist'; +// Google GeoChart has no `provinces` map file for some countries (e.g. TW, SG). +// There is no upstream list of them; each is learned at runtime when its +// provinces draw fails, via the GeoChart `onError` callback. This module-level +// cache carries what was learned across widget remounts, so within one page +// load each country pays the failed draw (a brief error flash) at most once. +const runtimeUnsupportedProvinceMapCountries = new Set< string >(); + +function getGeoChartCountryId( countryCode: string ): string { + if ( countryCode.toUpperCase() === 'TW' ) { + return 'Taiwan'; + } + + return countryCode.toUpperCase(); +} + +type LocationsInnerProps = Required< Pick< LocationsAttributes, 'max' | 'geoGranularity' > >; /** - * Locations widget inner component. Reads report params from WidgetRoot context. + * Locations widget inner component. Reads report params from WidgetRoot + * context. Attributes arrive already normalized by the outer component, so + * defaults are applied in exactly one place. * - * @param {LocationsAttributes} attributes - The widget attributes. + * @param {LocationsInnerProps} props - The normalized widget attributes. * @return The rendered widget content. */ -function LocationsInner( { max, geoGranularity }: LocationsAttributes ) { +function LocationsInner( { max, geoGranularity }: LocationsInnerProps ) { const { reportParams } = useWidgetRootContext(); + const [ unsupportedProvinceMapCountries, setUnsupportedProvinceMapCountries ] = useState< + Set< string > + >( () => new Set( runtimeUnsupportedProvinceMapCountries ) ); const { drillDownItem: selectedCountry, drillDown: selectCountry, resetDrillDown: clearSelectedCountry, - } = useWidgetDrillDown< { code: string; name: string } >(); + } = useWidgetDrillDown< DrillDownCountry >(); // The "View by" control lives in the widget host header (the - // `relevance: 'high'` attribute); changing it resets any drill-down. + // `relevance: 'high'` attribute). City mode disables country drill-down. useEffect( () => { - clearSelectedCountry(); - }, [ geoGranularity, clearSelectedCountry ] ); - - // City mode disables drill-down; in country mode a selected country switches - // the report to its regions. City wins while the reset effect above settles. - const drillMode: GeoMode = selectedCountry ? 'region' : 'country'; - const geoMode: GeoMode = geoGranularity === 'city' ? 'city' : drillMode; - - const { data, comparisonData, hasComparison, isLoading, isFetching, hasData, isError } = - useLocationViews( { - reportParams, - max, - geoMode, - countryFilter: selectedCountry?.code, - } ); + if ( geoGranularity === 'city' ) { + clearSelectedCountry(); + } + }, [ clearSelectedCountry, geoGranularity ] ); + + const activeSelectedCountry = geoGranularity === 'country' ? selectedCountry : undefined; + const geoMode: GeoMode = + geoGranularity === 'country' && activeSelectedCountry ? 'region' : geoGranularity; + + const { + data, + comparisonData, + hasComparison, + isLoading, + isFetching, + hasData, + isError, + isPlaceholderData, + } = useLocationViews( { + reportParams, + max, + geoMode, + countryFilter: geoMode === 'region' ? activeSelectedCountry?.code : undefined, + } ); const showLoading = isLoading || ( isFetching && hasData ); + const [ renderLocationState, setRenderLocationState ] = useState< RenderLocationState >( { + geoMode, + selectedCountry: activeSelectedCountry, + } ); + + useEffect( () => { + if ( isPlaceholderData ) { + return; + } + + setRenderLocationState( { geoMode, selectedCountry: activeSelectedCountry } ); + }, [ activeSelectedCountry, geoMode, isPlaceholderData ] ); + + const renderGeoMode = isPlaceholderData ? renderLocationState.geoMode : geoMode; + const renderSelectedCountry = isPlaceholderData + ? renderLocationState.selectedCountry + : activeSelectedCountry; + const selectedCountryCode = renderSelectedCountry?.code.toUpperCase(); + const useProvinceMap = + renderGeoMode === 'region' && + !! selectedCountryCode && + ! unsupportedProvinceMapCountries.has( selectedCountryCode ); + const useCountryFallbackMap = + renderGeoMode === 'region' && !! renderSelectedCountry && ! useProvinceMap; + const fallbackCountry = useCountryFallbackMap ? renderSelectedCountry : undefined; + const useCityCountryMap = renderGeoMode === 'city'; + const cityCountryRows = useMemo( () => { + const countryRows = new Map< string, { countryFull: string; value: number } >(); + + if ( ! useCityCountryMap ) { + return []; + } + + data.forEach( location => { + const countryCode = location.countryCode.toUpperCase(); + const current = countryRows.get( countryCode ); + countryRows.set( countryCode, { + countryFull: location.countryFull, + value: ( current?.value ?? 0 ) + location.value, + } ); + } ); + + return Array.from( countryRows.entries() ); + }, [ data, useCityCountryMap ] ); + const handleGeoChartError = useCallback( + ( error: GeoChartError ) => { + const message = `${ error.message ?? '' } ${ error.detailedMessage ?? '' }`; + // Any error during a provinces draw means this country's map is unusable — + // fall back regardless of the message text, which Google may localize. + // Stragglers from that failed draw keep arriving after the widget already + // switched to the fallback map (resize and drill-down layout shifts each + // redraw), so a selected country already learned as unsupported also + // qualifies without depending on the message. The English message match + // stays only as a last resort for errors arriving outside those states. + const isProvinceDrawError = !! selectedCountryCode && useProvinceMap; + const isKnownUnsupportedProvinceDraw = + !! selectedCountryCode && runtimeUnsupportedProvinceMapCountries.has( selectedCountryCode ); + + if ( + ! isProvinceDrawError && + ! isKnownUnsupportedProvinceDraw && + ! message.includes( MISSING_MAP_ERROR_MESSAGE ) + ) { + return; + } + + // Clear the error element Google injected into the chart container; the + // fallback redraw replaces the failed map, but the error element would + // otherwise linger above it. + if ( error.id && typeof window !== 'undefined' ) { + ( window as GoogleChartsWindow ).google?.visualization?.errors?.removeError?.( error.id ); + } + + if ( ! isProvinceDrawError ) { + return; + } + + runtimeUnsupportedProvinceMapCountries.add( selectedCountryCode ); + setUnsupportedProvinceMapCountries( previous => { + if ( previous.has( selectedCountryCode ) ) { + return previous; + } + + const next = new Set( previous ); + next.add( selectedCountryCode ); + return next; + } ); + }, + [ selectedCountryCode, useProvinceMap ] + ); const geoData = useMemo( (): GeoData => { + const useLocationHeader = renderGeoMode === 'region' && ! useCountryFallbackMap; const header: GoogleDataTableColumn[] = [ - geoMode === 'city' + useLocationHeader ? __( 'Location', 'jetpack-premium-analytics' ) : __( 'Country', 'jetpack-premium-analytics' ), __( 'Views', 'jetpack-premium-analytics' ), ]; + + if ( fallbackCountry ) { + const countryCode = fallbackCountry.code.toUpperCase(); + const value = data + .filter( location => location.countryCode.toUpperCase() === countryCode ) + .reduce( ( total, location ) => total + location.value, 0 ); + + return [ + header, + [ + { + v: getGeoChartCountryId( countryCode ), + f: fallbackCountry.name, + }, + value, + ], + ]; + } + + if ( useCityCountryMap ) { + return [ + header, + ...cityCountryRows.map( + ( [ countryCode, location ] ): GoogleDataTableRow => [ + { + v: getGeoChartCountryId( countryCode ), + f: location.countryFull, + }, + location.value, + ] + ), + ]; + } + const rows: GoogleDataTableRow[] = data.map( location => [ location.label, location.value ] ); return [ header, ...rows ]; - }, [ data, geoMode ] ); + }, [ + cityCountryRows, + data, + fallbackCountry, + renderGeoMode, + useCityCountryMap, + useCountryFallbackMap, + ] ); const leaderboardData = useMemo( () => { const maxValue = Math.max( ...data.map( l => l.value ), 0 ); @@ -119,12 +297,15 @@ function LocationsInner( { max, geoGranularity }: LocationsAttributes ) { delta: hasComparison ? calculateDelta( location.value, previousValue ) : 0, // Country mode: click to drill into regions. // Region/city mode: rows are not interactive. - ...( geoMode === 'country' && + ...( renderGeoMode === 'country' && location.countryCode && { onClick: () => - selectCountry( { code: location.countryCode, name: location.countryFull } ), + selectCountry( { + code: location.countryCode, + name: location.countryFull, + } ), // Without ariaLabel the button's accessible name is computed from - // its children: "Flag of X" (image alt) + "X" (visible label) → + // its children: "Flag of X" (image alt) + "X" (visible label) -> // screen readers announce the country name twice. Provide a concise // action label that replaces the computed name. ariaLabel: sprintf( @@ -135,9 +316,9 @@ function LocationsInner( { max, geoGranularity }: LocationsAttributes ) { } ), }; } ) as LeaderboardChartData; - }, [ comparisonData, data, geoMode, hasComparison, selectCountry ] ); + }, [ comparisonData, data, renderGeoMode, hasComparison, selectCountry ] ); - const backLink = selectedCountry ? ( + const backLink = renderSelectedCountry ? ( ) : null; + const bodyHeader = backLink ? ( + + { backLink } + + ) : null; + if ( isLoading && data.length === 0 ) { - return ; + return ( +
+ { bodyHeader } + +
+ ); } if ( isError ) { return ( - <> - { backLink } +
+ { bodyHeader } { __( 'Could not load location data.', 'jetpack-premium-analytics' ) } - +
); } @@ -165,8 +357,8 @@ function LocationsInner( { max, geoGranularity }: LocationsAttributes ) { // back link visible so users can drill back up from an empty region view. if ( ! data.length ) { return ( - <> - { backLink } +
+ { bodyHeader } { __( @@ -175,44 +367,44 @@ function LocationsInner( { max, geoGranularity }: LocationsAttributes ) { ) } - +
); } return ( - <> +
{ showLoading && } - { backLink } -
- - - { geoMode !== 'city' && ( -
- -
- ) } +
+
+ { bodyHeader } + +
+
+ +
- +
); } /** - * Locations widget: visitor views by country/region, as a world map plus a + * Locations widget: visitor views by country/region/city, as a map plus a * leaderboard. Click a country to drill into its regions. Ported from the * Jetpack Stats Locations module. * diff --git a/projects/packages/premium-analytics/widgets/locations/stories/locations-widget.stories.tsx b/projects/packages/premium-analytics/widgets/locations/stories/locations-widget.stories.tsx index 2952becb34df..2a1b13b5bc7f 100644 --- a/projects/packages/premium-analytics/widgets/locations/stories/locations-widget.stories.tsx +++ b/projects/packages/premium-analytics/widgets/locations/stories/locations-widget.stories.tsx @@ -8,9 +8,9 @@ import { import { registerReportMocks } from '../../../packages/widgets-toolkit/src/stories/mocks/register-report-mocks'; import { registerStatsMocks } from '../../../packages/widgets-toolkit/src/stories/mocks/register-stats-mocks'; import LocationsRender from '../render'; -import widgetDefinition from '../widget'; +import widgetDefinition, { type LocationsAttributes } from '../widget'; import type { Decorator, Meta, StoryObj } from '@storybook/react'; -import type { WidgetRenderProps } from '@wordpress/widget-primitives'; +import type { WidgetRenderProps, WidgetType } from '@wordpress/widget-primitives'; import type { ComponentProps, ComponentType } from 'react'; registerReportMocks(); @@ -22,11 +22,14 @@ const storyWidgetType = { name: widgetDefinition.name, title: widgetDefinition.title, icon: widgetDefinition.icon, + attributes: widgetDefinition.attributes as WidgetType[ 'attributes' ], + example: widgetDefinition.example, presentation: 'framed' as const, }; interface LocationsStoryControls { withComparison: boolean; + geoGranularity: NonNullable< LocationsAttributes[ 'geoGranularity' ] >; } interface LocationsDashboardStoryProps @@ -39,17 +42,19 @@ const withWidgetCanvas: Decorator = Story => (
); -function getLocationsAttributes( - withComparison = false -): ComponentProps< typeof LocationsRender >[ 'attributes' ] { +function getLocationsAttributes( { + withComparison, + geoGranularity, +}: LocationsStoryControls ): ComponentProps< typeof LocationsRender >[ 'attributes' ] { return { + geoGranularity, max: 10, reportParams: getDefaultQueryParams( withComparison ), }; } -function renderLocationsWidget( { withComparison }: LocationsStoryControls ) { - return ; +function renderLocationsWidget( controls: LocationsStoryControls ) { + return ; } function LocationsDashboardRender( props: WidgetRenderProps< unknown > ) { @@ -58,6 +63,7 @@ function LocationsDashboardRender( props: WidgetRenderProps< unknown > ) { function LocationsDashboardStory( { withComparison, + geoGranularity, ...dashboardArgs }: LocationsDashboardStoryProps ) { return ( @@ -66,7 +72,7 @@ function LocationsDashboardStory( { widgetType={ storyWidgetType } renderModule={ LOCATIONS_RENDER_MODULE } renderComponent={ LocationsDashboardRender as ComponentType< WidgetRenderProps< unknown > > } - attributes={ getLocationsAttributes( withComparison ) } + attributes={ getLocationsAttributes( { withComparison, geoGranularity } ) } /> ); } @@ -80,12 +86,17 @@ const meta = { control: 'boolean', description: 'Include previous-period comparison report params.', }, + geoGranularity: { + control: 'radio', + options: [ 'country', 'city' ], + description: 'The "View by" toolbar attribute rendered by the widget host.', + }, }, parameters: { docs: { description: { component: - 'The "Locations" widget. Shows visitor views by country or city, with country drill-down into regions, using the global dashboard date range.', + 'The "Locations" widget. Shows visitor views by country or city, with country drill-down into regions, using the global dashboard date range. The Countries/Cities view is the `geoGranularity` attribute (`relevance: \'high\'`), exposed as a control by the widget host.', }, }, }, @@ -97,13 +108,20 @@ type DashboardStory = StoryObj< LocationsDashboardStoryProps >; export const Default: StoryObj< LocationsStoryControls > = { render: renderLocationsWidget, - args: { withComparison: false }, + args: { withComparison: false, geoGranularity: 'country' }, decorators: [ withWidgetCanvas ], }; export const WithComparison: StoryObj< LocationsStoryControls > = { render: renderLocationsWidget, - args: { withComparison: true }, + args: { withComparison: true, geoGranularity: 'country' }, + decorators: [ withWidgetCanvas ], +}; + +// Cities mode — city rows in the leaderboard, aggregated by country on the map. +export const CitiesMode: StoryObj< LocationsStoryControls > = { + render: renderLocationsWidget, + args: { withComparison: false, geoGranularity: 'city' }, decorators: [ withWidgetCanvas ], }; @@ -114,6 +132,7 @@ export const WidgetDashboardWithWidget: DashboardStory = { widgetWidth: 2, widgetHeight: 1, withComparison: true, + geoGranularity: 'country', }, argTypes: { ...widgetDashboardWithWidgetArgTypes, @@ -121,5 +140,10 @@ export const WidgetDashboardWithWidget: DashboardStory = { control: 'boolean', description: 'Include previous-period comparison report params.', }, + geoGranularity: { + control: 'radio', + options: [ 'country', 'city' ], + description: 'The "View by" toolbar attribute rendered by the widget host.', + }, }, }; diff --git a/projects/packages/premium-analytics/widgets/locations/style.module.css b/projects/packages/premium-analytics/widgets/locations/style.module.css index 64f0d80aa3ee..75ef6655c134 100644 --- a/projects/packages/premium-analytics/widgets/locations/style.module.css +++ b/projects/packages/premium-analytics/widgets/locations/style.module.css @@ -16,11 +16,33 @@ } .backLink { - align-self: flex-start; + order: 1; + align-self: center; min-inline-size: 0; + margin-inline-end: auto; margin-block-end: 0; } +.content { + position: relative; + display: flex; + flex-direction: column; + flex: 1 1 0; + min-height: 0; +} + +.bodyHeader { + + /* Above WidgetLoadingOverlay (z-index: 1): body chrome like the back link + * stays usable during refetches, per the widget loading-state convention. */ + position: relative; + z-index: 2; + flex-wrap: wrap; + justify-content: flex-end; + gap: var(--wpds-dimension-gap-sm, 8px) var(--wpds-dimension-gap-lg, 16px); + margin-block-end: var(--wpds-dimension-gap-md, 12px); +} + .chartArea { display: grid; grid-template-columns: 280fr 400fr; @@ -32,8 +54,17 @@ min-height: 0; } +.leaderboardPanel { + display: flex; + flex-direction: column; + min-width: 0; + min-height: 0; + align-self: stretch; +} + .leaderboard { - align-self: start; + flex: 1 1 0; + align-self: stretch; min-width: 0; min-height: 0; overflow: hidden; @@ -56,10 +87,6 @@ white-space: nowrap; } -.noMap { - grid-template-columns: 1fr; -} - .geoChart { min-width: 0; height: 100%; @@ -74,6 +101,14 @@ * the map. */ @container (max-width: 431px) { + .bodyHeader { + align-items: stretch; + } + + .backLink { + order: 2; + } + .geoChart { display: none; } diff --git a/projects/packages/premium-analytics/widgets/locations/use-location-views.ts b/projects/packages/premium-analytics/widgets/locations/use-location-views.ts index 9d231de76da4..d08de4d88589 100644 --- a/projects/packages/premium-analytics/widgets/locations/use-location-views.ts +++ b/projects/packages/premium-analytics/widgets/locations/use-location-views.ts @@ -49,6 +49,7 @@ interface LocationViewsState { isFetching: boolean; hasData: boolean; isError: boolean; + isPlaceholderData: boolean; } /** @@ -98,6 +99,7 @@ export default function useLocationViews( { const { primary, comparison, hasComparison, isLoading, isFetching, hasData, isError } = useStatsLocations( statsParams ); + const isPlaceholderData = primary.isPlaceholderData || comparison.isPlaceholderData; const report = primary.data as StatsNormalizedReport< StatsLocationsItem > | undefined; const comparisonReport = comparison.data as @@ -122,5 +124,6 @@ export default function useLocationViews( { isFetching, hasData, isError, + isPlaceholderData, }; } diff --git a/projects/packages/premium-analytics/widgets/locations/widget.ts b/projects/packages/premium-analytics/widgets/locations/widget.ts index a41bc21d4f66..44ce33172270 100644 --- a/projects/packages/premium-analytics/widgets/locations/widget.ts +++ b/projects/packages/premium-analytics/widgets/locations/widget.ts @@ -15,15 +15,16 @@ export type LocationsAttributes = { * * Ported from the Jetpack Stats "Locations" module. v1 ships Countries mode * (with region drill-down) and Cities mode via the `location-views/{geoMode}` - * endpoint. + * endpoint. City rows are listed in the leaderboard and summarized on the map + * by country. * * Data: fetched via the PA proxy at `stats/location-views/{country|region|city}`. * Date range comes from WidgetRoot's reportParams (the shared dashboard date * picker). * * Known limitation: Google GeoChart `provinces` resolution is unavailable for - * some territories (e.g. Taiwan); those fall back to the world map without - * regional detail. + * some countries/territories; unsupported region maps fall back at runtime to + * highlighting the country on the world map. */ export default { name: 'jpa/locations',