From bedd29d35d81b74a2a9f8256611b8dc2b540236f Mon Sep 17 00:00:00 2001 From: Aleksej Date: Tue, 21 Jul 2026 16:25:49 +0200 Subject: [PATCH 01/55] Add all PDF tile configurations. --- .../KeyMetrics/key-metrics-pdf-tiles.js | 1724 +++++++++++++++++ 1 file changed, 1724 insertions(+) create mode 100644 assets/js/components/KeyMetrics/key-metrics-pdf-tiles.js diff --git a/assets/js/components/KeyMetrics/key-metrics-pdf-tiles.js b/assets/js/components/KeyMetrics/key-metrics-pdf-tiles.js new file mode 100644 index 00000000000..104efe9c143 --- /dev/null +++ b/assets/js/components/KeyMetrics/key-metrics-pdf-tiles.js @@ -0,0 +1,1724 @@ +/** + * Key Metrics PDF tile configurations. + * + * Site Kit by Google, Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * WordPress dependencies + */ +import { __, sprintf } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import getPDFTileChange from '@/js/components/pdf-export/getPDFTileChange'; +import lazyWithPreload from '@/js/components/pdf-export/lazy-with-preload'; +import { CORE_SITE } from '@/js/googlesitekit/datastore/site/constants'; +import { + CORE_USER, + KM_ANALYTICS_ADSENSE_TOP_EARNING_CONTENT, + KM_ANALYTICS_ENGAGED_TRAFFIC_SOURCE, + KM_ANALYTICS_LEAST_ENGAGING_PAGES, + KM_ANALYTICS_MOST_ENGAGING_PAGES, + KM_ANALYTICS_NEW_VISITORS, + KM_ANALYTICS_PAGES_PER_VISIT, + KM_ANALYTICS_POPULAR_AUTHORS, + KM_ANALYTICS_POPULAR_CONTENT, + KM_ANALYTICS_POPULAR_PRODUCTS, + KM_ANALYTICS_RETURNING_VISITORS, + KM_ANALYTICS_TOP_CATEGORIES, + KM_ANALYTICS_TOP_CITIES, + KM_ANALYTICS_TOP_CITIES_DRIVING_ADD_TO_CART, + KM_ANALYTICS_TOP_CITIES_DRIVING_LEADS, + KM_ANALYTICS_TOP_CITIES_DRIVING_PURCHASES, + KM_ANALYTICS_TOP_CONVERTING_TRAFFIC_SOURCE, + KM_ANALYTICS_TOP_COUNTRIES, + KM_ANALYTICS_TOP_DEVICE_DRIVING_PURCHASES, + KM_ANALYTICS_TOP_PAGES_DRIVING_LEADS, + KM_ANALYTICS_TOP_RECENT_TRENDING_PAGES, + KM_ANALYTICS_TOP_RETURNING_VISITOR_PAGES, + KM_ANALYTICS_TOP_TRAFFIC_SOURCE, + KM_ANALYTICS_TOP_TRAFFIC_SOURCE_DRIVING_ADD_TO_CART, + KM_ANALYTICS_TOP_TRAFFIC_SOURCE_DRIVING_LEADS, + KM_ANALYTICS_TOP_TRAFFIC_SOURCE_DRIVING_PURCHASES, + KM_ANALYTICS_VISITS_PER_VISITOR, + KM_ANALYTICS_VISIT_LENGTH, + KM_SEARCH_CONSOLE_POPULAR_KEYWORDS, +} from '@/js/googlesitekit/datastore/user/constants'; +import { getTopEarningContentReportOptions } from '@/js/modules/adsense/components/widgets/TopEarningContentWidget'; +import { MODULES_ADSENSE } from '@/js/modules/adsense/datastore/constants'; +import { + getEngagedTrafficSourceReportOptions, + getEngagedTrafficSourceSubtext, +} from '@/js/modules/analytics-4/components/widgets/EngagedTrafficSourceWidget'; +import { getLeastEngagingPagesReportOptions } from '@/js/modules/analytics-4/components/widgets/LeastEngagingPagesWidget'; +import { getMostEngagingPagesReportOptions } from '@/js/modules/analytics-4/components/widgets/MostEngagingPagesWidget'; +import { + getNewVisitorsReportOptions, + getNewVisitorsSubtext, +} from '@/js/modules/analytics-4/components/widgets/NewVisitorsWidget'; +import { + getPagesPerVisitReportOptions, + getPagesPerVisitSubtext, +} from '@/js/modules/analytics-4/components/widgets/PagesPerVisitWidget'; +import { getPopularAuthorsReportOptions } from '@/js/modules/analytics-4/components/widgets/PopularAuthorsWidget'; +import { getPopularContentReportOptions } from '@/js/modules/analytics-4/components/widgets/PopularContentWidget'; +import { getPopularProductsReportOptions } from '@/js/modules/analytics-4/components/widgets/PopularProductsWidget'; +import { + getReturningVisitorsReportOptions, + getReturningVisitorsSubtext, +} from '@/js/modules/analytics-4/components/widgets/ReturningVisitorsWidget'; +import { getTopCategoriesReportOptions } from '@/js/modules/analytics-4/components/widgets/TopCategoriesWidget'; +import { getTopCitiesDrivingAddToCartReportOptions } from '@/js/modules/analytics-4/components/widgets/TopCitiesDrivingAddToCartWidget'; +import { + getTopCitiesDrivingLeadsEventNames, + getTopCitiesDrivingLeadsReportOptions, +} from '@/js/modules/analytics-4/components/widgets/TopCitiesDrivingLeadsWidget'; +import { getTopCitiesDrivingPurchasesReportOptions } from '@/js/modules/analytics-4/components/widgets/TopCitiesDrivingPurchasesWidget'; +import { getTopCitiesReportOptions } from '@/js/modules/analytics-4/components/widgets/TopCitiesWidget'; +import { + getTopConvertingTrafficSourceReportOptions, + getTopConvertingTrafficSourceSubtext, +} from '@/js/modules/analytics-4/components/widgets/TopConvertingTrafficSourceWidget'; +import { getTopCountriesReportOptions } from '@/js/modules/analytics-4/components/widgets/TopCountriesWidget'; +import { + getTopDeviceDrivingPurchasesReportOptions, + getTopDeviceDrivingPurchasesSubtext, +} from '@/js/modules/analytics-4/components/widgets/TopDeviceDrivingPurchasesWidget'; +import { + getTopPagesDrivingLeadsEventNames, + getTopPagesDrivingLeadsReportOptions, +} from '@/js/modules/analytics-4/components/widgets/TopPagesDrivingLeadsWidget'; +import { getTopRecentTrendingPagesReportOptions } from '@/js/modules/analytics-4/components/widgets/TopRecentTrendingPagesWidget'; +import { getTopReturningVisitorPagesReportOptions } from '@/js/modules/analytics-4/components/widgets/TopReturningVisitorPages'; +import { + getTopTrafficSourceDrivingAddToCartReportOptions, + getTopTrafficSourceDrivingAddToCartSubtext, +} from '@/js/modules/analytics-4/components/widgets/TopTrafficSourceDrivingAddToCartWidget'; +import { + getTopTrafficSourceDrivingLeadsEventNames, + getTopTrafficSourceDrivingLeadsReportOptions, + getTopTrafficSourceDrivingLeadsSubtext, +} from '@/js/modules/analytics-4/components/widgets/TopTrafficSourceDrivingLeadsWidget'; +import { + getTopTrafficSourceDrivingPurchasesReportOptions, + getTopTrafficSourceDrivingPurchasesSubtext, +} from '@/js/modules/analytics-4/components/widgets/TopTrafficSourceDrivingPurchasesWidget'; +import { + getTopTrafficSourceReportOptions, + getTopTrafficSourceSubtext, +} from '@/js/modules/analytics-4/components/widgets/TopTrafficSourceWidget'; +import { + getVisitLengthReportOptions, + getVisitLengthSubtext, +} from '@/js/modules/analytics-4/components/widgets/VisitLengthWidget'; +import { + getVisitsPerVisitorReportOptions, + getVisitsPerVisitorSubtext, +} from '@/js/modules/analytics-4/components/widgets/VisitsPerVisitorWidget'; +import { MODULES_ANALYTICS_4 } from '@/js/modules/analytics-4/datastore/constants'; +import { + decodeAmpersand, + splitCategories, +} from '@/js/modules/analytics-4/utils'; +import { + getPagePaths, + getPageTitleMap, + getPageTitlesReportOptions, +} from '@/js/modules/analytics-4/utils/page-titles-report'; +import { reportRowsWithSetValues } from '@/js/modules/analytics-4/utils/report-rows-with-set-values'; +import { getPopularKeywordsReportOptions } from '@/js/modules/search-console/components/widgets/PopularKeywordsWidget'; +import { MODULES_SEARCH_CONSOLE } from '@/js/modules/search-console/datastore/constants'; +import { listFormat, numFmt } from '@/js/util'; +import createKeyMetricTileDataLoader from './create-key-metric-tile-data-loader'; + +/** + * The Key Metrics PDF tile components, lazy-loaded into the PDF chunk so + * `@react-pdf/renderer` stays out of the dashboard bundle. Each metric's + * `pdfTile.TileComponent` references the one that matches its tile shape. + */ +const PDFNumericMetricTile = lazyWithPreload( () => + import( + /* webpackChunkName: "googlesitekit-vendor-lazy-pdf" */ + '@/js/components/pdf-export/shared-react-pdf-components/PDFNumericMetricTile' + ) +); +const PDFMetricTileText = lazyWithPreload( () => + import( + /* webpackChunkName: "googlesitekit-vendor-lazy-pdf" */ + '@/js/components/pdf-export/shared-react-pdf-components/PDFMetricTileText' + ) +); +const PDFMetricTileTable = lazyWithPreload( () => + import( + /* webpackChunkName: "googlesitekit-vendor-lazy-pdf" */ + '@/js/components/pdf-export/shared-react-pdf-components/PDFMetricTileTable' + ) +); + +// The percentage format the text tiles share for their share/rate values, +// matching the dashboard tiles. +const TILE_PERCENT_FORMAT = { + style: 'percent', + signDisplay: 'never', + maximumFractionDigits: 1, +}; + +/** + * Narrows the export's date range to a single period for the table tiles. + * + * The PDF export always carries compare dates, because the numeric and text + * tiles need the previous period for their change badge. The table tiles mirror + * dashboard widgets that request a single range, so passing the compare dates + * through would make Analytics 4 append a `dateRange` dimension and return + * previous-period rows the table would then show. Table tiles build their report + * options from this instead of the raw `dates`. + * + * @since n.e.x.t + * + * @param {Object} dates The export date range, including the compare dates. + * @return {Object} The date range with only `startDate` and `endDate`. + */ +function pdfTableDates( dates ) { + return { startDate: dates.startDate, endDate: dates.endDate }; +} + +/** + * Extracts a "top source share" text tile from a total report and a per-source + * report, mirroring the dashboard's relative-share traffic/device tiles. + * + * The per-source report is ordered so its first row per date range is the top + * source; its share is that source's metric over the total report's metric. The + * change is the absolute point difference, matching the dashboard badge. + * + * @since n.e.x.t + * + * @param {Object} totalReport The total-metric report response. + * @param {Object} sourceReport The per-source report response. + * @param {Function} buildSubtext Maps the current share to the tile's sub-text. + * @return {Object|null} The tile data, or `null` when there is no top source. + */ +function extractTopSourceShareTile( totalReport, sourceReport, buildSubtext ) { + const { rows: totalRows = [] } = totalReport || {}; + const { rows: sourceRows = [] } = sourceReport || {}; + + function sourceRowFor( dateRange ) { + return sourceRows.find( + ( row ) => row?.dimensionValues?.[ 1 ]?.value === dateRange + ); + } + function totalFor( dateRange ) { + return ( + Number( + totalRows.find( + ( row ) => row?.dimensionValues?.[ 0 ]?.value === dateRange + )?.metricValues?.[ 0 ]?.value + ) || 0 + ); + } + + const topSource = + sourceRowFor( 'date_range_0' )?.dimensionValues?.[ 0 ]?.value; + + // No top source row means the report has no data, so drop the tile. + if ( ! topSource ) { + return null; + } + + function rateFor( dateRange ) { + const total = totalFor( dateRange ); + const sourceValue = + Number( sourceRowFor( dateRange )?.metricValues?.[ 0 ]?.value ) || + 0; + return total ? sourceValue / total : 0; + } + + const currentRate = rateFor( 'date_range_0' ); + const previousRate = rateFor( 'date_range_1' ); + + return { + value: topSource, + subtext: buildSubtext( currentRate ), + ...getPDFTileChange( previousRate, currentRate, { isAbsolute: true } ), + }; +} + +/** + * Each Key Metric's PDF export configuration, keyed by metric slug. + * + * This lives apart from `KEY_METRICS_WIDGETS` on purpose: the tiles import the + * dashboard widget modules for their shared report options and sub-text, and + * those widgets import `KEY_METRICS_WIDGETS` back (through `MetricTileWrapper`). + * Keeping the PDF configs here means the metadata module never imports a + * component, so that cycle cannot form. + * + * - `TileComponent`: the `@react-pdf/renderer` component for the tile, wrapped + * with `lazyWithPreload` so the renderer stays out of the dashboard bundle. + * - `getTileData( { registry, dates, signal } )`: resolves the report(s) the tile + * needs and returns the data the `TileComponent` consumes, or `null` when the + * report has no data. + * + * @since n.e.x.t + */ +export const KEY_METRICS_PDF_TILES = { + [ KM_ANALYTICS_ADSENSE_TOP_EARNING_CONTENT ]: { + TileComponent: PDFMetricTileTable, + getTileData: createKeyMetricTileDataLoader( + // The tile ranks pages by AdSense revenue and labels them by + // page title, so this resolves the linked account ID and the + // ranked page paths before requesting the earnings report and + // the matching page titles report. + async ( rawDates, registry ) => { + // Table tiles request a single date range; drop the export's compare dates. + const dates = pdfTableDates( rawDates ); + // The AdSense account ID lives in AdSense settings, which + // the export path does not otherwise resolve, so resolve + // them before reading the ID or the ad source filter targets + // a malformed `Google AdSense account (undefined)`. + await registry.resolveSelect( MODULES_ADSENSE ).getSettings(); + + const adSenseAccountID = registry + .select( MODULES_ADSENSE ) + .getAccountID(); + + const options = getTopEarningContentReportOptions( dates, { + adSenseAccountID, + } ); + + // The page titles report needs the ranked page paths, so + // resolve the earnings report first and derive its options + // from those paths. + const report = await registry + .resolveSelect( MODULES_ANALYTICS_4 ) + .getReport( options ); + const pagePaths = getPagePaths( report ); + + // No ranked pages means the report has no data, so fetch + // nothing and let the empty reports drop the tile. + if ( pagePaths.length === 0 ) { + return []; + } + + return [ + { moduleStore: MODULES_ANALYTICS_4, options }, + { + moduleStore: MODULES_ANALYTICS_4, + options: getPageTitlesReportOptions( dates, pagePaths ), + }, + ]; + }, + ( [ earningsReport, titlesReport ] ) => { + const { rows = [] } = earningsReport || {}; + + // No rows means the report has no data, so drop the tile. + if ( rows.length === 0 ) { + return null; + } + + const currencyCode = earningsReport?.metadata?.currencyCode; + const titles = getPageTitleMap( + getPagePaths( earningsReport ), + titlesReport + ); + + return { + linked: true, + rows: rows.map( ( row ) => ( { + primary: titles[ row.dimensionValues[ 0 ].value ], + metric: numFmt( row.metricValues[ 0 ].value, { + style: 'currency', + currency: currencyCode, + } ), + } ) ), + }; + } + ), + }, + [ KM_ANALYTICS_TOP_RECENT_TRENDING_PAGES ]: { + TileComponent: PDFMetricTileTable, + getTileData: createKeyMetricTileDataLoader( + // The report window and its page-path filter derive from the + // reference date, and the page titles report depends on the page + // paths this report returns, so resolve both before requesting. + async ( dates, registry ) => { + const referenceDate = registry + .select( CORE_USER ) + .getReferenceDate(); + const reportOptions = + getTopRecentTrendingPagesReportOptions( referenceDate ); + + const pagesReport = await registry + .resolveSelect( MODULES_ANALYTICS_4 ) + .getReport( reportOptions ); + + const requests = [ + { + moduleStore: MODULES_ANALYTICS_4, + options: reportOptions, + }, + ]; + + // Request page titles only when there are pages to resolve + // them for, mirroring the dashboard tile's title lookup. + const pagePaths = getPagePaths( pagesReport ); + if ( pagePaths.length > 0 ) { + requests.push( { + moduleStore: MODULES_ANALYTICS_4, + options: getPageTitlesReportOptions( + reportOptions, + pagePaths + ), + } ); + } + + return requests; + }, + ( [ pagesReport, titlesReport ] ) => { + const { rows = [] } = pagesReport || {}; + + // No rows means the report has no data, so drop the tile. + if ( rows.length === 0 ) { + return null; + } + + // Map each page path to its title, matching the dashboard + // tile which shows the page title rather than the raw path. + const titles = getPageTitleMap( + getPagePaths( pagesReport ), + titlesReport + ); + + return { + linked: true, + rows: rows.map( ( row ) => ( { + primary: decodeAmpersand( + titles[ row.dimensionValues?.[ 0 ]?.value ] + ), + metric: numFmt( row.metricValues?.[ 0 ]?.value ), + } ) ), + limit: 3, + }; + } + ), + }, + [ KM_ANALYTICS_POPULAR_AUTHORS ]: { + TileComponent: PDFMetricTileTable, + getTileData: createKeyMetricTileDataLoader( + ( dates ) => [ + { + moduleStore: MODULES_ANALYTICS_4, + options: getPopularAuthorsReportOptions( + pdfTableDates( dates ) + ), + }, + ], + ( [ report ] ) => { + const { rows = [] } = report || {}; + + if ( ! rows.length ) { + return null; + } + + return { + rows: rows.map( ( row ) => ( { + primary: row.dimensionValues[ 0 ].value, + metric: numFmt( row.metricValues[ 0 ].value ), + } ) ), + limit: 3, + }; + } + ), + }, + [ KM_ANALYTICS_TOP_CATEGORIES ]: { + TileComponent: PDFMetricTileTable, + getTileData: createKeyMetricTileDataLoader( + ( dates ) => [ + { + moduleStore: MODULES_ANALYTICS_4, + options: getTopCategoriesReportOptions( + pdfTableDates( dates ) + ), + }, + ], + ( [ report ] ) => { + const { rows = [] } = report || {}; + + if ( ! rows.length ) { + return null; + } + + return { + rows: rows.map( ( row ) => { + const categoryValue = row.dimensionValues?.[ 0 ]?.value; + const categoriesList = + typeof categoryValue === 'string' + ? splitCategories( categoryValue ) + : []; + + return { + // Join the post's categories exactly like the dashboard tile. + primary: listFormat( categoriesList.map( String ), { + style: 'narrow', + } ), + metric: numFmt( row.metricValues?.[ 0 ]?.value ), + }; + } ), + limit: 3, + }; + } + ), + }, + [ KM_ANALYTICS_POPULAR_CONTENT ]: { + TileComponent: PDFMetricTileTable, + getTileData: createKeyMetricTileDataLoader( + // The page titles report depends on the page paths in the + // content report, so this resolves that report first and then + // requests the titles for those paths. + async ( rawDates, registry ) => { + // Table tiles request a single date range; drop the export's compare dates. + const dates = pdfTableDates( rawDates ); + const options = getPopularContentReportOptions( dates ); + + const report = await registry + .resolveSelect( MODULES_ANALYTICS_4 ) + .getReport( options ); + + const pagePaths = getPagePaths( report ); + + const requests = [ + { + moduleStore: MODULES_ANALYTICS_4, + options, + }, + ]; + + // Only request titles when there are page paths to resolve. + if ( pagePaths.length > 0 ) { + requests.push( { + moduleStore: MODULES_ANALYTICS_4, + options: getPageTitlesReportOptions( dates, pagePaths ), + } ); + } + + return requests; + }, + ( [ report, titlesReport ] ) => { + const { rows = [] } = report || {}; + + // No rows means the report has no data, so drop the tile. + if ( rows.length === 0 ) { + return null; + } + + const pagePaths = getPagePaths( report ); + const titles = getPageTitleMap( pagePaths, titlesReport ); + + return { + linked: true, + rows: rows.map( ( row ) => ( { + // The page path maps to its title, matching the + // dashboard tile's primary column. + primary: decodeAmpersand( + titles[ row.dimensionValues[ 0 ].value ] + ), + metric: numFmt( row.metricValues[ 0 ].value ), + } ) ), + }; + } + ), + }, + [ KM_ANALYTICS_POPULAR_PRODUCTS ]: { + TileComponent: PDFMetricTileTable, + getTileData: createKeyMetricTileDataLoader( + // The report options depend on the detected product post + // type, so this resolves it from the registry before + // building the requests. + async ( rawDates, registry ) => { + const productPostType = await registry + .resolveSelect( CORE_SITE ) + .getProductPostType(); + + // No detected product post type means there is no + // product data, so fetch nothing and let the tile drop. + if ( ! productPostType ) { + return []; + } + + // Table tiles request a single date range; drop the + // export's compare dates. + const dates = pdfTableDates( rawDates ); + const options = getPopularProductsReportOptions( + dates, + productPostType + ); + + // Resolve the products report first so its page paths + // drive the page titles request, matching the dashboard + // tile's titles lookup. + const report = await registry + .resolveSelect( MODULES_ANALYTICS_4 ) + .getReport( options ); + const pagePaths = getPagePaths( report ); + + // No page paths means the products report has no data, + // so fetch nothing and let the tile drop. + if ( pagePaths.length === 0 ) { + return []; + } + + return [ + { + moduleStore: MODULES_ANALYTICS_4, + options, + }, + { + moduleStore: MODULES_ANALYTICS_4, + options: getPageTitlesReportOptions( dates, pagePaths ), + }, + ]; + }, + ( [ report, titlesReport ] ) => { + const { rows = [] } = report || {}; + + // No rows means the report has no data, so drop the tile. + if ( rows.length === 0 ) { + return null; + } + + // Map each page path to its title, mirroring the + // dashboard tile's page titles lookup. + const titles = getPageTitleMap( + getPagePaths( report ), + titlesReport + ); + + return { + linked: true, + rows: rows.map( ( row ) => ( { + primary: decodeAmpersand( + titles[ row.dimensionValues[ 0 ].value ] + ), + metric: numFmt( row.metricValues[ 0 ].value ), + } ) ), + }; + } + ), + }, + [ KM_ANALYTICS_PAGES_PER_VISIT ]: { + TileComponent: PDFNumericMetricTile, + getTileData: createKeyMetricTileDataLoader( + ( dates ) => [ + { + moduleStore: MODULES_ANALYTICS_4, + options: getPagesPerVisitReportOptions( dates ), + }, + ], + ( [ report ] ) => { + const { rows = [] } = report || {}; + + // No rows means the report has no data, so drop the tile. + if ( rows.length === 0 ) { + return null; + } + + function findRow( dateRange ) { + return rows.find( + ( row ) => + row?.dimensionValues?.[ 0 ]?.value === dateRange + ); + } + + const current = + Number( + findRow( 'date_range_0' )?.metricValues?.[ 0 ]?.value + ) || 0; + const previous = + Number( + findRow( 'date_range_1' )?.metricValues?.[ 0 ]?.value + ) || 0; + const totalPageViews = + Number( + findRow( 'date_range_0' )?.metricValues?.[ 1 ]?.value + ) || 0; + + return { + value: numFmt( current, { + style: 'decimal', + maximumFractionDigits: 2, + } ), + subtext: getPagesPerVisitSubtext( totalPageViews ), + ...getPDFTileChange( previous, current ), + }; + } + ), + }, + [ KM_ANALYTICS_VISIT_LENGTH ]: { + TileComponent: PDFNumericMetricTile, + getTileData: createKeyMetricTileDataLoader( + ( dates ) => [ + { + moduleStore: MODULES_ANALYTICS_4, + options: getVisitLengthReportOptions( dates ), + }, + ], + ( [ report ] ) => { + const { rows = [] } = report || {}; + + // No rows means the report has no data, so drop the tile. + if ( rows.length === 0 ) { + return null; + } + + function findRow( dateRange ) { + return rows.find( + ( row ) => + row?.dimensionValues?.[ 0 ]?.value === dateRange + ); + } + + const current = + Number( + findRow( 'date_range_0' )?.metricValues?.[ 0 ]?.value + ) || 0; + const previous = + Number( + findRow( 'date_range_1' )?.metricValues?.[ 0 ]?.value + ) || 0; + const totalSessions = + Number( + findRow( 'date_range_0' )?.metricValues?.[ 1 ]?.value + ) || 0; + + return { + // `'s'` formats the duration, matching the dashboard tile. + value: numFmt( current, 's' ), + subtext: getVisitLengthSubtext( totalSessions ), + ...getPDFTileChange( previous, current ), + }; + } + ), + }, + [ KM_ANALYTICS_VISITS_PER_VISITOR ]: { + TileComponent: PDFNumericMetricTile, + getTileData: createKeyMetricTileDataLoader( + ( dates ) => [ + { + moduleStore: MODULES_ANALYTICS_4, + options: getVisitsPerVisitorReportOptions( dates ), + }, + ], + ( [ report ] ) => { + const { rows = [] } = report || {}; + + // No rows means the report has no data, so drop the tile. + if ( rows.length === 0 ) { + return null; + } + + function findRow( dateRange ) { + return rows.find( + ( row ) => + row?.dimensionValues?.[ 0 ]?.value === dateRange + ); + } + + const current = + Number( + findRow( 'date_range_0' )?.metricValues?.[ 0 ]?.value + ) || 0; + const previous = + Number( + findRow( 'date_range_1' )?.metricValues?.[ 0 ]?.value + ) || 0; + const totalSessions = + Number( + findRow( 'date_range_0' )?.metricValues?.[ 1 ]?.value + ) || 0; + + return { + value: numFmt( current ), + subtext: getVisitsPerVisitorSubtext( totalSessions ), + ...getPDFTileChange( previous, current ), + }; + } + ), + }, + [ KM_ANALYTICS_MOST_ENGAGING_PAGES ]: { + TileComponent: PDFMetricTileTable, + getTileData: createKeyMetricTileDataLoader( + async ( rawDates, registry ) => { + // Table tiles request a single date range; drop the export's compare dates. + const dates = pdfTableDates( rawDates ); + const { pageViews } = + getMostEngagingPagesReportOptions( dates ); + + // Resolve the page views report first to derive the + // average page views the engaging pages report filters on. + const pageViewsReport = await registry + .resolveSelect( MODULES_ANALYTICS_4 ) + .getReport( pageViews ); + + const averagePageViews = + Math.round( + pageViewsReport?.totals?.[ 0 ]?.metricValues?.[ 0 ] + ?.value / pageViewsReport?.rowCount + ) || 0; + + const { engagingPages } = getMostEngagingPagesReportOptions( + dates, + averagePageViews + ); + + // Resolve the engaging pages report so its page paths can + // drive the page titles report. + const report = await registry + .resolveSelect( MODULES_ANALYTICS_4 ) + .getReport( engagingPages ); + + const pagePaths = getPagePaths( report ); + + const requests = [ + { + moduleStore: MODULES_ANALYTICS_4, + options: engagingPages, + }, + ]; + + // Request page titles only when there are page paths to + // resolve, mirroring the dashboard tile's title lookup. + if ( pagePaths.length > 0 ) { + requests.push( { + moduleStore: MODULES_ANALYTICS_4, + options: getPageTitlesReportOptions( dates, pagePaths ), + } ); + } + + return requests; + }, + ( [ report, titlesReport ] ) => { + const { rows = [] } = report || {}; + + // No rows means the report has no data, so drop the tile. + if ( rows.length === 0 ) { + return null; + } + + const titles = getPageTitleMap( + getPagePaths( report ), + titlesReport + ); + + return { + linked: true, + rows: rows.map( ( row ) => ( { + primary: decodeAmpersand( + titles[ row.dimensionValues?.[ 0 ]?.value ] + ), + metric: numFmt( + row.metricValues?.[ 0 ]?.value, + TILE_PERCENT_FORMAT + ), + } ) ), + }; + } + ), + }, + [ KM_ANALYTICS_LEAST_ENGAGING_PAGES ]: { + TileComponent: PDFMetricTileTable, + getTileData: createKeyMetricTileDataLoader( + // The ranked report filters to pages at or above the median page + // views, and the titles report keys off that report's page paths, + // so resolve those reports before building the requests. + async ( rawDates, registry ) => { + // Table tiles request a single date range; drop the export's compare dates. + const dates = pdfTableDates( rawDates ); + const analytics = registry.resolveSelect( MODULES_ANALYTICS_4 ); + + // Derive the median page views the ranked report filters + // against, matching the dashboard's LeastEngagingPagesWidget. + const { pageViews: pageViewsOptions } = + getLeastEngagingPagesReportOptions( dates ); + const pageViewsReport = await analytics.getReport( + pageViewsOptions + ); + + const medianIndex = parseInt( + pageViewsReport?.rowCount / 2, + 10 + ); + const medianPageViews = + parseInt( + pageViewsReport?.rows?.[ medianIndex ] + ?.metricValues?.[ 0 ]?.value, + 10 + ) || 0; + + const { report: reportOptions } = + getLeastEngagingPagesReportOptions( + dates, + medianPageViews + ); + + // Collect the ranked report's page paths so the titles report + // can resolve each path to its page title. + const report = await analytics.getReport( reportOptions ); + const pagePaths = getPagePaths( report ); + + // No pages means no data, so fetch nothing and let the empty + // report drop the tile. + if ( pagePaths.length === 0 ) { + return []; + } + + return [ + { + moduleStore: MODULES_ANALYTICS_4, + options: reportOptions, + }, + { + moduleStore: MODULES_ANALYTICS_4, + options: getPageTitlesReportOptions( dates, pagePaths ), + }, + ]; + }, + ( [ report, titlesReport ] ) => { + const { rows = [] } = report || {}; + + // No rows means the report has no data, so drop the tile. + if ( rows.length === 0 ) { + return null; + } + + // The primary column shows the page title, not the raw path, + // so resolve each path to its title exactly as the dashboard + // tile does. + const pagePaths = getPagePaths( report ); + const titles = getPageTitleMap( pagePaths, titlesReport ); + + return { + linked: true, + rows: rows.map( ( row ) => { + const pagePath = row?.dimensionValues?.[ 0 ]?.value; + return { + primary: decodeAmpersand( titles[ pagePath ] ), + // The metric is the bounce rate, formatted as a + // percent to match the dashboard tile. + metric: numFmt( + row?.metricValues?.[ 0 ]?.value, + TILE_PERCENT_FORMAT + ), + }; + } ), + }; + } + ), + }, + [ KM_ANALYTICS_TOP_RETURNING_VISITOR_PAGES ]: { + TileComponent: PDFMetricTileTable, + getTileData: createKeyMetricTileDataLoader( + // The primary label is the page title, which comes from a second + // report keyed to the pages the first report returns, so this + // resolves the main report before building the requests. + async ( rawDates, registry ) => { + // Table tiles request a single date range; drop the export's compare dates. + const dates = pdfTableDates( rawDates ); + const options = + getTopReturningVisitorPagesReportOptions( dates ); + + const report = await registry + .resolveSelect( MODULES_ANALYTICS_4 ) + .getReport( options ); + + const requests = [ + { + moduleStore: MODULES_ANALYTICS_4, + options, + }, + ]; + + // Request the page titles only when there are pages to + // resolve titles for. + const pagePaths = getPagePaths( report ); + if ( pagePaths.length > 0 ) { + requests.push( { + moduleStore: MODULES_ANALYTICS_4, + options: getPageTitlesReportOptions( dates, pagePaths ), + } ); + } + + return requests; + }, + ( [ report, titlesReport ] ) => { + const { rows = [] } = report || {}; + + // No rows means the report has no data, so drop the tile. + if ( rows.length === 0 ) { + return null; + } + + const titles = getPageTitleMap( + getPagePaths( report ), + titlesReport + ); + + return { + linked: true, + rows: rows.map( ( row ) => ( { + primary: decodeAmpersand( + titles[ row.dimensionValues?.[ 0 ]?.value ] + ), + metric: numFmt( row.metricValues?.[ 0 ]?.value ), + } ) ), + limit: 3, + }; + } + ), + }, + [ KM_ANALYTICS_NEW_VISITORS ]: { + TileComponent: PDFNumericMetricTile, + getTileData: createKeyMetricTileDataLoader( + ( dates ) => [ + { + moduleStore: MODULES_ANALYTICS_4, + options: getNewVisitorsReportOptions( dates ), + }, + ], + ( [ report ] ) => { + const { rows = [], totals = [] } = report || {}; + + // No rows means the report has no data, so drop the tile. + if ( rows.length === 0 ) { + return null; + } + + // The prominent value is the current period's new visitors, + // matching the dashboard's NewVisitorsWidget. + const newVisitors = + Number( + rows.find( + ( row ) => + row?.dimensionValues?.[ 0 ]?.value === 'new' && + row?.dimensionValues?.[ 1 ]?.value === + 'date_range_0' + )?.metricValues?.[ 0 ]?.value + ) || 0; + + // The change compares the current and previous total + // visitors, again matching the dashboard tile. + const currentValue = + Number( totals[ 0 ]?.metricValues?.[ 0 ]?.value ) || 0; + const previousValue = + Number( totals[ 1 ]?.metricValues?.[ 0 ]?.value ) || 0; + + return { + value: numFmt( newVisitors ), + subtext: getNewVisitorsSubtext( currentValue ), + ...getPDFTileChange( previousValue, currentValue ), + }; + } + ), + }, + [ KM_ANALYTICS_RETURNING_VISITORS ]: { + TileComponent: PDFNumericMetricTile, + getTileData: createKeyMetricTileDataLoader( + ( dates ) => [ + { + moduleStore: MODULES_ANALYTICS_4, + options: getReturningVisitorsReportOptions( dates ), + }, + ], + ( [ report ] ) => { + const { rows = [], totals = [] } = report || {}; + + // No rows means the report has no data, so drop the tile. + if ( rows.length === 0 ) { + return null; + } + + function findValue( dateRange ) { + return ( + Number( + rows.find( + ( row ) => + row?.dimensionValues?.[ 0 ]?.value === + 'returning' && + row?.dimensionValues?.[ 1 ]?.value === + dateRange + )?.metricValues?.[ 0 ]?.value + ) || 0 + ); + } + + const returning = findValue( 'date_range_0' ); + const prevReturning = findValue( 'date_range_1' ); + const total = + Number( totals[ 0 ]?.metricValues?.[ 0 ]?.value ) || 0; + const prevTotal = + Number( totals[ 1 ]?.metricValues?.[ 0 ]?.value ) || 0; + + // The value is the share of returning visitors, matching the + // dashboard's ReturningVisitorsWidget. + const currentPercentage = total > 0 ? returning / total : 0; + const prevPercentage = + prevTotal > 0 ? prevReturning / prevTotal : 0; + + return { + value: numFmt( currentPercentage, { + style: 'percent', + signDisplay: 'never', + maximumFractionDigits: 1, + } ), + subtext: getReturningVisitorsSubtext( total ), + // The metric is a percentage, so the badge shows the + // absolute point change, matching the dashboard tile. + ...getPDFTileChange( prevPercentage, currentPercentage, { + isAbsolute: true, + } ), + }; + } + ), + }, + [ KM_ANALYTICS_TOP_TRAFFIC_SOURCE ]: { + TileComponent: PDFMetricTileText, + getTileData: createKeyMetricTileDataLoader( + ( dates ) => { + const { totalUsers, trafficSource } = + getTopTrafficSourceReportOptions( dates ); + return [ + { + moduleStore: MODULES_ANALYTICS_4, + options: totalUsers, + }, + { + moduleStore: MODULES_ANALYTICS_4, + options: trafficSource, + }, + ]; + }, + ( [ totalReport, sourceReport ] ) => + extractTopSourceShareTile( + totalReport, + sourceReport, + getTopTrafficSourceSubtext + ) + ), + }, + [ KM_ANALYTICS_TOP_TRAFFIC_SOURCE_DRIVING_ADD_TO_CART ]: { + TileComponent: PDFMetricTileText, + getTileData: createKeyMetricTileDataLoader( + ( dates ) => { + const { totalAddToCart, trafficSource } = + getTopTrafficSourceDrivingAddToCartReportOptions( dates ); + return [ + { + moduleStore: MODULES_ANALYTICS_4, + options: totalAddToCart, + }, + { + moduleStore: MODULES_ANALYTICS_4, + options: trafficSource, + }, + ]; + }, + ( [ totalReport, sourceReport ] ) => + extractTopSourceShareTile( + totalReport, + sourceReport, + getTopTrafficSourceDrivingAddToCartSubtext + ) + ), + }, + [ KM_ANALYTICS_TOP_TRAFFIC_SOURCE_DRIVING_LEADS ]: { + TileComponent: PDFMetricTileText, + getTileData: createKeyMetricTileDataLoader( + // The report options depend on the detected lead events, so this + // resolves them from the registry before building the requests. + async ( dates, registry ) => { + const detectedEvents = await registry + .resolveSelect( MODULES_ANALYTICS_4 ) + .getDetectedEvents(); + const eventNames = + getTopTrafficSourceDrivingLeadsEventNames( detectedEvents ); + + // No detected lead events means no data, so fetch nothing and + // let the empty reports drop the tile. + if ( eventNames.length === 0 ) { + return []; + } + + const { totalLeads, trafficSource } = + getTopTrafficSourceDrivingLeadsReportOptions( + dates, + eventNames + ); + return [ + { + moduleStore: MODULES_ANALYTICS_4, + options: totalLeads, + }, + { + moduleStore: MODULES_ANALYTICS_4, + options: trafficSource, + }, + ]; + }, + ( [ totalReport, sourceReport ] ) => { + const { rows: totalRows = [] } = totalReport || {}; + const { rows: sourceRows = [] } = sourceReport || {}; + + // The date-range dimension shifts position with the event + // filter, so match a row by any dimension holding the range. + function rowFor( rows, dateRange ) { + return rows.find( ( row ) => + ( row?.dimensionValues || [] ).some( + ( dimension ) => dimension?.value === dateRange + ) + ); + } + + const topSource = rowFor( sourceRows, 'date_range_0' ) + ?.dimensionValues?.[ 0 ]?.value; + + // No top source row means the report has no data. + if ( ! topSource ) { + return null; + } + + function rateFor( dateRange ) { + const total = + Number( + rowFor( totalRows, dateRange )?.metricValues?.[ 0 ] + ?.value + ) || 0; + const sourceValue = + Number( + rowFor( sourceRows, dateRange )?.metricValues?.[ 0 ] + ?.value + ) || 0; + return total ? sourceValue / total : 0; + } + + const currentRate = rateFor( 'date_range_0' ); + const previousRate = rateFor( 'date_range_1' ); + + return { + value: topSource, + subtext: + getTopTrafficSourceDrivingLeadsSubtext( currentRate ), + ...getPDFTileChange( previousRate, currentRate, { + isAbsolute: true, + } ), + }; + } + ), + }, + [ KM_ANALYTICS_TOP_TRAFFIC_SOURCE_DRIVING_PURCHASES ]: { + TileComponent: PDFMetricTileText, + getTileData: createKeyMetricTileDataLoader( + async ( dates, registry ) => { + const { totalPurchases, trafficSource } = + getTopTrafficSourceDrivingPurchasesReportOptions( dates ); + + // `ecommercePurchases` returns a zero-valued row rather than + // no data, so the per-source report can still name a top + // source for a period with no purchases. Confirm a purchase + // happened before requesting it, matching the dashboard's + // TopTrafficSourceDrivingPurchasesWidget. + const totalReport = await registry + .resolveSelect( MODULES_ANALYTICS_4 ) + .getReport( totalPurchases ); + + const hasPurchases = ( totalReport?.rows || [] ).some( + ( row ) => Number( row?.metricValues?.[ 0 ]?.value ) > 0 + ); + + if ( ! hasPurchases ) { + return []; + } + + return [ + { + moduleStore: MODULES_ANALYTICS_4, + options: totalPurchases, + }, + { + moduleStore: MODULES_ANALYTICS_4, + options: trafficSource, + }, + ]; + }, + ( [ totalReport, sourceReport ] ) => + extractTopSourceShareTile( + totalReport, + sourceReport, + getTopTrafficSourceDrivingPurchasesSubtext + ) + ), + }, + [ KM_ANALYTICS_ENGAGED_TRAFFIC_SOURCE ]: { + TileComponent: PDFMetricTileText, + getTileData: createKeyMetricTileDataLoader( + ( dates ) => [ + { + moduleStore: MODULES_ANALYTICS_4, + options: getEngagedTrafficSourceReportOptions( dates ), + }, + ], + ( [ report ] ) => { + const { rows = [], totals = [] } = report || {}; + + function rowFor( collection, dateRange ) { + return collection.find( + ( row ) => + row?.dimensionValues?.[ 1 ]?.value === dateRange + ); + } + + const topSource = rowFor( rows, 'date_range_0' ) + ?.dimensionValues?.[ 0 ]?.value; + + // No top source row means the report has no data. + if ( ! topSource ) { + return null; + } + + function rateFor( dateRange ) { + const engaged = + Number( + rowFor( rows, dateRange )?.metricValues?.[ 0 ] + ?.value + ) || 0; + const total = + Number( + rowFor( totals, dateRange )?.metricValues?.[ 0 ] + ?.value + ) || 0; + return total ? engaged / total : 0; + } + + const currentRate = rateFor( 'date_range_0' ); + const previousRate = rateFor( 'date_range_1' ); + const currentTotal = + Number( + rowFor( totals, 'date_range_0' )?.metricValues?.[ 0 ] + ?.value + ) || 0; + + return { + value: topSource, + subtext: getEngagedTrafficSourceSubtext( + currentRate, + currentTotal + ), + ...getPDFTileChange( previousRate, currentRate, { + isAbsolute: true, + } ), + }; + } + ), + }, + [ KM_ANALYTICS_TOP_CONVERTING_TRAFFIC_SOURCE ]: { + TileComponent: PDFMetricTileText, + getTileData: createKeyMetricTileDataLoader( + ( dates ) => [ + { + moduleStore: MODULES_ANALYTICS_4, + options: + getTopConvertingTrafficSourceReportOptions( dates ), + }, + ], + ( [ report ] ) => { + const { rows = [] } = report || {}; + + function rowFor( dateRange ) { + return rows.find( + ( row ) => + row?.dimensionValues?.[ 1 ]?.value === dateRange + ); + } + + const currentRow = rowFor( 'date_range_0' ); + const topChannel = currentRow?.dimensionValues?.[ 0 ]?.value; + + // No top channel row means the report has no data. + if ( ! topChannel ) { + return null; + } + + // The metric is already the key-event rate for the channel. + const currentRate = + Number( currentRow?.metricValues?.[ 0 ]?.value ) || 0; + const previousRate = + Number( + rowFor( 'date_range_1' )?.metricValues?.[ 0 ]?.value + ) || 0; + + return { + value: topChannel, + subtext: + getTopConvertingTrafficSourceSubtext( currentRate ), + ...getPDFTileChange( previousRate, currentRate, { + isAbsolute: true, + } ), + }; + } + ), + }, + [ KM_ANALYTICS_TOP_CITIES ]: { + TileComponent: PDFMetricTileTable, + getTileData: createKeyMetricTileDataLoader( + ( dates ) => [ + { + moduleStore: MODULES_ANALYTICS_4, + options: getTopCitiesReportOptions( + pdfTableDates( dates ) + ), + }, + ], + ( [ report ] ) => { + // Mirror the dashboard tile: drop "(not set)"/empty cities and cap at the top three, showing each city's share of total users. + const { rows = [], totals = [] } = report || {}; + // Coerce the total and guard the division, so a missing or zero + // total yields 0% rather than NaN% or ∞%, as the Top Countries + // tile does. + const totalUsers = + Number( totals[ 0 ]?.metricValues?.[ 0 ]?.value ) || 0; + const tileRows = reportRowsWithSetValues( rows ).map( + ( row ) => ( { + primary: row.dimensionValues[ 0 ].value, + metric: numFmt( + totalUsers + ? Number( row.metricValues[ 0 ].value ) / + totalUsers + : 0, + TILE_PERCENT_FORMAT + ), + } ) + ); + if ( ! tileRows.length ) { + return null; + } + return { rows: tileRows }; + } + ), + }, + [ KM_ANALYTICS_TOP_CITIES_DRIVING_LEADS ]: { + TileComponent: PDFMetricTileTable, + getTileData: createKeyMetricTileDataLoader( + // The report options depend on the detected lead events, so this + // resolves them from the registry before building the request. + async ( rawDates, registry ) => { + const detectedEvents = await registry + .resolveSelect( MODULES_ANALYTICS_4 ) + .getDetectedEvents(); + const eventNames = + getTopCitiesDrivingLeadsEventNames( detectedEvents ); + + // No detected lead events means no data, so fetch nothing and + // let the empty report drop the tile. + if ( eventNames.length === 0 ) { + return []; + } + + // Table tiles request a single date range; drop the + // export's compare dates. + const dates = pdfTableDates( rawDates ); + return [ + { + moduleStore: MODULES_ANALYTICS_4, + options: getTopCitiesDrivingLeadsReportOptions( + dates, + eventNames + ), + }, + ]; + }, + ( [ citiesReport ] ) => { + const { rows = [] } = citiesReport || {}; + + // No rows means the report has no data. + if ( rows.length === 0 ) { + return null; + } + + return { + rows: rows.map( ( row ) => ( { + primary: row.dimensionValues[ 0 ].value, + metric: numFmt( row.metricValues[ 0 ].value ), + } ) ), + limit: 3, + }; + } + ), + }, + [ KM_ANALYTICS_TOP_CITIES_DRIVING_ADD_TO_CART ]: { + TileComponent: PDFMetricTileTable, + getTileData: createKeyMetricTileDataLoader( + ( dates ) => [ + { + moduleStore: MODULES_ANALYTICS_4, + options: getTopCitiesDrivingAddToCartReportOptions( + pdfTableDates( dates ) + ), + }, + ], + ( [ report ] ) => { + const { rows = [] } = report || {}; + + // The report is already ordered by add to carts and capped at + // three rows, so map the cities straight through in that order. + const tileRows = rows + .filter( ( row ) => row?.dimensionValues?.[ 0 ]?.value ) + .map( ( row ) => ( { + primary: row.dimensionValues[ 0 ].value, + metric: numFmt( row.metricValues?.[ 0 ]?.value ), + } ) ); + + // No city rows means the report has no data. + if ( tileRows.length === 0 ) { + return null; + } + + return { rows: tileRows }; + } + ), + }, + [ KM_ANALYTICS_TOP_CITIES_DRIVING_PURCHASES ]: { + TileComponent: PDFMetricTileTable, + getTileData: createKeyMetricTileDataLoader( + ( dates ) => [ + { + moduleStore: MODULES_ANALYTICS_4, + options: getTopCitiesDrivingPurchasesReportOptions( + pdfTableDates( dates ) + ), + }, + ], + ( [ report ] ) => { + const { rows = [] } = report || {}; + + // No rows means the report has no data, so drop the tile. + if ( ! rows.length ) { + return null; + } + + return { + rows: rows.map( ( row ) => ( { + primary: row.dimensionValues[ 0 ].value, + metric: numFmt( row.metricValues[ 0 ].value ), + } ) ), + limit: 3, + }; + } + ), + }, + [ KM_ANALYTICS_TOP_DEVICE_DRIVING_PURCHASES ]: { + TileComponent: PDFMetricTileText, + getTileData: createKeyMetricTileDataLoader( + async ( dates, registry ) => { + const { totalPurchases, device } = + getTopDeviceDrivingPurchasesReportOptions( dates ); + + // `ecommercePurchases` returns a zero-valued row rather than + // no data, so the per-device report can still name a top + // device for a period with no purchases. Confirm a purchase + // happened before requesting it, matching the dashboard's + // TopDeviceDrivingPurchasesWidget. + const totalReport = await registry + .resolveSelect( MODULES_ANALYTICS_4 ) + .getReport( totalPurchases ); + + const hasPurchases = ( totalReport?.rows || [] ).some( + ( row ) => Number( row?.metricValues?.[ 0 ]?.value ) > 0 + ); + + if ( ! hasPurchases ) { + return []; + } + + return [ + { + moduleStore: MODULES_ANALYTICS_4, + options: totalPurchases, + }, + { moduleStore: MODULES_ANALYTICS_4, options: device }, + ]; + }, + ( [ totalReport, deviceReport ] ) => + extractTopSourceShareTile( + totalReport, + deviceReport, + getTopDeviceDrivingPurchasesSubtext + ) + ), + }, + [ KM_ANALYTICS_TOP_COUNTRIES ]: { + TileComponent: PDFMetricTileTable, + getTileData: createKeyMetricTileDataLoader( + ( dates ) => [ + { + moduleStore: MODULES_ANALYTICS_4, + options: getTopCountriesReportOptions( + pdfTableDates( dates ) + ), + }, + ], + ( [ report ] ) => { + const { rows = [], totals = [] } = report || {}; + const totalUsers = + Number( totals[ 0 ]?.metricValues?.[ 0 ]?.value ) || 0; + + // Drop the "(not set)" and empty country rows the dashboard tile hides. + const namedRows = rows.filter( + ( row ) => + row?.dimensionValues?.[ 0 ]?.value !== '(not set)' && + row?.dimensionValues?.[ 0 ]?.value !== '' + ); + + // No named country rows means the report has no data, so drop the tile. + if ( ! namedRows.length ) { + return null; + } + + return { + rows: namedRows.map( ( row ) => ( { + primary: row.dimensionValues[ 0 ].value, + metric: numFmt( + totalUsers + ? Number( row.metricValues[ 0 ].value ) / + totalUsers + : 0, + TILE_PERCENT_FORMAT + ), + } ) ), + limit: 3, + }; + } + ), + }, + [ KM_SEARCH_CONSOLE_POPULAR_KEYWORDS ]: { + TileComponent: PDFMetricTileTable, + getTileData: createKeyMetricTileDataLoader( + ( dates ) => [ + { + moduleStore: MODULES_SEARCH_CONSOLE, + options: getPopularKeywordsReportOptions( + pdfTableDates( dates ) + ), + }, + ], + ( [ report ] ) => { + // The Search Console report is a flat array of rows; sort by clickthrough rate descending to match the dashboard tile. + const rows = [ ...( report || [] ) ].sort( + ( { ctr: ctrA = 0 }, { ctr: ctrB = 0 } ) => ctrB - ctrA + ); + + if ( ! rows.length ) { + return null; + } + + return { + linked: true, + rows: rows.map( ( row ) => ( { + primary: row.keys[ 0 ], + metric: sprintf( + /* translators: %s: clickthrough rate value. */ + __( '%s CTR', 'google-site-kit' ), + numFmt( row.ctr, '%' ) + ), + } ) ), + limit: 3, + }; + } + ), + }, + [ KM_ANALYTICS_TOP_PAGES_DRIVING_LEADS ]: { + TileComponent: PDFMetricTileTable, + getTileData: createKeyMetricTileDataLoader( + // The report options depend on the detected lead events, so this + // resolves them from the registry before building the requests. + async ( dates, registry ) => { + const detectedEvents = await registry + .resolveSelect( MODULES_ANALYTICS_4 ) + .getDetectedEvents(); + const eventNames = + getTopPagesDrivingLeadsEventNames( detectedEvents ); + + // No detected lead events means no data, so fetch nothing and + // let the empty reports drop the tile. + if ( eventNames.length === 0 ) { + return []; + } + + // The dashboard tile shows a single period, so request the + // ranked report without the comparison range. + const options = getTopPagesDrivingLeadsReportOptions( + pdfTableDates( dates ), + eventNames + ); + + // The page titles come from a second report keyed off the + // ranked report's pages, so resolve that report to read its + // page paths before requesting them. + const report = await registry + .resolveSelect( MODULES_ANALYTICS_4 ) + .getReport( options ); + const pagePaths = getPagePaths( report ); + + const requests = [ + { moduleStore: MODULES_ANALYTICS_4, options }, + ]; + + if ( pagePaths.length > 0 ) { + requests.push( { + moduleStore: MODULES_ANALYTICS_4, + options: getPageTitlesReportOptions( + pdfTableDates( dates ), + pagePaths + ), + } ); + } + + return requests; + }, + ( [ report, titlesReport ] ) => { + const { rows = [] } = report || {}; + + // No ranked rows means the report has no data. + if ( rows.length === 0 ) { + return null; + } + + // Match each page path to its title, mirroring the dashboard + // tile which shows the page title rather than the raw path. + const titles = getPageTitleMap( + getPagePaths( report ), + titlesReport + ); + + return { + linked: true, + rows: rows.map( ( row ) => ( { + primary: decodeAmpersand( + titles[ row.dimensionValues?.[ 0 ]?.value ] + ), + metric: numFmt( row.metricValues?.[ 0 ]?.value ), + } ) ), + }; + } + ), + }, +}; From 96efcbd8a1f53cfc146c7928ba296075d4151d21 Mon Sep 17 00:00:00 2001 From: Aleksej Date: Tue, 21 Jul 2026 16:27:33 +0200 Subject: [PATCH 02/55] Add tests for every tile. --- .../KeyMetrics/key-metrics-pdf-tiles.test.js | 213 ++++++++++++++++++ 1 file changed, 213 insertions(+) create mode 100644 assets/js/components/KeyMetrics/key-metrics-pdf-tiles.test.js diff --git a/assets/js/components/KeyMetrics/key-metrics-pdf-tiles.test.js b/assets/js/components/KeyMetrics/key-metrics-pdf-tiles.test.js new file mode 100644 index 00000000000..4fc22820f16 --- /dev/null +++ b/assets/js/components/KeyMetrics/key-metrics-pdf-tiles.test.js @@ -0,0 +1,213 @@ +/** + * Key Metrics PDF tiles smoke tests. + * + * Site Kit by Google, Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * External dependencies + */ +import TestRenderer from 'react-test-renderer'; + +/** + * Internal dependencies + */ +import { MODULES_SEARCH_CONSOLE } from '@/js/modules/search-console/datastore/constants'; +import { KEY_METRICS_PDF_TILES } from './key-metrics-pdf-tiles'; +import { KEY_METRICS_WIDGETS } from './key-metrics-widgets'; + +/** + * Collects every text string rendered in a react-test-renderer tree, so the + * smoke test can assert a tile actually painted its heading and values. + * + * @since n.e.x.t + * + * @param {Object|string|number|null} node The current tree node. + * @param {string[]} output The strings collected so far. + * @return {string[]} The collected text strings. + */ +function findTextStrings( node, output = [] ) { + if ( node === null || node === undefined ) { + return output; + } + if ( typeof node === 'string' || typeof node === 'number' ) { + output.push( String( node ) ); + return output; + } + if ( Array.isArray( node.children ) ) { + node.children.forEach( ( child ) => findTextStrings( child, output ) ); + } + return output; +} + +const DATES = { + startDate: '2025-01-08', + endDate: '2025-02-04', + compareStartDate: '2024-12-11', + compareEndDate: '2025-01-07', +}; + +// A report shaped generously enough for every tile family: two ranked rows that +// each carry a date-range dimension, per-range totals, a row count, a currency +// code and a page-path dimension header. +const REPORT = { + rowCount: 2, + metadata: { currencyCode: 'USD' }, + dimensionHeaders: [ { name: 'pagePath' } ], + rows: [ + { + dimensionValues: [ + { value: '/alpha' }, + { value: 'date_range_0' }, + { value: 'date_range_0' }, + ], + metricValues: [ { value: '10' }, { value: '5' } ], + }, + { + dimensionValues: [ + { value: '/beta' }, + { value: 'date_range_1' }, + { value: 'date_range_1' }, + ], + metricValues: [ { value: '8' }, { value: '4' } ], + }, + ], + totals: [ + { + dimensionValues: [ { value: 'date_range_0' } ], + metricValues: [ { value: '100' }, { value: '50' } ], + }, + { + dimensionValues: [ { value: 'date_range_1' } ], + metricValues: [ { value: '80' }, { value: '40' } ], + }, + ], +}; + +// Search Console returns a flat array of rows keyed by search query. +const SEARCH_CONSOLE_REPORT = [ + { keys: [ 'site kit' ], ctr: 0.42, clicks: 120, impressions: 285 }, + { keys: [ 'analytics plugin' ], ctr: 0.19, clicks: 40, impressions: 210 }, +]; + +/** + * Builds a registry that answers every selector and fetch a PDF tile can reach, + * so each tile's `getTileData` can run end to end without real network access. + * + * @since n.e.x.t + * + * @return {Object} A mock registry. + */ +function smokeRegistry() { + const resolved = { + getReport: jest.fn( () => Promise.resolve( REPORT ) ), + getProductPostType: jest.fn( () => Promise.resolve( 'product' ) ), + getDetectedEvents: jest.fn( () => + Promise.resolve( [ + 'purchase', + 'add_to_cart', + 'submit_lead_form', + 'contact', + 'generate_lead', + ] ) + ), + getSettings: jest.fn( () => Promise.resolve( {} ) ), + getReferenceDate: jest.fn( () => Promise.resolve( '2025-02-04' ) ), + }; + + return { + resolveSelect: jest.fn( () => resolved ), + select: jest.fn( () => ( { + getAccountID: jest.fn( () => 'pub-1234567890' ), + getReferenceDate: jest.fn( () => '2025-02-04' ), + } ) ), + // Search Console reports are a flat array of rows, unlike the Analytics 4 + // `{ rows, totals }` shape, so answer each store with its own shape. + dispatch: jest.fn( ( store ) => ( { + fetchGetReport: jest.fn( () => + Promise.resolve( { + response: + store === MODULES_SEARCH_CONSOLE + ? SEARCH_CONSOLE_REPORT + : REPORT, + } ) + ), + } ) ), + }; +} + +describe( 'KEY_METRICS_PDF_TILES smoke test', () => { + it( 'covers every metric a user can add to their dashboard', () => { + const widgetSlugs = Object.keys( KEY_METRICS_WIDGETS ).sort(); + const tileSlugs = Object.keys( KEY_METRICS_PDF_TILES ).sort(); + + expect( tileSlugs ).toEqual( widgetSlugs ); + } ); + + it.each( Object.keys( KEY_METRICS_PDF_TILES ) )( + '%s resolves its tile data without throwing', + async ( slug ) => { + const { TileComponent, getTileData } = + KEY_METRICS_PDF_TILES[ slug ]; + + expect( TileComponent ).toBeDefined(); + + const data = await getTileData( { + registry: smokeRegistry(), + dates: DATES, + signal: new AbortController().signal, + } ); + + // A tile either resolves data for its component, or returns `null` + // when its report has no data. Anything else means the extract broke. + if ( data !== null ) { + expect( typeof data ).toBe( 'object' ); + } + } + ); + + it.each( Object.keys( KEY_METRICS_PDF_TILES ) )( + '%s renders its resolved data into the PDF tile', + async ( slug ) => { + const { TileComponent, getTileData } = + KEY_METRICS_PDF_TILES[ slug ]; + + const data = await getTileData( { + registry: smokeRegistry(), + dates: DATES, + signal: new AbortController().signal, + } ); + + // A tile with no data is dropped before it ever renders. + expect( data ).not.toBeNull(); + + // The tile components are lazy so the dashboard bundle stays free of + // the PDF renderer; resolve the real component before rendering it. + const { default: Resolved } = await TileComponent.preload(); + const { title } = KEY_METRICS_WIDGETS[ slug ]; + + const tree = TestRenderer.create( + + ).toJSON(); + + expect( tree ).toBeTruthy(); + + const text = findTextStrings( tree ); + // The heading always renders, plus at least one value or row. + expect( text ).toContain( title ); + expect( text.length ).toBeGreaterThan( 1 ); + } + ); +} ); From 8925e6cb9d085a15d26a080afd015b2aa901e851 Mon Sep 17 00:00:00 2001 From: Aleksej Date: Tue, 21 Jul 2026 16:28:37 +0200 Subject: [PATCH 03/55] Order by area, module-filter, outage. --- assets/js/components/KeyMetrics/getPDFData.ts | 162 +++++++++++------- 1 file changed, 101 insertions(+), 61 deletions(-) diff --git a/assets/js/components/KeyMetrics/getPDFData.ts b/assets/js/components/KeyMetrics/getPDFData.ts index 662e6f0646e..0100214dc9b 100644 --- a/assets/js/components/KeyMetrics/getPDFData.ts +++ b/assets/js/components/KeyMetrics/getPDFData.ts @@ -25,46 +25,49 @@ import { ComponentType } from 'react'; * Internal dependencies */ import { CORE_USER } from '@/js/googlesitekit/datastore/user/constants'; +import { CORE_WIDGETS } from '@/js/googlesitekit/widgets/datastore/constants'; +import { AREA_MAIN_DASHBOARD_KEY_METRICS_PRIMARY } from '@/js/googlesitekit/widgets/default-areas'; import { GetPDFDataParams } from '@/js/googlesitekit/widgets/types'; +import { KEY_METRICS_PDF_TILES } from './key-metrics-pdf-tiles'; import { KEY_METRICS_WIDGETS } from './key-metrics-widgets'; /** - * The `KEY_METRICS_WIDGETS` entry fields this loader reads. The map lives in a - * `.js` file, so this types just the fields used here and adds a string index so - * entries can be looked up by slug. + * One metric's PDF export configuration. Both maps live in `.js` files, so this + * types just the fields read here and adds a string index for slug lookups. */ -interface KeyMetricPDFEntry { - /** The tile heading. */ - title: string; - /** The tile's PDF export config, present only for metrics with a PDF tile. */ - pdfTile?: { - /** The tile's `@react-pdf/renderer` component, `lazyWithPreload`-wrapped. */ - TileComponent: ComponentType< never > & { - preload?: () => Promise< { - default: ComponentType< never >; - } >; - }; - /** Resolves the tile's data, or `null` when the export is canceled. */ - getTileData: ( params: GetPDFDataParams ) => Promise< unknown >; +interface KeyMetricPDFTileConfig { + /** The tile's `@react-pdf/renderer` component, `lazyWithPreload`-wrapped. */ + TileComponent: ComponentType< never > & { + preload?: () => Promise< { + default: ComponentType< never >; + } >; }; + /** Resolves the tile's data, or `null` when the report has no data. */ + getTileData: ( params: GetPDFDataParams ) => Promise< unknown >; } const keyMetricsWidgets = KEY_METRICS_WIDGETS as Record< string, - KeyMetricPDFEntry + { title: string } +>; + +const keyMetricsPDFTiles = KEY_METRICS_PDF_TILES as Record< + string, + KeyMetricPDFTileConfig >; /** * The props shape the aggregate component spreads into a tile: its `title` plus * the fields the tile's `getTileData` resolved. The map above stores each tile as * `ComponentType< never >` so every tile's own props shape fits it, so a - * component crossing into the rendered tile is converted to this type. + * component is converted to this type as it crosses into the rendered tile. */ type TileRenderComponent = ComponentType< Record< string, unknown > >; /** * One rendered Key Metrics tile: enough to render its `TileComponent`, plus the - * `data` it consumes (or `null` when that tile's data failed to load). + * `data` it consumes. Only tiles with data are composed, so `data` is always + * present — a tile whose report has no data (or failed to load) is left out. * * @since n.e.x.t */ @@ -75,7 +78,7 @@ export interface KeyMetricsPDFTile { title: string; /** The tile's `@react-pdf/renderer` component. */ TileComponent: TileRenderComponent; - /** The normalised tile data, or `null` when this tile's data failed to load. */ + /** The normalised tile data the `TileComponent` consumes. */ data: unknown; } @@ -92,45 +95,74 @@ export interface KeyMetricsPDFData { /** * Composes the Key Metrics PDF section data from the user's configured tiles. * - * Reads the user's key metric slugs, keeps only those whose `KEY_METRICS_WIDGETS` - * entry defines a `pdfTile` config (others are skipped silently), and loads each - * remaining tile's data through its `pdfTile.getTileData`. A single tile's - * failure is captured as `data: null` so the other tiles still render; the loader - * throws only when every tile fails. Tiles keep the user's configured order. + * Orders the tiles exactly as the dashboard does: it reads the active widgets of + * the Key Metrics area, which the widget registry already returns in the + * dashboard's render order (by widget priority), rather than the user's raw + * selection order. `viewableModules` scopes those widgets to the modules the + * current user can view, so a shared dashboard exports the same tiles it shows. + * It keeps only the widgets that have an entry in `KEY_METRICS_PDF_TILES` (the + * aggregate section widget and CTA tiles are skipped) and loads each tile's data + * through its `getTileData`. A tile whose report has no data is left out and the + * remaining tiles reflow to fill the grid; a tile whose report fails to load is + * also left out, but when every attempted tile fails that way the loader throws, + * so a genuine outage surfaces as an export error rather than an empty section. + * When no tile has data the loader returns `{ data: null }` so the orchestrator + * omits the whole section. * * @since n.e.x.t * - * @param params Loader parameters. - * @param params.registry WordPress data registry. - * @param params.dates Report date range. - * @param params.signal Cancellation signal. - * @return The Key Metrics tiles, or `{ data: null }` when canceled. + * @param params Loader parameters. + * @param params.registry WordPress data registry. + * @param params.dates Report date range. + * @param params.signal Cancellation signal. + * @param params.viewableModules The modules the user can view, or `undefined` for the owner. + * @return The Key Metrics tiles, or `{ data: null }` when canceled or no tile has data. */ export default async function getPDFData( { registry, dates, signal, + viewableModules, }: GetPDFDataParams ): Promise< KeyMetricsPDFData > { if ( signal.aborted ) { return { data: null }; } - const keyMetrics: string[] = - ( await registry.resolveSelect( CORE_USER ).getKeyMetrics() ) || []; + // Resolve the user's key metrics first: the area's widgets decide whether + // they are active from `isKeyMetricActive`, which reads this selection. + await registry.resolveSelect( CORE_USER ).getKeyMetrics(); if ( signal.aborted ) { return { data: null }; } + // The registry returns the area's active widgets in the dashboard's render + // order, so the exported tiles match what the user sees. Scoping to + // `viewableModules` drops tiles for modules the user cannot view, matching + // the dashboard; widgets without a PDF tile config (the aggregate section + // widget, the CTA tiles) are dropped below. + const orderedSlugs: string[] = registry + .select( CORE_WIDGETS ) + .getWidgets( AREA_MAIN_DASHBOARD_KEY_METRICS_PRIMARY, { + // `getWidgets` treats an empty array as "allow nothing", so an owner + // (or a viewer with no shared modules) passes `undefined` to skip the + // filter entirely, matching the orchestrator's area discovery. + modules: viewableModules || undefined, + } ) + .map( ( widget: { slug: string } ) => widget.slug ); + + // Tracks tiles that failed to load (threw), as opposed to tiles left out for + // having no data. When every attempted tile failed, the section is treated as + // an outage below rather than a genuinely empty section. let failureCount = 0; - // Only the configured metrics that have a PDF tile config render in the PDF, - // in the user's configured order. Others are skipped silently. - const tilePromises: Promise< KeyMetricsPDFTile >[] = []; + // A tile with no data (or a failed load) resolves to `null` and is filtered + // out below. + const tilePromises: Promise< KeyMetricsPDFTile | null >[] = []; - for ( const slug of keyMetrics ) { - const entry = keyMetricsWidgets[ slug ]; - const pdfTile = entry?.pdfTile; + for ( const slug of orderedSlugs ) { + const pdfTile = keyMetricsPDFTiles[ slug ]; + const title = keyMetricsWidgets[ slug ]?.title; if ( ! pdfTile ) { continue; @@ -139,13 +171,10 @@ export default async function getPDFData( { tilePromises.push( ( async () => { // The tile's `TileComponent` is a `lazyWithPreload` component so - // `key-metrics-widgets.js` stays free of `@react-pdf/renderer` on - // the dashboard bundle. Preload it here and hand its resolved - // default to the aggregate component, which renders it directly - // (the react-pdf renderer does not honour Suspense). - let TileComponent = - pdfTile.TileComponent as TileRenderComponent; - + // the dashboard bundle stays free of `@react-pdf/renderer`. + // Preload it here and hand its resolved default to the aggregate + // component, which renders it directly (the react-pdf renderer + // does not honour Suspense). try { const [ data, resolved ] = await Promise.all( [ pdfTile.getTileData( { registry, dates, signal } ), @@ -155,37 +184,48 @@ export default async function getPDFData( { default: pdfTile.TileComponent, } ), ] ); - TileComponent = resolved.default as TileRenderComponent; - return { slug, title: entry.title, TileComponent, data }; - } catch { - failureCount += 1; + + // The tile's report has no data, so leave it out entirely. + if ( data === null || data === undefined ) { + return null; + } + return { slug, - title: entry.title, - TileComponent, - data: null, + title, + TileComponent: resolved.default as TileRenderComponent, + data, }; + } catch { + // A failed report drops just this tile, but is counted so a + // section where every tile failed can surface as an error. + failureCount += 1; + return null; } } )() ); } - const tiles = await Promise.all( tilePromises ); + const settled = await Promise.all( tilePromises ); if ( signal.aborted ) { return { data: null }; } - // With no tiles to render, omit the section rather than render an empty one, - // matching how the report drops a widget with no data. + const tiles = settled.filter( + ( tile ): tile is KeyMetricsPDFTile => tile !== null + ); + if ( tiles.length === 0 ) { - return { data: null }; - } + // Every attempted tile failed to load, so surface the outage as an + // export error instead of a silently empty section. Mirrors the + // orchestrator, which errors only when every widget fails. + if ( failureCount > 0 && failureCount === tilePromises.length ) { + throw new Error( 'All Key Metrics PDF tiles failed to load.' ); + } - // Every tile failing leaves the section with no data, so surface it as an - // error rather than an empty section. - if ( failureCount === tiles.length ) { - throw new Error( 'All Key Metrics PDF tiles failed to load.' ); + // No configured tile has data, so omit the whole section from the report. + return { data: null }; } return { data: { tiles } }; From ea3757bf4c12470cac3f61cc1f4c84ea165a0d0a Mon Sep 17 00:00:00 2001 From: Aleksej Date: Tue, 21 Jul 2026 16:29:01 +0200 Subject: [PATCH 04/55] Cover ordering, filter, outage, abort. --- .../components/KeyMetrics/getPDFData.test.ts | 306 ++++++++++++++---- 1 file changed, 247 insertions(+), 59 deletions(-) diff --git a/assets/js/components/KeyMetrics/getPDFData.test.ts b/assets/js/components/KeyMetrics/getPDFData.test.ts index cd1cc9cc7da..fc0540a54ca 100644 --- a/assets/js/components/KeyMetrics/getPDFData.test.ts +++ b/assets/js/components/KeyMetrics/getPDFData.test.ts @@ -21,11 +21,14 @@ */ import { GetPDFDataParams } from '@/js/googlesitekit/widgets/types'; import getPDFData from './getPDFData'; +import { KEY_METRICS_PDF_TILES } from './key-metrics-pdf-tiles'; import { KEY_METRICS_WIDGETS } from './key-metrics-widgets'; jest.mock( './key-metrics-widgets', () => ( { KEY_METRICS_WIDGETS: {} } ) ); +jest.mock( './key-metrics-pdf-tiles', () => ( { KEY_METRICS_PDF_TILES: {} } ) ); const widgets = KEY_METRICS_WIDGETS as Record< string, unknown >; +const tiles = KEY_METRICS_PDF_TILES as Record< string, unknown >; const DATES = { startDate: '2025-01-08', @@ -35,17 +38,46 @@ const DATES = { }; /** - * Builds a mock registry whose `getKeyMetrics` resolves to the given slugs. + * Builds a mock registry whose Key Metrics area returns the given slugs, in + * order. The loader reads the area's active widgets (the dashboard's render + * order), so this is what drives the exported tile order. `widgetModules` mirrors + * the real `getWidgets` module filter: a slug is dropped when the caller passes a + * `modules` scope that does not include every module the widget requires. * * @since n.e.x.t * - * @param slugs The user's configured key metric slugs. + * @param slugs The area's active widget slugs, in dashboard order. + * @param widgetModules Map of slug to the modules that widget requires. + * @param keyMetricsSlugs The raw key metric selection order, when it must differ from the area order. Defaults to `slugs`. * @return A mock registry. */ -function createRegistry( slugs: string[] ) { +function createRegistry( + slugs: string[], + widgetModules: Record< string, string[] > = {}, + keyMetricsSlugs: string[] = slugs +) { return { resolveSelect: jest.fn( () => ( { - getKeyMetrics: jest.fn( () => Promise.resolve( slugs ) ), + getKeyMetrics: jest.fn( () => Promise.resolve( keyMetricsSlugs ) ), + } ) ), + select: jest.fn( () => ( { + getWidgets: jest.fn( + ( + _areaSlug: string, + { modules }: { modules?: string[] } = {} + ) => + slugs + .filter( ( slug ) => { + const required = widgetModules[ slug ]; + if ( ! required || ! modules ) { + return true; + } + return required.every( ( module ) => + modules.includes( module ) + ); + } ) + .map( ( slug ) => ( { slug } ) ) + ), } ) ), } as unknown as GetPDFDataParams[ 'registry' ]; } @@ -60,22 +92,19 @@ function TileB() { describe( 'Key Metrics getPDFData', () => { beforeEach( () => { Object.keys( widgets ).forEach( ( key ) => delete widgets[ key ] ); + Object.keys( tiles ).forEach( ( key ) => delete tiles[ key ] ); } ); - it( 'skips metrics without a pdfTile and returns the rest in configured order', async () => { + it( 'skips widgets without a pdfTile and returns the rest in the dashboard order', async () => { const getTileDataA = jest.fn( () => Promise.resolve( { value: 'A' } ) ); const getTileDataB = jest.fn( () => Promise.resolve( { value: 'B' } ) ); - widgets.metricA = { - title: 'Metric A', - pdfTile: { TileComponent: TileA, getTileData: getTileDataA }, - }; + widgets.metricA = { title: 'Metric A' }; + tiles.metricA = { TileComponent: TileA, getTileData: getTileDataA }; // No `pdfTile`, so it is skipped. widgets.metricNoPDF = { title: 'No PDF' }; - widgets.metricB = { - title: 'Metric B', - pdfTile: { TileComponent: TileB, getTileData: getTileDataB }, - }; + widgets.metricB = { title: 'Metric B' }; + tiles.metricB = { TileComponent: TileB, getTileData: getTileDataB }; const registry = createRegistry( [ 'metricA', @@ -107,22 +136,48 @@ describe( 'Key Metrics getPDFData', () => { ] ); } ); - it( 'captures a single tile failure as null data without failing the others', async () => { - widgets.metricA = { - title: 'Metric A', - pdfTile: { - TileComponent: TileA, - getTileData: jest.fn( () => - Promise.reject( new Error( 'boom' ) ) - ), - }, + it( 'orders tiles by the area widgets, not by the raw key metric selection', async () => { + widgets.metricA = { title: 'Metric A' }; + tiles.metricA = { + TileComponent: TileA, + getTileData: jest.fn( () => Promise.resolve( { value: 'A' } ) ), }; - widgets.metricB = { - title: 'Metric B', - pdfTile: { - TileComponent: TileB, - getTileData: jest.fn( () => Promise.resolve( { value: 'B' } ) ), - }, + widgets.metricB = { title: 'Metric B' }; + tiles.metricB = { + TileComponent: TileB, + getTileData: jest.fn( () => Promise.resolve( { value: 'B' } ) ), + }; + + // The area returns B before A (the dashboard's render order) while the raw + // key metric selection is the opposite order, so the assertion pins the + // exported order to the area, not to the selection. + const registry = createRegistry( [ 'metricB', 'metricA' ], {}, [ + 'metricA', + 'metricB', + ] ); + + const result = await getPDFData( { + registry, + dates: DATES, + signal: new AbortController().signal, + } ); + + expect( result.data?.tiles.map( ( tile ) => tile.slug ) ).toEqual( [ + 'metricB', + 'metricA', + ] ); + } ); + + it( 'excludes a failed tile and keeps the tiles that loaded', async () => { + widgets.metricA = { title: 'Metric A' }; + tiles.metricA = { + TileComponent: TileA, + getTileData: jest.fn( () => Promise.reject( new Error( 'boom' ) ) ), + }; + widgets.metricB = { title: 'Metric B' }; + tiles.metricB = { + TileComponent: TileB, + getTileData: jest.fn( () => Promise.resolve( { value: 'B' } ) ), }; const registry = createRegistry( [ 'metricA', 'metricB' ] ); @@ -134,12 +189,6 @@ describe( 'Key Metrics getPDFData', () => { } ); expect( result.data?.tiles ).toEqual( [ - { - slug: 'metricA', - title: 'Metric A', - TileComponent: TileA, - data: null, - }, { slug: 'metricB', title: 'Metric B', @@ -149,24 +198,73 @@ describe( 'Key Metrics getPDFData', () => { ] ); } ); - it( 'throws only when every tile fails', async () => { - widgets.metricA = { - title: 'Metric A', - pdfTile: { - TileComponent: TileA, - getTileData: jest.fn( () => - Promise.reject( new Error( 'a' ) ) - ), - }, + it( 'excludes a tile whose report returns no data', async () => { + widgets.metricA = { title: 'Metric A' }; + tiles.metricA = { + TileComponent: TileA, + // A `null` result means the tile's report has no data. + getTileData: jest.fn( () => Promise.resolve( null ) ), }; - widgets.metricB = { - title: 'Metric B', - pdfTile: { + widgets.metricB = { title: 'Metric B' }; + tiles.metricB = { + TileComponent: TileB, + getTileData: jest.fn( () => Promise.resolve( { value: 'B' } ) ), + }; + + const registry = createRegistry( [ 'metricA', 'metricB' ] ); + + const result = await getPDFData( { + registry, + dates: DATES, + signal: new AbortController().signal, + } ); + + expect( result.data?.tiles ).toEqual( [ + { + slug: 'metricB', + title: 'Metric B', TileComponent: TileB, - getTileData: jest.fn( () => - Promise.reject( new Error( 'b' ) ) - ), + data: { value: 'B' }, }, + ] ); + } ); + + it( 'omits the section without throwing when one tile fails and the rest have no data', async () => { + widgets.metricA = { title: 'Metric A' }; + tiles.metricA = { + TileComponent: TileA, + getTileData: jest.fn( () => Promise.reject( new Error( 'a' ) ) ), + }; + widgets.metricB = { title: 'Metric B' }; + tiles.metricB = { + TileComponent: TileB, + // No data for this tile, so the failure is not the whole story. + getTileData: jest.fn( () => Promise.resolve( null ) ), + }; + + const registry = createRegistry( [ 'metricA', 'metricB' ] ); + + // Not every attempted tile failed (one merely had no data), so this is a + // genuinely empty section rather than an outage. + const result = await getPDFData( { + registry, + dates: DATES, + signal: new AbortController().signal, + } ); + + expect( result ).toEqual( { data: null } ); + } ); + + it( 'throws when every attempted tile fails to load, so an outage is not hidden', async () => { + widgets.metricA = { title: 'Metric A' }; + tiles.metricA = { + TileComponent: TileA, + getTileData: jest.fn( () => Promise.reject( new Error( 'a' ) ) ), + }; + widgets.metricB = { title: 'Metric B' }; + tiles.metricB = { + TileComponent: TileB, + getTileData: jest.fn( () => Promise.reject( new Error( 'b' ) ) ), }; const registry = createRegistry( [ 'metricA', 'metricB' ] ); @@ -180,6 +278,47 @@ describe( 'Key Metrics getPDFData', () => { ).rejects.toThrow( 'All Key Metrics PDF tiles failed to load.' ); } ); + it( 'drops tiles for modules the user cannot view', async () => { + const getTileDataAdSense = jest.fn( () => + Promise.resolve( { value: 'A' } ) + ); + const getTileDataAnalytics = jest.fn( () => + Promise.resolve( { value: 'B' } ) + ); + + widgets.metricAdSense = { title: 'AdSense metric' }; + tiles.metricAdSense = { + TileComponent: TileA, + getTileData: getTileDataAdSense, + }; + widgets.metricAnalytics = { title: 'Analytics metric' }; + tiles.metricAnalytics = { + TileComponent: TileB, + getTileData: getTileDataAnalytics, + }; + + const registry = createRegistry( + [ 'metricAdSense', 'metricAnalytics' ], + { + metricAdSense: [ 'adsense' ], + metricAnalytics: [ 'analytics-4' ], + } + ); + + const result = await getPDFData( { + registry, + dates: DATES, + signal: new AbortController().signal, + // AdSense is not shared, so its tile is neither loaded nor exported. + viewableModules: [ 'analytics-4' ], + } ); + + expect( getTileDataAdSense ).not.toHaveBeenCalled(); + expect( result.data?.tiles.map( ( tile ) => tile.slug ) ).toEqual( [ + 'metricAnalytics', + ] ); + } ); + it( 'returns null data when no configured metric has a pdfTile, so the section is omitted', async () => { // No `pdfTile`, so no tiles are composed. widgets.metricNoPDF = { title: 'No PDF' }; @@ -197,10 +336,8 @@ describe( 'Key Metrics getPDFData', () => { it( 'returns null data without loading tiles when the signal is already aborted', async () => { const getTileData = jest.fn(); - widgets.metricA = { - title: 'Metric A', - pdfTile: { TileComponent: TileA, getTileData }, - }; + widgets.metricA = { title: 'Metric A' }; + tiles.metricA = { TileComponent: TileA, getTileData }; const registry = createRegistry( [ 'metricA' ] ); const controller = new AbortController(); @@ -216,6 +353,59 @@ describe( 'Key Metrics getPDFData', () => { expect( getTileData ).not.toHaveBeenCalled(); } ); + it( 'returns null data without loading tiles when aborted while resolving the key metrics', async () => { + const controller = new AbortController(); + const getTileData = jest.fn(); + widgets.metricA = { title: 'Metric A' }; + tiles.metricA = { TileComponent: TileA, getTileData }; + + // Abort mid-flight: the selection resolves, but the export is canceled + // before the area widgets are read. + const registry = { + resolveSelect: jest.fn( () => ( { + getKeyMetrics: jest.fn( () => { + controller.abort(); + return Promise.resolve( [ 'metricA' ] ); + } ), + } ) ), + select: jest.fn( () => ( { + getWidgets: jest.fn( () => [ { slug: 'metricA' } ] ), + } ) ), + } as unknown as GetPDFDataParams[ 'registry' ]; + + const result = await getPDFData( { + registry, + dates: DATES, + signal: controller.signal, + } ); + + expect( result ).toEqual( { data: null } ); + expect( getTileData ).not.toHaveBeenCalled(); + } ); + + it( 'returns null data when aborted after the tiles have loaded', async () => { + const controller = new AbortController(); + widgets.metricA = { title: 'Metric A' }; + tiles.metricA = { + TileComponent: TileA, + // The tile loads, but the export is canceled before it is composed. + getTileData: jest.fn( () => { + controller.abort(); + return Promise.resolve( { value: 'A' } ); + } ), + }; + + const registry = createRegistry( [ 'metricA' ] ); + + const result = await getPDFData( { + registry, + dates: DATES, + signal: controller.signal, + } ); + + expect( result ).toEqual( { data: null } ); + } ); + it( 'preloads a lazy TileComponent and returns its resolved default', async () => { function ResolvedTile() { return null; @@ -225,12 +415,10 @@ describe( 'Key Metrics getPDFData', () => { ); const LazyTile = Object.assign( () => null, { preload } ); - widgets.metricA = { - title: 'Metric A', - pdfTile: { - TileComponent: LazyTile, - getTileData: jest.fn( () => Promise.resolve( { value: 'A' } ) ), - }, + widgets.metricA = { title: 'Metric A' }; + tiles.metricA = { + TileComponent: LazyTile, + getTileData: jest.fn( () => Promise.resolve( { value: 'A' } ) ), }; const registry = createRegistry( [ 'metricA' ] ); From 79fb06ea50ee780e7ea54492c5a59fcea45f6889 Mon Sep 17 00:00:00 2001 From: Aleksej Date: Tue, 21 Jul 2026 16:29:26 +0200 Subject: [PATCH 05/55] Tile data loader factory; doc. --- .../create-key-metric-tile-data-loader.ts | 28 ++++++++++++++----- 1 file changed, 21 insertions(+), 7 deletions(-) diff --git a/assets/js/components/KeyMetrics/create-key-metric-tile-data-loader.ts b/assets/js/components/KeyMetrics/create-key-metric-tile-data-loader.ts index a5c6072c025..caac5df4774 100644 --- a/assets/js/components/KeyMetrics/create-key-metric-tile-data-loader.ts +++ b/assets/js/components/KeyMetrics/create-key-metric-tile-data-loader.ts @@ -52,18 +52,28 @@ interface FetchReportResult { * threading the export's abort `signal` through each request, then hands the * resolved report responses (in the same order) to `extract`, which normalises * them into the shape the tile's `TileComponent` consumes. It returns `null` - * early when the export is canceled, and throws when any report fails so the - * aggregate loader can skip just that tile. + * early when the export is canceled, and throws when one of those fetched + * reports fails so the aggregate loader can skip just that tile. `extract` + * returns `null` when its report has no data, which the aggregate loader treats + * the same way — the tile is left out of the report entirely. + * + * Note this covers only the reports `buildReports` returns. A report a tile + * resolves inside `buildReports` (e.g. to discover page paths) is fetched via + * `resolveSelect`, which yields `undefined` rather than throwing on failure, so + * such a failure reads as no data, not as a thrown error. * * @since n.e.x.t * - * @param buildReports Returns the reports to fetch for the given date range. - * @param extract Maps the resolved report responses to the tile's data. + * @param buildReports Returns the reports to fetch for the date range. It receives the registry too, so a tile whose report options depend on resolved state can await it; it may return a promise. + * @param extract Maps the resolved report responses to the tile's data, or `null` when the report has no data. * @return A `getTileData( { registry, dates, signal } )` loader. */ export default function createKeyMetricTileDataLoader< TData >( - buildReports: ( dates: PDFReportDates ) => TileReportRequest[], - extract: ( reports: unknown[] ) => TData + buildReports: ( + dates: PDFReportDates, + registry: GetPDFDataParams[ 'registry' ] + ) => TileReportRequest[] | Promise< TileReportRequest[] >, + extract: ( reports: unknown[] ) => TData | null ): ( params: GetPDFDataParams ) => Promise< TData | null > { return async function getTileData( { registry, @@ -74,7 +84,11 @@ export default function createKeyMetricTileDataLoader< TData >( return null; } - const requests = buildReports( dates ); + const requests = await buildReports( dates, registry ); + + if ( signal.aborted ) { + return null; + } const results: FetchReportResult[] = await Promise.all( requests.map( ( { moduleStore, options } ) => From 5fc23d5571adf36db58a01be22438416531136c6 Mon Sep 17 00:00:00 2001 From: Aleksej Date: Tue, 21 Jul 2026 16:29:42 +0200 Subject: [PATCH 06/55] Update loader call signature. --- .../KeyMetrics/create-key-metric-tile-data-loader.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/assets/js/components/KeyMetrics/create-key-metric-tile-data-loader.test.ts b/assets/js/components/KeyMetrics/create-key-metric-tile-data-loader.test.ts index edb8e4232cb..36d7e9e9f08 100644 --- a/assets/js/components/KeyMetrics/create-key-metric-tile-data-loader.test.ts +++ b/assets/js/components/KeyMetrics/create-key-metric-tile-data-loader.test.ts @@ -66,7 +66,7 @@ describe( 'createKeyMetricTileDataLoader', () => { ); const result = await getTileData( { registry, dates: DATES, signal } ); - expect( buildReports ).toHaveBeenCalledWith( DATES ); + expect( buildReports ).toHaveBeenCalledWith( DATES, registry ); expect( dispatch ).toHaveBeenCalledWith( 'test/store-a' ); expect( dispatch ).toHaveBeenCalledWith( 'test/store-b' ); expect( fetchGetReport ).toHaveBeenCalledWith( From 290ffa2c731ff28040c9f30b9cffdaf8a9e150c4 Mon Sep 17 00:00:00 2001 From: Aleksej Date: Tue, 21 Jul 2026 16:30:05 +0200 Subject: [PATCH 07/55] Four-column grid, uniform tile height. --- .../components/KeyMetrics/KeyMetricsPDF.tsx | 37 ++++++++++--------- 1 file changed, 19 insertions(+), 18 deletions(-) diff --git a/assets/js/components/KeyMetrics/KeyMetricsPDF.tsx b/assets/js/components/KeyMetrics/KeyMetricsPDF.tsx index a32b25b3d00..dc7759b5cd6 100644 --- a/assets/js/components/KeyMetrics/KeyMetricsPDF.tsx +++ b/assets/js/components/KeyMetrics/KeyMetricsPDF.tsx @@ -40,7 +40,10 @@ const styles = createPDFStyles( { marginBottom: 24, }, tileInner: { - // A small gutter between the grid columns. + // Fills the tile's height so its card stretches to the row's tallest tile, + // giving every tile in a row a uniform height as on the dashboard. The + // margin is a small gutter between the grid columns. + flexGrow: 1, marginRight: 24, }, } ); @@ -50,25 +53,23 @@ const KeyMetricsPDF: FC< PDFWidgetComponentProps > = ( { data } ) => { return ( - { tiles - .filter( ( tile ) => tile.data ) - .map( ( tile ) => { - const TileComponent = tile.TileComponent; + { tiles.map( ( tile ) => { + const TileComponent = tile.TileComponent; - return ( - - - ) } - /> - + return ( + + + ) } + /> - ); - } ) } + + ); + } ) } ); }; From f0402cc1c11ab3fd90c0c8bf284a39666681cfa2 Mon Sep 17 00:00:00 2001 From: Aleksej Date: Tue, 21 Jul 2026 16:30:24 +0200 Subject: [PATCH 08/55] Align tile render-prop typing. --- .../KeyMetrics/KeyMetricsPDF.test.tsx | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/assets/js/components/KeyMetrics/KeyMetricsPDF.test.tsx b/assets/js/components/KeyMetrics/KeyMetricsPDF.test.tsx index 2e2dd1a79b3..abaf99f0438 100644 --- a/assets/js/components/KeyMetrics/KeyMetricsPDF.test.tsx +++ b/assets/js/components/KeyMetrics/KeyMetricsPDF.test.tsx @@ -144,29 +144,29 @@ describe( 'KeyMetricsPDF', () => { } ); } ); - it( 'drops a tile whose data is null rather than showing a placeholder', () => { - const TileComponent = jest.fn( () => null ); - + it( 'renders exactly the tiles it is given, so the grid reflows with no gaps', () => { + // The aggregate loader drops no-data tiles before this component, so every + // tile here renders and there is no per-tile placeholder. const tree = render( [ { slug: 'metricA', title: 'New visitors', - TileComponent, + TileComponent: NumericTile, data: { value: '42' }, }, { - slug: 'metricB', - title: 'Returning visitors', - TileComponent, - data: null, + slug: 'metricC', + title: 'Visit length', + TileComponent: NumericTile, + data: { value: '3m' }, }, ] ); const text = findTextStrings( tree ).join( ' ' ); + expect( text ).toContain( 'New visitors: 42' ); + expect( text ).toContain( 'Visit length: 3m' ); // No placeholder copy is ever rendered. expect( text ).not.toContain( 'Data unavailable' ); - // The failed tile is dropped; only the tile with data renders. - expect( TileComponent ).toHaveBeenCalledTimes( 1 ); } ); it( 'renders no tiles when the tile list is empty', () => { From 5a630ecf8023cf2b3a3d059d156a73894a76b2d5 Mon Sep 17 00:00:00 2001 From: Aleksej Date: Tue, 21 Jul 2026 16:33:13 +0200 Subject: [PATCH 09/55] Remove inline PDF tile configs. --- .../KeyMetrics/key-metrics-widgets.js | 88 +------------------ 1 file changed, 4 insertions(+), 84 deletions(-) diff --git a/assets/js/components/KeyMetrics/key-metrics-widgets.js b/assets/js/components/KeyMetrics/key-metrics-widgets.js index ea05795bc05..fc68888075d 100644 --- a/assets/js/components/KeyMetrics/key-metrics-widgets.js +++ b/assets/js/components/KeyMetrics/key-metrics-widgets.js @@ -19,12 +19,11 @@ /** * WordPress dependencies */ -import { __, sprintf } from '@wordpress/i18n'; +import { __ } from '@wordpress/i18n'; /** * Internal dependencies */ -import lazyWithPreload from '@/js/components/pdf-export/lazy-with-preload'; import { CORE_SITE } from '@/js/googlesitekit/datastore/site/constants'; import { CORE_USER, @@ -61,7 +60,6 @@ import { ENUM_CONVERSION_EVENTS, MODULES_ANALYTICS_4, } from '@/js/modules/analytics-4/datastore/constants'; -import { calculateChange, numFmt } from '@/js/util'; import { KEY_METRICS_GROUP_CONTENT_PERFORMANCE, KEY_METRICS_GROUP_DRIVING_TRAFFIC, @@ -69,7 +67,6 @@ import { KEY_METRICS_GROUP_SELLING_PRODUCTS, KEY_METRICS_GROUP_VISITORS, } from './constants'; -import createKeyMetricTileDataLoader from './create-key-metric-tile-data-loader'; import { shouldDisplayWidgetWithConversionEvent } from './shouldDisplayWidgetWithConversionEvent'; /** @@ -143,20 +140,10 @@ function shouldDisplayWidgetWithCustomDimensions( { * * Each entry configures how the metric appears in the selection panel and on the * dashboard (`title`, `description`, `infoTooltip`, `metadata`, and the various - * display predicates). An entry may also define an optional `pdfTile` describing - * how the metric renders in the PDF export: + * display predicates). * - * - `pdfTile.TileComponent`: the `@react-pdf/renderer` component for the tile, - * wrapped with `lazyWithPreload` so this module stays free of the PDF renderer - * on the dashboard bundle. It receives the tile `title` plus the fields - * returned by `getTileData`. - * - `pdfTile.getTileData( { registry, dates, signal } )`: resolves the report(s) - * the tile needs and returns the normalised data the `TileComponent` consumes, - * or `null` when the export is canceled. - * - * Entries without a `pdfTile` field do not render in the PDF and are skipped. - * - * @since n.e.x.t Added the optional `pdfTile` field. + * A metric's PDF export configuration lives in `key-metrics-pdf-tiles.js`, + * keyed by the same slug, so this module never imports a widget component. */ const KEY_METRICS_WIDGETS = { [ KM_ANALYTICS_ADSENSE_TOP_EARNING_CONTENT ]: { @@ -331,73 +318,6 @@ const KEY_METRICS_WIDGETS = { 'google-site-kit' ), metadata: { group: KEY_METRICS_GROUP_VISITORS.SLUG }, - pdfTile: { - TileComponent: lazyWithPreload( () => - import( - /* webpackChunkName: "googlesitekit-vendor-lazy-pdf" */ - '@/js/components/pdf-export/shared-react-pdf-components/PDFNumericMetricTile' - ) - ), - getTileData: createKeyMetricTileDataLoader( - ( dates ) => [ - { - moduleStore: MODULES_ANALYTICS_4, - options: { - ...dates, - dimensions: [ 'newVsReturning' ], - metrics: [ { name: 'activeUsers' } ], - reportID: - 'analytics-4_new-visitors-widget_widget_reportOptions', - }, - }, - ], - ( [ report ] ) => { - const { rows = [], totals = [] } = report || {}; - - // The prominent value is the current period's new visitors, - // matching the dashboard's NewVisitorsWidget. - const newVisitors = - Number( - rows.find( - ( row ) => - row?.dimensionValues?.[ 0 ]?.value === - 'new' && - row?.dimensionValues?.[ 1 ]?.value === - 'date_range_0' - )?.metricValues?.[ 0 ]?.value - ) || 0; - - // The change compares the current and previous total - // visitors, again matching the dashboard tile. - const currentValue = - Number( totals[ 0 ]?.metricValues?.[ 0 ]?.value ) || 0; - const previousValue = - Number( totals[ 1 ]?.metricValues?.[ 0 ]?.value ) || 0; - const change = calculateChange( - previousValue, - currentValue - ); - - return { - value: numFmt( newVisitors ), - subtext: sprintf( - /* translators: %s: total number of visitors, such as "1,234". */ - __( 'of %s total visitors', 'google-site-kit' ), - numFmt( currentValue, { style: 'decimal' } ) - ), - change: - typeof change === 'number' - ? numFmt( change, { - style: 'percent', - signDisplay: 'exceptZero', - maximumFractionDigits: 1, - } ) - : undefined, - isNegative: typeof change === 'number' && change < 0, - }; - } - ), - }, }, [ KM_ANALYTICS_RETURNING_VISITORS ]: { title: __( 'Returning visitors', 'google-site-kit' ), From f4c5c7166233056b3db325ca9472f1f10b26bb89 Mon Sep 17 00:00:00 2001 From: Aleksej Date: Tue, 21 Jul 2026 16:33:35 +0200 Subject: [PATCH 10/55] Move and expand tile tests. --- .../KeyMetrics/key-metrics-widgets.test.js | 418 +++++++++++++++++- 1 file changed, 397 insertions(+), 21 deletions(-) diff --git a/assets/js/components/KeyMetrics/key-metrics-widgets.test.js b/assets/js/components/KeyMetrics/key-metrics-widgets.test.js index 9f06a14f503..67dd08d1cc4 100644 --- a/assets/js/components/KeyMetrics/key-metrics-widgets.test.js +++ b/assets/js/components/KeyMetrics/key-metrics-widgets.test.js @@ -19,8 +19,15 @@ /** * Internal dependencies */ -import { KM_ANALYTICS_NEW_VISITORS } from '@/js/googlesitekit/datastore/user/constants'; +import { + KM_ANALYTICS_NEW_VISITORS, + KM_ANALYTICS_RETURNING_VISITORS, + KM_ANALYTICS_TOP_CITIES, + KM_ANALYTICS_TOP_TRAFFIC_SOURCE, + KM_ANALYTICS_TOP_TRAFFIC_SOURCE_DRIVING_PURCHASES, +} from '@/js/googlesitekit/datastore/user/constants'; import { numFmt } from '@/js/util'; +import { KEY_METRICS_PDF_TILES } from './key-metrics-pdf-tiles'; import { KEY_METRICS_WIDGETS } from './key-metrics-widgets'; const DATES = { @@ -30,9 +37,12 @@ const DATES = { compareEndDate: '2025-01-07', }; +// The percent format the Top Cities tile uses for each city's share. +const PERCENT_1DP = { style: 'percent', maximumFractionDigits: 1 }; + /** - * Builds a registry whose `fetchGetReport` resolves with the given report, so - * the New Visitors `getTileData` can be exercised against a report fixture. + * Builds a registry whose `fetchGetReport` resolves with the given report, so a + * tile's `getTileData` can be exercised against a report fixture. * * @since n.e.x.t * @@ -46,6 +56,99 @@ function registryReturning( report ) { return { dispatch: jest.fn( () => ( { fetchGetReport } ) ) }; } +/** + * Builds a registry whose `fetchGetReport` resolves with the given reports in + * order, so a tile that requests more than one report gets each in turn. + * + * @since n.e.x.t + * + * @param {Object[]} reports The report responses to resolve, in fetch order. + * @return {Object} A mock registry. + */ +function registryReturningReports( reports ) { + let call = 0; + const fetchGetReport = jest.fn( () => + Promise.resolve( { response: reports[ call++ ] } ) + ); + return { dispatch: jest.fn( () => ( { fetchGetReport } ) ) }; +} + +/** + * Loads one tile's data against a single report fixture. + * + * @since n.e.x.t + * + * @param {string} slug The key metric slug. + * @param {Object} report The report response the tile fetches. + * @return {Promise} The resolved tile data. + */ +function loadTile( slug, report ) { + return KEY_METRICS_PDF_TILES[ slug ].getTileData( { + registry: registryReturning( report ), + dates: DATES, + signal: new AbortController().signal, + } ); +} + +/** + * Loads one tile's data against several report fixtures, in fetch order. + * + * @since n.e.x.t + * + * @param {string} slug The key metric slug. + * @param {Object[]} reports The report responses the tile fetches, in order. + * @return {Promise} The resolved tile data. + */ +function loadTileWithReports( slug, reports ) { + return KEY_METRICS_PDF_TILES[ slug ].getTileData( { + registry: registryReturningReports( reports ), + dates: DATES, + signal: new AbortController().signal, + } ); +} + +/** + * Builds a registry for the tiles that read a report before deciding what to + * fetch: `resolveSelect().getReport` returns `resolvedReport` for that first + * read, and `fetchGetReport` returns `fetchReports` in order for the rest. + * + * @since n.e.x.t + * + * @param {Object} resolvedReport The report the tile reads up front. + * @param {Object[]} fetchReports The report responses the tile then fetches. + * @return {Object} A mock registry. + */ +function registryWithResolvedReport( resolvedReport, fetchReports ) { + let call = 0; + const fetchGetReport = jest.fn( () => + Promise.resolve( { response: fetchReports[ call++ ] } ) + ); + return { + resolveSelect: jest.fn( () => ( { + getReport: jest.fn( () => Promise.resolve( resolvedReport ) ), + } ) ), + dispatch: jest.fn( () => ( { fetchGetReport } ) ), + }; +} + +/** + * Loads a tile that reads a report before deciding what to fetch. + * + * @since n.e.x.t + * + * @param {string} slug The key metric slug. + * @param {Object} resolvedReport The report the tile reads up front. + * @param {Object[]} fetchReports The report responses the tile then fetches. + * @return {Promise} The resolved tile data. + */ +function loadPrecheckedTile( slug, resolvedReport, fetchReports ) { + return KEY_METRICS_PDF_TILES[ slug ].getTileData( { + registry: registryWithResolvedReport( resolvedReport, fetchReports ), + dates: DATES, + signal: new AbortController().signal, + } ); +} + /** * Loads the New Visitors tile data against the given report fixture. * @@ -55,30 +158,29 @@ function registryReturning( report ) { * @return {Promise} The resolved tile data. */ function loadNewVisitorsTile( report ) { - return KEY_METRICS_WIDGETS[ KM_ANALYTICS_NEW_VISITORS ].pdfTile.getTileData( - { - registry: registryReturning( report ), - dates: DATES, - signal: new AbortController().signal, - } - ); + return loadTile( KM_ANALYTICS_NEW_VISITORS, report ); } -describe( 'KEY_METRICS_WIDGETS pdfTile', () => { - it( 'defines a pdfTile config for the New Visitors metric', () => { - const { pdfTile } = KEY_METRICS_WIDGETS[ KM_ANALYTICS_NEW_VISITORS ]; +describe( 'KEY_METRICS_PDF_TILES', () => { + it( 'defines a PDF tile config for the New Visitors metric', () => { + const pdfTile = KEY_METRICS_PDF_TILES[ KM_ANALYTICS_NEW_VISITORS ]; expect( pdfTile ).toBeDefined(); expect( pdfTile.TileComponent ).toBeDefined(); expect( typeof pdfTile.getTileData ).toBe( 'function' ); } ); - it( 'does not define a pdfTile config for any other metric', () => { - Object.entries( KEY_METRICS_WIDGETS ) - .filter( ( [ slug ] ) => slug !== KM_ANALYTICS_NEW_VISITORS ) - .forEach( ( [ , entry ] ) => { - expect( entry.pdfTile ).toBeUndefined(); - } ); + it( 'gives every Key Metric a PDF tile with a TileComponent and getTileData', () => { + const slugs = Object.keys( KEY_METRICS_WIDGETS ); + expect( slugs.length ).toBeGreaterThan( 0 ); + + // Every metric a user can add to their dashboard renders in the PDF. + slugs.forEach( ( slug ) => { + const pdfTile = KEY_METRICS_PDF_TILES[ slug ]; + expect( pdfTile ).toBeDefined(); + expect( pdfTile.TileComponent ).toBeDefined(); + expect( typeof pdfTile.getTileData ).toBe( 'function' ); + } ); } ); describe( 'New Visitors getTileData', () => { @@ -148,11 +250,285 @@ describe( 'KEY_METRICS_WIDGETS pdfTile', () => { expect( data.isNegative ).toBe( true ); } ); - it( 'falls back to zero for the value when the report has no rows or totals', async () => { + it( 'returns null when the report has no rows, so the tile is dropped', async () => { const data = await loadNewVisitorsTile( {} ); - expect( data.value ).toBe( numFmt( 0 ) ); + expect( data ).toBeNull(); + } ); + + it( 'keeps the compare dates, since the change needs the previous period', async () => { + const fetchGetReport = jest.fn( () => + Promise.resolve( { response: {} } ) + ); + const registry = { + dispatch: jest.fn( () => ( { fetchGetReport } ) ), + }; + + await KEY_METRICS_PDF_TILES[ + KM_ANALYTICS_NEW_VISITORS + ].getTileData( { + registry, + dates: DATES, + signal: new AbortController().signal, + } ); + + const options = fetchGetReport.mock.calls[ 0 ][ 0 ]; + expect( options.compareStartDate ).toBe( DATES.compareStartDate ); + expect( options.compareEndDate ).toBe( DATES.compareEndDate ); + } ); + } ); + + describe( 'Returning Visitors getTileData (numeric family, percentage)', () => { + it( 'reads the returning share and the absolute point change', async () => { + // 300 of 1,000 returned this period (30%); 200 of 1,000 the previous + // period (20%), so the absolute change is +10 points. + const report = { + rows: [ + { + dimensionValues: [ + { value: 'returning' }, + { value: 'date_range_0' }, + ], + metricValues: [ { value: '300' } ], + }, + { + dimensionValues: [ + { value: 'returning' }, + { value: 'date_range_1' }, + ], + metricValues: [ { value: '200' } ], + }, + ], + totals: [ + { metricValues: [ { value: '1000' } ] }, + { metricValues: [ { value: '1000' } ] }, + ], + }; + + const data = await loadTile( + KM_ANALYTICS_RETURNING_VISITORS, + report + ); + + // The value is the returning share, formatted as a percentage. + expect( data.value ).toBe( + numFmt( 0.3, { + style: 'percent', + signDisplay: 'never', + maximumFractionDigits: 1, + } ) + ); + // The badge shows the absolute point change (30% - 20% = +10%). + expect( data.isNegative ).toBe( false ); + expect( data.change ).toEqual( expect.stringContaining( '10' ) ); + expect( data.subtext ).toContain( + numFmt( 1000, { style: 'decimal' } ) + ); + expect( data.subtext ).toContain( 'total visitors' ); + } ); + + it( 'returns null when the report has no rows, so the tile is dropped', async () => { + const data = await loadTile( KM_ANALYTICS_RETURNING_VISITORS, {} ); + + expect( data ).toBeNull(); + } ); + } ); + + describe( 'Top Traffic Source getTileData (text family, two reports)', () => { + // The tile combines a total-users report and a per-channel report. + const totalReport = { + rows: [ + { + dimensionValues: [ { value: 'date_range_0' } ], + metricValues: [ { value: '1000' } ], + }, + { + dimensionValues: [ { value: 'date_range_1' } ], + metricValues: [ { value: '800' } ], + }, + ], + }; + const sourceReport = { + rows: [ + { + dimensionValues: [ + { value: 'Organic Search' }, + { value: 'date_range_0' }, + ], + metricValues: [ { value: '400' } ], + }, + { + dimensionValues: [ + { value: 'Organic Search' }, + { value: 'date_range_1' }, + ], + metricValues: [ { value: '200' } ], + }, + ], + }; + + it( 'reads the top channel, its share, and the absolute point change', async () => { + const data = await loadTileWithReports( + KM_ANALYTICS_TOP_TRAFFIC_SOURCE, + [ totalReport, sourceReport ] + ); + + // The value is the top channel name. + expect( data.value ).toBe( 'Organic Search' ); + // 400 / 1,000 = 40% this period; the sub-text shows the share. + expect( data.subtext ).toContain( + numFmt( 0.4, { + style: 'percent', + signDisplay: 'never', + maximumFractionDigits: 1, + } ) + ); + expect( data.subtext ).toContain( 'of total traffic' ); + // 40% vs 25% (200/800) previous, so the absolute change is +15 points. expect( data.isNegative ).toBe( false ); + expect( data.change ).toEqual( expect.stringContaining( '15' ) ); + } ); + + it( 'returns null when the per-channel report has no rows', async () => { + const data = await loadTileWithReports( + KM_ANALYTICS_TOP_TRAFFIC_SOURCE, + [ totalReport, {} ] + ); + + expect( data ).toBeNull(); + } ); + } ); + + describe( 'Top Cities getTileData (table family, ranked share)', () => { + it( 'maps the top cities to share rows, dropping "(not set)" and capping the list', async () => { + const report = { + totals: [ { metricValues: [ { value: '1000' } ] } ], + rows: [ + { + dimensionValues: [ { value: 'Dublin' } ], + metricValues: [ { value: '500' } ], + }, + { + dimensionValues: [ { value: 'London' } ], + metricValues: [ { value: '250' } ], + }, + { + dimensionValues: [ { value: '(not set)' } ], + metricValues: [ { value: '150' } ], + }, + { + dimensionValues: [ { value: 'Paris' } ], + metricValues: [ { value: '100' } ], + }, + ], + }; + + const data = await loadTile( KM_ANALYTICS_TOP_CITIES, report ); + + // Each city's share of total users, "(not set)" dropped. + expect( data.rows ).toEqual( [ + { primary: 'Dublin', metric: numFmt( 0.5, PERCENT_1DP ) }, + { primary: 'London', metric: numFmt( 0.25, PERCENT_1DP ) }, + { primary: 'Paris', metric: numFmt( 0.1, PERCENT_1DP ) }, + ] ); + } ); + + it( 'returns null when the report has no rows, so the tile is dropped', async () => { + const data = await loadTile( KM_ANALYTICS_TOP_CITIES, { + rows: [], + totals: [], + } ); + + expect( data ).toBeNull(); + } ); + + it( 'requests a single date range, dropping the export compare dates', async () => { + // Table tiles mirror single-range dashboard widgets; passing the + // export's compare dates would make GA4 add a second date range and + // return previous-period rows the table would then show. + const fetchGetReport = jest.fn( () => + Promise.resolve( { response: { rows: [], totals: [] } } ) + ); + const registry = { + dispatch: jest.fn( () => ( { fetchGetReport } ) ), + }; + + await KEY_METRICS_PDF_TILES[ KM_ANALYTICS_TOP_CITIES ].getTileData( + { + registry, + dates: DATES, + signal: new AbortController().signal, + } + ); + + const options = fetchGetReport.mock.calls[ 0 ][ 0 ]; + expect( options.startDate ).toBe( DATES.startDate ); + expect( options.endDate ).toBe( DATES.endDate ); + expect( options.compareStartDate ).toBeUndefined(); + expect( options.compareEndDate ).toBeUndefined(); + } ); + } ); + + describe( 'Top Traffic Source Driving Purchases getTileData (purchase pre-check)', () => { + // The per-source report always names a top source, because + // `ecommercePurchases` reports a zero-valued row rather than no data. + const sourceReport = { + rows: [ + { + dimensionValues: [ + { value: 'Organic Search' }, + { value: 'date_range_0' }, + ], + metricValues: [ { value: '5' } ], + }, + ], + }; + + it( 'drops the tile when no period has a purchase, despite a named top source', async () => { + const noPurchases = { + rows: [ + { + dimensionValues: [ { value: 'date_range_0' } ], + metricValues: [ { value: '0' } ], + }, + { + dimensionValues: [ { value: 'date_range_1' } ], + metricValues: [ { value: '0' } ], + }, + ], + }; + + const data = await loadPrecheckedTile( + KM_ANALYTICS_TOP_TRAFFIC_SOURCE_DRIVING_PURCHASES, + noPurchases, + [ noPurchases, sourceReport ] + ); + + expect( data ).toBeNull(); + } ); + + it( 'renders the top source when at least one period has a purchase', async () => { + const withPurchases = { + rows: [ + { + dimensionValues: [ { value: 'date_range_0' } ], + metricValues: [ { value: '10' } ], + }, + { + dimensionValues: [ { value: 'date_range_1' } ], + metricValues: [ { value: '8' } ], + }, + ], + }; + + const data = await loadPrecheckedTile( + KM_ANALYTICS_TOP_TRAFFIC_SOURCE_DRIVING_PURCHASES, + withPurchases, + [ withPurchases, sourceReport ] + ); + + expect( data ).not.toBeNull(); + expect( data.value ).toBe( 'Organic Search' ); } ); } ); } ); From 98ee97e69e542aa8c0599effcf4b9b5098a75024 Mon Sep 17 00:00:00 2001 From: Aleksej Date: Tue, 21 Jul 2026 16:33:57 +0200 Subject: [PATCH 11/55] Pass viewableModules into getData. --- assets/js/components/pdf-export/PDFExportOrchestrator.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/assets/js/components/pdf-export/PDFExportOrchestrator.tsx b/assets/js/components/pdf-export/PDFExportOrchestrator.tsx index 54ec237a533..396abb54395 100644 --- a/assets/js/components/pdf-export/PDFExportOrchestrator.tsx +++ b/assets/js/components/pdf-export/PDFExportOrchestrator.tsx @@ -336,6 +336,10 @@ const PDFExportOrchestrator: FC< PDFExportOrchestratorProps > = ( { registry, dates, signal, + // The same module visibility the area discovery used, so a + // loader that composes several modules' tiles (Key Metrics) can + // keep only the tiles the user can view, as the dashboard does. + viewableModules, } ); throwIfAborted( signal ); From bf22a55be379adecf1ee8ba6a293ca77a340a4c6 Mon Sep 17 00:00:00 2001 From: Aleksej Date: Tue, 21 Jul 2026 16:34:28 +0200 Subject: [PATCH 12/55] Add change badge helper. --- .../components/pdf-export/getPDFTileChange.ts | 71 +++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 assets/js/components/pdf-export/getPDFTileChange.ts diff --git a/assets/js/components/pdf-export/getPDFTileChange.ts b/assets/js/components/pdf-export/getPDFTileChange.ts new file mode 100644 index 00000000000..eb846843a81 --- /dev/null +++ b/assets/js/components/pdf-export/getPDFTileChange.ts @@ -0,0 +1,71 @@ +/** + * PDF tile change helper. + * + * Site Kit by Google, Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Internal dependencies + */ +import { calculateChange, numFmt } from '@/js/util'; + +/** A tile's period-over-period change, pre-formatted for the change badge. */ +export interface PDFTileChange { + /** The formatted, signed change, e.g. "+5.1%", or `undefined` when it can't be computed. */ + change?: string; + /** Whether the change is negative, controlling the badge color. */ + isNegative: boolean; +} + +/** + * Computes a Key Metrics PDF tile's change badge fields from its current and + * previous values, mirroring the dashboard's `ChangeBadge`. + * + * The change is the relative difference by default, or the absolute difference + * when `isAbsolute` is set (used for metrics that are themselves percentages, so + * the badge shows a point difference rather than a percentage of a percentage). + * The result is `undefined` when the change can't be computed (e.g. the previous + * value is zero), which hides the badge. + * + * @since n.e.x.t + * + * @param {number} previousValue The previous period's value. + * @param {number} currentValue The current period's value. + * @param {Object} [options] Options. + * @param {boolean} [options.isAbsolute] Whether to use the absolute rather than relative change. Defaults to `false`. + * @return {PDFTileChange} The formatted change and its sign. + */ +export default function getPDFTileChange( + previousValue: number, + currentValue: number, + { isAbsolute = false }: { isAbsolute?: boolean } = {} +): PDFTileChange { + const change = isAbsolute + ? currentValue - previousValue + : calculateChange( previousValue, currentValue ); + + if ( change === null || change === undefined ) { + return { isNegative: false }; + } + + return { + change: numFmt( change, { + style: 'percent', + signDisplay: 'exceptZero', + maximumFractionDigits: 1, + } ), + isNegative: change < 0, + }; +} From de027f8f3933a8a715bbf278c04e87818e90c692 Mon Sep 17 00:00:00 2001 From: Aleksej Date: Tue, 21 Jul 2026 16:35:11 +0200 Subject: [PATCH 13/55] Add ranked-list PDF tile. --- .../PDFMetricTileTable.tsx | 143 ++++++++++++++++++ 1 file changed, 143 insertions(+) create mode 100644 assets/js/components/pdf-export/shared-react-pdf-components/PDFMetricTileTable.tsx diff --git a/assets/js/components/pdf-export/shared-react-pdf-components/PDFMetricTileTable.tsx b/assets/js/components/pdf-export/shared-react-pdf-components/PDFMetricTileTable.tsx new file mode 100644 index 00000000000..03ecd62bf5e --- /dev/null +++ b/assets/js/components/pdf-export/shared-react-pdf-components/PDFMetricTileTable.tsx @@ -0,0 +1,143 @@ +/** + * PDFMetricTileTable component for @react-pdf/renderer. + * + * Site Kit by Google, Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * External dependencies + */ +import { View } from '@react-pdf/renderer'; +import { FC } from 'react'; + +/** + * Internal dependencies + */ +import { createPDFStyles } from '@/js/components/pdf-export/pdf-scale'; +import { PDF_COLORS } from '@/js/components/pdf-export/pdf-theme'; +import PDFCard from './PDFCard'; +import PDFTypography from './PDFTypography'; + +const styles = createPDFStyles( { + // Matches the dashboard tile's `min-height: 150px`, and fills the grid cell so + // every tile in a row shares the tallest tile's height. + card: { + minHeight: 150, + flexGrow: 1, + }, + // 12px / 400 / on-surface-variant, matching the dashboard tile heading. + title: { + color: PDF_COLORS.SURFACES_ON_SURFACE_VARIANT, + }, + // The ranked list sits 12px below the heading, as on the dashboard. + table: { + flexDirection: 'column', + marginTop: 12, + }, + // One entry: the label on the left, its metric on the right. The rows need no + // padding of their own; the 1.33 line height spaces them as the design does. + row: { + flexDirection: 'row', + justifyContent: 'space-between', + }, + // The label cell takes the width the metric leaves. Truncation needs a + // bounded box, so the text is measured against this cell rather than growing + // past the metric. The dashboard uses a 10px gutter here, but react-pdf's + // truncation reserves less room for the ellipsis than CSS `text-overflow` + // does, so a wider gutter keeps the label clear of the metric. + primaryCell: { + flexGrow: 1, + flexShrink: 1, + marginRight: 20, + }, + // Truncates on a single line, matching the dashboard's `text-overflow`. + primary: { + textOverflow: 'ellipsis', + }, + // A label the dashboard renders as a link keeps that link colour. + primaryLink: { + color: PDF_COLORS.CONTENT_SECONDARY, + }, + // The metric hugs the right edge and is never truncated. + metric: { + flexShrink: 0, + }, +} ); + +/** One ranked entry: a primary label and its formatted metric. */ +export interface PDFMetricTileTableRow { + /** The primary label, e.g. a page title, city, or author name. */ + primary: string; + /** The pre-formatted metric, e.g. "1.2K" or "34%". */ + metric: string; +} + +export interface PDFMetricTileTableProps { + /** The tile heading, e.g. "Top cities". */ + title: string; + /** The ranked entries, in the dashboard tile's order. */ + rows: PDFMetricTileTableRow[]; + /** The maximum number of rows to render. Defaults to every row. */ + limit?: number; + /** Whether the dashboard tile renders its labels as links, which colours them. */ + linked?: boolean; +} + +const PDFMetricTileTable: FC< PDFMetricTileTableProps > = ( { + title, + rows, + limit, + linked = false, +} ) => { + const visibleRows = + typeof limit === 'number' ? rows.slice( 0, limit ) : rows; + + const primaryStyles = linked + ? [ styles.primary, styles.primaryLink ] + : styles.primary; + + return ( + + + { title } + + + { visibleRows.map( ( row, index ) => ( + + + + { row.primary } + + + + { row.metric } + + + ) ) } + + + ); +}; + +export default PDFMetricTileTable; From d2527be02028ee531b605d037c512d3e38a82042 Mon Sep 17 00:00:00 2001 From: Aleksej Date: Tue, 21 Jul 2026 16:36:02 +0200 Subject: [PATCH 14/55] Add table tile tests. --- .../PDFMetricTileTable.test.tsx | 191 ++++++++++++++++++ 1 file changed, 191 insertions(+) create mode 100644 assets/js/components/pdf-export/shared-react-pdf-components/PDFMetricTileTable.test.tsx diff --git a/assets/js/components/pdf-export/shared-react-pdf-components/PDFMetricTileTable.test.tsx b/assets/js/components/pdf-export/shared-react-pdf-components/PDFMetricTileTable.test.tsx new file mode 100644 index 00000000000..a656b07ffa7 --- /dev/null +++ b/assets/js/components/pdf-export/shared-react-pdf-components/PDFMetricTileTable.test.tsx @@ -0,0 +1,191 @@ +/** + * PDFMetricTileTable tests. + * + * Site Kit by Google, Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * External dependencies + */ +import TestRenderer from 'react-test-renderer'; + +/** + * Internal dependencies + */ +import { scalePDFValue } from '@/js/components/pdf-export/pdf-scale'; +import PDFMetricTileTable from './PDFMetricTileTable'; + +/** + * Collects every text string rendered in a react-test-renderer tree, so a test + * can assert on the tile copy without walking the tree itself. + * + * @since n.e.x.t + * + * @param {(string|number|Object|null)} node The current tree node. + * @param {string[]} output The strings collected so far. + * @return {string[]} The collected text strings. + */ +function findTextStrings( + node: + | string + | number + | TestRenderer.ReactTestRendererJSON + | null + | undefined, + output: string[] = [] +): string[] { + if ( node === null || node === undefined ) { + return output; + } + if ( typeof node === 'string' ) { + output.push( node ); + return output; + } + if ( typeof node === 'number' ) { + output.push( String( node ) ); + return output; + } + if ( Array.isArray( node.children ) ) { + node.children.forEach( ( child ) => findTextStrings( child, output ) ); + } + return output; +} + +/** + * Renders `PDFMetricTileTable` with the given props and returns its collected + * text strings. + * + * @since n.e.x.t + * + * @param {Object} props The tile props. + * @return {string[]} The rendered text strings. + */ +function renderTable( + props: React.ComponentProps< typeof PDFMetricTileTable > +): string[] { + const renderer = TestRenderer.create( ); + const tree = renderer.toJSON(); + if ( Array.isArray( tree ) ) { + throw new Error( 'Unexpected render output.' ); + } + return findTextStrings( tree ); +} + +const ROWS = [ + { primary: 'New York', metric: '40%' }, + { primary: 'London', metric: '30%' }, + { primary: 'Tokyo', metric: '20%' }, + { primary: 'Paris', metric: '10%' }, +]; + +describe( 'PDFMetricTileTable', () => { + it( 'renders the title and every ranked row with its metric', () => { + const text = renderTable( { + title: 'Top cities', + rows: ROWS, + } ).join( ' ' ); + + expect( text ).toContain( 'Top cities' ); + ROWS.forEach( ( { primary, metric } ) => { + expect( text ).toContain( primary ); + expect( text ).toContain( metric ); + } ); + } ); + + it( 'caps the rendered rows at the limit', () => { + const text = renderTable( { + title: 'Top cities', + rows: ROWS, + limit: 2, + } ).join( ' ' ); + + expect( text ).toContain( 'New York' ); + expect( text ).toContain( 'London' ); + // Rows beyond the limit are not rendered. + expect( text ).not.toContain( 'Tokyo' ); + expect( text ).not.toContain( 'Paris' ); + } ); + + it( 'matches the dashboard tile typography and truncates a long label', () => { + const tree = TestRenderer.create( + + ).toJSON(); + const json = JSON.stringify( tree ); + + // Lengths are scaled to PDF points, so compare against the scaled value: + // 12px text with 0.2px letter spacing, as on the dashboard. + expect( json ).toContain( `"fontSize":${ scalePDFValue( 12 ) }` ); + expect( json ).toContain( `"letterSpacing":${ scalePDFValue( 0.2 ) }` ); + // Line height is a multiplier, so it is not scaled: 12 × 1.33 ≈ 16px. + expect( json ).toContain( '"lineHeight":1.33' ); + // The label truncates on one line rather than wrapping, which needs a + // bounded cell around it. + expect( json ).toContain( '"textOverflow":"ellipsis"' ); + expect( json ).toContain( '"maxLines":1' ); + expect( json ).toContain( '"flexGrow":1,"flexShrink":1' ); + // The row carries no padding of its own; the line height spaces the + // entries, as the design intends. + expect( json ).toContain( + '"style":{"flexDirection":"row","justifyContent":"space-between"}' + ); + } ); + + it( 'gives the card the dashboard tile min height so a row of tiles is uniform', () => { + const tree = TestRenderer.create( + + ).toJSON(); + const json = JSON.stringify( tree ); + + // The dashboard tile is `min-height: 150px` and fills its grid cell, so the + // card carries the scaled min height and grows to the row's tallest tile. + expect( json ).toContain( `"minHeight":${ scalePDFValue( 150 ) }` ); + expect( json ).toContain( '"flexGrow":1' ); + } ); + + it( 'colours the label like a link only when the dashboard tile links it', () => { + const linked = JSON.stringify( + TestRenderer.create( + + ).toJSON() + ); + const plain = JSON.stringify( + TestRenderer.create( + + ).toJSON() + ); + + expect( linked ).toContain( '#108080' ); + expect( plain ).not.toContain( '#108080' ); + } ); + + it( 'renders only the title when there are no rows', () => { + const strings = renderTable( { title: 'Top cities', rows: [] } ); + + expect( strings ).toEqual( [ 'Top cities' ] ); + } ); +} ); From 3ccd1b087d5c63572182ee2793ae6e00cd1fc2c7 Mon Sep 17 00:00:00 2001 From: Aleksej Date: Tue, 21 Jul 2026 16:36:35 +0200 Subject: [PATCH 15/55] Add text metric PDF tile. --- .../PDFMetricTileText.tsx | 111 ++++++++++++++++++ 1 file changed, 111 insertions(+) create mode 100644 assets/js/components/pdf-export/shared-react-pdf-components/PDFMetricTileText.tsx diff --git a/assets/js/components/pdf-export/shared-react-pdf-components/PDFMetricTileText.tsx b/assets/js/components/pdf-export/shared-react-pdf-components/PDFMetricTileText.tsx new file mode 100644 index 00000000000..3b720ef5e84 --- /dev/null +++ b/assets/js/components/pdf-export/shared-react-pdf-components/PDFMetricTileText.tsx @@ -0,0 +1,111 @@ +/** + * PDFMetricTileText component for @react-pdf/renderer. + * + * Site Kit by Google, Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * External dependencies + */ +import { View } from '@react-pdf/renderer'; +import { FC } from 'react'; + +/** + * Internal dependencies + */ +import { createPDFStyles } from '@/js/components/pdf-export/pdf-scale'; +import { PDF_COLORS } from '@/js/components/pdf-export/pdf-theme'; +import PDFCard from './PDFCard'; +import PDFChangeBadge from './PDFChangeBadge'; +import PDFTypography from './PDFTypography'; + +const styles = createPDFStyles( { + // Matches the dashboard tile's `min-height: 150px`, and fills the grid cell so + // every tile in a row shares the tallest tile's height. + card: { + minHeight: 150, + flexGrow: 1, + }, + // 12px / 400 / on-surface-variant, matching the dashboard tile heading. + title: { + color: PDF_COLORS.SURFACES_ON_SURFACE_VARIANT, + }, + // The primary text value, e.g. "Organic Search", 4px below the title. Unlike + // the numeric tile this is a text label, so it uses the title type rather than + // the large headline value. + value: { + marginTop: 4, + }, + // 14px / on-surface, e.g. "of 1,234 total visitors". + subtext: { + marginTop: 2, + }, + // The badge is a pill, so a row wrapper keeps it hugging its content instead + // of stretching to the card width. It sits 12px below the sub-text. + badgeRow: { + flexDirection: 'row', + marginTop: 12, + }, +} ); + +export interface PDFMetricTileTextProps { + /** The tile heading, e.g. "Top traffic source". */ + title: string; + /** The primary text value, e.g. "Organic Search". */ + value: string; + /** Optional caption below the value, e.g. "3.4K visitors". */ + subtext?: string; + /** Pre-formatted, signed change for the badge, e.g. "+5.1%". Hides the badge when omitted. */ + change?: string; + /** Whether the change is negative, controlling the badge color. */ + isNegative?: boolean; +} + +// Mirrors the dashboard's `MetricTileText`: it shares the full-card layout of +// `PDFNumericMetricTile`, but its value is a text label (e.g. a traffic source +// name) rather than a large formatted number. +const PDFMetricTileText: FC< PDFMetricTileTextProps > = ( { + title, + value, + subtext, + change, + isNegative = false, +} ) => { + return ( + + + { title } + + + { value } + + { !! subtext && ( + + { subtext } + + ) } + { !! change && ( + + + + ) } + + ); +}; + +export default PDFMetricTileText; From d8b5499ab041236cd0307f5bd86f32b7e7430c47 Mon Sep 17 00:00:00 2001 From: Aleksej Date: Tue, 21 Jul 2026 16:37:04 +0200 Subject: [PATCH 16/55] Add text tile tests. --- .../PDFMetricTileText.test.tsx | 164 ++++++++++++++++++ 1 file changed, 164 insertions(+) create mode 100644 assets/js/components/pdf-export/shared-react-pdf-components/PDFMetricTileText.test.tsx diff --git a/assets/js/components/pdf-export/shared-react-pdf-components/PDFMetricTileText.test.tsx b/assets/js/components/pdf-export/shared-react-pdf-components/PDFMetricTileText.test.tsx new file mode 100644 index 00000000000..c74f541ced0 --- /dev/null +++ b/assets/js/components/pdf-export/shared-react-pdf-components/PDFMetricTileText.test.tsx @@ -0,0 +1,164 @@ +/** + * PDFMetricTileText tests. + * + * Site Kit by Google, Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * External dependencies + */ +import TestRenderer from 'react-test-renderer'; + +/** + * Internal dependencies + */ +import PDFMetricTileText from './PDFMetricTileText'; + +/** + * Collects every text string rendered in a react-test-renderer tree, so a test + * can assert on the tile copy without walking the tree itself. + * + * @since n.e.x.t + * + * @param {(string|number|Object|null)} node The current tree node. + * @param {string[]} output The strings collected so far. + * @return {string[]} The collected text strings. + */ +function findTextStrings( + node: + | string + | number + | TestRenderer.ReactTestRendererJSON + | null + | undefined, + output: string[] = [] +): string[] { + if ( node === null || node === undefined ) { + return output; + } + if ( typeof node === 'string' ) { + output.push( node ); + return output; + } + if ( typeof node === 'number' ) { + output.push( String( node ) ); + return output; + } + if ( Array.isArray( node.children ) ) { + node.children.forEach( ( child ) => findTextStrings( child, output ) ); + } + return output; +} + +/** + * Renders `PDFMetricTileText` with the given props and returns its JSON tree. + * + * @since n.e.x.t + * + * @param {Object} props The tile props. + * @return {Object} The rendered tree. + */ +function renderTextTree( + props: React.ComponentProps< typeof PDFMetricTileText > +): TestRenderer.ReactTestRendererJSON { + const tree = TestRenderer.create( + + ).toJSON(); + if ( ! tree || Array.isArray( tree ) ) { + throw new Error( 'Unexpected render output.' ); + } + return tree; +} + +/** + * Renders `PDFMetricTileText` and returns its collected text, space-joined. + * + * @since n.e.x.t + * + * @param {Object} props The tile props. + * @return {string} The rendered text. + */ +function renderText( + props: React.ComponentProps< typeof PDFMetricTileText > +): string { + return findTextStrings( renderTextTree( props ) ).join( ' ' ); +} + +describe( 'PDFMetricTileText', () => { + it( 'renders the title, value, sub-text, and change', () => { + const text = renderText( { + title: 'Top traffic source', + value: 'Organic Search', + subtext: '3.4K visitors', + change: '+5.1%', + isNegative: false, + } ); + + expect( text ).toContain( 'Top traffic source' ); + expect( text ).toContain( 'Organic Search' ); + expect( text ).toContain( '3.4K visitors' ); + expect( text ).toContain( '+5.1%' ); + } ); + + it( 'omits the sub-text when none is provided', () => { + const text = renderText( { + title: 'Top traffic source', + value: 'Organic Search', + } ); + + expect( text ).toContain( 'Organic Search' ); + expect( text ).not.toContain( 'visitors' ); + } ); + + it( 'omits the change badge when no change is provided', () => { + // The same props, with and without a change, so the assertion pins the + // omission to the missing `change` rather than to copy that never renders. + const withChange = renderText( { + title: 'Top traffic source', + value: 'Organic Search', + change: '+5.1%', + } ); + const withoutChange = renderText( { + title: 'Top traffic source', + value: 'Organic Search', + } ); + + expect( withChange ).toContain( '+5.1%' ); + expect( withoutChange ).not.toContain( '+5.1%' ); + } ); + + it( 'colours the badge green for a positive change and red for a negative one', () => { + const positive = JSON.stringify( + renderTextTree( { + title: 'Top traffic source', + value: 'Organic Search', + change: '+5.1%', + } ) + ); + // The rise chip background. + expect( positive ).toContain( '#d8ffc0' ); + + const negative = JSON.stringify( + renderTextTree( { + title: 'Top traffic source', + value: 'Organic Search', + change: '-5.0%', + isNegative: true, + } ) + ); + // The fall chip background. + expect( negative ).toContain( '#ffded3' ); + } ); +} ); From 4322c83167d71b3ecca66d381112c9f3cd714c64 Mon Sep 17 00:00:00 2001 From: Aleksej Date: Tue, 21 Jul 2026 16:37:31 +0200 Subject: [PATCH 17/55] Add card min-height styling. --- .../shared-react-pdf-components/PDFNumericMetricTile.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/assets/js/components/pdf-export/shared-react-pdf-components/PDFNumericMetricTile.tsx b/assets/js/components/pdf-export/shared-react-pdf-components/PDFNumericMetricTile.tsx index 19355d37e22..9d98ca4d669 100644 --- a/assets/js/components/pdf-export/shared-react-pdf-components/PDFNumericMetricTile.tsx +++ b/assets/js/components/pdf-export/shared-react-pdf-components/PDFNumericMetricTile.tsx @@ -32,6 +32,12 @@ import PDFChangeBadge from './PDFChangeBadge'; import PDFTypography from './PDFTypography'; const styles = createPDFStyles( { + // Matches the dashboard tile's `min-height: 150px`, and fills the grid cell so + // every tile in a row shares the tallest tile's height. + card: { + minHeight: 150, + flexGrow: 1, + }, // 12px / 400 / on-surface-variant, matching the dashboard tile heading. title: { color: PDF_COLORS.SURFACES_ON_SURFACE_VARIANT, @@ -74,7 +80,7 @@ const PDFNumericMetricTile: FC< PDFNumericMetricTileProps > = ( { isNegative = false, } ) => { return ( - + { title } From 98c86062eddd1bfc4da9caa97a7e327ebff097e0 Mon Sep 17 00:00:00 2001 From: Aleksej Date: Tue, 21 Jul 2026 16:37:50 +0200 Subject: [PATCH 18/55] Fold maxLines into text style. --- .../shared-react-pdf-components/PDFTypography.tsx | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/assets/js/components/pdf-export/shared-react-pdf-components/PDFTypography.tsx b/assets/js/components/pdf-export/shared-react-pdf-components/PDFTypography.tsx index dc8d24be91e..472c9ebaa4e 100644 --- a/assets/js/components/pdf-export/shared-react-pdf-components/PDFTypography.tsx +++ b/assets/js/components/pdf-export/shared-react-pdf-components/PDFTypography.tsx @@ -42,12 +42,15 @@ export interface PDFTypographyProps { size?: PDFTypographySize; /** Style merged on top of the base text styles. */ style?: Style | Style[]; + /** Caps the text at this many lines. Pair with `textOverflow: 'ellipsis'` to truncate. */ + maxLines?: number; } const PDFTypography: FC< PDFTypographyProps > = ( { type = 'body', size = 'medium', style, + maxLines, children, } ) => { const baseStyles: Style[] = [ @@ -55,6 +58,12 @@ const PDFTypography: FC< PDFTypographyProps > = ( { { color: PDF_COLORS.SURFACES_ON_SURFACE }, ]; + // `@react-pdf/renderer` reads `maxLines` off the text's style, not as a + // prop, so it is merged into the styles rather than passed to `Text`. + if ( typeof maxLines === 'number' ) { + baseStyles.push( { maxLines } ); + } + return ( { children } From b2ec51587a3fafc47a5dae46dd6508c157c82242 Mon Sep 17 00:00:00 2001 From: Aleksej Date: Tue, 21 Jul 2026 16:38:12 +0200 Subject: [PATCH 19/55] Read the new PDF tiles map. --- assets/js/googlesitekit/widgets/register-defaults.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/assets/js/googlesitekit/widgets/register-defaults.js b/assets/js/googlesitekit/widgets/register-defaults.js index de64078e16c..63790db34b7 100644 --- a/assets/js/googlesitekit/widgets/register-defaults.js +++ b/assets/js/googlesitekit/widgets/register-defaults.js @@ -33,7 +33,7 @@ import { import AddMetricCTATile from '@/js/components/KeyMetrics/AddMetricCTATile'; import { KEY_METRICS_BACK_NOTICE_SLUG } from '@/js/components/KeyMetrics/constants'; import getKeyMetricsPDFData from '@/js/components/KeyMetrics/getPDFData'; -import { KEY_METRICS_WIDGETS } from '@/js/components/KeyMetrics/key-metrics-widgets'; +import { KEY_METRICS_PDF_TILES } from '@/js/components/KeyMetrics/key-metrics-pdf-tiles'; import KeyMetricsNewBadge from '@/js/components/KeyMetrics/KeyMetricsNewBadge'; import MetricsWidgetSubtitle from '@/js/components/KeyMetrics/MetricsWidgetSubtitle'; import lazyWithPreload from '@/js/components/pdf-export/lazy-with-preload'; @@ -415,7 +415,7 @@ export function registerDefaults( widgetsAPI ) { // The Key Metrics PDF section renders `WidgetNull` on the dashboard (it // occupies no grid slot); it exists to compose the user's configured key // metric tiles into the PDF export. It appears in the export only when at - // least one configured metric has a `pdfTile` config. Registered only when + // least one configured metric has a PDF tile config. Registered only when // the `pdfGeneration` feature flag is enabled. if ( isFeatureEnabled( 'pdfGeneration' ) ) { widgetsAPI.registerWidget( @@ -436,7 +436,7 @@ export function registerDefaults( widgetsAPI ) { // section once it appears; by export time they are loaded. isActive: ( select ) => ( select( CORE_USER ).getKeyMetrics() || [] ).some( - ( slug ) => !! KEY_METRICS_WIDGETS[ slug ]?.pdfTile + ( slug ) => !! KEY_METRICS_PDF_TILES[ slug ] ), }, }, From e03fc982a6d42168b39bed4eddc39e5f26ed403c Mon Sep 17 00:00:00 2001 From: Aleksej Date: Tue, 21 Jul 2026 16:38:31 +0200 Subject: [PATCH 20/55] Update aggregate widget assertions. --- .../googlesitekit/widgets/register-defaults.test.js | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/assets/js/googlesitekit/widgets/register-defaults.test.js b/assets/js/googlesitekit/widgets/register-defaults.test.js index 0daa9b56274..5ef9f807623 100644 --- a/assets/js/googlesitekit/widgets/register-defaults.test.js +++ b/assets/js/googlesitekit/widgets/register-defaults.test.js @@ -156,7 +156,7 @@ describe( 'registerDefaults - keyMetricsPDFSection', () => { ).toBeUndefined(); } ); - it( 'pdf.isActive is true only when a configured metric has a pdfTile config', () => { + it( 'pdf.isActive is true only when a configured metric has a PDF tile config', () => { enabledFeatures.add( 'pdfGeneration' ); const widgetsAPI = createWidgetsAPI(); @@ -167,18 +167,23 @@ describe( 'registerDefaults - keyMetricsPDFSection', () => { 'keyMetricsPDFSection' ); - // New Visitors has a pdfTile config. + // Every Key Metric has a PDF tile, so any real metric activates it. expect( settings.pdf.isActive( selectWithKeyMetrics( [ KM_ANALYTICS_NEW_VISITORS ] ) ) ).toBe( true ); - - // Returning Visitors does not, so a selection with only it is inactive. expect( settings.pdf.isActive( selectWithKeyMetrics( [ KM_ANALYTICS_RETURNING_VISITORS ] ) ) + ).toBe( true ); + + // A slug with no PDF tile leaves the section out of the report. + expect( + settings.pdf.isActive( + selectWithKeyMetrics( [ 'kmMetricWithoutAPDFTile' ] ) + ) ).toBe( false ); // No selection resolved yet. From 7560a1befc32e7282d01f541c422377b34359dee Mon Sep 17 00:00:00 2001 From: Aleksej Date: Tue, 21 Jul 2026 16:38:51 +0200 Subject: [PATCH 21/55] Add optional viewableModules getData param. --- assets/js/googlesitekit/widgets/types.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/assets/js/googlesitekit/widgets/types.ts b/assets/js/googlesitekit/widgets/types.ts index ad94ff4b2fd..0b3193fe44e 100644 --- a/assets/js/googlesitekit/widgets/types.ts +++ b/assets/js/googlesitekit/widgets/types.ts @@ -55,6 +55,12 @@ export interface GetPDFDataParams { dates: PDFReportDates; /** Signal that cancels the export. */ signal: AbortSignal; + /** + * The module slugs the current user can view, or `undefined` on the owner's + * dashboard where every module is viewable. A loader that composes several + * modules' data uses this to match the dashboard's module visibility. + */ + viewableModules?: string[]; } /** @@ -103,6 +109,7 @@ export interface WidgetPDFConfig { registry: unknown; dates: PDFReportDates; signal: AbortSignal; + viewableModules?: string[]; } ) => Promise< WidgetPDFData >; label?: string; // eslint-disable-next-line @typescript-eslint/no-explicit-any -- The registry `select` is loosely typed, so `isActive` predicates can read store selectors without casting. From 2afa959fbf976bd0927ade4878fada167af1a1e2 Mon Sep 17 00:00:00 2001 From: Aleksej Date: Tue, 21 Jul 2026 16:40:02 +0200 Subject: [PATCH 22/55] Export earnings report options helper. --- .../widgets/TopEarningContentWidget.js | 47 ++++++++++++++----- 1 file changed, 35 insertions(+), 12 deletions(-) diff --git a/assets/js/modules/adsense/components/widgets/TopEarningContentWidget.js b/assets/js/modules/adsense/components/widgets/TopEarningContentWidget.js index 37992c027e8..cc40dc0161f 100644 --- a/assets/js/modules/adsense/components/widgets/TopEarningContentWidget.js +++ b/assets/js/modules/adsense/components/widgets/TopEarningContentWidget.js @@ -51,18 +51,25 @@ import { numFmt } from '@/js/util'; import whenActive from '@/js/util/when-active'; import ConnectAdSenseCTATileWidget from './ConnectAdSenseCTATileWidget'; -function TopEarningContentWidget( { Widget } ) { - const viewOnlyDashboard = useViewOnly(); - - const dates = useSelect( ( select ) => - select( CORE_USER ).getDateRangeDates() - ); - - const adSenseAccountID = useSelect( ( select ) => - select( MODULES_ADSENSE ).getAccountID() - ); - - const reportOptions = { +/** + * Builds the Analytics 4 report options for the Top Earning Content metric. + * + * Returns the page-path and ad-source report the tile ranks by ad revenue. Both + * this widget and the metric's PDF tile import this, so the dashboard tile and + * the report request the same data. + * + * @since n.e.x.t + * + * @param {Object} dates The date range. + * @param {Object} [params] Extra report options. + * @param {string} [params.adSenseAccountID] The AdSense account ID the `adSourceName` filter targets. + * @return {Object} The `getReport` options. + */ +export function getTopEarningContentReportOptions( + dates, + { adSenseAccountID } = {} +) { + return { ...dates, dimensions: [ 'pagePath', 'adSourceName' ], metrics: [ { name: 'totalAdRevenue' } ], @@ -78,6 +85,22 @@ function TopEarningContentWidget( { Widget } ) { limit: 3, reportID: 'adsense_top-earning-content-widget_widget_reportOptions', }; +} + +function TopEarningContentWidget( { Widget } ) { + const viewOnlyDashboard = useViewOnly(); + + const dates = useSelect( ( select ) => + select( CORE_USER ).getDateRangeDates() + ); + + const adSenseAccountID = useSelect( ( select ) => + select( MODULES_ADSENSE ).getAccountID() + ); + + const reportOptions = getTopEarningContentReportOptions( dates, { + adSenseAccountID, + } ); const report = useInViewSelect( ( select ) => { From ae30031e7251d342518517399bed832811e20979 Mon Sep 17 00:00:00 2001 From: Aleksej Date: Tue, 21 Jul 2026 16:40:19 +0200 Subject: [PATCH 23/55] Export engaged source report options. --- .../widgets/EngagedTrafficSourceWidget.js | 68 +++++++++++++++---- 1 file changed, 54 insertions(+), 14 deletions(-) diff --git a/assets/js/modules/analytics-4/components/widgets/EngagedTrafficSourceWidget.js b/assets/js/modules/analytics-4/components/widgets/EngagedTrafficSourceWidget.js index 705c47b6cab..5d28cb7fafd 100644 --- a/assets/js/modules/analytics-4/components/widgets/EngagedTrafficSourceWidget.js +++ b/assets/js/modules/analytics-4/components/widgets/EngagedTrafficSourceWidget.js @@ -42,6 +42,56 @@ import { numFmt } from '@/js/util'; import whenActive from '@/js/util/when-active'; import ConnectGA4CTATileWidget from './ConnectGA4CTATileWidget'; +/** + * Builds the Analytics 4 report options for the Engaged Traffic Source metric. + * + * Both this widget and the metric's PDF tile import this, so the dashboard tile + * and the report request the same data. + * + * @since n.e.x.t + * + * @param {Object} dates The date range, including the compare dates. + * @return {Object} The Analytics 4 `getReport` options. + */ +export function getEngagedTrafficSourceReportOptions( dates ) { + return { + ...dates, + dimensions: [ 'sessionDefaultChannelGroup' ], + metrics: [ { name: 'engagedSessions' } ], + orderBy: 'engagedSessions', + limit: 1, + reportID: + 'analytics-4_engaged-traffic-source-widget_widget_reportOptions', + }; +} + +/** + * Builds the sub-text for the Engaged Traffic Source metric tile. + * + * Both this widget and the metric's PDF tile import this, so the dashboard tile + * and the PDF tile show the same sub-text. + * + * @since n.e.x.t + * + * @param {number} rate The engaged sessions rate for the top source. + * @param {number} totalEngagedSessions The total number of engaged sessions. + * @return {string} The metric tile sub-text. + */ +export function getEngagedTrafficSourceSubtext( rate, totalEngagedSessions ) { + const format = { + style: 'percent', + signDisplay: 'never', + maximumFractionDigits: 1, + }; + + return sprintf( + /* translators: 1. Percentage of total engaged sessions. 2: Total number of engaged sessions. */ + __( '%1$s of %2$s engaged sessions', 'google-site-kit' ), + numFmt( rate, format ), + numFmt( totalEngagedSessions, { style: 'decimal' } ) + ); +} + function EngagedTrafficSourceWidget( props ) { const { Widget } = props; @@ -51,15 +101,7 @@ function EngagedTrafficSourceWidget( props ) { } ) ); - const reportOptions = { - ...dates, - dimensions: [ 'sessionDefaultChannelGroup' ], - metrics: [ { name: 'engagedSessions' } ], - orderBy: 'engagedSessions', - limit: 1, - reportID: - 'analytics-4_engaged-traffic-source-widget_widget_reportOptions', - }; + const reportOptions = getEngagedTrafficSourceReportOptions( dates ); const report = useInViewSelect( ( select ) => select( MODULES_ANALYTICS_4 ).getReport( reportOptions ), @@ -129,11 +171,9 @@ function EngagedTrafficSourceWidget( props ) { widgetSlug={ KM_ANALYTICS_ENGAGED_TRAFFIC_SOURCE } metricValue={ topTrafficSource } metricValueFormat={ format } - subText={ sprintf( - /* translators: 1. Percentage of total engaged sessions. 2: Total number of engaged sessions. */ - __( '%1$s of %2$s engaged sessions', 'google-site-kit' ), - numFmt( currentEngagedSessionsRate, format ), - numFmt( currentTotalEngagedSessions, { style: 'decimal' } ) + subText={ getEngagedTrafficSourceSubtext( + currentEngagedSessionsRate, + currentTotalEngagedSessions ) } previousValue={ previousEngagedSessionsRate } currentValue={ currentEngagedSessionsRate } From 902012bdc5b35cfd9ea4f574598acd5598ae6aa3 Mon Sep 17 00:00:00 2001 From: Aleksej Date: Tue, 21 Jul 2026 16:41:14 +0200 Subject: [PATCH 24/55] Export least-engaging report options. --- .../widgets/LeastEngagingPagesWidget.js | 78 +++++++++++++------ 1 file changed, 54 insertions(+), 24 deletions(-) diff --git a/assets/js/modules/analytics-4/components/widgets/LeastEngagingPagesWidget.js b/assets/js/modules/analytics-4/components/widgets/LeastEngagingPagesWidget.js index b89cc0ebda1..d485a6501ae 100644 --- a/assets/js/modules/analytics-4/components/widgets/LeastEngagingPagesWidget.js +++ b/assets/js/modules/analytics-4/components/widgets/LeastEngagingPagesWidget.js @@ -43,16 +43,26 @@ import { numFmt } from '@/js/util'; import whenActive from '@/js/util/when-active'; import ConnectGA4CTATileWidget from './ConnectGA4CTATileWidget'; -function LeastEngagingPagesWidget( props ) { - const { Widget } = props; - - const viewOnlyDashboard = useViewOnly(); - - const dates = useSelect( ( select ) => - select( CORE_USER ).getDateRangeDates() - ); - - const pageViewsReportOptions = { +/** + * Builds the GA4 `getReport` options for the Least engaging pages widget. + * + * Returns both reports the widget needs: the `pageViews` report that ranks every + * page by views so the caller can derive the median views, and the `report` that + * lists the least engaging pages by bounce rate, filtered to pages with at least + * the median views. The `report` filter depends on that median, so callers pass + * it once known. + * + * @since n.e.x.t + * + * @param {Object} dates The date range for the reports. + * @param {number} [medianPageViews] The median page views the `report` filters against. Defaults to `0`. + * @return {Object} The `pageViews` and `report` report options. + */ +export function getLeastEngagingPagesReportOptions( + dates, + medianPageViews = 0 +) { + const pageViews = { ...dates, dimensions: [ 'pagePath' ], metrics: [ { name: 'screenPageViews' } ], @@ -66,20 +76,7 @@ function LeastEngagingPagesWidget( props ) { 'analytics-4_least-engaging-pages-widget_widget_pageViewsReportOptions', }; - const pageViewsReport = useInViewSelect( - ( select ) => - select( MODULES_ANALYTICS_4 ).getReport( pageViewsReportOptions ), - [ pageViewsReportOptions ] - ); - - const medianIndex = parseInt( pageViewsReport?.rowCount / 2, 10 ); - const medianPageViews = - parseInt( - pageViewsReport?.rows?.[ medianIndex ]?.metricValues?.[ 0 ]?.value, - 10 - ) || 0; - - const reportOptions = { + const report = { ...dates, dimensions: [ 'pagePath' ], metrics: [ 'bounceRate', 'screenPageViews' ], @@ -104,6 +101,39 @@ function LeastEngagingPagesWidget( props ) { 'analytics-4_least-engaging-pages-widget_widget_reportOptions', }; + return { pageViews, report }; +} + +function LeastEngagingPagesWidget( props ) { + const { Widget } = props; + + const viewOnlyDashboard = useViewOnly(); + + const dates = useSelect( ( select ) => + select( CORE_USER ).getDateRangeDates() + ); + + const { pageViews: pageViewsReportOptions } = + getLeastEngagingPagesReportOptions( dates ); + + const pageViewsReport = useInViewSelect( + ( select ) => + select( MODULES_ANALYTICS_4 ).getReport( pageViewsReportOptions ), + [ pageViewsReportOptions ] + ); + + const medianIndex = parseInt( pageViewsReport?.rowCount / 2, 10 ); + const medianPageViews = + parseInt( + pageViewsReport?.rows?.[ medianIndex ]?.metricValues?.[ 0 ]?.value, + 10 + ) || 0; + + const { report: reportOptions } = getLeastEngagingPagesReportOptions( + dates, + medianPageViews + ); + const loadedPageViewsReport = useSelect( ( select ) => select( MODULES_ANALYTICS_4 ).hasFinishedResolution( 'getReport', [ pageViewsReportOptions, From 49045b45812982c92503b5da37437e007f1c8ea1 Mon Sep 17 00:00:00 2001 From: Aleksej Date: Tue, 21 Jul 2026 16:42:31 +0200 Subject: [PATCH 25/55] Export most-engaging report options. --- .../widgets/MostEngagingPagesWidget.js | 93 ++++++++++++------- 1 file changed, 61 insertions(+), 32 deletions(-) diff --git a/assets/js/modules/analytics-4/components/widgets/MostEngagingPagesWidget.js b/assets/js/modules/analytics-4/components/widgets/MostEngagingPagesWidget.js index 44ff78bf5af..075b3daff13 100644 --- a/assets/js/modules/analytics-4/components/widgets/MostEngagingPagesWidget.js +++ b/assets/js/modules/analytics-4/components/widgets/MostEngagingPagesWidget.js @@ -43,6 +43,61 @@ import { numFmt } from '@/js/util'; import whenActive from '@/js/util/when-active'; import ConnectGA4CTATileWidget from './ConnectGA4CTATileWidget'; +/** + * Builds the `getReport` options for the Most engaging pages widget, so the + * dashboard tile and the PDF export request the same data. + * + * The engaging pages report filters to pages at or above the average page + * views, a value derived from the page views report at runtime, so it is + * passed in rather than recomputed here. + * + * @since n.e.x.t + * + * @param {Object} dates The date range for the reports. + * @param {number} [averagePageViews] The average page views threshold the engaging pages report filters on. Defaults to `0`. + * @return {Object} The `pageViews` and `engagingPages` `getReport` options. + */ +export function getMostEngagingPagesReportOptions( + dates, + averagePageViews = 0 +) { + return { + pageViews: { + ...dates, + dimensions: [ 'pagePath' ], + metrics: [ { name: 'screenPageViews' } ], + limit: 1, + reportID: + 'analytics-4_most-engaging-pages-widget_widget_pageViewsReportOptions', + }, + engagingPages: { + ...dates, + dimensions: [ 'pagePath' ], + metrics: [ 'engagementRate', 'screenPageViews' ], + orderby: [ + { + metric: { metricName: 'engagementRate' }, + desc: true, + }, + { + metric: { metricName: 'screenPageViews' }, + desc: true, + }, + ], + metricFilters: { + screenPageViews: { + filterType: 'numericFilter', + operation: 'GREATER_THAN_OR_EQUAL', + value: { int64Value: averagePageViews }, + }, + }, + limit: 3, + reportID: + 'analytics-4_most-engaging-pages-widget_widget_reportOptions', + }, + }; +} + function MostEngagingPagesWidget( props ) { const { Widget } = props; @@ -52,14 +107,8 @@ function MostEngagingPagesWidget( props ) { select( CORE_USER ).getDateRangeDates() ); - const pageViewsReportOptions = { - ...dates, - dimensions: [ 'pagePath' ], - metrics: [ { name: 'screenPageViews' } ], - limit: 1, - reportID: - 'analytics-4_most-engaging-pages-widget_widget_pageViewsReportOptions', - }; + const { pageViews: pageViewsReportOptions } = + getMostEngagingPagesReportOptions( dates ); const pageViewsReport = useInViewSelect( ( select ) => @@ -73,30 +122,10 @@ function MostEngagingPagesWidget( props ) { pageViewsReport?.rowCount ) || 0; - const reportOptions = { - ...dates, - dimensions: [ 'pagePath' ], - metrics: [ 'engagementRate', 'screenPageViews' ], - orderby: [ - { - metric: { metricName: 'engagementRate' }, - desc: true, - }, - { - metric: { metricName: 'screenPageViews' }, - desc: true, - }, - ], - metricFilters: { - screenPageViews: { - filterType: 'numericFilter', - operation: 'GREATER_THAN_OR_EQUAL', - value: { int64Value: averagePageViews }, - }, - }, - limit: 3, - reportID: 'analytics-4_most-engaging-pages-widget_widget_reportOptions', - }; + const { engagingPages: reportOptions } = getMostEngagingPagesReportOptions( + dates, + averagePageViews + ); const pageViewsReportErrors = useSelect( ( select ) => select( MODULES_ANALYTICS_4 ).getErrorForSelector( 'getReport', [ From e244fec93831e4e5621118886db926e6c7201327 Mon Sep 17 00:00:00 2001 From: Aleksej Date: Tue, 21 Jul 2026 16:43:05 +0200 Subject: [PATCH 26/55] Export new-visitors report options. --- .../components/widgets/NewVisitorsWidget.js | 52 +++++++++++++++---- 1 file changed, 41 insertions(+), 11 deletions(-) diff --git a/assets/js/modules/analytics-4/components/widgets/NewVisitorsWidget.js b/assets/js/modules/analytics-4/components/widgets/NewVisitorsWidget.js index bce5d507619..bdf1089faa7 100644 --- a/assets/js/modules/analytics-4/components/widgets/NewVisitorsWidget.js +++ b/assets/js/modules/analytics-4/components/widgets/NewVisitorsWidget.js @@ -42,6 +42,45 @@ import { numFmt } from '@/js/util/i18n'; import whenActive from '@/js/util/when-active'; import ConnectGA4CTATileWidget from './ConnectGA4CTATileWidget'; +/** + * Builds the Analytics 4 report options for the New Visitors metric. + * + * Both this widget and the metric's PDF tile import this, so the dashboard tile + * and the report request the same data. + * + * @since n.e.x.t + * + * @param {Object} dates The date range, including the compare dates. + * @return {Object} The Analytics 4 `getReport` options. + */ +export function getNewVisitorsReportOptions( dates ) { + return { + ...dates, + dimensions: [ 'newVsReturning' ], + metrics: [ { name: 'activeUsers' } ], + reportID: 'analytics-4_new-visitors-widget_widget_reportOptions', + }; +} + +/** + * Builds the sub-text for the New Visitors metric tile. + * + * Both this widget and the metric's PDF tile import this, so the dashboard tile + * and the PDF tile display the same sub-text. + * + * @since n.e.x.t + * + * @param {number} total The total number of visitors. + * @return {string} The formatted sub-text. + */ +export function getNewVisitorsSubtext( total ) { + return sprintf( + /* translators: %s: Number of total visitors visiting the site, such as "1,234". */ + __( 'of %s total visitors', 'google-site-kit' ), + numFmt( total, { style: 'decimal' } ) + ); +} + function NewVisitorsWidget( { Widget } ) { const dates = useSelect( ( select ) => select( CORE_USER ).getDateRangeDates( { @@ -49,12 +88,7 @@ function NewVisitorsWidget( { Widget } ) { } ) ); - const reportOptions = { - ...dates, - dimensions: [ 'newVsReturning' ], - metrics: [ { name: 'activeUsers' } ], - reportID: 'analytics-4_new-visitors-widget_widget_reportOptions', - }; + const reportOptions = getNewVisitorsReportOptions( dates ); const report = useInViewSelect( ( select ) => select( MODULES_ANALYTICS_4 ).getReport( reportOptions ), @@ -96,11 +130,7 @@ function NewVisitorsWidget( { Widget } ) { Widget={ Widget } widgetSlug={ KM_ANALYTICS_NEW_VISITORS } metricValue={ newVisitors } - subText={ sprintf( - /* translators: %d: Number of total visitors visiting the site. */ - __( 'of %s total visitors', 'google-site-kit' ), - numFmt( total, { style: 'decimal' } ) - ) } + subText={ getNewVisitorsSubtext( total ) } previousValue={ prevTotal } currentValue={ total } loading={ loading } From 13776c1b456d641e697b22f33c13674d3c58e096 Mon Sep 17 00:00:00 2001 From: Aleksej Date: Tue, 21 Jul 2026 16:43:31 +0200 Subject: [PATCH 27/55] Export pages-per-visit report options. --- .../components/widgets/PagesPerVisitWidget.js | 56 ++++++++++++++----- 1 file changed, 43 insertions(+), 13 deletions(-) diff --git a/assets/js/modules/analytics-4/components/widgets/PagesPerVisitWidget.js b/assets/js/modules/analytics-4/components/widgets/PagesPerVisitWidget.js index 459cea3ada3..fb3cf0cd20f 100644 --- a/assets/js/modules/analytics-4/components/widgets/PagesPerVisitWidget.js +++ b/assets/js/modules/analytics-4/components/widgets/PagesPerVisitWidget.js @@ -42,14 +42,19 @@ import { numFmt } from '@/js/util/i18n'; import whenActive from '@/js/util/when-active'; import ConnectGA4CTATileWidget from './ConnectGA4CTATileWidget'; -function PagesPerVisitWidget( { Widget } ) { - const dates = useSelect( ( select ) => - select( CORE_USER ).getDateRangeDates( { - compare: true, - } ) - ); - - const reportOptions = { +/** + * Builds the Analytics 4 report options for the Pages Per Visit metric. + * + * Both this widget and the metric's PDF tile import this, so the dashboard tile + * and the report request the same data. + * + * @since n.e.x.t + * + * @param {Object} dates The date range, including the compare dates. + * @return {Object} The Analytics 4 `getReport` options. + */ +export function getPagesPerVisitReportOptions( dates ) { + return { ...dates, metrics: [ { name: 'screenPageViewsPerSession' }, @@ -57,6 +62,35 @@ function PagesPerVisitWidget( { Widget } ) { ], reportID: 'analytics-4_pages-per-visit-widget_widget_reportOptions', }; +} + +/** + * Builds the sub-text for the Pages Per Visit metric tile. + * + * Both this widget and the metric's PDF tile import this, so the dashboard tile + * and the PDF tile display the same sub-text. + * + * @since n.e.x.t + * + * @param {number} pageViews The total number of page views. + * @return {string} The formatted sub-text. + */ +export function getPagesPerVisitSubtext( pageViews ) { + return sprintf( + /* translators: %s: Number of total page views, such as "1,234". */ + __( '%s page views', 'google-site-kit' ), + numFmt( pageViews, { style: 'decimal' } ) + ); +} + +function PagesPerVisitWidget( { Widget } ) { + const dates = useSelect( ( select ) => + select( CORE_USER ).getDateRangeDates( { + compare: true, + } ) + ); + + const reportOptions = getPagesPerVisitReportOptions( dates ); const report = useInViewSelect( ( select ) => select( MODULES_ANALYTICS_4 ).getReport( reportOptions ), @@ -109,11 +143,7 @@ function PagesPerVisitWidget( { Widget } ) { widgetSlug={ KM_ANALYTICS_PAGES_PER_VISIT } metricValue={ currentPagesPerVisit } metricValueFormat={ format } - subText={ sprintf( - /* translators: %s: Number of total page views. */ - __( '%s page views', 'google-site-kit' ), - numFmt( currentTotalPageViews, { style: 'decimal' } ) - ) } + subText={ getPagesPerVisitSubtext( currentTotalPageViews ) } previousValue={ previousPagesPerVisit } currentValue={ currentPagesPerVisit } loading={ loading } From 354444a4c5e7af85b16a0b39b05eeef499cd2ec1 Mon Sep 17 00:00:00 2001 From: Aleksej Date: Tue, 21 Jul 2026 16:43:46 +0200 Subject: [PATCH 28/55] Export popular-authors report options. --- .../widgets/PopularAuthorsWidget.js | 29 ++++++++++++++----- 1 file changed, 22 insertions(+), 7 deletions(-) diff --git a/assets/js/modules/analytics-4/components/widgets/PopularAuthorsWidget.js b/assets/js/modules/analytics-4/components/widgets/PopularAuthorsWidget.js index c02b1478bde..547111266b6 100644 --- a/assets/js/modules/analytics-4/components/widgets/PopularAuthorsWidget.js +++ b/assets/js/modules/analytics-4/components/widgets/PopularAuthorsWidget.js @@ -47,16 +47,17 @@ import whenActive from '@/js/util/when-active'; import ConnectGA4CTATileWidget from './ConnectGA4CTATileWidget'; /** - * Gets the report options for the Popular Authors widget. + * Builds the Analytics 4 report options for the Popular Authors metric. * - * @since 1.113.0 + * Both this widget and the metric's PDF tile import this, so the dashboard tile + * and the report request the same data. * - * @param {Function} select Data store 'select' function. - * @return {Object} The report options. + * @since n.e.x.t + * + * @param {Object} dates The date range. + * @return {Object} The Analytics 4 `getReport` options. */ -function getPopularAuthorsWidgetReportOptions( select ) { - const dates = select( CORE_USER ).getDateRangeDates(); - +export function getPopularAuthorsReportOptions( dates ) { return { ...dates, dimensions: [ 'customEvent:googlesitekit_post_author' ], @@ -80,6 +81,20 @@ function getPopularAuthorsWidgetReportOptions( select ) { }; } +/** + * Gets the report options for the Popular Authors widget. + * + * @since 1.113.0 + * + * @param {Function} select Data store 'select' function. + * @return {Object} The report options. + */ +function getPopularAuthorsWidgetReportOptions( select ) { + return getPopularAuthorsReportOptions( + select( CORE_USER ).getDateRangeDates() + ); +} + function PopularAuthorsWidget( props ) { const { Widget } = props; From 1537294f79bbf885010b29ffc5716bccc563bfa7 Mon Sep 17 00:00:00 2001 From: Aleksej Date: Tue, 21 Jul 2026 16:44:07 +0200 Subject: [PATCH 29/55] Export popular-content report options. --- .../widgets/PopularContentWidget.js | 35 +++++++++++++------ 1 file changed, 25 insertions(+), 10 deletions(-) diff --git a/assets/js/modules/analytics-4/components/widgets/PopularContentWidget.js b/assets/js/modules/analytics-4/components/widgets/PopularContentWidget.js index 30826ccc991..d3c512cd1a8 100644 --- a/assets/js/modules/analytics-4/components/widgets/PopularContentWidget.js +++ b/assets/js/modules/analytics-4/components/widgets/PopularContentWidget.js @@ -43,16 +43,19 @@ import { numFmt } from '@/js/util'; import whenActive from '@/js/util/when-active'; import ConnectGA4CTATileWidget from './ConnectGA4CTATileWidget'; -function PopularContentWidget( props ) { - const { Widget } = props; - - const viewOnlyDashboard = useViewOnly(); - - const dates = useSelect( ( select ) => - select( CORE_USER ).getDateRangeDates() - ); - - const reportOptions = { +/** + * Builds the Analytics 4 report options for the Most popular content metric. + * + * Both this widget and the metric's PDF tile import this, so the dashboard tile + * and the report request the same data. + * + * @since n.e.x.t + * + * @param {Object} dates The date range for the report. + * @return {Object} The Analytics 4 `getReport` options. + */ +export function getPopularContentReportOptions( dates ) { + return { ...dates, dimensions: [ 'pagePath' ], metrics: [ { name: 'screenPageViews' } ], @@ -66,6 +69,18 @@ function PopularContentWidget( props ) { keepEmptyRows: false, reportID: 'analytics-4_popular-content-widget_widget_reportOptions', }; +} + +function PopularContentWidget( props ) { + const { Widget } = props; + + const viewOnlyDashboard = useViewOnly(); + + const dates = useSelect( ( select ) => + select( CORE_USER ).getDateRangeDates() + ); + + const reportOptions = getPopularContentReportOptions( dates ); const report = useInViewSelect( ( select ) => select( MODULES_ANALYTICS_4 ).getReport( reportOptions ), From b31c25cb840cdd956ee7e642625d0c8b74da20e5 Mon Sep 17 00:00:00 2001 From: Aleksej Date: Tue, 21 Jul 2026 16:44:21 +0200 Subject: [PATCH 30/55] Export popular-products report options. --- .../widgets/PopularProductsWidget.js | 33 ++++++++++++++----- 1 file changed, 24 insertions(+), 9 deletions(-) diff --git a/assets/js/modules/analytics-4/components/widgets/PopularProductsWidget.js b/assets/js/modules/analytics-4/components/widgets/PopularProductsWidget.js index c29068b0b0b..ad1931172f8 100644 --- a/assets/js/modules/analytics-4/components/widgets/PopularProductsWidget.js +++ b/assets/js/modules/analytics-4/components/widgets/PopularProductsWidget.js @@ -58,18 +58,18 @@ import whenActive from '@/js/util/when-active'; import ConnectGA4CTATileWidget from './ConnectGA4CTATileWidget'; /** - * Gets the report options for the Popular Products widget. + * Builds the Analytics 4 report options for the Popular Products metric. * - * @since 1.127.0 + * Both this widget and the metric's PDF tile import this, so the dashboard tile + * and the report request the same data. * - * @param {Function} select Data store 'select' function. - * @return {Object} The report options. + * @since n.e.x.t + * + * @param {Object} dates The date range. + * @param {string} productPostType The detected product post type to filter on. + * @return {Object} The Analytics 4 `getReport` options. */ -function getPopularProductsWidgetReportOptions( select ) { - const dates = select( CORE_USER ).getDateRangeDates(); - - const productPostType = select( CORE_SITE ).getProductPostType(); - +export function getPopularProductsReportOptions( dates, productPostType ) { return { ...dates, dimensions: [ 'pagePath' ], @@ -93,6 +93,21 @@ function getPopularProductsWidgetReportOptions( select ) { }; } +/** + * Gets the report options for the Popular Products widget. + * + * @since 1.127.0 + * + * @param {Function} select Data store 'select' function. + * @return {Object} The report options. + */ +function getPopularProductsWidgetReportOptions( select ) { + return getPopularProductsReportOptions( + select( CORE_USER ).getDateRangeDates(), + select( CORE_SITE ).getProductPostType() + ); +} + function PopularProductsWidget( props ) { const { Widget, WidgetNull } = props; From 3d7c8b9343ccbb1a7902b59855f16d2250cc4c92 Mon Sep 17 00:00:00 2001 From: Aleksej Date: Tue, 21 Jul 2026 16:44:36 +0200 Subject: [PATCH 31/55] Export returning-visitors report options. --- .../widgets/ReturningVisitorsWidget.js | 52 +++++++++++++++---- 1 file changed, 41 insertions(+), 11 deletions(-) diff --git a/assets/js/modules/analytics-4/components/widgets/ReturningVisitorsWidget.js b/assets/js/modules/analytics-4/components/widgets/ReturningVisitorsWidget.js index 61657748c42..934770e3be6 100644 --- a/assets/js/modules/analytics-4/components/widgets/ReturningVisitorsWidget.js +++ b/assets/js/modules/analytics-4/components/widgets/ReturningVisitorsWidget.js @@ -42,6 +42,45 @@ import { numFmt } from '@/js/util'; import whenActive from '@/js/util/when-active'; import ConnectGA4CTATileWidget from './ConnectGA4CTATileWidget'; +/** + * Builds the Analytics 4 report options for the Returning Visitors metric. + * + * Both this widget and the metric's PDF tile import this, so the dashboard tile + * and the report request the same data. + * + * @since n.e.x.t + * + * @param {Object} dates The date range, including the compare dates. + * @return {Object} The Analytics 4 `getReport` options. + */ +export function getReturningVisitorsReportOptions( dates ) { + return { + ...dates, + dimensions: [ 'newVsReturning' ], + metrics: [ { name: 'activeUsers' } ], + reportID: 'analytics-4_returning-visitors-widget_widget_reportOptions', + }; +} + +/** + * Builds the sub-text for the Returning Visitors metric tile. + * + * Both this widget and the metric's PDF tile import this, so the dashboard tile + * and the PDF tile display the same sub-text. + * + * @since n.e.x.t + * + * @param {number} total The total number of visitors. + * @return {string} The formatted sub-text. + */ +export function getReturningVisitorsSubtext( total ) { + return sprintf( + /* translators: %s: Number of total visitors visiting the site, such as "1,234". */ + __( 'of %s total visitors', 'google-site-kit' ), + numFmt( total, { style: 'decimal' } ) + ); +} + function ReturningVisitorsWidget( { Widget } ) { const dates = useSelect( ( select ) => select( CORE_USER ).getDateRangeDates( { @@ -49,12 +88,7 @@ function ReturningVisitorsWidget( { Widget } ) { } ) ); - const reportOptions = { - ...dates, - dimensions: [ 'newVsReturning' ], - metrics: [ { name: 'activeUsers' } ], - reportID: 'analytics-4_returning-visitors-widget_widget_reportOptions', - }; + const reportOptions = getReturningVisitorsReportOptions( dates ); const report = useInViewSelect( ( select ) => select( MODULES_ANALYTICS_4 ).getReport( reportOptions ), @@ -110,11 +144,7 @@ function ReturningVisitorsWidget( { Widget } ) { widgetSlug={ KM_ANALYTICS_RETURNING_VISITORS } metricValue={ currentPercentage } metricValueFormat={ format } - subText={ sprintf( - /* translators: %d: Number of total visitors visiting the site. */ - __( 'of %s total visitors', 'google-site-kit' ), - numFmt( total, { style: 'decimal' } ) - ) } + subText={ getReturningVisitorsSubtext( total ) } previousValue={ prevPercentage } currentValue={ currentPercentage } loading={ loading } From b28e50c489ce994c1f49645c863f4f25d38c1663 Mon Sep 17 00:00:00 2001 From: Aleksej Date: Tue, 21 Jul 2026 16:44:54 +0200 Subject: [PATCH 32/55] Export top-categories report options. --- .../components/widgets/TopCategoriesWidget.js | 29 ++++++++++++++----- 1 file changed, 22 insertions(+), 7 deletions(-) diff --git a/assets/js/modules/analytics-4/components/widgets/TopCategoriesWidget.js b/assets/js/modules/analytics-4/components/widgets/TopCategoriesWidget.js index 0d02332bd84..ea690496d25 100644 --- a/assets/js/modules/analytics-4/components/widgets/TopCategoriesWidget.js +++ b/assets/js/modules/analytics-4/components/widgets/TopCategoriesWidget.js @@ -48,16 +48,17 @@ import whenActive from '@/js/util/when-active'; import ConnectGA4CTATileWidget from './ConnectGA4CTATileWidget'; /** - * Gets the report options for the Top Categories widget. + * Builds the Analytics 4 report options for the Top Categories metric. * - * @since 1.113.0 + * Both this widget and the metric's PDF tile import this, so the dashboard tile + * and the report request the same data. * - * @param {Function} select Data store 'select' function. - * @return {Object} The report options. + * @since n.e.x.t + * + * @param {Object} dates The date range. + * @return {Object} The Analytics 4 `getReport` options. */ -function getReportOptions( select ) { - const dates = select( CORE_USER ).getDateRangeDates(); - +export function getTopCategoriesReportOptions( dates ) { return { ...dates, dimensions: [ 'customEvent:googlesitekit_post_categories' ], @@ -83,6 +84,20 @@ function getReportOptions( select ) { }; } +/** + * Gets the report options for the Top Categories widget. + * + * @since 1.113.0 + * + * @param {Function} select Data store 'select' function. + * @return {Object} The report options. + */ +function getReportOptions( select ) { + return getTopCategoriesReportOptions( + select( CORE_USER ).getDateRangeDates() + ); +} + function TopCategoriesWidget( { Widget } ) { const topCategoriesReportOptions = useSelect( getReportOptions ); From b9979706cac05cd695916fe5a7336fbbc385c724 Mon Sep 17 00:00:00 2001 From: Aleksej Date: Tue, 21 Jul 2026 16:45:25 +0200 Subject: [PATCH 33/55] Export cities add-to-cart options. --- .../TopCitiesDrivingAddToCartWidget.js | 38 +++++++++++++------ 1 file changed, 27 insertions(+), 11 deletions(-) diff --git a/assets/js/modules/analytics-4/components/widgets/TopCitiesDrivingAddToCartWidget.js b/assets/js/modules/analytics-4/components/widgets/TopCitiesDrivingAddToCartWidget.js index 5483f878aa3..aa3dcaf4c75 100644 --- a/assets/js/modules/analytics-4/components/widgets/TopCitiesDrivingAddToCartWidget.js +++ b/assets/js/modules/analytics-4/components/widgets/TopCitiesDrivingAddToCartWidget.js @@ -40,17 +40,19 @@ import { numFmt } from '@/js/util'; import whenActive from '@/js/util/when-active'; import ConnectGA4CTATileWidget from './ConnectGA4CTATileWidget'; -function TopCitiesDrivingAddToCartWidget( { Widget } ) { - const dates = useSelect( ( select ) => - select( CORE_USER ).getDateRangeDates() - ); - - const detectedEvents = useSelect( ( select ) => - select( MODULES_ANALYTICS_4 ).getDetectedEvents() - ); - const hasRequiredEvent = detectedEvents?.includes( 'add_to_cart' ); - - const topCitiesReportOptions = { +/** + * Builds the Analytics 4 report options for the Top Cities Driving Add to Cart metric. + * + * Both this widget and the metric's PDF tile import this, so the dashboard tile + * and the report request the same data. + * + * @since n.e.x.t + * + * @param {Object} dates The date range. + * @return {Object} The `getReport` options for the top cities report. + */ +export function getTopCitiesDrivingAddToCartReportOptions( dates ) { + return { ...dates, dimensions: [ 'city' ], dimensionFilters: { @@ -73,6 +75,20 @@ function TopCitiesDrivingAddToCartWidget( { Widget } ) { reportID: 'analytics-4_top-cities-driving-add-to-cart-widget_widget_topCitiesReportOptions', }; +} + +function TopCitiesDrivingAddToCartWidget( { Widget } ) { + const dates = useSelect( ( select ) => + select( CORE_USER ).getDateRangeDates() + ); + + const detectedEvents = useSelect( ( select ) => + select( MODULES_ANALYTICS_4 ).getDetectedEvents() + ); + const hasRequiredEvent = detectedEvents?.includes( 'add_to_cart' ); + + const topCitiesReportOptions = + getTopCitiesDrivingAddToCartReportOptions( dates ); const topCitiesReport = useInViewSelect( ( select ) => From 326d8c8798ffe3473b66b72c703d12227e6434ca Mon Sep 17 00:00:00 2001 From: Aleksej Date: Tue, 21 Jul 2026 16:55:11 +0200 Subject: [PATCH 34/55] Export cities leads options, events. --- .../widgets/TopCitiesDrivingLeadsWidget.js | 57 ++++++++++++++++--- 1 file changed, 48 insertions(+), 9 deletions(-) diff --git a/assets/js/modules/analytics-4/components/widgets/TopCitiesDrivingLeadsWidget.js b/assets/js/modules/analytics-4/components/widgets/TopCitiesDrivingLeadsWidget.js index 722ff4c019b..d5e37bc048a 100644 --- a/assets/js/modules/analytics-4/components/widgets/TopCitiesDrivingLeadsWidget.js +++ b/assets/js/modules/analytics-4/components/widgets/TopCitiesDrivingLeadsWidget.js @@ -43,14 +43,33 @@ import { numFmt } from '@/js/util'; import whenActive from '@/js/util/when-active'; import ConnectGA4CTATileWidget from './ConnectGA4CTATileWidget'; -function TopCitiesDrivingLeadsWidget( { Widget } ) { - const dates = useSelect( ( select ) => - select( CORE_USER ).getDateRangeDates() - ); - - const detectedEvents = useSelect( ( select ) => - select( MODULES_ANALYTICS_4 ).getDetectedEvents() - ); +/** + * Builds the Analytics 4 report options for the Top Cities Driving Leads metric. + * + * Both this widget and the metric's PDF tile import this, so the dashboard tile + * and the report request the same data. + * + * @since n.e.x.t + * + * @param {Object} dates The date range for the report. + * @param {string[]} eventNames The lead event names to filter by. + * @return {Object} The `getReport` options for the top cities report. + */ +/** + * Resolves the lead conversion event names to filter the report by, from the + * property's detected events. + * + * Mirrors the dashboard widget: it keeps the detected lead events, and drops + * `CONTACT` when `SUBMIT_LEAD_FORM` is also present to avoid double-counting. + * Both this widget and the metric's PDF tile import this, so the dashboard tile + * and the report request filter by the same events. + * + * @since n.e.x.t + * + * @param {string[]} [detectedEvents] The property's detected conversion events. + * @return {string[]} The lead event names to filter by. + */ +export function getTopCitiesDrivingLeadsEventNames( detectedEvents ) { const eventNames = [ ENUM_CONVERSION_EVENTS.SUBMIT_LEAD_FORM, ENUM_CONVERSION_EVENTS.CONTACT, @@ -67,7 +86,11 @@ function TopCitiesDrivingLeadsWidget( { Widget } ) { ); } - const topCitiesReportOptions = { + return eventNames; +} + +export function getTopCitiesDrivingLeadsReportOptions( dates, eventNames ) { + return { ...dates, dimensions: [ 'city', 'eventName' ], dimensionFilters: { @@ -94,6 +117,22 @@ function TopCitiesDrivingLeadsWidget( { Widget } ) { reportID: 'analytics-4_top-cities-driving-leads-widget_widget_topCitiesReportOptions', }; +} + +function TopCitiesDrivingLeadsWidget( { Widget } ) { + const dates = useSelect( ( select ) => + select( CORE_USER ).getDateRangeDates() + ); + + const detectedEvents = useSelect( ( select ) => + select( MODULES_ANALYTICS_4 ).getDetectedEvents() + ); + const eventNames = getTopCitiesDrivingLeadsEventNames( detectedEvents ); + + const topCitiesReportOptions = getTopCitiesDrivingLeadsReportOptions( + dates, + eventNames + ); const topCitiesReport = useInViewSelect( ( select ) => From 328ffd995fe0d8a8dd1649e7eebeb7c859f7581b Mon Sep 17 00:00:00 2001 From: Aleksej Date: Tue, 21 Jul 2026 16:55:31 +0200 Subject: [PATCH 35/55] Export cities purchases report options. --- .../TopCitiesDrivingPurchasesWidget.js | 38 +++++++++++++------ 1 file changed, 27 insertions(+), 11 deletions(-) diff --git a/assets/js/modules/analytics-4/components/widgets/TopCitiesDrivingPurchasesWidget.js b/assets/js/modules/analytics-4/components/widgets/TopCitiesDrivingPurchasesWidget.js index e762318c6c0..dadd58c8c34 100644 --- a/assets/js/modules/analytics-4/components/widgets/TopCitiesDrivingPurchasesWidget.js +++ b/assets/js/modules/analytics-4/components/widgets/TopCitiesDrivingPurchasesWidget.js @@ -40,17 +40,19 @@ import { numFmt } from '@/js/util'; import whenActive from '@/js/util/when-active'; import ConnectGA4CTATileWidget from './ConnectGA4CTATileWidget'; -function TopCitiesDrivingPurchasesWidget( { Widget } ) { - const dates = useSelect( ( select ) => - select( CORE_USER ).getDateRangeDates() - ); - - const detectedEvents = useSelect( ( select ) => - select( MODULES_ANALYTICS_4 ).getDetectedEvents() - ); - const hasDetectedEvent = detectedEvents?.includes( 'purchase' ); - - const topCitiesReportOptions = { +/** + * Builds the Analytics 4 report options for the Top Cities Driving Purchases metric. + * + * Both this widget and the metric's PDF tile import this, so the dashboard tile + * and the report request the same data. + * + * @since n.e.x.t + * + * @param {Object} dates The date range. + * @return {Object} The Analytics 4 `getReport` options. + */ +export function getTopCitiesDrivingPurchasesReportOptions( dates ) { + return { ...dates, dimensions: [ 'city' ], dimensionFilters: { @@ -79,6 +81,20 @@ function TopCitiesDrivingPurchasesWidget( { Widget } ) { reportID: 'analytics-4_top-cities-driving-purchases-widget_widget_topCitiesReportOptions', }; +} + +function TopCitiesDrivingPurchasesWidget( { Widget } ) { + const dates = useSelect( ( select ) => + select( CORE_USER ).getDateRangeDates() + ); + + const detectedEvents = useSelect( ( select ) => + select( MODULES_ANALYTICS_4 ).getDetectedEvents() + ); + const hasDetectedEvent = detectedEvents?.includes( 'purchase' ); + + const topCitiesReportOptions = + getTopCitiesDrivingPurchasesReportOptions( dates ); const topCitiesReport = useInViewSelect( ( select ) => From e63c5117a0f06d5a1149ad6410ab888ad26ac6e3 Mon Sep 17 00:00:00 2001 From: Aleksej Date: Tue, 21 Jul 2026 16:56:14 +0200 Subject: [PATCH 36/55] Export top-cities report options. --- .../components/widgets/TopCitiesWidget.js | 27 ++++++++++++++----- 1 file changed, 21 insertions(+), 6 deletions(-) diff --git a/assets/js/modules/analytics-4/components/widgets/TopCitiesWidget.js b/assets/js/modules/analytics-4/components/widgets/TopCitiesWidget.js index 870b3df5a40..a97119b4a16 100644 --- a/assets/js/modules/analytics-4/components/widgets/TopCitiesWidget.js +++ b/assets/js/modules/analytics-4/components/widgets/TopCitiesWidget.js @@ -41,12 +41,19 @@ import { numFmt } from '@/js/util'; import whenActive from '@/js/util/when-active'; import ConnectGA4CTATileWidget from './ConnectGA4CTATileWidget'; -function TopCitiesWidget( { Widget } ) { - const dates = useSelect( ( select ) => - select( CORE_USER ).getDateRangeDates() - ); - - const topCitiesReportOptions = { +/** + * Builds the Analytics 4 report options for the Top Cities metric. + * + * Both this widget and the metric's PDF tile import this, so the dashboard tile + * and the report request the same data. + * + * @since n.e.x.t + * + * @param {Object} dates The date range. + * @return {Object} The Analytics 4 `getReport` options. + */ +export function getTopCitiesReportOptions( dates ) { + return { ...dates, dimensions: [ 'city' ], metrics: [ { name: 'totalUsers' } ], @@ -61,6 +68,14 @@ function TopCitiesWidget( { Widget } ) { limit: 4, reportID: 'analytics-4_top-cities-widget_widget_topCitiesReportOptions', }; +} + +function TopCitiesWidget( { Widget } ) { + const dates = useSelect( ( select ) => + select( CORE_USER ).getDateRangeDates() + ); + + const topCitiesReportOptions = getTopCitiesReportOptions( dates ); const topCitiesReport = useInViewSelect( ( select ) => From 86b152db6c4401cf1bc8ffbe8db71e05f02e9391 Mon Sep 17 00:00:00 2001 From: Aleksej Date: Tue, 21 Jul 2026 16:56:29 +0200 Subject: [PATCH 37/55] Export converting source report options. --- .../TopConvertingTrafficSourceWidget.js | 63 +++++++++++++++---- 1 file changed, 50 insertions(+), 13 deletions(-) diff --git a/assets/js/modules/analytics-4/components/widgets/TopConvertingTrafficSourceWidget.js b/assets/js/modules/analytics-4/components/widgets/TopConvertingTrafficSourceWidget.js index 16c9ff21214..49824dbb50c 100644 --- a/assets/js/modules/analytics-4/components/widgets/TopConvertingTrafficSourceWidget.js +++ b/assets/js/modules/analytics-4/components/widgets/TopConvertingTrafficSourceWidget.js @@ -42,14 +42,20 @@ import { numFmt } from '@/js/util'; import whenActive from '@/js/util/when-active'; import ConnectGA4CTATileWidget from './ConnectGA4CTATileWidget'; -function TopConvertingTrafficSourceWidget( { Widget } ) { - const dates = useSelect( ( select ) => - select( CORE_USER ).getDateRangeDates( { - compare: true, - } ) - ); - - const reportOptions = { +/** + * Builds the Analytics 4 report options for the Top Converting Traffic Source + * metric. + * + * Both this widget and the metric's PDF tile import this, so the dashboard tile + * and the report request the same data. + * + * @since n.e.x.t + * + * @param {Object} dates The date range, including the compare dates. + * @return {Object} The Analytics 4 `getReport` options. + */ +export function getTopConvertingTrafficSourceReportOptions( dates ) { + return { ...dates, dimensions: [ 'sessionDefaultChannelGroup' ], metrics: [ @@ -62,6 +68,41 @@ function TopConvertingTrafficSourceWidget( { Widget } ) { reportID: 'analytics-4_top-converting-traffic-source-widget_widget_reportOptions', }; +} + +/** + * Builds the sub-text for the Top Converting Traffic Source metric tile. + * + * Both this widget and the metric's PDF tile import this, so the dashboard tile + * and the PDF tile show the same sub-text. + * + * @since n.e.x.t + * + * @param {number} rate The share of visits that led to key events. + * @return {string} The metric tile sub-text. + */ +export function getTopConvertingTrafficSourceSubtext( rate ) { + const format = { + style: 'percent', + signDisplay: 'never', + maximumFractionDigits: 1, + }; + + return sprintf( + /* translators: %s: Percentage of visits that led to key events. */ + __( '%s of visits led to key events', 'google-site-kit' ), + numFmt( rate, format ) + ); +} + +function TopConvertingTrafficSourceWidget( { Widget } ) { + const dates = useSelect( ( select ) => + select( CORE_USER ).getDateRangeDates( { + compare: true, + } ) + ); + + const reportOptions = getTopConvertingTrafficSourceReportOptions( dates ); const report = useInViewSelect( ( select ) => select( MODULES_ANALYTICS_4 ).getReport( reportOptions ), @@ -120,11 +161,7 @@ function TopConvertingTrafficSourceWidget( { Widget } ) { widgetSlug={ KM_ANALYTICS_TOP_CONVERTING_TRAFFIC_SOURCE } metricValue={ topChannelGroup } metricValueFormat={ format } - subText={ sprintf( - /* translators: %d: Percentage of visits that led to key events. */ - __( '%s of visits led to key events', 'google-site-kit' ), - numFmt( topKeyEventRate, format ) - ) } + subText={ getTopConvertingTrafficSourceSubtext( topKeyEventRate ) } previousValue={ previousTopKeyEventRate } currentValue={ topKeyEventRate } loading={ loading } From 12e908c61b77414be316cbab5f8cdbf3cc905b10 Mon Sep 17 00:00:00 2001 From: Aleksej Date: Tue, 21 Jul 2026 16:56:43 +0200 Subject: [PATCH 38/55] Export top-countries report options. --- .../components/widgets/TopCountriesWidget.js | 27 ++++++++++++++----- 1 file changed, 21 insertions(+), 6 deletions(-) diff --git a/assets/js/modules/analytics-4/components/widgets/TopCountriesWidget.js b/assets/js/modules/analytics-4/components/widgets/TopCountriesWidget.js index 034c92e53be..78b376afdde 100644 --- a/assets/js/modules/analytics-4/components/widgets/TopCountriesWidget.js +++ b/assets/js/modules/analytics-4/components/widgets/TopCountriesWidget.js @@ -41,12 +41,19 @@ import { numFmt } from '@/js/util'; import whenActive from '@/js/util/when-active'; import ConnectGA4CTATileWidget from './ConnectGA4CTATileWidget'; -function TopCountriesWidget( { Widget } ) { - const dates = useSelect( ( select ) => - select( CORE_USER ).getDateRangeDates() - ); - - const topCountriesReportOptions = { +/** + * Builds the Analytics 4 report options for the Top Countries metric. + * + * Both this widget and the metric's PDF tile import this, so the dashboard tile + * and the report request the same data. + * + * @since n.e.x.t + * + * @param {Object} dates The date range. + * @return {Object} The Analytics 4 `getReport` options. + */ +export function getTopCountriesReportOptions( dates ) { + return { ...dates, dimensions: [ 'country' ], metrics: [ { name: 'totalUsers' } ], @@ -62,6 +69,14 @@ function TopCountriesWidget( { Widget } ) { reportID: 'analytics-4_top-countries-widget_widget_topCountriesReportOptions', }; +} + +function TopCountriesWidget( { Widget } ) { + const dates = useSelect( ( select ) => + select( CORE_USER ).getDateRangeDates() + ); + + const topCountriesReportOptions = getTopCountriesReportOptions( dates ); const topCountriesReport = useInViewSelect( ( select ) => From 6cd0ab535262fe0b78983dbfb6b6c993099f6a78 Mon Sep 17 00:00:00 2001 From: Aleksej Date: Tue, 21 Jul 2026 16:56:56 +0200 Subject: [PATCH 39/55] Export device purchases report options. --- .../TopDeviceDrivingPurchasesWidget.js | 100 +++++++++++++----- 1 file changed, 72 insertions(+), 28 deletions(-) diff --git a/assets/js/modules/analytics-4/components/widgets/TopDeviceDrivingPurchasesWidget.js b/assets/js/modules/analytics-4/components/widgets/TopDeviceDrivingPurchasesWidget.js index fcee5c76759..9c756696c0c 100644 --- a/assets/js/modules/analytics-4/components/widgets/TopDeviceDrivingPurchasesWidget.js +++ b/assets/js/modules/analytics-4/components/widgets/TopDeviceDrivingPurchasesWidget.js @@ -42,6 +42,72 @@ import { numFmt } from '@/js/util'; import whenActive from '@/js/util/when-active'; import ConnectGA4CTATileWidget from './ConnectGA4CTATileWidget'; +/** + * Builds the Analytics 4 report options for the Top Device Driving Purchases + * metric. + * + * Returns both the total-purchases report and the per-device report the tile + * combines. Both this widget and the metric's PDF tile import this, so the + * dashboard tile and the report request the same data. + * + * @since n.e.x.t + * + * @param {Object} dates The date range, including the compare dates. + * @return {Object} The `totalPurchases` and `device` `getReport` options. + */ +export function getTopDeviceDrivingPurchasesReportOptions( dates ) { + return { + totalPurchases: { + ...dates, + metrics: [ + { + name: 'ecommercePurchases', + }, + ], + reportID: + 'analytics-4_top-device-driving-purchases-widget_widget_totalPurchasesReportOptions', + }, + device: { + ...dates, + dimensions: [ 'deviceCategory' ], + metrics: [ + { + name: 'ecommercePurchases', + }, + ], + limit: 1, + orderBy: 'ecommercePurchases', + reportID: + 'analytics-4_top-device-driving-purchases-widget_widget_deviceReportOptions', + }, + }; +} + +/** + * Builds the sub-text for the Top Device Driving Purchases metric tile. + * + * Both this widget and the metric's PDF tile import this, so the dashboard tile + * and the PDF tile show the same sub-text. + * + * @since n.e.x.t + * + * @param {number} rate The relative share of total purchases for the top device. + * @return {string} The metric tile sub-text. + */ +export function getTopDeviceDrivingPurchasesSubtext( rate ) { + const format = { + style: 'percent', + signDisplay: 'never', + maximumFractionDigits: 1, + }; + + return sprintf( + /* translators: %s: Percentage of purchases for the current top device compared to the number of purchases for all devices. */ + __( '%s of total purchases', 'google-site-kit' ), + numFmt( rate, format ) + ); +} + function TopDeviceDrivingPurchases( { Widget } ) { const dates = useSelect( ( select ) => select( CORE_USER ).getDateRangeDates( { @@ -54,30 +120,10 @@ function TopDeviceDrivingPurchases( { Widget } ) { ); const hasDetectedEvent = detectedEvents?.includes( 'purchase' ); - const totalPurchasesReportOptions = { - ...dates, - metrics: [ - { - name: 'ecommercePurchases', - }, - ], - reportID: - 'analytics-4_top-device-driving-purchases-widget_widget_totalPurchasesReportOptions', - }; - - const deviceReportOptions = { - ...dates, - dimensions: [ 'deviceCategory' ], - metrics: [ - { - name: 'ecommercePurchases', - }, - ], - limit: 1, - orderBy: 'ecommercePurchases', - reportID: - 'analytics-4_top-device-driving-purchases-widget_widget_deviceReportOptions', - }; + const { + totalPurchases: totalPurchasesReportOptions, + device: deviceReportOptions, + } = getTopDeviceDrivingPurchasesReportOptions( dates ); const totalPurchasesReport = useInViewSelect( ( select ) => @@ -197,10 +243,8 @@ function TopDeviceDrivingPurchases( { Widget } ) { widgetSlug={ KM_ANALYTICS_TOP_DEVICE_DRIVING_PURCHASES } metricValue={ topDevice } metricValueFormat={ format } - subText={ sprintf( - /* translators: %d: Percentage of purchases for the current top device compared to the number of purchases for all devices. */ - __( '%s of total purchases', 'google-site-kit' ), - numFmt( relativeCurrentTopDevicePurchases, format ) + subText={ getTopDeviceDrivingPurchasesSubtext( + relativeCurrentTopDevicePurchases ) } previousValue={ relativePreviousTopDevicePurchases } currentValue={ relativeCurrentTopDevicePurchases } From 9a0226c70555831b68ed297b9d1aea0f2d1da27b Mon Sep 17 00:00:00 2001 From: Aleksej Date: Tue, 21 Jul 2026 16:57:10 +0200 Subject: [PATCH 40/55] Export pages leads options, events. --- .../widgets/TopPagesDrivingLeadsWidget.js | 63 +++++++++++++++---- 1 file changed, 50 insertions(+), 13 deletions(-) diff --git a/assets/js/modules/analytics-4/components/widgets/TopPagesDrivingLeadsWidget.js b/assets/js/modules/analytics-4/components/widgets/TopPagesDrivingLeadsWidget.js index 55b3adb2c3d..6cd38a2faac 100644 --- a/assets/js/modules/analytics-4/components/widgets/TopPagesDrivingLeadsWidget.js +++ b/assets/js/modules/analytics-4/components/widgets/TopPagesDrivingLeadsWidget.js @@ -46,18 +46,19 @@ import { numFmt } from '@/js/util'; import whenActive from '@/js/util/when-active'; import ConnectGA4CTATileWidget from './ConnectGA4CTATileWidget'; -function TopPagesDrivingLeadsWidget( props ) { - const { Widget } = props; - - const viewOnlyDashboard = useViewOnly(); - - const dates = useSelect( ( select ) => - select( CORE_USER ).getDateRangeDates() - ); - - const detectedEvents = useSelect( ( select ) => - select( MODULES_ANALYTICS_4 ).getDetectedEvents() - ); +/** + * Resolves the lead conversion event names to filter the report by, from the + * property's detected events. + * + * Mirrors the dashboard widget: it keeps the detected lead events, and drops + * `CONTACT` when `SUBMIT_LEAD_FORM` is also present to avoid double-counting. + * + * @since n.e.x.t + * + * @param {string[]} [detectedEvents] The property's detected conversion events. + * @return {string[]} The lead event names to filter by. + */ +export function getTopPagesDrivingLeadsEventNames( detectedEvents ) { const eventNames = [ ENUM_CONVERSION_EVENTS.SUBMIT_LEAD_FORM, ENUM_CONVERSION_EVENTS.CONTACT, @@ -74,7 +75,23 @@ function TopPagesDrivingLeadsWidget( props ) { ); } - const reportOptions = { + return eventNames; +} + +/** + * Builds the Analytics 4 report options for the Top Pages Driving Leads metric. + * + * Both this widget and the metric's PDF tile import this, so the dashboard tile + * and the report request the same data. + * + * @since n.e.x.t + * + * @param {Object} dates The date range. + * @param {string[]} eventNames The lead event names to filter by. + * @return {Object} The `getReport` options. + */ +export function getTopPagesDrivingLeadsReportOptions( dates, eventNames ) { + return { ...dates, dimensions: [ 'pagePath', 'eventName' ], dimensionFilters: { @@ -95,6 +112,26 @@ function TopPagesDrivingLeadsWidget( props ) { reportID: 'analytics-4_top-pages-driving-leads-widget_widget_reportOptions', }; +} + +function TopPagesDrivingLeadsWidget( props ) { + const { Widget } = props; + + const viewOnlyDashboard = useViewOnly(); + + const dates = useSelect( ( select ) => + select( CORE_USER ).getDateRangeDates() + ); + + const detectedEvents = useSelect( ( select ) => + select( MODULES_ANALYTICS_4 ).getDetectedEvents() + ); + const eventNames = getTopPagesDrivingLeadsEventNames( detectedEvents ); + + const reportOptions = getTopPagesDrivingLeadsReportOptions( + dates, + eventNames + ); const report = useInViewSelect( ( select ) => From 915bce2babcf8b4afa7b070febdaabf2903beb93 Mon Sep 17 00:00:00 2001 From: Aleksej Date: Tue, 21 Jul 2026 16:57:23 +0200 Subject: [PATCH 41/55] Rename and export trending options. --- .../widgets/TopRecentTrendingPagesWidget.js | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/assets/js/modules/analytics-4/components/widgets/TopRecentTrendingPagesWidget.js b/assets/js/modules/analytics-4/components/widgets/TopRecentTrendingPagesWidget.js index 45f1c4719e1..87a567353de 100644 --- a/assets/js/modules/analytics-4/components/widgets/TopRecentTrendingPagesWidget.js +++ b/assets/js/modules/analytics-4/components/widgets/TopRecentTrendingPagesWidget.js @@ -77,18 +77,19 @@ export function getDateRange( referenceDate ) { * The function utilizes the dates from the last three days to * prepare the filter and structure required for the report. * - * This is defined as a function outside the component so that both - * the component and the higher-order-component (`withCustomDimensions`) - * can use it. + * This is defined as a function outside the component so that the + * component, the higher-order-component (`withCustomDimensions`), and + * the metric's PDF tile all request the same data. * * @since 1.113.0 * @since 1.132.0 Update the function to receive the reference date instead of the select function. + * @since n.e.x.t Renamed to `getTopRecentTrendingPagesReportOptions`. * * @param {string} referenceDate The reference date. * @return {Object} The report options containing dimensions, filters, * metrics, and other parameters. */ -export function getReportOptions( referenceDate ) { +export function getTopRecentTrendingPagesReportOptions( referenceDate ) { const yesterday = getPreviousDate( referenceDate, 1 ); const twoDaysAgo = getPreviousDate( referenceDate, 2 ); const threeDaysAgo = getPreviousDate( referenceDate, 3 ); @@ -126,6 +127,9 @@ export function getReportOptions( referenceDate ) { return reportOptions; } +// Backwards-compatible alias for existing importers (tests and stories). +export { getTopRecentTrendingPagesReportOptions as getReportOptions }; + function CustomZeroDataMessage() { return __( 'No data to display: either no pages were published in the last three days, or they haven’t received any visitors yet', @@ -140,7 +144,7 @@ function TopRecentTrendingPagesWidget( { Widget } ) { const referenceDate = select( CORE_USER ).getReferenceDate(); return [ getDateRange( referenceDate ), - getReportOptions( referenceDate ), + getTopRecentTrendingPagesReportOptions( referenceDate ), ]; } ); @@ -250,7 +254,9 @@ export default compose( } ), withCustomDimensions( { reportOptions( select ) { - return getReportOptions( select( CORE_USER ).getReferenceDate() ); + return getTopRecentTrendingPagesReportOptions( + select( CORE_USER ).getReferenceDate() + ); }, } ) )( TopRecentTrendingPagesWidget ); From 5497f7186c4b685ee3d53f45c4f5a4cb4a43bb0d Mon Sep 17 00:00:00 2001 From: Aleksej Date: Tue, 21 Jul 2026 16:57:37 +0200 Subject: [PATCH 42/55] Export returning-visitor pages options. --- .../widgets/TopReturningVisitorPages.js | 35 +++++++++++++------ 1 file changed, 25 insertions(+), 10 deletions(-) diff --git a/assets/js/modules/analytics-4/components/widgets/TopReturningVisitorPages.js b/assets/js/modules/analytics-4/components/widgets/TopReturningVisitorPages.js index db9563eacc7..07e9e3446c0 100644 --- a/assets/js/modules/analytics-4/components/widgets/TopReturningVisitorPages.js +++ b/assets/js/modules/analytics-4/components/widgets/TopReturningVisitorPages.js @@ -43,16 +43,19 @@ import { numFmt } from '@/js/util'; import whenActive from '@/js/util/when-active'; import ConnectGA4CTATileWidget from './ConnectGA4CTATileWidget'; -function TopReturningVisitorPages( props ) { - const { Widget } = props; - - const viewOnlyDashboard = useViewOnly(); - - const dates = useSelect( ( select ) => - select( CORE_USER ).getDateRangeDates() - ); - - const reportOptions = { +/** + * Builds the Analytics 4 report options for the Top pages by returning visitors metric. + * + * Both this widget and the metric's PDF tile import this, so the dashboard tile + * and the report request the same data. + * + * @since n.e.x.t + * + * @param {Object} dates The date range. + * @return {Object} The Analytics 4 `getReport` options. + */ +export function getTopReturningVisitorPagesReportOptions( dates ) { + return { ...dates, dimensions: [ 'pagePath' ], dimensionFilters: { @@ -70,6 +73,18 @@ function TopReturningVisitorPages( props ) { reportID: 'analytics-4_top-returning-visitor-pages-widget_widget_reportOptions', }; +} + +function TopReturningVisitorPages( props ) { + const { Widget } = props; + + const viewOnlyDashboard = useViewOnly(); + + const dates = useSelect( ( select ) => + select( CORE_USER ).getDateRangeDates() + ); + + const reportOptions = getTopReturningVisitorPagesReportOptions( dates ); const report = useInViewSelect( ( select ) => select( MODULES_ANALYTICS_4 ).getReport( reportOptions ), From 0450507ef4ff906445c081631ed013c0be5adae4 Mon Sep 17 00:00:00 2001 From: Aleksej Date: Tue, 21 Jul 2026 16:57:51 +0200 Subject: [PATCH 43/55] Export source add-to-cart options. --- .../TopTrafficSourceDrivingAddToCartWidget.js | 100 +++++++++++++----- 1 file changed, 72 insertions(+), 28 deletions(-) diff --git a/assets/js/modules/analytics-4/components/widgets/TopTrafficSourceDrivingAddToCartWidget.js b/assets/js/modules/analytics-4/components/widgets/TopTrafficSourceDrivingAddToCartWidget.js index 4c67cfb4748..58e76a6997a 100644 --- a/assets/js/modules/analytics-4/components/widgets/TopTrafficSourceDrivingAddToCartWidget.js +++ b/assets/js/modules/analytics-4/components/widgets/TopTrafficSourceDrivingAddToCartWidget.js @@ -45,6 +45,72 @@ import { numFmt } from '@/js/util'; import whenActive from '@/js/util/when-active'; import ConnectGA4CTATileWidget from './ConnectGA4CTATileWidget'; +/** + * Builds the Analytics 4 report options for the Top Traffic Source Driving Add + * to Cart metric. + * + * Returns both the total add-to-cart report and the per-channel report the tile + * combines. Both this widget and the metric's PDF tile import this, so the + * dashboard tile and the report request the same data. + * + * @since n.e.x.t + * + * @param {Object} dates The date range, including the compare dates. + * @return {Object} The `totalAddToCart` and `trafficSource` `getReport` options. + */ +export function getTopTrafficSourceDrivingAddToCartReportOptions( dates ) { + return { + totalAddToCart: { + ...dates, + metrics: [ + { + name: 'addToCarts', + }, + ], + reportID: + 'analytics-4_top-traffic-source-driving-add-to-cart-widget_widget_totalAddToCartReportOptions', + }, + trafficSource: { + ...dates, + dimensions: [ 'sessionDefaultChannelGroup' ], + metrics: [ + { + name: 'addToCarts', + }, + ], + limit: 1, + orderBy: 'addToCarts', + reportID: + 'analytics-4_top-traffic-source-driving-add-to-cart-widget_widget_trafficSourceReportOptions', + }, + }; +} + +/** + * Builds the sub-text for the Top Traffic Source Driving Add to Cart metric tile. + * + * Both this widget and the metric's PDF tile import this, so the dashboard tile + * and the PDF tile show the same sub-text. + * + * @since n.e.x.t + * + * @param {number} rate The relative share of total add to carts for the top source. + * @return {string} The metric tile sub-text. + */ +export function getTopTrafficSourceDrivingAddToCartSubtext( rate ) { + const format = { + style: 'percent', + signDisplay: 'never', + maximumFractionDigits: 1, + }; + + return sprintf( + /* translators: %s: Percentage of add to carts for the current top traffic source compared to the number of total add to carts for all traffic sources. */ + __( '%s of total add to carts', 'google-site-kit' ), + numFmt( rate, format ) + ); +} + function TopTrafficSourceDrivingAddToCartWidget( { Widget } ) { const dates = useSelect( ( select ) => select( CORE_USER ).getDateRangeDates( { @@ -59,30 +125,10 @@ function TopTrafficSourceDrivingAddToCartWidget( { Widget } ) { ENUM_CONVERSION_EVENTS.ADD_TO_CART ); - const totalAddToCartReportOptions = { - ...dates, - metrics: [ - { - name: 'addToCarts', - }, - ], - reportID: - 'analytics-4_top-traffic-source-driving-add-to-cart-widget_widget_totalAddToCartReportOptions', - }; - - const trafficSourceReportOptions = { - ...dates, - dimensions: [ 'sessionDefaultChannelGroup' ], - metrics: [ - { - name: 'addToCarts', - }, - ], - limit: 1, - orderBy: 'addToCarts', - reportID: - 'analytics-4_top-traffic-source-driving-add-to-cart-widget_widget_trafficSourceReportOptions', - }; + const { + totalAddToCart: totalAddToCartReportOptions, + trafficSource: trafficSourceReportOptions, + } = getTopTrafficSourceDrivingAddToCartReportOptions( dates ); const totalAddToCartReport = useInViewSelect( ( select ) => @@ -199,10 +245,8 @@ function TopTrafficSourceDrivingAddToCartWidget( { Widget } ) { widgetSlug={ KM_ANALYTICS_TOP_TRAFFIC_SOURCE_DRIVING_ADD_TO_CART } metricValue={ topTrafficSource } metricValueFormat={ format } - subText={ sprintf( - /* translators: %s: Percentage of add to carts for the current top traffic source compared to the number of total add to carts for all traffic sources. */ - __( '%s of total add to carts', 'google-site-kit' ), - numFmt( relativeCurrentTopTrafficSourceAddToCart, format ) + subText={ getTopTrafficSourceDrivingAddToCartSubtext( + relativeCurrentTopTrafficSourceAddToCart ) } previousValue={ relativePreviousTopTrafficSourceAddToCart } currentValue={ relativeCurrentTopTrafficSourceAddToCart } From 5600d1df4c6feb89d7dd3be8be1add30056ca8e5 Mon Sep 17 00:00:00 2001 From: Aleksej Date: Tue, 21 Jul 2026 16:58:04 +0200 Subject: [PATCH 44/55] Export source leads options, events. --- .../TopTrafficSourceDrivingLeadsWidget.js | 163 ++++++++++++------ 1 file changed, 114 insertions(+), 49 deletions(-) diff --git a/assets/js/modules/analytics-4/components/widgets/TopTrafficSourceDrivingLeadsWidget.js b/assets/js/modules/analytics-4/components/widgets/TopTrafficSourceDrivingLeadsWidget.js index f76d6a45e00..bd660bcf7c7 100644 --- a/assets/js/modules/analytics-4/components/widgets/TopTrafficSourceDrivingLeadsWidget.js +++ b/assets/js/modules/analytics-4/components/widgets/TopTrafficSourceDrivingLeadsWidget.js @@ -61,16 +61,19 @@ function getDateRangeIndex( reportRows, dateRangeSlug ) { return 0; } -function TopTrafficSourceDrivingLeadsWidget( { Widget } ) { - const dates = useSelect( ( select ) => - select( CORE_USER ).getDateRangeDates( { - compare: true, - } ) - ); - - const detectedEvents = useSelect( ( select ) => - select( MODULES_ANALYTICS_4 ).getDetectedEvents() - ); +/** + * Resolves the lead conversion event names to filter the reports by, from the + * property's detected events. + * + * Mirrors the dashboard widget: it keeps the detected lead events, and drops + * `CONTACT` when `SUBMIT_LEAD_FORM` is also present to avoid double-counting. + * + * @since n.e.x.t + * + * @param {string[]} [detectedEvents] The property's detected conversion events. + * @return {string[]} The lead event names to filter by. + */ +export function getTopTrafficSourceDrivingLeadsEventNames( detectedEvents ) { const eventNames = [ ENUM_CONVERSION_EVENTS.SUBMIT_LEAD_FORM, ENUM_CONVERSION_EVENTS.CONTACT, @@ -87,44 +90,111 @@ function TopTrafficSourceDrivingLeadsWidget( { Widget } ) { ); } - const totalLeadsReportOptions = { - ...dates, - metrics: [ - { - name: 'eventCount', + return eventNames; +} + +/** + * Builds the Analytics 4 report options for the Top Traffic Source Driving Leads + * metric. + * + * Returns both the total-leads report and the per-channel report the tile + * combines, each filtered to the given lead event names. Both this widget and + * the metric's PDF tile import this, so the dashboard tile and the report + * request the same data. + * + * @since n.e.x.t + * + * @param {Object} dates The date range, including the compare dates. + * @param {string[]} eventNames The lead event names to filter by. + * @return {Object} The `totalLeads` and `trafficSource` `getReport` options. + */ +export function getTopTrafficSourceDrivingLeadsReportOptions( + dates, + eventNames +) { + return { + totalLeads: { + ...dates, + metrics: [ + { + name: 'eventCount', + }, + ], + dimensions: [ 'eventName' ], + dimensionFilters: { + eventName: { + filterType: 'inListFilter', + value: eventNames, + }, }, - ], - dimensions: [ 'eventName' ], - dimensionFilters: { - eventName: { - filterType: 'inListFilter', - value: eventNames, + reportID: + 'analytics-4_top-traffic-source-driving-leads-widget_widget_totalLeadsReportOptions', + }, + trafficSource: { + ...dates, + dimensions: [ 'sessionDefaultChannelGroup', 'eventName' ], + dimensionFilters: { + eventName: { + filterType: 'inListFilter', + value: eventNames, + }, }, + metrics: [ + { + name: 'eventCount', + }, + ], + limit: 1, + orderBy: 'eventCount', + reportID: + 'analytics-4_top-traffic-source-driving-leads-widget_widget_trafficSourceReportOptions', }, - reportID: - 'analytics-4_top-traffic-source-driving-leads-widget_widget_totalLeadsReportOptions', }; +} - const trafficSourceReportOptions = { - ...dates, - dimensions: [ 'sessionDefaultChannelGroup', 'eventName' ], - dimensionFilters: { - eventName: { - filterType: 'inListFilter', - value: eventNames, - }, - }, - metrics: [ - { - name: 'eventCount', - }, - ], - limit: 1, - orderBy: 'eventCount', - reportID: - 'analytics-4_top-traffic-source-driving-leads-widget_widget_trafficSourceReportOptions', +/** + * Builds the sub-text for the Top Traffic Source Driving Leads metric tile. + * + * Both this widget and the metric's PDF tile import this, so the dashboard tile + * and the PDF tile show the same sub-text. + * + * @since n.e.x.t + * + * @param {number} rate The relative share of total leads for the top source. + * @return {string} The metric tile sub-text. + */ +export function getTopTrafficSourceDrivingLeadsSubtext( rate ) { + const format = { + style: 'percent', + signDisplay: 'never', + maximumFractionDigits: 1, }; + return sprintf( + /* translators: %s: Percentage of leads for the current top traffic source compared to the number of total leads for all traffic sources. */ + __( '%s of total leads', 'google-site-kit' ), + numFmt( rate, format ) + ); +} + +function TopTrafficSourceDrivingLeadsWidget( { Widget } ) { + const dates = useSelect( ( select ) => + select( CORE_USER ).getDateRangeDates( { + compare: true, + } ) + ); + + const detectedEvents = useSelect( ( select ) => + select( MODULES_ANALYTICS_4 ).getDetectedEvents() + ); + const eventNames = + getTopTrafficSourceDrivingLeadsEventNames( detectedEvents ); + + const { + totalLeads: totalLeadsReportOptions, + trafficSource: trafficSourceReportOptions, + } = getTopTrafficSourceDrivingLeadsReportOptions( dates, eventNames ); + const totalLeadsReport = useInViewSelect( ( select ) => eventNames.length @@ -273,14 +343,9 @@ function TopTrafficSourceDrivingLeadsWidget( { Widget } ) { widgetSlug={ KM_ANALYTICS_TOP_TRAFFIC_SOURCE_DRIVING_LEADS } metricValue={ topTrafficSource } metricValueFormat={ format } - subText={ - // eslint-disable-next-line @wordpress/valid-sprintf - sprintf( - /* translators: %d: Percentage of leads for the current top traffic source compared to the number of total leads for all traffic sources. */ - __( '%s of total leads', 'google-site-kit' ), - numFmt( relativeCurrentTopTrafficSourceUsers, format ) - ) - } + subText={ getTopTrafficSourceDrivingLeadsSubtext( + relativeCurrentTopTrafficSourceUsers + ) } previousValue={ relativePreviousTopTrafficSourceUsers } currentValue={ relativeCurrentTopTrafficSourceUsers } loading={ loading } From 3c6de4d7f0ab6fd67863b4ccc47920587cb52457 Mon Sep 17 00:00:00 2001 From: Aleksej Date: Tue, 21 Jul 2026 16:58:17 +0200 Subject: [PATCH 45/55] Export source purchases report options. --- .../TopTrafficSourceDrivingPurchasesWidget.js | 100 +++++++++++++----- 1 file changed, 72 insertions(+), 28 deletions(-) diff --git a/assets/js/modules/analytics-4/components/widgets/TopTrafficSourceDrivingPurchasesWidget.js b/assets/js/modules/analytics-4/components/widgets/TopTrafficSourceDrivingPurchasesWidget.js index c055431175a..187ad0e1546 100644 --- a/assets/js/modules/analytics-4/components/widgets/TopTrafficSourceDrivingPurchasesWidget.js +++ b/assets/js/modules/analytics-4/components/widgets/TopTrafficSourceDrivingPurchasesWidget.js @@ -42,6 +42,72 @@ import { numFmt } from '@/js/util'; import whenActive from '@/js/util/when-active'; import ConnectGA4CTATileWidget from './ConnectGA4CTATileWidget'; +/** + * Builds the Analytics 4 report options for the Top Traffic Source Driving + * Purchases metric. + * + * Returns both the total-purchases report and the per-channel report the tile + * combines. Both this widget and the metric's PDF tile import this, so the + * dashboard tile and the report request the same data. + * + * @since n.e.x.t + * + * @param {Object} dates The date range, including the compare dates. + * @return {Object} The `totalPurchases` and `trafficSource` `getReport` options. + */ +export function getTopTrafficSourceDrivingPurchasesReportOptions( dates ) { + return { + totalPurchases: { + ...dates, + metrics: [ + { + name: 'ecommercePurchases', + }, + ], + reportID: + 'analytics-4_top-traffic-source-driving-purchases-widget_widget_totalPurchasesReportOptions', + }, + trafficSource: { + ...dates, + dimensions: [ 'sessionDefaultChannelGroup' ], + metrics: [ + { + name: 'ecommercePurchases', + }, + ], + limit: 1, + orderBy: 'ecommercePurchases', + reportID: + 'analytics-4_top-traffic-source-driving-purchases-widget_widget_trafficSourceReportOptions', + }, + }; +} + +/** + * Builds the sub-text for the Top Traffic Source Driving Purchases metric tile. + * + * Both this widget and the metric's PDF tile import this, so the dashboard tile + * and the PDF tile show the same sub-text. + * + * @since n.e.x.t + * + * @param {number} rate The relative share of total purchases for the top source. + * @return {string} The metric tile sub-text. + */ +export function getTopTrafficSourceDrivingPurchasesSubtext( rate ) { + const format = { + style: 'percent', + signDisplay: 'never', + maximumFractionDigits: 1, + }; + + return sprintf( + /* translators: %s: Percentage of purchases for the current top traffic source compared to the number of total purchases for all traffic sources. */ + __( '%s of total purchases', 'google-site-kit' ), + numFmt( rate, format ) + ); +} + function TopTrafficSourceDrivingPurchasesWidget( { Widget } ) { const dates = useSelect( ( select ) => select( CORE_USER ).getDateRangeDates( { @@ -54,30 +120,10 @@ function TopTrafficSourceDrivingPurchasesWidget( { Widget } ) { ); const hasDetectedEvent = detectedEvents?.includes( 'purchase' ); - const totalPurchasesReportOptions = { - ...dates, - metrics: [ - { - name: 'ecommercePurchases', - }, - ], - reportID: - 'analytics-4_top-traffic-source-driving-purchases-widget_widget_totalPurchasesReportOptions', - }; - - const trafficSourceReportOptions = { - ...dates, - dimensions: [ 'sessionDefaultChannelGroup' ], - metrics: [ - { - name: 'ecommercePurchases', - }, - ], - limit: 1, - orderBy: 'ecommercePurchases', - reportID: - 'analytics-4_top-traffic-source-driving-purchases-widget_widget_trafficSourceReportOptions', - }; + const { + totalPurchases: totalPurchasesReportOptions, + trafficSource: trafficSourceReportOptions, + } = getTopTrafficSourceDrivingPurchasesReportOptions( dates ); const totalPurchasesReport = useInViewSelect( ( select ) => @@ -206,10 +252,8 @@ function TopTrafficSourceDrivingPurchasesWidget( { Widget } ) { widgetSlug={ KM_ANALYTICS_TOP_TRAFFIC_SOURCE_DRIVING_PURCHASES } metricValue={ topTrafficSource } metricValueFormat={ format } - subText={ sprintf( - /* translators: %s: Percentage of purchases for the current top traffic source compared to the number of total purchases for all traffic sources. */ - __( '%s of total purchases', 'google-site-kit' ), - numFmt( relativeCurrentTopTrafficSourcePurchases, format ) + subText={ getTopTrafficSourceDrivingPurchasesSubtext( + relativeCurrentTopTrafficSourcePurchases ) } previousValue={ relativePreviousTopTrafficSourcePurchases } currentValue={ relativeCurrentTopTrafficSourcePurchases } From c052cc328458008b989db9e973dbaf847dcc1b65 Mon Sep 17 00:00:00 2001 From: Aleksej Date: Tue, 21 Jul 2026 16:58:30 +0200 Subject: [PATCH 46/55] Export top-traffic-source report options. --- .../widgets/TopTrafficSourceWidget.js | 104 ++++++++++++------ 1 file changed, 72 insertions(+), 32 deletions(-) diff --git a/assets/js/modules/analytics-4/components/widgets/TopTrafficSourceWidget.js b/assets/js/modules/analytics-4/components/widgets/TopTrafficSourceWidget.js index 41f3f7155e7..504995e431a 100644 --- a/assets/js/modules/analytics-4/components/widgets/TopTrafficSourceWidget.js +++ b/assets/js/modules/analytics-4/components/widgets/TopTrafficSourceWidget.js @@ -42,6 +42,71 @@ import { numFmt } from '@/js/util'; import whenActive from '@/js/util/when-active'; import ConnectGA4CTATileWidget from './ConnectGA4CTATileWidget'; +/** + * Builds the Analytics 4 report options for the Top Traffic Source metric. + * + * Returns both the total-users report and the per-channel report the tile + * combines. Both this widget and the metric's PDF tile import this, so the + * dashboard tile and the report request the same data. + * + * @since n.e.x.t + * + * @param {Object} dates The date range, including the compare dates. + * @return {Object} The `totalUsers` and `trafficSource` `getReport` options. + */ +export function getTopTrafficSourceReportOptions( dates ) { + return { + totalUsers: { + ...dates, + metrics: [ + { + name: 'totalUsers', + }, + ], + reportID: + 'analytics-4_top-traffic-source-widget_widget_totalUsersReportOptions', + }, + trafficSource: { + ...dates, + dimensions: [ 'sessionDefaultChannelGroup' ], + metrics: [ + { + name: 'totalUsers', + }, + ], + limit: 1, + orderBy: 'totalUsers', + reportID: + 'analytics-4_top-traffic-source-widget_widget_trafficSourceReportOptions', + }, + }; +} + +/** + * Builds the sub-text for the Top Traffic Source metric tile. + * + * Both this widget and the metric's PDF tile import this, so the dashboard tile + * and the PDF tile show the same sub-text. + * + * @since n.e.x.t + * + * @param {number} rate The relative share of total traffic for the top source. + * @return {string} The metric tile sub-text. + */ +export function getTopTrafficSourceSubtext( rate ) { + const format = { + style: 'percent', + signDisplay: 'never', + maximumFractionDigits: 1, + }; + + return sprintf( + /* translators: %s: Percentage of users for the current top traffic source compared to the number of total users for all traffic sources. */ + __( '%s of total traffic', 'google-site-kit' ), + numFmt( rate, format ) + ); +} + function TopTrafficSourceWidget( { Widget } ) { const dates = useSelect( ( select ) => select( CORE_USER ).getDateRangeDates( { @@ -49,30 +114,10 @@ function TopTrafficSourceWidget( { Widget } ) { } ) ); - const totalUsersReportOptions = { - ...dates, - metrics: [ - { - name: 'totalUsers', - }, - ], - reportID: - 'analytics-4_top-traffic-source-widget_widget_totalUsersReportOptions', - }; - - const trafficSourceReportOptions = { - ...dates, - dimensions: [ 'sessionDefaultChannelGroup' ], - metrics: [ - { - name: 'totalUsers', - }, - ], - limit: 1, - orderBy: 'totalUsers', - reportID: - 'analytics-4_top-traffic-source-widget_widget_trafficSourceReportOptions', - }; + const { + totalUsers: totalUsersReportOptions, + trafficSource: trafficSourceReportOptions, + } = getTopTrafficSourceReportOptions( dates ); const totalUsersReport = useInViewSelect( ( select ) => @@ -176,14 +221,9 @@ function TopTrafficSourceWidget( { Widget } ) { widgetSlug={ KM_ANALYTICS_TOP_TRAFFIC_SOURCE } metricValue={ topTrafficSource } metricValueFormat={ format } - subText={ - // eslint-disable-next-line @wordpress/valid-sprintf - sprintf( - /* translators: %d: Percentage of users for the current top traffic source compared to the number of total users for all traffic sources. */ - __( '%s of total traffic', 'google-site-kit' ), - numFmt( relativeCurrentTopTrafficSourceUsers, format ) - ) - } + subText={ getTopTrafficSourceSubtext( + relativeCurrentTopTrafficSourceUsers + ) } previousValue={ relativePreviousTopTrafficSourceUsers } currentValue={ relativeCurrentTopTrafficSourceUsers } loading={ loading } From 597e88c8c5e024a4029e113f33c35c0d0f58fbdd Mon Sep 17 00:00:00 2001 From: Aleksej Date: Tue, 21 Jul 2026 16:58:43 +0200 Subject: [PATCH 47/55] Export visit-length report options. --- .../components/widgets/VisitLengthWidget.js | 50 +++++++++++++++---- 1 file changed, 40 insertions(+), 10 deletions(-) diff --git a/assets/js/modules/analytics-4/components/widgets/VisitLengthWidget.js b/assets/js/modules/analytics-4/components/widgets/VisitLengthWidget.js index 3299c8061e4..837e2ff1903 100644 --- a/assets/js/modules/analytics-4/components/widgets/VisitLengthWidget.js +++ b/assets/js/modules/analytics-4/components/widgets/VisitLengthWidget.js @@ -42,6 +42,44 @@ import { numFmt } from '@/js/util/i18n'; import whenActive from '@/js/util/when-active'; import ConnectGA4CTATileWidget from './ConnectGA4CTATileWidget'; +/** + * Builds the Analytics 4 report options for the Visit Length metric. + * + * Both this widget and the metric's PDF tile import this, so the dashboard tile + * and the report request the same data. + * + * @since n.e.x.t + * + * @param {Object} dates The date range, including the compare dates. + * @return {Object} The Analytics 4 `getReport` options. + */ +export function getVisitLengthReportOptions( dates ) { + return { + ...dates, + metrics: [ { name: 'averageSessionDuration' }, { name: 'sessions' } ], + reportID: 'analytics-4_visit-length-widget_widget_reportOptions', + }; +} + +/** + * Builds the sub-text for the Visit Length metric tile. + * + * Both this widget and the metric's PDF tile import this, so the dashboard tile + * and the PDF tile display the same sub-text. + * + * @since n.e.x.t + * + * @param {number} totalSessions The total number of visits. + * @return {string} The formatted sub-text. + */ +export function getVisitLengthSubtext( totalSessions ) { + return sprintf( + /* translators: %s: Number of total visits to the site, such as "1,234". */ + __( '%s total visits', 'google-site-kit' ), + numFmt( totalSessions, { style: 'decimal' } ) + ); +} + function VisitLengthWidget( { Widget } ) { const dates = useSelect( ( select ) => select( CORE_USER ).getDateRangeDates( { @@ -49,11 +87,7 @@ function VisitLengthWidget( { Widget } ) { } ) ); - const reportOptions = { - ...dates, - metrics: [ { name: 'averageSessionDuration' }, { name: 'sessions' } ], - reportID: 'analytics-4_visit-length-widget_widget_reportOptions', - }; + const reportOptions = getVisitLengthReportOptions( dates ); const report = useInViewSelect( ( select ) => select( MODULES_ANALYTICS_4 ).getReport( reportOptions ), @@ -101,11 +135,7 @@ function VisitLengthWidget( { Widget } ) { widgetSlug={ KM_ANALYTICS_VISIT_LENGTH } metricValue={ currentVisitLength } metricValueFormat="s" - subText={ sprintf( - /* translators: %s: Number of total page views. */ - __( '%s total visits', 'google-site-kit' ), - numFmt( currentTotalSessions, { style: 'decimal' } ) - ) } + subText={ getVisitLengthSubtext( currentTotalSessions ) } previousValue={ previousVisitLength } currentValue={ currentVisitLength } loading={ loading } From a6601e861610599fb3afb2fb9ff0d710d1ab0cf5 Mon Sep 17 00:00:00 2001 From: Aleksej Date: Tue, 21 Jul 2026 16:58:57 +0200 Subject: [PATCH 48/55] Export visits-per-visitor report options. --- .../widgets/VisitsPerVisitorWidget.js | 50 +++++++++++++++---- 1 file changed, 40 insertions(+), 10 deletions(-) diff --git a/assets/js/modules/analytics-4/components/widgets/VisitsPerVisitorWidget.js b/assets/js/modules/analytics-4/components/widgets/VisitsPerVisitorWidget.js index 6d8222133d4..8ee65113680 100644 --- a/assets/js/modules/analytics-4/components/widgets/VisitsPerVisitorWidget.js +++ b/assets/js/modules/analytics-4/components/widgets/VisitsPerVisitorWidget.js @@ -42,6 +42,44 @@ import { numFmt } from '@/js/util'; import whenActive from '@/js/util/when-active'; import ConnectGA4CTATileWidget from './ConnectGA4CTATileWidget'; +/** + * Builds the Analytics 4 report options for the Visits Per Visitor metric. + * + * Both this widget and the metric's PDF tile import this, so the dashboard tile + * and the report request the same data. + * + * @since n.e.x.t + * + * @param {Object} dates The date range, including the compare dates. + * @return {Object} The Analytics 4 `getReport` options. + */ +export function getVisitsPerVisitorReportOptions( dates ) { + return { + ...dates, + metrics: [ { name: 'sessionsPerUser' }, { name: 'sessions' } ], + reportID: 'analytics-4_visits-per-visitor-widget_widget_reportOptions', + }; +} + +/** + * Builds the sub-text for the Visits Per Visitor metric tile. + * + * Both this widget and the metric's PDF tile import this, so the dashboard tile + * and the PDF tile display the same sub-text. + * + * @since n.e.x.t + * + * @param {number} totalSessions The total number of visits. + * @return {string} The formatted sub-text. + */ +export function getVisitsPerVisitorSubtext( totalSessions ) { + return sprintf( + /* translators: %s: Number of total visits to the site, such as "1,234". */ + __( '%s total visits', 'google-site-kit' ), + numFmt( totalSessions, { style: 'decimal' } ) + ); +} + function VisitsPerVisitorWidget( { Widget } ) { const dates = useSelect( ( select ) => select( CORE_USER ).getDateRangeDates( { @@ -49,11 +87,7 @@ function VisitsPerVisitorWidget( { Widget } ) { } ) ); - const reportOptions = { - ...dates, - metrics: [ { name: 'sessionsPerUser' }, { name: 'sessions' } ], - reportID: 'analytics-4_visits-per-visitor-widget_widget_reportOptions', - }; + const reportOptions = getVisitsPerVisitorReportOptions( dates ); const report = useInViewSelect( ( select ) => select( MODULES_ANALYTICS_4 ).getReport( reportOptions ), @@ -98,11 +132,7 @@ function VisitsPerVisitorWidget( { Widget } ) { Widget={ Widget } widgetSlug={ KM_ANALYTICS_VISITS_PER_VISITOR } metricValue={ currentVisitsPerVisitor } - subText={ sprintf( - /* translators: %d: Number of total visits to the site. */ - __( '%s total visits', 'google-site-kit' ), - numFmt( currentTotalSessions, { style: 'decimal' } ) - ) } + subText={ getVisitsPerVisitorSubtext( currentTotalSessions ) } previousValue={ Number( previousVisitsPerVisitor ) } currentValue={ Number( currentVisitsPerVisitor ) } loading={ loading } From 8c855e8e23bbcfc75ca417f400ad8d8e06690dc5 Mon Sep 17 00:00:00 2001 From: Aleksej Date: Tue, 21 Jul 2026 16:59:10 +0200 Subject: [PATCH 49/55] Export popular-keywords report options. --- .../widgets/PopularKeywordsWidget.js | 24 ++++++++++++++----- 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/assets/js/modules/search-console/components/widgets/PopularKeywordsWidget.js b/assets/js/modules/search-console/components/widgets/PopularKeywordsWidget.js index 6e0c16687d1..15ad6b3ab64 100644 --- a/assets/js/modules/search-console/components/widgets/PopularKeywordsWidget.js +++ b/assets/js/modules/search-console/components/widgets/PopularKeywordsWidget.js @@ -45,6 +45,23 @@ import { MODULES_SEARCH_CONSOLE } from '@/js/modules/search-console/datastore/co import { generateDateRangeArgs } from '@/js/modules/search-console/util'; import { numFmt } from '@/js/util'; +/** + * Builds the report options for the Popular Keywords widget. + * + * @since n.e.x.t + * + * @param {Object} dates The date range dates, e.g. `startDate` and `endDate`. + * @return {Object} The Search Console report options. + */ +export function getPopularKeywordsReportOptions( dates ) { + return { + ...dates, + dimensions: 'query', + limit: 100, + reportID: 'search-console_popular-keywords-widget_widget_reportOptions', + }; +} + export default function PopularKeywordsWidget( { Widget } ) { const viewOnlyDashboard = useViewOnly(); @@ -52,12 +69,7 @@ export default function PopularKeywordsWidget( { Widget } ) { select( CORE_USER ).getDateRangeDates() ); - const reportOptions = { - ...dates, - dimensions: 'query', - limit: 100, - reportID: 'search-console_popular-keywords-widget_widget_reportOptions', - }; + const reportOptions = getPopularKeywordsReportOptions( dates ); const report = useInViewSelect( ( select ) => From 5bcea5eeb7760d944001fda87df4f4ec26cafcc5 Mon Sep 17 00:00:00 2001 From: "Matthew Riley MacPherson (tofumatt)" Date: Tue, 21 Jul 2026 21:33:02 +0100 Subject: [PATCH 50/55] Update code comment styles. --- .../KeyMetrics/key-metrics-pdf-tiles.test.js | 12 ++++++++---- .../KeyMetrics/key-metrics-widgets.test.js | 4 +++- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/assets/js/components/KeyMetrics/key-metrics-pdf-tiles.test.js b/assets/js/components/KeyMetrics/key-metrics-pdf-tiles.test.js index 4fc22820f16..3bc68025752 100644 --- a/assets/js/components/KeyMetrics/key-metrics-pdf-tiles.test.js +++ b/assets/js/components/KeyMetrics/key-metrics-pdf-tiles.test.js @@ -59,9 +59,11 @@ const DATES = { compareEndDate: '2025-01-07', }; -// A report shaped generously enough for every tile family: two ranked rows that -// each carry a date-range dimension, per-range totals, a row count, a currency -// code and a page-path dimension header. +/** + * A report shaped generously enough for every tile family: two ranked rows that + * each carry a date-range dimension, per-range totals, a row count, a currency + * code and a page-path dimension header. + */ const REPORT = { rowCount: 2, metadata: { currencyCode: 'USD' }, @@ -96,7 +98,9 @@ const REPORT = { ], }; -// Search Console returns a flat array of rows keyed by search query. +/** + * Search Console report returns a flat array of rows keyed by search query. + */ const SEARCH_CONSOLE_REPORT = [ { keys: [ 'site kit' ], ctr: 0.42, clicks: 120, impressions: 285 }, { keys: [ 'analytics plugin' ], ctr: 0.19, clicks: 40, impressions: 210 }, diff --git a/assets/js/components/KeyMetrics/key-metrics-widgets.test.js b/assets/js/components/KeyMetrics/key-metrics-widgets.test.js index 67dd08d1cc4..ecb884e3749 100644 --- a/assets/js/components/KeyMetrics/key-metrics-widgets.test.js +++ b/assets/js/components/KeyMetrics/key-metrics-widgets.test.js @@ -37,7 +37,9 @@ const DATES = { compareEndDate: '2025-01-07', }; -// The percent format the Top Cities tile uses for each city's share. +/** + * The percent format the Top Cities tile uses for each city's share. + */ const PERCENT_1DP = { style: 'percent', maximumFractionDigits: 1 }; /** From fa8d85f890ff8f4383d2c164ef2cce56788a1d22 Mon Sep 17 00:00:00 2001 From: Matthew Riley MacPherson Date: Tue, 21 Jul 2026 21:58:26 +0100 Subject: [PATCH 51/55] Update code comment. --- assets/js/components/KeyMetrics/getPDFData.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/assets/js/components/KeyMetrics/getPDFData.ts b/assets/js/components/KeyMetrics/getPDFData.ts index 0100214dc9b..c657c0c7c40 100644 --- a/assets/js/components/KeyMetrics/getPDFData.ts +++ b/assets/js/components/KeyMetrics/getPDFData.ts @@ -151,10 +151,12 @@ export default async function getPDFData( { } ) .map( ( widget: { slug: string } ) => widget.slug ); - // Tracks tiles that failed to load (threw), as opposed to tiles left out for - // having no data. When every attempted tile failed, the section is treated as - // an outage below rather than a genuinely empty section. - let failureCount = 0; + /** + * Tracks tiles that failed to load (threw), as opposed to tiles left out + * for having no data. When every attempted tile failed, the section + * is treated as an outage below rather than a genuinely empty section. + */ + let failureCount = 0; // A tile with no data (or a failed load) resolves to `null` and is filtered // out below. From b74ea44398af281cfd1d12d5a2ea388d83cfde9f Mon Sep 17 00:00:00 2001 From: Matthew Riley MacPherson Date: Tue, 21 Jul 2026 21:58:55 +0100 Subject: [PATCH 52/55] Update code comment. --- assets/js/components/KeyMetrics/key-metrics-pdf-tiles.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/assets/js/components/KeyMetrics/key-metrics-pdf-tiles.js b/assets/js/components/KeyMetrics/key-metrics-pdf-tiles.js index 104efe9c143..7884aa2e5e2 100644 --- a/assets/js/components/KeyMetrics/key-metrics-pdf-tiles.js +++ b/assets/js/components/KeyMetrics/key-metrics-pdf-tiles.js @@ -169,8 +169,10 @@ const PDFMetricTileTable = lazyWithPreload( () => ) ); -// The percentage format the text tiles share for their share/rate values, -// matching the dashboard tiles. +/** + * The percentage format the text tiles share for their share/rate values, + * matching the dashboard tiles. + */ const TILE_PERCENT_FORMAT = { style: 'percent', signDisplay: 'never', From d76b8b045d5d71cb9a6da4b8389663a7ad7c1e66 Mon Sep 17 00:00:00 2001 From: Matthew Riley MacPherson Date: Tue, 21 Jul 2026 22:00:46 +0100 Subject: [PATCH 53/55] Update code comment. --- .../shared-react-pdf-components/PDFMetricTileText.tsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/assets/js/components/pdf-export/shared-react-pdf-components/PDFMetricTileText.tsx b/assets/js/components/pdf-export/shared-react-pdf-components/PDFMetricTileText.tsx index 3b720ef5e84..0a32057b090 100644 --- a/assets/js/components/pdf-export/shared-react-pdf-components/PDFMetricTileText.tsx +++ b/assets/js/components/pdf-export/shared-react-pdf-components/PDFMetricTileText.tsx @@ -73,9 +73,11 @@ export interface PDFMetricTileTextProps { isNegative?: boolean; } -// Mirrors the dashboard's `MetricTileText`: it shares the full-card layout of -// `PDFNumericMetricTile`, but its value is a text label (e.g. a traffic source -// name) rather than a large formatted number. +/** + * Mirrors the dashboard's `MetricTileText`: it shares the full-card layout of + * `PDFNumericMetricTile`, but its value is a text label (e.g. a traffic source + * name) rather than a large formatted number. + */ const PDFMetricTileText: FC< PDFMetricTileTextProps > = ( { title, value, From 09309957c2ab7acc102cce80f31500f7f3e4f6a9 Mon Sep 17 00:00:00 2001 From: "Matthew Riley MacPherson (tofumatt)" Date: Tue, 21 Jul 2026 22:01:11 +0100 Subject: [PATCH 54/55] Rename variable. --- assets/js/components/KeyMetrics/getPDFData.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/assets/js/components/KeyMetrics/getPDFData.ts b/assets/js/components/KeyMetrics/getPDFData.ts index c657c0c7c40..21481e66c0c 100644 --- a/assets/js/components/KeyMetrics/getPDFData.ts +++ b/assets/js/components/KeyMetrics/getPDFData.ts @@ -156,7 +156,7 @@ export default async function getPDFData( { * for having no data. When every attempted tile failed, the section * is treated as an outage below rather than a genuinely empty section. */ - let failureCount = 0; + let failureCount = 0; // A tile with no data (or a failed load) resolves to `null` and is filtered // out below. @@ -208,13 +208,13 @@ export default async function getPDFData( { ); } - const settled = await Promise.all( tilePromises ); + const loadedTiles = await Promise.all( tilePromises ); if ( signal.aborted ) { return { data: null }; } - const tiles = settled.filter( + const tiles = loadedTiles.filter( ( tile ): tile is KeyMetricsPDFTile => tile !== null ); From 4c563096cef4ea73b099df8600f03dcd331108e7 Mon Sep 17 00:00:00 2001 From: "Matthew Riley MacPherson (tofumatt)" Date: Tue, 21 Jul 2026 22:14:03 +0100 Subject: [PATCH 55/55] Add JSDoc string. --- .../shared-react-pdf-components/PDFMetricTileText.tsx | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/assets/js/components/pdf-export/shared-react-pdf-components/PDFMetricTileText.tsx b/assets/js/components/pdf-export/shared-react-pdf-components/PDFMetricTileText.tsx index 0a32057b090..d9a06146da7 100644 --- a/assets/js/components/pdf-export/shared-react-pdf-components/PDFMetricTileText.tsx +++ b/assets/js/components/pdf-export/shared-react-pdf-components/PDFMetricTileText.tsx @@ -77,6 +77,16 @@ export interface PDFMetricTileTextProps { * Mirrors the dashboard's `MetricTileText`: it shares the full-card layout of * `PDFNumericMetricTile`, but its value is a text label (e.g. a traffic source * name) rather than a large formatted number. + * + * @since n.e.x.t + * + * @param {Object} props Component props. + * @param {string} props.title Title. + * @param {string} props.value Value. + * @param {string} props.subtext Optional subtext. + * @param {string} props.change Optional change for the badge. + * @param {boolean} props.isNegative Whether the change is negative. + * @return The PDF-rendered tile text. */ const PDFMetricTileText: FC< PDFMetricTileTextProps > = ( { title,