+
+ { 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',