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 caac5df4774..36fdc8ad436 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 @@ -20,7 +20,7 @@ * Internal dependencies */ import { - GetPDFDataParams, + PDFDataLoaderParams, PDFReportDates, } from '@/js/googlesitekit/widgets/types'; @@ -71,15 +71,15 @@ interface FetchReportResult { export default function createKeyMetricTileDataLoader< TData >( buildReports: ( dates: PDFReportDates, - registry: GetPDFDataParams[ 'registry' ] + registry: PDFDataLoaderParams[ 'registry' ] ) => TileReportRequest[] | Promise< TileReportRequest[] >, extract: ( reports: unknown[] ) => TData | null -): ( params: GetPDFDataParams ) => Promise< TData | null > { +): ( params: PDFDataLoaderParams ) => Promise< TData | null > { return async function getTileData( { registry, dates, signal, - }: GetPDFDataParams ): Promise< TData | null > { + }: PDFDataLoaderParams ): Promise< TData | null > { if ( signal.aborted ) { return null; } diff --git a/assets/js/components/KeyMetrics/getPDFData.test.ts b/assets/js/components/KeyMetrics/getPDFData.test.ts index fc0540a54ca..f890fe884a7 100644 --- a/assets/js/components/KeyMetrics/getPDFData.test.ts +++ b/assets/js/components/KeyMetrics/getPDFData.test.ts @@ -113,8 +113,15 @@ describe( 'Key Metrics getPDFData', () => { ] ); const { signal } = new AbortController(); - const result = await getPDFData( { registry, dates: DATES, signal } ); + const result = await getPDFData( { + registry, + dates: DATES, + signal, + viewOnly: false, + } ); + // The aggregate loader has no view-only branch, so each tile loader + // receives only the registry, dates, and signal. expect( getTileDataA ).toHaveBeenCalledWith( { registry, dates: DATES, @@ -160,6 +167,7 @@ describe( 'Key Metrics getPDFData', () => { registry, dates: DATES, signal: new AbortController().signal, + viewOnly: false, } ); expect( result.data?.tiles.map( ( tile ) => tile.slug ) ).toEqual( [ @@ -186,6 +194,7 @@ describe( 'Key Metrics getPDFData', () => { registry, dates: DATES, signal: new AbortController().signal, + viewOnly: false, } ); expect( result.data?.tiles ).toEqual( [ @@ -217,6 +226,7 @@ describe( 'Key Metrics getPDFData', () => { registry, dates: DATES, signal: new AbortController().signal, + viewOnly: false, } ); expect( result.data?.tiles ).toEqual( [ @@ -250,6 +260,7 @@ describe( 'Key Metrics getPDFData', () => { registry, dates: DATES, signal: new AbortController().signal, + viewOnly: false, } ); expect( result ).toEqual( { data: null } ); @@ -274,6 +285,7 @@ describe( 'Key Metrics getPDFData', () => { registry, dates: DATES, signal: new AbortController().signal, + viewOnly: false, } ) ).rejects.toThrow( 'All Key Metrics PDF tiles failed to load.' ); } ); @@ -309,7 +321,9 @@ describe( 'Key Metrics getPDFData', () => { registry, dates: DATES, signal: new AbortController().signal, - // AdSense is not shared, so its tile is neither loaded nor exported. + // Only a view-only dashboard populates `viewableModules`. + viewOnly: true, + // AdSense isn't shared, so its tile is neither loaded nor exported. viewableModules: [ 'analytics-4' ], } ); @@ -329,6 +343,7 @@ describe( 'Key Metrics getPDFData', () => { registry, dates: DATES, signal: new AbortController().signal, + viewOnly: false, } ); expect( result ).toEqual( { data: null } ); @@ -347,6 +362,7 @@ describe( 'Key Metrics getPDFData', () => { registry, dates: DATES, signal: controller.signal, + viewOnly: false, } ); expect( result ).toEqual( { data: null } ); @@ -377,6 +393,7 @@ describe( 'Key Metrics getPDFData', () => { registry, dates: DATES, signal: controller.signal, + viewOnly: false, } ); expect( result ).toEqual( { data: null } ); @@ -401,6 +418,7 @@ describe( 'Key Metrics getPDFData', () => { registry, dates: DATES, signal: controller.signal, + viewOnly: false, } ); expect( result ).toEqual( { data: null } ); @@ -427,6 +445,7 @@ describe( 'Key Metrics getPDFData', () => { registry, dates: DATES, signal: new AbortController().signal, + viewOnly: false, } ); expect( preload ).toHaveBeenCalledTimes( 1 ); diff --git a/assets/js/components/KeyMetrics/getPDFData.ts b/assets/js/components/KeyMetrics/getPDFData.ts index 21481e66c0c..3feed2e494b 100644 --- a/assets/js/components/KeyMetrics/getPDFData.ts +++ b/assets/js/components/KeyMetrics/getPDFData.ts @@ -27,7 +27,10 @@ import { ComponentType } from 'react'; 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 { + GetPDFDataParams, + PDFDataLoaderParams, +} from '@/js/googlesitekit/widgets/types'; import { KEY_METRICS_PDF_TILES } from './key-metrics-pdf-tiles'; import { KEY_METRICS_WIDGETS } from './key-metrics-widgets'; @@ -43,7 +46,7 @@ interface KeyMetricPDFTileConfig { } >; }; /** Resolves the tile's data, or `null` when the report has no data. */ - getTileData: ( params: GetPDFDataParams ) => Promise< unknown >; + getTileData: ( params: PDFDataLoaderParams ) => Promise< unknown >; } const keyMetricsWidgets = KEY_METRICS_WIDGETS as Record< diff --git a/assets/js/components/pdf-export/PDFExportOrchestrator.test.tsx b/assets/js/components/pdf-export/PDFExportOrchestrator.test.tsx index 90db505b5d8..10e61950fc8 100644 --- a/assets/js/components/pdf-export/PDFExportOrchestrator.test.tsx +++ b/assets/js/components/pdf-export/PDFExportOrchestrator.test.tsx @@ -29,7 +29,10 @@ import { WPDataRegistry } from '@wordpress/data/build-types/registry'; /** * Internal dependencies */ -import { VIEW_CONTEXT_MAIN_DASHBOARD } from '@/js/googlesitekit/constants'; +import { + VIEW_CONTEXT_MAIN_DASHBOARD, + VIEW_CONTEXT_MAIN_DASHBOARD_VIEW_ONLY, +} from '@/js/googlesitekit/constants'; import { CORE_PDF } from '@/js/googlesitekit/datastore/pdf/constants'; import { CORE_SITE } from '@/js/googlesitekit/datastore/site/constants'; import { CORE_USER } from '@/js/googlesitekit/datastore/user/constants'; @@ -46,6 +49,7 @@ import { createTestRegistry, provideModules, provideSiteInfo, + provideUserCapabilities, provideUserInfo, render, waitFor, @@ -191,10 +195,21 @@ describe( 'PDFExportOrchestrator', () => { dispatch.assignWidget( widgetSlug, areaSlug ); } - function renderOrchestrator() { + /** + * Renders the orchestrator under a dashboard view context. + * + * @since 1.181.0 + * @since n.e.x.t Added the `viewContext` parameter. + * + * @param {string} viewContext The dashboard view context to render under. + * @return {Object} The render result for the orchestrator. + */ + function renderOrchestrator( + viewContext: string = VIEW_CONTEXT_MAIN_DASHBOARD + ) { return render( {} } />, { registry, - viewContext: VIEW_CONTEXT_MAIN_DASHBOARD, + viewContext, } ); } @@ -290,15 +305,45 @@ describe( 'PDFExportOrchestrator', () => { expect( getData ).toHaveBeenCalledTimes( 1 ); - const { dates, signal } = getData.mock.calls[ 0 ][ 0 ]; - // End date is shifted back one day from the reference date. + const { dates, signal, viewOnly } = getData.mock.calls[ 0 ][ 0 ]; + // The end date shifts back one day from the reference date. expect( dates.endDate ).toBe( '2021-01-09' ); expect( dates.compareStartDate ).toBeDefined(); expect( signal ).toBeInstanceOf( AbortSignal ); + // The main dashboard isn't view-only, so `viewOnly` is false and each + // widget's `getData` loader resolves the links the PDF shows. + expect( viewOnly ).toBe( false ); expect( pdf ).toHaveBeenCalledTimes( 1 ); } ); + it( 'should pass viewOnly as true to each widget loader on a view-only dashboard', async () => { + // On a view-only dashboard, the orchestrator reads the viewable + // modules, and that read needs the user's capabilities in the store. + provideUserCapabilities( registry ); + + const getData: jest.Mock = jest.fn( () => + Promise.resolve( { data: { totalUsers: 100 } } ) + ); + registerPDFWidget( 'trafficArea', 'trafficWidget', getData ); + registry.dispatch( CORE_PDF ).setSelection( { + contextSlugs: [ CONTEXT_MAIN_DASHBOARD_TRAFFIC ], + widgetSlugs: [ 'trafficWidget' ], + } ); + + renderOrchestrator( VIEW_CONTEXT_MAIN_DASHBOARD_VIEW_ONLY ); + + await waitFor( () => { + expect( registry.select( CORE_PDF ).getStatus() ).toBe( 'success' ); + } ); + + expect( getData ).toHaveBeenCalledTimes( 1 ); + + // Each widget's `getData` loader reads `viewOnly` and leaves out the + // links a view-only dashboard doesn't show. + expect( getData.mock.calls[ 0 ][ 0 ].viewOnly ).toBe( true ); + } ); + it( 'should transition to error and not build a PDF when the only widget fails', async () => { const getData = jest.fn( () => Promise.reject( new Error( 'report failed' ) ) diff --git a/assets/js/components/pdf-export/PDFExportOrchestrator.tsx b/assets/js/components/pdf-export/PDFExportOrchestrator.tsx index 396abb54395..d7338b9bc91 100644 --- a/assets/js/components/pdf-export/PDFExportOrchestrator.tsx +++ b/assets/js/components/pdf-export/PDFExportOrchestrator.tsx @@ -336,6 +336,7 @@ const PDFExportOrchestrator: FC< PDFExportOrchestratorProps > = ( { registry, dates, signal, + viewOnly, // 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. diff --git a/assets/js/components/pdf-export/shared-react-pdf-components/PDFHeader.tsx b/assets/js/components/pdf-export/shared-react-pdf-components/PDFHeader.tsx index 723a1a56db9..a099e77756f 100644 --- a/assets/js/components/pdf-export/shared-react-pdf-components/PDFHeader.tsx +++ b/assets/js/components/pdf-export/shared-react-pdf-components/PDFHeader.tsx @@ -201,24 +201,14 @@ const PDFHeader: FC< PDFHeaderProps > = ( { - { dashboardURL ? ( - - { getSiteHost( siteURL ) } - - ) : ( - - { getSiteHost( siteURL ) } - - ) } + + { getSiteHost( siteURL ) } + diff --git a/assets/js/components/pdf-export/shared-react-pdf-components/PDFLink.test.tsx b/assets/js/components/pdf-export/shared-react-pdf-components/PDFLink.test.tsx index f7178a990c1..7b352da0094 100644 --- a/assets/js/components/pdf-export/shared-react-pdf-components/PDFLink.test.tsx +++ b/assets/js/components/pdf-export/shared-react-pdf-components/PDFLink.test.tsx @@ -107,4 +107,45 @@ describe( 'PDFLink', () => { expect( linkJSON ).toContain( '#6c726e' ); } ); + + it( 'renders plain text in the default color when the href is an empty string', () => { + // An empty `href` is means a row with no link, so the text + // should render as plain text instead of as a link. + const tree = renderLink( { href: '', children: 'View dashboard' } ); + + expect( tree.type ).toBe( 'pdf-text' ); + + const treeJSON = JSON.stringify( tree ); + expect( treeJSON ).toContain( 'View dashboard' ); + expect( treeJSON ).not.toContain( PDF_COLORS.CONTENT_SECONDARY ); + } ); + + it( 'renders plain text in the default color when the href is undefined', () => { + // An undefined/missing `href` should renders plain text, + // the same as an empty string above. + const tree = renderLink( { + href: undefined, + children: 'View dashboard', + } ); + + expect( tree.type ).toBe( 'pdf-text' ); + + const treeJSON = JSON.stringify( tree ); + expect( treeJSON ).toContain( 'View dashboard' ); + expect( treeJSON ).not.toContain( PDF_COLORS.CONTENT_SECONDARY ); + } ); + + it( 'omits the trailing icon when the href is empty', () => { + const tree = renderLink( { + href: '', + trailingIcon: ( + + + + ), + children: 'View dashboard', + } ); + + expect( JSON.stringify( tree ) ).not.toContain( 'M1 2 3 4' ); + } ); } ); diff --git a/assets/js/components/pdf-export/shared-react-pdf-components/PDFLink.tsx b/assets/js/components/pdf-export/shared-react-pdf-components/PDFLink.tsx index abb6c62730f..768449e79fb 100644 --- a/assets/js/components/pdf-export/shared-react-pdf-components/PDFLink.tsx +++ b/assets/js/components/pdf-export/shared-react-pdf-components/PDFLink.tsx @@ -1,5 +1,6 @@ /** - * A text link for the PDF report, with an optional trailing icon. + * A text link for the PDF report, with an optional trailing icon. With no URL, + * the text renders plain. * * Site Kit by Google, Copyright 2026 Google LLC * @@ -42,7 +43,7 @@ const styles = createPDFStyles( { } ); export interface PDFLinkProps { - /** Link target URL. */ + /** Link target URL. When an empty string: plaintext is still rendered (in the default text color and with no trailing icon). */ href?: string; /** Typography type for the link text. */ type?: PDFTypographyType; @@ -62,6 +63,14 @@ const PDFLink: FC< PDFLinkProps > = ( { style, children, } ) => { + if ( ! href ) { + return ( + + { children } + + ); + } + const linkColor: Style = { color: PDF_COLORS.CONTENT_SECONDARY }; return ( diff --git a/assets/js/googlesitekit/widgets/types.ts b/assets/js/googlesitekit/widgets/types.ts index 0b3193fe44e..6fbcb13bd12 100644 --- a/assets/js/googlesitekit/widgets/types.ts +++ b/assets/js/googlesitekit/widgets/types.ts @@ -39,11 +39,11 @@ export interface PDFReportDates { } /** - * Parameters a PDF widget's `getData` loader receives. + * Base parameters a PDF widget's `getData` loader receives. * - * @since 1.183.0 + * @since n.e.x.t */ -export interface GetPDFDataParams { +export interface PDFDataLoaderParams { /** WordPress data registry, with `resolveSelect` added. */ registry: WPDataRegistry & { // `resolveSelect` is on the registry at runtime but missing from the @@ -63,6 +63,17 @@ export interface GetPDFDataParams { viewableModules?: string[]; } +/** + * Parameters a PDF widget's `getData` loader receives. + * + * @since 1.183.0 + * @since n.e.x.t Added `viewOnly`. + */ +export interface GetPDFDataParams extends PDFDataLoaderParams { + /** Whether the export runs on a view-only dashboard, where a loader leaves out the links an administrator sees. */ + viewOnly: boolean; +} + /** * Props every PDF widget `Component` receives from the report mapper. * @@ -102,15 +113,11 @@ export interface WidgetPDFData { * PDF export configuration for a widget. * * @since 1.181.0 + * @since n.e.x.t Added `viewOnly` to the `getData` params. */ export interface WidgetPDFConfig { Component: PDFWidgetComponent; - getData: ( params: { - registry: unknown; - dates: PDFReportDates; - signal: AbortSignal; - viewableModules?: string[]; - } ) => Promise< WidgetPDFData >; + getData: ( params: GetPDFDataParams ) => 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. isActive?: ( select: ( storeName: string ) => any ) => boolean; diff --git a/assets/js/modules/adsense/components/dashboard/DashboardTopEarningPagesWidgetGA4/getPDFData.test.ts b/assets/js/modules/adsense/components/dashboard/DashboardTopEarningPagesWidgetGA4/getPDFData.test.ts index 6510965fe03..8d63e07718d 100644 --- a/assets/js/modules/adsense/components/dashboard/DashboardTopEarningPagesWidgetGA4/getPDFData.test.ts +++ b/assets/js/modules/adsense/components/dashboard/DashboardTopEarningPagesWidgetGA4/getPDFData.test.ts @@ -20,13 +20,18 @@ * External dependencies */ import fetchMock from 'fetch-mock-jest'; -import { createTestRegistry, waitForDefaultTimeouts } from 'tests/js/utils'; +import { + createTestRegistry, + provideUserInfo, + waitForDefaultTimeouts, +} from 'tests/js/utils'; /** * Internal dependencies */ import { GetPDFDataParams } from '@/js/googlesitekit/widgets/types'; import { MODULES_ADSENSE } from '@/js/modules/adsense/datastore/constants'; +import { MODULES_ANALYTICS_4 } from '@/js/modules/analytics-4/datastore/constants'; import getPDFData from './getPDFData'; // The registry `getPDFData` receives: the WordPress data registry with @@ -41,10 +46,15 @@ const reportEndpoint = new RegExp( ); /** - * AdSense account ID seeded into the registry. + * AdSense account ID the registry settings hold. */ const ACCOUNT_ID = 'pub-1234567890'; +/** + * Analytics property ID the registry settings hold, for the page links. + */ +const PROPERTY_ID = '123456789'; + /** * Date range fixture passed to the loader. */ @@ -112,17 +122,44 @@ function provideReports( { } ) ); } +/** + * Builds the Analytics report link the loader resolves for a page path. + * + * The link comes from the same selector the loader uses, so a test checks the + * page filter and date range, not the URL format. + * + * @since n.e.x.t + * + * @param {Registry} registry Registry that holds the Analytics property. + * @param {string} pagePath Page path from a report row. + * @return {string} The All pages and screens report link for the page. + */ +function getExpectedPageLink( registry: Registry, pagePath: string ): string { + return registry + .select( MODULES_ANALYTICS_4 ) + .getServiceReportURL( 'all-pages-and-screens', { + filters: { unifiedPagePathScreen: pagePath }, + dates: { startDate: DATES.startDate, endDate: DATES.endDate }, + } ); +} + describe( 'DashboardTopEarningPagesWidgetGA4 getPDFData', () => { let registry: Registry; beforeEach( () => { registry = createTestRegistry() as Registry; + provideUserInfo( registry ); registry .dispatch( MODULES_ADSENSE ) .receiveGetSettings( { accountID: ACCOUNT_ID } ); + // Receive the Analytics property so each page link builds without + // fetching the module settings, which these report tests don't mock. + registry + .dispatch( MODULES_ANALYTICS_4 ) + .receiveGetSettings( { propertyID: PROPERTY_ID } ); } ); - it( 'returns rows, currency code, and page titles from the two reports', async () => { + it( 'returns rows, currency code, page titles, and page links from the two reports', async () => { const requestedReports: string[] = []; fetchMock.get( reportEndpoint, ( requestURL ) => { @@ -142,20 +179,50 @@ describe( 'DashboardTopEarningPagesWidgetGA4 getPDFData', () => { registry, dates: DATES, signal: new AbortController().signal, + viewOnly: false, } ); // The loader requests the main report first, then the page titles report. expect( requestedReports ).toEqual( [ 'main', 'page-titles' ] ); + // An unset Analytics property would make the expected link and the + // loader's link both empty, so check the link holds a URL first. + const homeLink = getExpectedPageLink( registry, '/' ); + expect( homeLink ).toBeTruthy(); + expect( result ).toEqual( { data: { rows: MAIN_REPORT.rows, currencyCode: 'EUR', titles: { '/': 'Home', '/about': 'About' }, + links: { + '/': homeLink, + '/about': getExpectedPageLink( registry, '/about' ), + }, }, } ); } ); + it( 'does not include page links on a view-only dashboard', async () => { + provideReports(); + + const result = await getPDFData( { + registry, + dates: DATES, + signal: new AbortController().signal, + viewOnly: true, + } ); + + // The dashboard widget shows a view-only user each page title as plain + // text, so the PDF gets the rows and titles with no link to render. + expect( result.data?.rows ).toEqual( MAIN_REPORT.rows ); + expect( result.data?.titles ).toEqual( { + '/': 'Home', + '/about': 'About', + } ); + expect( result.data?.links ).toEqual( {} ); + } ); + it( 'requests the main report with the expected options', async () => { provideReports(); @@ -163,6 +230,7 @@ describe( 'DashboardTopEarningPagesWidgetGA4 getPDFData', () => { registry, dates: DATES, signal: new AbortController().signal, + viewOnly: false, } ); // The first report request is the main Top earning pages report. @@ -181,9 +249,15 @@ describe( 'DashboardTopEarningPagesWidgetGA4 getPDFData', () => { } ); it( 'resolves the AdSense settings before building the report when they are not loaded yet', async () => { - // Fresh registry without the settings seeded in `beforeEach`, matching - // an export that starts before the AdSense store has loaded them. + // Fresh registry without the AdSense settings from `beforeEach`, + // matching an export that starts before the AdSense store has loaded + // them. The user info and the Analytics property go back in, so the + // page links build without their own settings fetch. registry = createTestRegistry() as Registry; + provideUserInfo( registry ); + registry + .dispatch( MODULES_ANALYTICS_4 ) + .receiveGetSettings( { propertyID: PROPERTY_ID } ); fetchMock.getOnce( new RegExp( '^/google-site-kit/v1/modules/adsense/data/settings' ), @@ -195,6 +269,7 @@ describe( 'DashboardTopEarningPagesWidgetGA4 getPDFData', () => { registry, dates: DATES, signal: new AbortController().signal, + viewOnly: false, } ); // The report filter targets the fetched account, not `(undefined)`. @@ -207,6 +282,70 @@ describe( 'DashboardTopEarningPagesWidgetGA4 getPDFData', () => { expect( mainRequest ).not.toContain( '(undefined)' ); } ); + it( 'resolves the Analytics settings before building the links when they are not loaded yet', async () => { + // Fresh registry without the Analytics settings from `beforeEach`, so the + // export starts before the Analytics store has loaded the property. The + // AdSense settings go back in, so the main report builds on its own. + registry = createTestRegistry() as Registry; + provideUserInfo( registry ); + registry + .dispatch( MODULES_ADSENSE ) + .receiveGetSettings( { accountID: ACCOUNT_ID } ); + + const analyticsSettings = new RegExp( + '^/google-site-kit/v1/modules/analytics-4/data/settings' + ); + fetchMock.get( analyticsSettings, { + body: { propertyID: PROPERTY_ID }, + status: 200, + } ); + provideReports(); + + const result = await getPDFData( { + registry, + dates: DATES, + signal: new AbortController().signal, + viewOnly: false, + } ); + + // The loader fetched the Analytics settings, so each page link holds the + // property's report URL instead of the empty string an unset property gives. + expect( fetchMock ).toHaveFetched( analyticsSettings ); + expect( result.data?.links[ '/' ] ).toBeTruthy(); + expect( result.data?.links[ '/' ] ).toBe( + getExpectedPageLink( registry, '/' ) + ); + } ); + + it( 'does not resolve the Analytics settings on a view-only dashboard', async () => { + // Fresh registry without the Analytics settings, and a view-only export, + // which builds no page links and so never reads the property. + registry = createTestRegistry() as Registry; + provideUserInfo( registry ); + registry + .dispatch( MODULES_ADSENSE ) + .receiveGetSettings( { accountID: ACCOUNT_ID } ); + + const analyticsSettings = new RegExp( + '^/google-site-kit/v1/modules/analytics-4/data/settings' + ); + fetchMock.get( analyticsSettings, { + body: { propertyID: PROPERTY_ID }, + status: 200, + } ); + provideReports(); + + const result = await getPDFData( { + registry, + dates: DATES, + signal: new AbortController().signal, + viewOnly: true, + } ); + + expect( fetchMock ).not.toHaveFetched( analyticsSettings ); + expect( result.data?.links ).toEqual( {} ); + } ); + it( 'keeps the first title for a repeated path and falls back to "(unknown)" for a missing one', async () => { const mainReport = { metadata: { currencyCode: 'EUR' }, @@ -245,6 +384,7 @@ describe( 'DashboardTopEarningPagesWidgetGA4 getPDFData', () => { registry, dates: DATES, signal: new AbortController().signal, + viewOnly: false, } ); expect( result.data?.titles ).toEqual( { @@ -259,7 +399,12 @@ describe( 'DashboardTopEarningPagesWidgetGA4 getPDFData', () => { const { signal } = new AbortController(); - await getPDFData( { registry, dates: DATES, signal } ); + await getPDFData( { + registry, + dates: DATES, + signal, + viewOnly: false, + } ); // The registry starts resolver runs from a timeout. Wait for those // timeouts, so an extra run would add its request to the calls this test @@ -288,6 +433,7 @@ describe( 'DashboardTopEarningPagesWidgetGA4 getPDFData', () => { registry, dates: DATES, signal: new AbortController().signal, + viewOnly: false, } ); await waitForDefaultTimeouts(); @@ -304,6 +450,7 @@ describe( 'DashboardTopEarningPagesWidgetGA4 getPDFData', () => { registry, dates: DATES, signal: controller.signal, + viewOnly: false, } ); expect( result ).toEqual( { data: null } ); @@ -330,6 +477,7 @@ describe( 'DashboardTopEarningPagesWidgetGA4 getPDFData', () => { registry, dates: DATES, signal: controller.signal, + viewOnly: false, } ); // Wait for the main report request to dispatch before aborting. diff --git a/assets/js/modules/adsense/components/dashboard/DashboardTopEarningPagesWidgetGA4/getPDFData.ts b/assets/js/modules/adsense/components/dashboard/DashboardTopEarningPagesWidgetGA4/getPDFData.ts index 1451ee992c7..ce8a894ff50 100644 --- a/assets/js/modules/adsense/components/dashboard/DashboardTopEarningPagesWidgetGA4/getPDFData.ts +++ b/assets/js/modules/adsense/components/dashboard/DashboardTopEarningPagesWidgetGA4/getPDFData.ts @@ -23,6 +23,7 @@ import { GetPDFDataParams } from '@/js/googlesitekit/widgets/types'; import { MODULES_ADSENSE } from '@/js/modules/adsense/datastore/constants'; import { MODULES_ANALYTICS_4 } from '@/js/modules/analytics-4/datastore/constants'; import { Report, ReportRow } from '@/js/modules/analytics-4/datastore/types'; +import { getAllPagesReportURL } from '@/js/modules/analytics-4/utils/page-report-url'; import { getPagePaths, getPageTitleMap, @@ -30,10 +31,18 @@ import { } from '@/js/modules/analytics-4/utils/page-titles-report'; import { getTopEarningPagesReportOptions } from './getTopEarningPagesReportOptions'; +/** + * Data the Top earning pages PDF widget renders. + */ export interface TopEarningPagesPDFData { + /** Rows of the Top earning pages report. */ rows: ReportRow[]; + /** Currency code from the report metadata, for formatting the earnings. */ currencyCode: string; + /** Map of page path to page title. */ titles: Record< string, string >; + /** Map of page path to its Analytics report link. Empty on a view-only dashboard, where each page title shows as plain text. */ + links: Record< string, string >; } /** @@ -47,38 +56,87 @@ interface GetPDFDataResult { data: TopEarningPagesPDFData | null; } +/** + * Maps each page path to its Analytics report link. + * + * Builds the same All pages and screens report link the dashboard widget shows + * for each page. The PDF component renders each page title as this link. + * + * @since n.e.x.t + * + * @param {Object} params Link map parameters. + * @param {Object} params.registry WordPress data registry. + * @param {Object} params.dates Report date range. + * @param {string[]} params.pagePaths Page paths from the main report rows. + * @param {boolean} params.viewOnly Whether the export runs on a view-only dashboard. + * @return {Object} Map of page path to its Analytics report link, empty for a view-only user. + */ +function getPageLinkMap( { + registry, + dates, + pagePaths, + viewOnly, +}: Pick< GetPDFDataParams, 'registry' | 'dates' | 'viewOnly' > & { + pagePaths: string[]; +} ): Record< string, string > { + // A view-only user sees each page title as plain text on the dashboard, so + // the PDF builds no links either. + if ( viewOnly ) { + return {}; + } + + const analytics = registry.select( MODULES_ANALYTICS_4 ); + const { startDate, endDate } = dates; + const links: Record< string, string > = {}; + + pagePaths.forEach( ( pagePath ) => { + links[ pagePath ] = + getAllPagesReportURL( analytics, pagePath, { + startDate, + endDate, + } ) ?? ''; + } ); + + return links; +} + /** * Loads the report rows, currency code, and page titles for the Top earning * pages PDF widget. * * Resolves the linked AdSense account ID, loads the Top earning pages report, - * then a second report that matches each page path to its title. Passes the - * abort signal to both requests and returns `{ data: null }` as soon as the - * signal aborts, or when the report has no rows, so cancelling the export stops - * the work and the orchestrator omits the widget. + * then a second report that matches each page path to its title. Builds an + * Analytics report link for each page, the same link the dashboard widget + * shows. For a view-only user, the loader builds no links, because the + * dashboard widget shows the page title as plain text. Passes the cancellation + * signal to both requests. Returns `{ data: null }` when the signal fires or + * the report has no rows, so the report document skips this widget. * * @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 report rows, currency code, and the page-path-to-title map. + * @param {Object} params Loader parameters. + * @param {Object} params.registry WordPress data registry. + * @param {Object} params.dates Report date range. + * @param {AbortSignal} params.signal Cancellation signal. + * @param {boolean} params.viewOnly Whether the export runs on a view-only dashboard. + * @return {Promise} The report rows, currency code, page titles, and per-page links. */ export default async function getPDFData( { registry, dates, signal, + viewOnly, }: GetPDFDataParams ): Promise< GetPDFDataResult > { if ( signal.aborted ) { return { data: null }; } // The linked account ID lives in the AdSense settings, and the PDF export - // path does not otherwise resolve them (eligibility only checks Analytics' - // `adSenseLinked`). Resolve them before reading the ID, or it can be - // undefined and the report queries a malformed `Google AdSense account - // (undefined)` ad source, returning an empty or wrong report. + // path doesn't otherwise resolve them (eligibility only checks the + // Analytics `adSenseLinked` setting). Resolve them before reading the ID, + // or it stays `undefined` and the report queries a malformed `Google + // AdSense account (undefined)` ad source, returning an empty or wrong + // report. await registry.resolveSelect( MODULES_ADSENSE ).getSettings(); if ( signal.aborted ) { @@ -109,11 +167,26 @@ export default async function getPDFData( { return { data: null }; } + // Each page title links to its Analytics report, which `getServiceReportURL` + // builds from the Analytics property ID in the Analytics settings. The PDF + // export path resolves the AdSense settings but not the Analytics ones, so + // resolve them here, or the property ID stays undefined and every link comes + // back empty. A view-only export builds no links, so it skips the resolution. + if ( ! viewOnly ) { + await registry.resolveSelect( MODULES_ANALYTICS_4 ).getSettings(); + + if ( signal.aborted ) { + return { data: null }; + } + } + const currencyCode = report?.metadata?.currencyCode ?? ''; const pagePaths = getPagePaths( report ); + const links = getPageLinkMap( { registry, dates, pagePaths, viewOnly } ); + if ( pagePaths.length === 0 ) { - return { data: { rows, currencyCode, titles: {} } }; + return { data: { rows, currencyCode, titles: {}, links } }; } const titlesArgs = getPageTitlesReportOptions( dates, pagePaths ); @@ -132,6 +205,7 @@ export default async function getPDFData( { rows, currencyCode, titles: getPageTitleMap( pagePaths, titlesReport ), + links, }, }; } diff --git a/assets/js/modules/adsense/components/dashboard/DashboardTopEarningPagesWidgetGA4/indexPDF.test.tsx b/assets/js/modules/adsense/components/dashboard/DashboardTopEarningPagesWidgetGA4/indexPDF.test.tsx index da780848909..6b7d09fb8ab 100644 --- a/assets/js/modules/adsense/components/dashboard/DashboardTopEarningPagesWidgetGA4/indexPDF.test.tsx +++ b/assets/js/modules/adsense/components/dashboard/DashboardTopEarningPagesWidgetGA4/indexPDF.test.tsx @@ -49,6 +49,10 @@ const DATA = { ], currencyCode: 'EUR', titles: { '/home-page': 'Home Title', '/about-page': 'About Title' }, + links: { + '/home-page': 'https://example.com/analytics-report/home-page', + '/about-page': 'https://example.com/analytics-report/about-page', + }, }; /** @@ -99,6 +103,30 @@ describe( 'DashboardTopEarningPagesWidgetGA4PDF', () => { expect( json ).toContain( 'About Title' ); } ); + it( 'renders each page title as its Analytics report link', () => { + const json = renderJSON( { data: DATA } ); + + expect( json ).toContain( DATA.links[ '/home-page' ] ); + expect( json ).toContain( DATA.links[ '/about-page' ] ); + // The `Link` primitive from `@react-pdf` renders as `pdf-link` under + // the test mock. So a `pdf-link` in the tree means the title rendered + // as a link, not as text that happens to hold the URL. + expect( json ).toContain( 'pdf-link' ); + } ); + + it( 'renders each page title as plain text when a row has no link', () => { + // An empty `links` map gives every row an empty link, so each title + // renders as plain text. + const json = renderJSON( { data: { ...DATA, links: {} } } ); + + expect( json ).toContain( 'Home Title' ); + expect( json ).toContain( 'About Title' ); + // No `pdf-link` in the tree means every title rendered as plain text. + expect( json ).not.toContain( 'pdf-link' ); + // Plain text drops the teal link color for the default text color. + expect( json ).not.toContain( '#108080' ); + } ); + it( 'numbers each row by its rank in the page title cell', () => { const json = renderJSON( { data: DATA } ); @@ -106,7 +134,7 @@ describe( 'DashboardTopEarningPagesWidgetGA4PDF', () => { expect( json ).toContain( '2.' ); } ); - it( 'renders the page title in the Site Kit teal color', () => { + it( 'renders the page title links in the Site Kit teal color', () => { const json = renderJSON( { data: DATA } ); expect( json ).toContain( '#108080' ); diff --git a/assets/js/modules/adsense/components/dashboard/DashboardTopEarningPagesWidgetGA4/indexPDF.tsx b/assets/js/modules/adsense/components/dashboard/DashboardTopEarningPagesWidgetGA4/indexPDF.tsx index fd813baa6e4..2eb2a6bf425 100644 --- a/assets/js/modules/adsense/components/dashboard/DashboardTopEarningPagesWidgetGA4/indexPDF.tsx +++ b/assets/js/modules/adsense/components/dashboard/DashboardTopEarningPagesWidgetGA4/indexPDF.tsx @@ -32,6 +32,7 @@ import { __ } from '@wordpress/i18n'; */ import { createPDFStyles } from '@/js/components/pdf-export/pdf-scale'; import { PDF_COLORS } from '@/js/components/pdf-export/pdf-theme'; +import PDFLink from '@/js/components/pdf-export/shared-react-pdf-components/PDFLink'; import PDFTable, { PDFTableColumn, } from '@/js/components/pdf-export/shared-react-pdf-components/PDFTable'; @@ -55,15 +56,21 @@ const styles = createPDFStyles( { rank: { color: PDF_COLORS.SURFACES_ON_SURFACE, }, - pageTitle: { + // The group fills the row remainder after the rank, so a long page title + // wraps inside the cell. + titleGroup: { flex: 1, - color: PDF_COLORS.CONTENT_SECONDARY, }, } ); interface TopEarningPageRow { + /** Position of the page in the table, starting at 1. */ rank: number; + /** Page title shown in the first cell. */ title: string; + /** Analytics report link for the page, which the title links to. Empty when the page has no link, so the title renders as plain text. */ + serviceURL: string; + /** Earnings amount, as the report's raw string. */ earnings: string; } @@ -73,6 +80,7 @@ const DashboardTopEarningPagesWidgetGA4PDF: FC< PDFWidgetComponentProps > = ( { const topEarningPagesData = data as TopEarningPagesPDFData | null; const rows = topEarningPagesData?.rows ?? []; const titles = topEarningPagesData?.titles ?? {}; + const links = topEarningPagesData?.links ?? {}; const currencyCode = topEarningPagesData?.currencyCode ?? ''; // The report's first dimension is `pagePath`, so the first dimension value is @@ -83,6 +91,7 @@ const DashboardTopEarningPagesWidgetGA4PDF: FC< PDFWidgetComponentProps > = ( { return { rank: index + 1, title: titles[ pagePath ] ?? '', + serviceURL: links[ pagePath ] ?? '', earnings: row.metricValues?.[ 0 ]?.value ?? '', }; } ); @@ -97,15 +106,18 @@ const DashboardTopEarningPagesWidgetGA4PDF: FC< PDFWidgetComponentProps > = ( { { // The page title column has no header, matching the dashboard widget. header: '', - // Shows the page title with the row rank to its left. + // Shows the page title with the row rank to its left. The title + // links to the page's Analytics report, like the dashboard widget. + // When the page has no link, `PDFLink` renders the title as plain + // text instead of as a link. cell: ( row ) => ( { `${ row.rank }.` } - - { row.title } - + + { row.title } + ), }, diff --git a/assets/js/modules/adsense/components/module/ModuleOverviewWidget/getPDFData.test.ts b/assets/js/modules/adsense/components/module/ModuleOverviewWidget/getPDFData.test.ts index 2dcf0481f1a..b0a3224da70 100644 --- a/assets/js/modules/adsense/components/module/ModuleOverviewWidget/getPDFData.test.ts +++ b/assets/js/modules/adsense/components/module/ModuleOverviewWidget/getPDFData.test.ts @@ -314,6 +314,7 @@ describe( 'ModuleOverviewWidget getPDFData', () => { registry, dates: DATES, signal: new AbortController().signal, + viewOnly: false, } ); expect( result.data ).toEqual( { @@ -348,7 +349,12 @@ describe( 'ModuleOverviewWidget getPDFData', () => { provideReportsWithData(); const signal = new AbortController().signal; - await getPDFData( { registry, dates: DATES, signal } ); + await getPDFData( { + registry, + dates: DATES, + signal, + viewOnly: false, + } ); expect( mockEnsureGoogleChartsLoaded ).toHaveBeenCalledTimes( 1 ); expect( mockRenderGoogleChartToDataURI ).toHaveBeenCalledTimes( 4 ); @@ -410,6 +416,7 @@ describe( 'ModuleOverviewWidget getPDFData', () => { registry, dates: DATES, signal: new AbortController().signal, + viewOnly: false, } ); expect( result.data?.metrics.pageCTR ).toBeNull(); @@ -451,6 +458,7 @@ describe( 'ModuleOverviewWidget getPDFData', () => { registry, dates: DATES, signal: new AbortController().signal, + viewOnly: false, } ); // Empty data is not a failure. The report skips the section. @@ -501,6 +509,7 @@ describe( 'ModuleOverviewWidget getPDFData', () => { registry, dates: DATES, signal: new AbortController().signal, + viewOnly: false, } ) ).rejects.toThrow( /Earning performance over time reports failed to load/ @@ -527,6 +536,7 @@ describe( 'ModuleOverviewWidget getPDFData', () => { registry, dates: DATES, signal: new AbortController().signal, + viewOnly: false, } ); expect( result.data?.metrics.estimatedEarnings ).toBeNull(); @@ -552,6 +562,7 @@ describe( 'ModuleOverviewWidget getPDFData', () => { registry, dates: DATES, signal: new AbortController().signal, + viewOnly: false, } ) ).rejects.toThrow( /all Earning performance over time charts failed to render/ @@ -568,6 +579,7 @@ describe( 'ModuleOverviewWidget getPDFData', () => { registry, dates: DATES, signal: controller.signal, + viewOnly: false, } ); expect( result ).toEqual( { data: null } ); @@ -595,6 +607,7 @@ describe( 'ModuleOverviewWidget getPDFData', () => { registry, dates: DATES, signal: controller.signal, + viewOnly: false, } ); // Wait for all four report fetches to dispatch before aborting. diff --git a/assets/js/modules/analytics-4/components/audience-segmentation/dashboard/AudienceTilesWidget/PDFYourVisitorGroupsTile.test.tsx b/assets/js/modules/analytics-4/components/audience-segmentation/dashboard/AudienceTilesWidget/PDFYourVisitorGroupsTile.test.tsx index b2d9921e59c..6b3171ce907 100644 --- a/assets/js/modules/analytics-4/components/audience-segmentation/dashboard/AudienceTilesWidget/PDFYourVisitorGroupsTile.test.tsx +++ b/assets/js/modules/analytics-4/components/audience-segmentation/dashboard/AudienceTilesWidget/PDFYourVisitorGroupsTile.test.tsx @@ -46,8 +46,16 @@ const TOP_CITIES = [ ]; const TOP_CONTENT = [ - { title: 'First post title', pageviews: 847 }, - { title: 'Second post title', pageviews: 596 }, + { + title: 'First post title', + pageviews: 847, + serviceURL: 'https://example.com/analytics-report/first-post', + }, + { + title: 'Second post title', + pageviews: 596, + serviceURL: 'https://example.com/analytics-report/second-post', + }, ]; /** @@ -129,6 +137,33 @@ describe( 'PDFYourVisitorGroupsTile', () => { expect( json ).toContain( '596' ); } ); + it( 'links each top content title to its Analytics report', () => { + const json = renderTile(); + + expect( json ).toContain( TOP_CONTENT[ 0 ].serviceURL ); + expect( json ).toContain( TOP_CONTENT[ 1 ].serviceURL ); + // The `Link` primitive from `@react-pdf` renders as `pdf-link` under + // the test mock. So a `pdf-link` in the tree means the title rendered + // as a link, not as text that happens to hold the URL. + expect( json ).toContain( 'pdf-link' ); + } ); + + it( 'renders each top content title as plain text when a row has no link', () => { + // An empty `serviceURL` gives every row an empty link, so each title + // renders as plain text. + const json = renderTile( { + topContent: TOP_CONTENT.map( ( content ) => ( { + ...content, + serviceURL: '', + } ) ), + } ); + + expect( json ).toContain( 'First post title' ); + expect( json ).toContain( 'Second post title' ); + // No `pdf-link` in the tree means every title rendered as plain text. + expect( json ).not.toContain( 'pdf-link' ); + } ); + it( "hides the delta chip when the change can't be calculated", () => { // A zero previous value leaves no calculable change for any metric. const json = renderTile( { diff --git a/assets/js/modules/analytics-4/components/audience-segmentation/dashboard/AudienceTilesWidget/PDFYourVisitorGroupsTile.tsx b/assets/js/modules/analytics-4/components/audience-segmentation/dashboard/AudienceTilesWidget/PDFYourVisitorGroupsTile.tsx index 39085d61f86..7a4eb789a42 100644 --- a/assets/js/modules/analytics-4/components/audience-segmentation/dashboard/AudienceTilesWidget/PDFYourVisitorGroupsTile.tsx +++ b/assets/js/modules/analytics-4/components/audience-segmentation/dashboard/AudienceTilesWidget/PDFYourVisitorGroupsTile.tsx @@ -43,6 +43,7 @@ import { import { createPDFStyles } from '@/js/components/pdf-export/pdf-scale'; import { PDF_COLORS } from '@/js/components/pdf-export/pdf-theme'; import PDFCard from '@/js/components/pdf-export/shared-react-pdf-components/PDFCard'; +import PDFLink from '@/js/components/pdf-export/shared-react-pdf-components/PDFLink'; import PDFTypography from '@/js/components/pdf-export/shared-react-pdf-components/PDFTypography'; import { numFmt } from '@/js/util'; import type { @@ -108,14 +109,16 @@ const styles = createPDFStyles( { contentRow: { flexDirection: 'row', justifyContent: 'space-between', - marginVertical: 6, + marginBottom: 6, }, - contentLink: { + // The title fills the row remainder before the pageviews count. + contentTitle: { flex: 1, - color: PDF_COLORS.CONTENT_SECONDARY, marginRight: 30, - // A long title truncates to one line with an ellipsis. `@react-pdf` - // wraps text by default, so `maxLines` caps it at one line. + }, + // A long title truncates to one line with an ellipsis. `@react-pdf` + // wraps text by default, so `maxLines` caps it at one line. + contentTitleText: { maxLines: 1, textOverflow: 'ellipsis', }, @@ -239,14 +242,22 @@ const PDFYourVisitorGroupsTile: FC< PDFYourVisitorGroupsTileProps > = ( { > { __( 'Top content by pageviews', 'google-site-kit' ) } + { /* + * The page title links to its Analytics report, like the + * dashboard tile. When the page has no link, `PDFLink` + * renders the title as plain text instead of as a link. + */ } { topContent.map( ( content ) => ( - - { content.title } - + + + { content.title } + + { numFmt( content.pageviews ) } diff --git a/assets/js/modules/analytics-4/components/audience-segmentation/dashboard/AudienceTilesWidget/buildPDFAudienceCard.test.ts b/assets/js/modules/analytics-4/components/audience-segmentation/dashboard/AudienceTilesWidget/buildPDFAudienceCard.test.ts index 3b24df69286..b8fde1f22f6 100644 --- a/assets/js/modules/analytics-4/components/audience-segmentation/dashboard/AudienceTilesWidget/buildPDFAudienceCard.test.ts +++ b/assets/js/modules/analytics-4/components/audience-segmentation/dashboard/AudienceTilesWidget/buildPDFAudienceCard.test.ts @@ -137,7 +137,19 @@ describe( 'buildTopCities', () => { } ); describe( 'buildTopContent', () => { - it( 'resolves each page path to its title and falls back to the path', () => { + /** + * Maps a page path to a stable link fixture. + * + * @since n.e.x.t + * + * @param {string} pagePath Page path of a top content row. + * @return {string} The link fixture for the page. + */ + function getContentServiceURL( pagePath: string ): string { + return `https://example.com/analytics-report${ pagePath }`; + } + + it( 'resolves each page path to its title and its link, and falls back to the path', () => { const content = buildTopContent( buildReport( [ { @@ -151,12 +163,21 @@ describe( 'buildTopContent', () => { ] ), buildReport( [ { dimensionValues: [ { value: '/a' }, { value: 'Title A' } ] }, - ] ) + ] ), + getContentServiceURL ); expect( content ).toEqual( [ - { title: 'Title A', pageviews: 847 }, - { title: '/b', pageviews: 5 }, + { + title: 'Title A', + pageviews: 847, + serviceURL: 'https://example.com/analytics-report/a', + }, + { + title: '/b', + pageviews: 5, + serviceURL: 'https://example.com/analytics-report/b', + }, ] ); } ); @@ -167,7 +188,7 @@ describe( 'buildTopContent', () => { } ) ); expect( - buildTopContent( buildReport( rows ), undefined ) + buildTopContent( buildReport( rows ), undefined, () => '' ) ).toHaveLength( 3 ); } ); } ); @@ -214,12 +235,18 @@ describe( 'buildPDFAudienceCard', () => { }, topContentPageTitlesResult: { response: buildReport( [] ) }, totalPageviews: 1000, + getContentServiceURL: () => '', ...overrides, }; } - it( 'builds a card with the audience name, metrics, and pageviews share', () => { - const card = buildPDFAudienceCard( buildCardInput() ); + it( 'builds a card with the audience name, metrics, pageviews share, and top content links', () => { + const card = buildPDFAudienceCard( + buildCardInput( { + getContentServiceURL: ( pagePath: string ) => + `https://example.com/analytics-report${ pagePath }`, + } ) + ); expect( card?.audienceName ).toBe( 'Custom A' ); expect( card?.metrics.visitors.current ).toBe( 10 ); @@ -230,6 +257,15 @@ describe( 'buildPDFAudienceCard', () => { expect( card?.topCities ).toEqual( [ { name: 'Dublin', percentage: 5 }, ] ); + // The card hands each top content page path to the link resolver, so + // the row holds the link its title renders as. + expect( card?.topContent ).toEqual( [ + { + title: '/a', + pageviews: 80, + serviceURL: 'https://example.com/analytics-report/a', + }, + ] ); } ); it( 'drops the audience when the metrics report failed', () => { diff --git a/assets/js/modules/analytics-4/components/audience-segmentation/dashboard/AudienceTilesWidget/buildPDFAudienceCard.ts b/assets/js/modules/analytics-4/components/audience-segmentation/dashboard/AudienceTilesWidget/buildPDFAudienceCard.ts index 0a8d9e80d4f..598dd5418be 100644 --- a/assets/js/modules/analytics-4/components/audience-segmentation/dashboard/AudienceTilesWidget/buildPDFAudienceCard.ts +++ b/assets/js/modules/analytics-4/components/audience-segmentation/dashboard/AudienceTilesWidget/buildPDFAudienceCard.ts @@ -58,6 +58,8 @@ export interface AudienceTileTopContent { title: string; /** The page's pageviews. */ pageviews: number; + /** Analytics report link for the page, which the title links to, when the page has one. */ + serviceURL?: string; } /** One audience card's fully loaded data. */ @@ -191,17 +193,20 @@ export function buildTopCities( } /** - * Builds an audience's top content, resolving each page path to its page title. + * Builds an audience's top content, resolving each page path to its page title + * and to its Analytics report link. * * @since n.e.x.t * - * @param report The top content report, or `undefined`. - * @param titlesReport The page titles report used to resolve a path to its title, or `undefined`. - * @return Up to three top content pages. + * @param {Object} report The top content report, or `undefined`. + * @param {Object} titlesReport The page titles report that resolves a path to its title, or `undefined`. + * @param {Function} getContentServiceURL Maps a page path to its Analytics report link, or to an empty string when the page has no link. + * @return {Object[]} Up to three top content pages. */ export function buildTopContent( report: Report | undefined, - titlesReport: Report | undefined + titlesReport: Report | undefined, + getContentServiceURL: ( pagePath: string ) => string ): AudienceTileTopContent[] { const titlesByPath = ( titlesReport?.rows || [] ).reduce( ( titles: Record< string, string >, row: ReportRow ) => { @@ -225,6 +230,7 @@ export function buildTopContent( return { title: titlesByPath[ pagePath ] || pagePath, pageviews: Number( row.metricValues?.[ 0 ]?.value || 0 ), + serviceURL: getContentServiceURL( pagePath ), }; } ); } @@ -261,6 +267,8 @@ export interface AudienceCardInput { topContentPageTitlesResult: FetchReportResult; /** The site's total pageviews, the percentage denominator. */ totalPageviews: number; + /** Maps a top content page path to its Analytics report link, or to an empty string when the page has no link. */ + getContentServiceURL: ( pagePath: string ) => string; } /** @@ -284,6 +292,7 @@ export function buildPDFAudienceCard( topContentResult, topContentPageTitlesResult, totalPageviews, + getContentServiceURL, } = input; // Don't render when a metrics or cities report has any error, or when @@ -324,7 +333,8 @@ export function buildPDFAudienceCard( ), topContent: buildTopContent( topContentResult.response, - topContentPageTitlesResult.response + topContentPageTitlesResult.response, + getContentServiceURL ), }; } diff --git a/assets/js/modules/analytics-4/components/audience-segmentation/dashboard/AudienceTilesWidget/getPDFData.test.ts b/assets/js/modules/analytics-4/components/audience-segmentation/dashboard/AudienceTilesWidget/getPDFData.test.ts index feaafb37411..43557fedf9d 100644 --- a/assets/js/modules/analytics-4/components/audience-segmentation/dashboard/AudienceTilesWidget/getPDFData.test.ts +++ b/assets/js/modules/analytics-4/components/audience-segmentation/dashboard/AudienceTilesWidget/getPDFData.test.ts @@ -196,6 +196,20 @@ function buildRegistry( { } return isSiteKitPartialData ? found : null; }, + // Serializes the report type, the page filter, and the date range into + // the link. A test then proves what the loader asked the selector by + // reading the link a top content row holds. + getServiceReportURL: ( + type: string, + { + filters, + dates, + }: { + filters: { unifiedPagePathScreen: string }; + dates: { startDate: string; endDate: string }; + } + ) => + `https://example.com/analytics-report/${ type }?path=${ filters.unifiedPagePathScreen }&range=${ dates.startDate }:${ dates.endDate }`, }; // The loader reads only `resolveSelect`, `select`, and `dispatch`. The mock @@ -222,20 +236,29 @@ function buildRegistry( { * * @since n.e.x.t * - * @param registry The mock registry. - * @param options Run options. - * @param options.aborted Whether the signal is aborted before the run. - * @return The loader result. + * @param {PDFRegistry} registry The mock registry. + * @param {Object} options Run options. + * @param {boolean} options.aborted Whether the signal aborts before the run. + * @param {boolean} options.viewOnly Whether the export runs on a view-only dashboard. + * @return {Promise} The loader result. */ function runPDFData( registry: PDFRegistry, - { aborted = false }: { aborted?: boolean } = {} + { + aborted = false, + viewOnly = false, + }: { aborted?: boolean; viewOnly?: boolean } = {} ) { const controller = new AbortController(); if ( aborted ) { controller.abort(); } - return getPDFData( { registry, dates: DATES, signal: controller.signal } ); + return getPDFData( { + registry, + dates: DATES, + signal: controller.signal, + viewOnly, + } ); } /** @@ -330,6 +353,7 @@ describe( 'AudienceTilesWidget getPDFData', () => { registry, dates: DATES, signal: controller.signal, + viewOnly: false, } ); expect( fetchGetReport ).toHaveBeenCalled(); @@ -398,10 +422,48 @@ describe( 'AudienceTilesWidget getPDFData', () => { { name: 'Dublin', percentage: 40 / card.metrics.visitors.current }, ] ); expect( card.topContent ).toEqual( [ - { title: 'Post One', pageviews: 80 }, + { + title: 'Post One', + pageviews: 80, + serviceURL: `https://example.com/analytics-report/all-pages-and-screens?path=/post-1&range=${ DATES.startDate }:${ DATES.endDate }`, + }, ] ); } ); + it( 'links each top content page to the same All pages and screens report the dashboard tile links to', async () => { + const { registry } = buildRegistry(); + + const audiences = getAudiences( await runPDFData( registry ) ); + + // The stub selector serializes its type, page filter, and date range + // into the link. Ensure the loader uses the same selector the + // dashboard tile uses, with the page path and the report + // date range. + audiences.forEach( ( audience ) => { + expect( audience.topContent[ 0 ].serviceURL ).toBe( + `https://example.com/analytics-report/all-pages-and-screens?path=/post-1&range=${ DATES.startDate }:${ DATES.endDate }` + ); + } ); + } ); + + it( 'builds no top content links on a view-only dashboard', async () => { + const { registry } = buildRegistry(); + + const audiences = getAudiences( + await runPDFData( registry, { viewOnly: true } ) + ); + + // The dashboard tile shows a view-only user each page title as plain + // text, so every card's top content rows hold no link. + const contentRows = audiences.flatMap( + ( audience ) => audience.topContent + ); + expect( contentRows ).not.toHaveLength( 0 ); + contentRows.forEach( ( content ) => { + expect( content.serviceURL ).toBe( '' ); + } ); + } ); + it( 'excludes a failed audience while the other two still load', async () => { const { registry } = buildRegistry( { failing: new Set( [ diff --git a/assets/js/modules/analytics-4/components/audience-segmentation/dashboard/AudienceTilesWidget/getPDFData.ts b/assets/js/modules/analytics-4/components/audience-segmentation/dashboard/AudienceTilesWidget/getPDFData.ts index 8a25e203a6d..773df876291 100644 --- a/assets/js/modules/analytics-4/components/audience-segmentation/dashboard/AudienceTilesWidget/getPDFData.ts +++ b/assets/js/modules/analytics-4/components/audience-segmentation/dashboard/AudienceTilesWidget/getPDFData.ts @@ -34,6 +34,7 @@ import { getAudienceTilesTopContentReportOptions, getAudienceTilesTotalPageviewsReportOptions, } from '@/js/modules/analytics-4/utils/audienceTilesReportOptions'; +import { getAllPagesReportURL } from '@/js/modules/analytics-4/utils/page-report-url'; import { AudienceTilePDFData, AvailableAudience, @@ -230,19 +231,23 @@ async function fetchAudienceReports( * fetches their reports in parallel, and builds a card from each. It drops any * audience whose reports failed, and returns `{ data: null }` when fewer than * two cards remain, so a single-card row never appears. It captures no charts. + * For a view-only user, the loader builds no top content links, because the + * dashboard tile shows each page title as plain text. * * @since n.e.x.t * - * @param params Loader parameters. - * @param params.registry WordPress data registry. - * @param params.dates Report date range, with the current day excluded. - * @param params.signal Cancellation signal. - * @return The loaded audience cards, or `{ data: null }` when the section is omitted or canceled. + * @param {Object} params Loader parameters. + * @param {Object} params.registry WordPress data registry. + * @param {Object} params.dates Report date range, with the current day excluded. + * @param {AbortSignal} params.signal Cancellation signal. + * @param {boolean} params.viewOnly Whether the export runs on a view-only dashboard. + * @return {Promise} The loaded audience cards, or `{ data: null }` when the loader omits the section or the user cancels the export. */ export default async function getPDFData( { registry, dates, signal, + viewOnly, }: GetPDFDataParams ): Promise< AudienceTilesPDFData > { if ( signal.aborted ) { return { data: null }; @@ -355,6 +360,35 @@ export default async function getPDFData( { ?.value ) || 0; + /** + * Maps a top content page path to its Analytics report link. + * + * Each page title links to the same All pages and screens report the + * dashboard tile links to. For a view-only user, this function resolves no + * link, so each page title renders as plain text, matching how the + * dashboard tile shows it. + * + * @since n.e.x.t + * + * @param {string} pagePath Page path of a top content row. + * @return {string} The page's Analytics report link, or an empty string for a view-only user. + */ + function getContentServiceURL( pagePath: string ): string { + if ( viewOnly ) { + return ''; + } + + const { startDate, endDate } = dates; + + return ( + getAllPagesReportURL( + registry.select( MODULES_ANALYTICS_4 ), + pagePath, + { startDate, endDate } + ) ?? '' + ); + } + const audiences = audienceResourceNames .map( ( audienceResourceName: string, index: number ) => { const audience = findAvailableAudience( audienceResourceName ); @@ -372,6 +406,7 @@ export default async function getPDFData( { topContentResult: cardResults[ offset + 1 ], topContentPageTitlesResult: cardResults[ offset + 2 ], totalPageviews, + getContentServiceURL, } ); } ) .filter( ( audience ): audience is AudienceTilePDFData => !! audience ); diff --git a/assets/js/modules/analytics-4/components/module/ModulePopularPagesWidgetGA4/ModulePopularPagesWidgetGA4PDF.test.tsx b/assets/js/modules/analytics-4/components/module/ModulePopularPagesWidgetGA4/ModulePopularPagesWidgetGA4PDF.test.tsx index 268a5bf4b6b..7554b49db3e 100644 --- a/assets/js/modules/analytics-4/components/module/ModulePopularPagesWidgetGA4/ModulePopularPagesWidgetGA4PDF.test.tsx +++ b/assets/js/modules/analytics-4/components/module/ModulePopularPagesWidgetGA4/ModulePopularPagesWidgetGA4PDF.test.tsx @@ -55,13 +55,11 @@ const DATA = { titles: { '/home-page': 'Home Title', '/about-page': 'About Title' }, links: { '/home-page': { - detailsURL: - 'http://example.com/wp-admin/admin.php?page=googlesitekit-dashboard&permaLink=http%3A%2F%2Fexample.com%2Fhome-page', + titleURL: 'https://example.com/analytics-report/report-1', permaLink: 'http://example.com/home-page', }, '/about-page': { - detailsURL: - 'http://example.com/wp-admin/admin.php?page=googlesitekit-dashboard&permaLink=http%3A%2F%2Fexample.com%2Fabout-page', + titleURL: 'https://example.com/analytics-report/report-2', permaLink: 'http://example.com/about-page', }, }, @@ -126,16 +124,29 @@ describe( 'ModulePopularPagesWidgetGA4PDF', () => { ); } ); - it( 'links each title to its entity dashboard and each URL to its page', () => { + it( 'links each title to its Analytics report and each URL to its page', () => { const json = renderJSON( { data: DATA } ); - // The title links to the entity dashboard. - expect( json ).toContain( - 'http://example.com/wp-admin/admin.php?page=googlesitekit-dashboard&permaLink=http%3A%2F%2Fexample.com%2Fhome-page' - ); + // The title links to the page's Analytics report. + expect( json ).toContain( DATA.links[ '/home-page' ].titleURL ); // The URL line links to the page itself. expect( json ).toContain( 'http://example.com/home-page' ); expect( json ).toContain( 'http://example.com/about-page' ); + // The `Link` primitive from `@react-pdf` renders as `pdf-link` under + // the test mock. So a `pdf-link` in the tree means the lines rendered + // as links, not as text that happens to hold the URL. + expect( json ).toContain( 'pdf-link' ); + } ); + + it( 'renders the title and the URL as plain text when a row has no links', () => { + // An empty `links` map gives the row empty links, so neither line + // links out. + const json = renderJSON( { data: { ...DATA, links: {} } } ); + + expect( json ).toContain( 'Home Title' ); + expect( json ).toContain( '/home-page' ); + // No `pdf-link` in the tree means both lines rendered as plain text. + expect( json ).not.toContain( 'pdf-link' ); } ); it( 'renders the title and URL links without an underline', () => { diff --git a/assets/js/modules/analytics-4/components/module/ModulePopularPagesWidgetGA4/ModulePopularPagesWidgetGA4PDF.tsx b/assets/js/modules/analytics-4/components/module/ModulePopularPagesWidgetGA4/ModulePopularPagesWidgetGA4PDF.tsx index 4eb88093fd8..134c29cc3bc 100644 --- a/assets/js/modules/analytics-4/components/module/ModulePopularPagesWidgetGA4/ModulePopularPagesWidgetGA4PDF.tsx +++ b/assets/js/modules/analytics-4/components/module/ModulePopularPagesWidgetGA4/ModulePopularPagesWidgetGA4PDF.tsx @@ -62,8 +62,8 @@ interface PopularPageRow { title: string; /** Page path shown under the title, shortened with an ellipsis when longer than `MAX_URL_LENGTH`. */ displayURL: string; - /** URL of the page's entity dashboard, which the title links to. */ - detailsURL: string; + /** The link the title points to: the Analytics report for an administrator, the entity dashboard for a view-only user. */ + titleURL: string; /** The page's own public URL, which the URL line links to. */ permaLink: string; /** Pageviews count, as the report's raw string. */ @@ -87,7 +87,7 @@ const ModulePopularPagesWidgetGA4PDF: FC< PDFWidgetComponentProps > = ( { const tableRows: PopularPageRow[] = rows.map( ( row, index ) => { const url = row.dimensionValues?.[ 0 ]?.value ?? ''; - const { detailsURL = '', permaLink = '' } = links[ url ] ?? {}; + const { titleURL = '', permaLink = '' } = links[ url ] ?? {}; const displayURL = url.length > MAX_URL_LENGTH ? `${ url.slice( 0, MAX_URL_LENGTH ) }…` @@ -97,7 +97,7 @@ const ModulePopularPagesWidgetGA4PDF: FC< PDFWidgetComponentProps > = ( { rank: index + 1, title: titles[ url ] ?? '', displayURL, - detailsURL, + titleURL, permaLink, pageviews: row.metricValues?.[ 0 ]?.value ?? '', sessions: row.metricValues?.[ 1 ]?.value ?? '', @@ -112,15 +112,18 @@ const ModulePopularPagesWidgetGA4PDF: FC< PDFWidgetComponentProps > = ( { // 42.8% of the 1084px row, about 464px. width: '42.8%', // Shows the page title above its URL, with the row rank to the - // left. The title links to the page's Site Kit entity dashboard, - // and the URL links to the page itself. + // left. The title links to the page's Analytics report for an + // administrator and to its entity dashboard for a view-only user, + // like the dashboard widget, and the URL links to the page itself. + // When a row has no link, `PDFLink` renders the line as plain text + // instead of as a link. cell: ( row ) => ( { `${ row.rank }.` } - { row.title } + { row.title } { row.displayURL } diff --git a/assets/js/modules/analytics-4/components/module/ModulePopularPagesWidgetGA4/getPDFData.test.ts b/assets/js/modules/analytics-4/components/module/ModulePopularPagesWidgetGA4/getPDFData.test.ts index f3cadb5d028..f5f46514b85 100644 --- a/assets/js/modules/analytics-4/components/module/ModulePopularPagesWidgetGA4/getPDFData.test.ts +++ b/assets/js/modules/analytics-4/components/module/ModulePopularPagesWidgetGA4/getPDFData.test.ts @@ -23,6 +23,7 @@ import fetchMock from 'fetch-mock-jest'; import { createTestRegistry, provideSiteInfo, + provideUserInfo, waitForDefaultTimeouts, } from 'tests/js/utils'; @@ -34,7 +35,10 @@ import { WPDataRegistry } from '@wordpress/data/build-types/registry'; /** * Internal dependencies */ +import { CORE_SITE } from '@/js/googlesitekit/datastore/site/constants'; import { GetPDFDataParams } from '@/js/googlesitekit/widgets/types'; +import { MODULES_ANALYTICS_4 } from '@/js/modules/analytics-4/datastore/constants'; +import { getFullURL } from '@/js/util'; import getPDFData from './getPDFData'; type Registry = WPDataRegistry & GetPDFDataParams[ 'registry' ]; @@ -93,22 +97,52 @@ const TITLES_REPORT = { }; /** - * Per-page links the loader builds for each page path, from the default test - * admin URL and reference site URL. The title links to the entity dashboard, - * and the permaLink to the page itself. + * Analytics property ID the registry settings hold, for the page links. */ -const LINKS = { - '/': { - detailsURL: - 'http://example.com/wp-admin/admin.php?page=googlesitekit-dashboard&permaLink=http%3A%2F%2Fexample.com%2F', - permaLink: 'http://example.com/', - }, - '/about': { - detailsURL: - 'http://example.com/wp-admin/admin.php?page=googlesitekit-dashboard&permaLink=http%3A%2F%2Fexample.com%2Fabout', - permaLink: 'http://example.com/about', - }, -}; +const PROPERTY_ID = '123456789'; + +/** + * Builds the Analytics report link the loader resolves for a page path. + * + * The link comes from the same selector the loader uses, so a test checks the + * page filter and date range, not the URL format. + * + * @since n.e.x.t + * + * @param {Registry} registry Registry that holds the Analytics property. + * @param {string} pagePath Page path from a report row. + * @return {string} The All pages and screens report link for the page. + */ +function getExpectedPageLink( registry: Registry, pagePath: string ): string { + return registry + .select( MODULES_ANALYTICS_4 ) + .getServiceReportURL( 'all-pages-and-screens', { + filters: { unifiedPagePathScreen: pagePath }, + dates: { startDate: DATES.startDate, endDate: DATES.endDate }, + } ); +} + +/** + * Builds the entity dashboard link the loader resolves for a view-only page path. + * + * The link comes from the same selector the loader uses, so a test checks the + * title falls back to the page's entity dashboard, not the URL format. + * + * @since n.e.x.t + * + * @param {Registry} registry Registry that holds the reference site URL. + * @param {string} pagePath Page path from a report row. + * @return {string} The page's entity dashboard link. + */ +function getExpectedDetailsURL( registry: Registry, pagePath: string ): string { + const siteURL = registry.select( CORE_SITE ).getReferenceSiteURL(); + + return registry + .select( CORE_SITE ) + .getAdminURL( 'googlesitekit-dashboard', { + permaLink: getFullURL( siteURL, pagePath ), + } ); +} /** * Sets up `fetchMock` so each report request returns the matching fixture. Only @@ -139,6 +173,12 @@ describe( 'ModulePopularPagesWidgetGA4 getPDFData', () => { beforeEach( () => { registry = createTestRegistry() as Registry; provideSiteInfo( registry ); + provideUserInfo( registry ); + // Receive the Analytics property so each page link builds without + // fetching the module settings, which these report tests don't mock. + registry + .dispatch( MODULES_ANALYTICS_4 ) + .receiveGetSettings( { propertyID: PROPERTY_ID } ); } ); it( 'returns rows, page titles, and per-page links from the two reports', async () => { @@ -161,16 +201,73 @@ describe( 'ModulePopularPagesWidgetGA4 getPDFData', () => { registry, dates: DATES, signal: new AbortController().signal, + viewOnly: false, } ); // The loader requests the main report first, then the page titles report. expect( requestedReports ).toEqual( [ 'main', 'page-titles' ] ); + // An unset Analytics property would make the expected link and the + // loader's link both empty, so check the link holds a URL first. + const homeLink = getExpectedPageLink( registry, '/' ); + expect( homeLink ).toBeTruthy(); + + expect( result ).toEqual( { + data: { + rows: MAIN_REPORT.rows, + titles: { '/': 'Home', '/about': 'About' }, + links: { + '/': { + titleURL: homeLink, + permaLink: 'http://example.com/', + }, + '/about': { + titleURL: getExpectedPageLink( registry, '/about' ), + permaLink: 'http://example.com/about', + }, + }, + }, + } ); + } ); + + it( 'links each title to its entity dashboard on a view-only dashboard', async () => { + fetchMock.get( reportEndpoint, ( requestURL ) => ( { + body: requestURL.includes( 'pageTitle' ) + ? TITLES_REPORT + : MAIN_REPORT, + status: 200, + } ) ); + + const result = await getPDFData( { + registry, + dates: DATES, + signal: new AbortController().signal, + viewOnly: true, + } ); + + // A view-only user can't reach the Analytics report, so each title falls + // back to the page's entity dashboard, the same link the dashboard widget + // renders. The URL line still links to the page itself. + const homeDetailsURL = getExpectedDetailsURL( registry, '/' ); + expect( homeDetailsURL ).toBeTruthy(); + expect( homeDetailsURL ).not.toBe( + getExpectedPageLink( registry, '/' ) + ); + expect( result ).toEqual( { data: { rows: MAIN_REPORT.rows, titles: { '/': 'Home', '/about': 'About' }, - links: LINKS, + links: { + '/': { + titleURL: homeDetailsURL, + permaLink: 'http://example.com/', + }, + '/about': { + titleURL: getExpectedDetailsURL( registry, '/about' ), + permaLink: 'http://example.com/about', + }, + }, }, } ); } ); @@ -214,6 +311,7 @@ describe( 'ModulePopularPagesWidgetGA4 getPDFData', () => { registry, dates: DATES, signal: new AbortController().signal, + viewOnly: false, } ); expect( result.data?.titles ).toEqual( { @@ -228,7 +326,12 @@ describe( 'ModulePopularPagesWidgetGA4 getPDFData', () => { const { signal } = new AbortController(); - await getPDFData( { registry, dates: DATES, signal } ); + await getPDFData( { + registry, + dates: DATES, + signal, + viewOnly: false, + } ); // The registry starts resolver runs from a timeout. Wait for those // timeouts, so an extra run would add its request to the calls this test @@ -254,6 +357,7 @@ describe( 'ModulePopularPagesWidgetGA4 getPDFData', () => { registry, dates: DATES, signal: new AbortController().signal, + viewOnly: false, } ); await waitForDefaultTimeouts(); @@ -272,6 +376,7 @@ describe( 'ModulePopularPagesWidgetGA4 getPDFData', () => { registry, dates: DATES, signal: controller.signal, + viewOnly: false, } ); expect( result ).toEqual( { data: null } ); @@ -298,6 +403,7 @@ describe( 'ModulePopularPagesWidgetGA4 getPDFData', () => { registry, dates: DATES, signal: controller.signal, + viewOnly: false, } ); // Wait for the main report request to dispatch before aborting. @@ -347,6 +453,7 @@ describe( 'ModulePopularPagesWidgetGA4 getPDFData', () => { registry, dates: DATES, signal: firstController.signal, + viewOnly: false, } ); while ( deferredResolvers.length < 1 ) { @@ -363,13 +470,23 @@ describe( 'ModulePopularPagesWidgetGA4 getPDFData', () => { registry, dates: DATES, signal: new AbortController().signal, + viewOnly: false, } ); expect( secondRun ).toEqual( { data: { rows: MAIN_REPORT.rows, titles: { '/': 'Home', '/about': 'About' }, - links: LINKS, + links: { + '/': { + titleURL: getExpectedPageLink( registry, '/' ), + permaLink: 'http://example.com/', + }, + '/about': { + titleURL: getExpectedPageLink( registry, '/about' ), + permaLink: 'http://example.com/about', + }, + }, }, } ); expect( fetchMock.calls( reportEndpoint ) ).toHaveLength( 3 ); diff --git a/assets/js/modules/analytics-4/components/module/ModulePopularPagesWidgetGA4/getPDFData.ts b/assets/js/modules/analytics-4/components/module/ModulePopularPagesWidgetGA4/getPDFData.ts index 3b87b88c54e..eb4d39fd73f 100644 --- a/assets/js/modules/analytics-4/components/module/ModulePopularPagesWidgetGA4/getPDFData.ts +++ b/assets/js/modules/analytics-4/components/module/ModulePopularPagesWidgetGA4/getPDFData.ts @@ -23,6 +23,7 @@ import { CORE_SITE } from '@/js/googlesitekit/datastore/site/constants'; import { GetPDFDataParams } from '@/js/googlesitekit/widgets/types'; import { MODULES_ANALYTICS_4 } from '@/js/modules/analytics-4/datastore/constants'; import { Report, ReportRow } from '@/js/modules/analytics-4/datastore/types'; +import { getAllPagesReportURL } from '@/js/modules/analytics-4/utils/page-report-url'; import { getPagePaths, getPageTitleMap, @@ -32,12 +33,12 @@ import { getFullURL } from '@/js/util'; import { getPopularPagesReportArgs } from './reportOptions'; /** - * Links for one page row: the entity dashboard URL for the title, and the - * page's own public URL for the URL line. + * Links for one page row: the link the page title points to, and the page's own + * public URL for the URL line. */ export interface PopularPageLinks { - /** URL of the page's entity dashboard, which the page title links to. */ - detailsURL: string; + /** The link the page title points to: the Analytics report for an administrator, the page's entity dashboard for a view-only user. */ + titleURL: string; /** The page's own public URL, which the URL line links to. */ permaLink: string; } @@ -46,45 +47,71 @@ export interface PopularPageLinks { * Data the Top content over time PDF widget renders. */ export interface PopularPagesPDFData { - /** Report rows, the page titles, and the per-page links, or `null` when the export is canceled or the report has no rows. */ + /** Report rows, the page titles, and the per-page links, or `null` when the user cancels the export or the report has no rows. */ data: { /** Rows of the Most popular pages report. */ rows: ReportRow[]; /** Map of page path to page title. */ titles: Record< string, string >; - /** Map of page path to its entity dashboard URL and public URL. */ + /** Map of page path to its title link and public URL. */ links: Record< string, PopularPageLinks >; } | null; } /** - * Maps each page path to its entity dashboard URL and public URL. + * Maps each page path to its title link and public URL. * - * The title links to the page's Site Kit detail view, the same entity dashboard - * link the dashboard builds for a page. The URL line links to the page itself. + * The title links to the same All pages and screens report the dashboard widget + * links to for an administrator, and to the page's entity dashboard for a + * view-only user, the same `serviceURL || detailsURL` fallback the dashboard + * widget's `DetailsPermaLinks` renders. The URL line links to the page itself + * for every user. * * @since 1.182.0 + * @since n.e.x.t Links the title to the Analytics report for an administrator and to the entity dashboard for a view-only user. * - * @param registry WordPress data registry. - * @param pagePaths Page paths from the main report rows. - * @return Map of page path to its entity dashboard URL and public URL. + * @param {Object} params Link map parameters. + * @param {Object} params.registry WordPress data registry. + * @param {Object} params.dates Report date range. + * @param {string[]} params.pagePaths Page paths from the main report rows. + * @param {boolean} params.viewOnly Whether the export runs on a view-only dashboard. + * @return {Object} Map of page path to its title link and public URL. */ -function getPopularPageLinkMap( - registry: GetPDFDataParams[ 'registry' ], - pagePaths: string[] -): Record< string, PopularPageLinks > { +function getPopularPageLinkMap( { + registry, + dates, + pagePaths, + viewOnly, +}: Pick< GetPDFDataParams, 'registry' | 'dates' | 'viewOnly' > & { + pagePaths: string[]; +} ): Record< string, PopularPageLinks > { const coreSite = registry.select( CORE_SITE ); const siteURL = coreSite.getReferenceSiteURL(); + const analytics = registry.select( MODULES_ANALYTICS_4 ); + const { startDate, endDate } = dates; const links: Record< string, PopularPageLinks > = {}; pagePaths.forEach( ( pagePath ) => { const permaLink = getFullURL( siteURL, pagePath ); + + // An administrator's title links to the Analytics report. A view-only + // user has no service link, so the title falls back to the page's entity + // dashboard, the same `serviceURL || detailsURL` fallback the dashboard + // widget renders for that user. + const serviceURL = viewOnly + ? null + : getAllPagesReportURL( analytics, pagePath, { + startDate, + endDate, + } ); + + const detailsURL = coreSite.getAdminURL( 'googlesitekit-dashboard', { + permaLink, + } ); + links[ pagePath ] = { - detailsURL: - coreSite.getAdminURL( 'googlesitekit-dashboard', { - permaLink, - } ) ?? '', + titleURL: serviceURL || detailsURL || '', permaLink, }; } ); @@ -102,17 +129,20 @@ function getPopularPageLinkMap( * * @since 1.182.0 * @since 1.183.0 Returns null data when the report has no rows. + * @since n.e.x.t Links each title to the Analytics report for an administrator and to the entity dashboard for a view-only user. * - * @param params Loader parameters. - * @param params.registry WordPress data registry. - * @param params.dates Report date range. - * @param params.signal Cancellation signal. - * @return The report rows and the page-path-to-title map. + * @param {Object} params Loader parameters. + * @param {Object} params.registry WordPress data registry. + * @param {Object} params.dates Report date range. + * @param {AbortSignal} params.signal Cancellation signal. + * @param {boolean} params.viewOnly Whether the export runs on a view-only dashboard. + * @return {Promise} The report rows and the page-path-to-title map. */ export default async function getPDFData( { registry, dates, signal, + viewOnly, }: GetPDFDataParams ): Promise< PopularPagesPDFData > { if ( signal.aborted ) { return { data: null }; @@ -143,7 +173,12 @@ export default async function getPDFData( { } const pagePaths = getPagePaths( report ); - const links = getPopularPageLinkMap( registry, pagePaths ); + const links = getPopularPageLinkMap( { + registry, + dates, + pagePaths, + viewOnly, + } ); if ( pagePaths.length === 0 ) { return { data: { rows, titles: {}, links } }; diff --git a/assets/js/modules/analytics-4/utils/page-report-url.test.ts b/assets/js/modules/analytics-4/utils/page-report-url.test.ts new file mode 100644 index 00000000000..9f5e44a53e1 --- /dev/null +++ b/assets/js/modules/analytics-4/utils/page-report-url.test.ts @@ -0,0 +1,57 @@ +/** + * Analytics 4 page report URL helper 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. + */ + +/** + * Internal dependencies + */ +import { getAllPagesReportURL } from './page-report-url'; + +describe( 'getAllPagesReportURL', () => { + const DATES = { startDate: '2025-01-01', endDate: '2025-01-28' }; + + it( 'reads the all-pages report URL filtered to the page path', () => { + const getServiceReportURL = jest.fn( + () => 'https://analytics.example.com/report' + ); + + const url = getAllPagesReportURL( + { getServiceReportURL }, + '/pricing', + DATES + ); + + expect( url ).toBe( 'https://analytics.example.com/report' ); + expect( getServiceReportURL ).toHaveBeenCalledWith( + 'all-pages-and-screens', + { + filters: { unifiedPagePathScreen: '/pricing' }, + dates: DATES, + } + ); + } ); + + it( 'returns undefined when the property has not loaded', () => { + const url = getAllPagesReportURL( + { getServiceReportURL: () => undefined }, + '/pricing', + DATES + ); + + expect( url ).toBeUndefined(); + } ); +} ); diff --git a/assets/js/modules/analytics-4/utils/page-report-url.ts b/assets/js/modules/analytics-4/utils/page-report-url.ts new file mode 100644 index 00000000000..104e07cb283 --- /dev/null +++ b/assets/js/modules/analytics-4/utils/page-report-url.ts @@ -0,0 +1,57 @@ +/** + * Analytics 4 page report URL helper. + * + * Shared by the PDF export loaders that link a page title to its All pages and + * screens report. + * + * 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. + */ + +/** + * The Analytics 4 selectors this helper reads. + */ +interface ReportURLSelect { + /** Returns the URL of an Analytics report for a report type and arguments. */ + getServiceReportURL: ( + reportType: string, + args: { + filters: Record< string, string >; + dates: { startDate: string; endDate: string }; + } + ) => string | undefined; +} + +/** + * Builds the All pages and screens report URL a page title links to. + * + * @since n.e.x.t + * + * @param {ReportURLSelect} analytics The Analytics 4 store's selectors. + * @param {string} pagePath The page path to filter the report to. + * @param {Object} dates The report's date range. + * @param {string} dates.startDate The first day of the range (YYYY-MM-DD). + * @param {string} dates.endDate The last day of the range (YYYY-MM-DD). + * @return {string|undefined} The report URL, or `undefined` before the Analytics property loads. + */ +export function getAllPagesReportURL( + analytics: ReportURLSelect, + pagePath: string, + dates: { startDate: string; endDate: string } +): string | undefined { + return analytics.getServiceReportURL( 'all-pages-and-screens', { + filters: { unifiedPagePathScreen: pagePath }, + dates, + } ); +} diff --git a/assets/js/modules/search-console/components/dashboard/DashboardPopularKeywordsWidget/DashboardPopularKeywordsWidgetPDF.test.tsx b/assets/js/modules/search-console/components/dashboard/DashboardPopularKeywordsWidget/DashboardPopularKeywordsWidgetPDF.test.tsx index 6b6d8e4a182..d0b695643bb 100644 --- a/assets/js/modules/search-console/components/dashboard/DashboardPopularKeywordsWidget/DashboardPopularKeywordsWidgetPDF.test.tsx +++ b/assets/js/modules/search-console/components/dashboard/DashboardPopularKeywordsWidget/DashboardPopularKeywordsWidgetPDF.test.tsx @@ -37,10 +37,8 @@ const DATA = { { keys: [ 'dog toys' ], clicks: 300, impressions: 900 }, ], links: { - 'cat food': - 'https://search.google.com/search-console/performance/search-analytics?cat-food', - 'dog toys': - 'https://search.google.com/search-console/performance/search-analytics?dog-toys', + 'cat food': 'https://example.com/search-console-report/cat-food', + 'dog toys': 'https://example.com/search-console-report/dog-toys', }, }; @@ -108,6 +106,21 @@ describe( 'DashboardPopularKeywordsWidgetPDF', () => { expect( json ).toContain( DATA.links[ 'cat food' ] ); expect( json ).toContain( DATA.links[ 'dog toys' ] ); + // The `Link` primitive from `@react-pdf` renders as `pdf-link` under + // the test mock. So a `pdf-link` in the tree means the query rendered + // as a link, not as text that happens to hold the URL. + expect( json ).toContain( 'pdf-link' ); + } ); + + it( 'renders each query as plain text when a query has no link', () => { + // An empty `links` map gives every query an empty link, so each one + // renders as plain text. + const json = renderJSON( { data: { ...DATA, links: {} } } ); + + expect( json ).toContain( 'cat food' ); + expect( json ).toContain( 'dog toys' ); + // No `pdf-link` in the tree means every query rendered as plain text. + expect( json ).not.toContain( 'pdf-link' ); } ); it( 'renders each query link in the link color', () => { diff --git a/assets/js/modules/search-console/components/dashboard/DashboardPopularKeywordsWidget/DashboardPopularKeywordsWidgetPDF.tsx b/assets/js/modules/search-console/components/dashboard/DashboardPopularKeywordsWidget/DashboardPopularKeywordsWidgetPDF.tsx index c21c00178b3..b46a9e320aa 100644 --- a/assets/js/modules/search-console/components/dashboard/DashboardPopularKeywordsWidget/DashboardPopularKeywordsWidgetPDF.tsx +++ b/assets/js/modules/search-console/components/dashboard/DashboardPopularKeywordsWidget/DashboardPopularKeywordsWidgetPDF.tsx @@ -54,7 +54,7 @@ interface SearchQueryRow { rank: number; /** The search query text. */ query: string; - /** Search Console report link for the query. */ + /** Search Console report link for the query. Empty when the query has no link, so the query renders as plain text. */ queryURL: string; /** Number of clicks for the query. */ clicks: number; @@ -89,7 +89,9 @@ const DashboardPopularKeywordsWidgetPDF: FC< PDFWidgetComponentProps > = ( { // 66.7% of the 1084px row (723px). width: '66.7%', // Show the rank number before the query. The query links to its - // Search Console report, like the dashboard widget. + // Search Console report, like the dashboard widget. When the query + // has no link, `PDFLink` renders it as plain text instead of as a + // link. cell: ( row ) => ( diff --git a/assets/js/modules/search-console/components/dashboard/DashboardPopularKeywordsWidget/getPDFData.test.ts b/assets/js/modules/search-console/components/dashboard/DashboardPopularKeywordsWidget/getPDFData.test.ts index 534b83db085..ce947f48b69 100644 --- a/assets/js/modules/search-console/components/dashboard/DashboardPopularKeywordsWidget/getPDFData.test.ts +++ b/assets/js/modules/search-console/components/dashboard/DashboardPopularKeywordsWidget/getPDFData.test.ts @@ -107,6 +107,7 @@ describe( 'DashboardPopularKeywordsWidget getPDFData', () => { registry, dates: DATES, signal: new AbortController().signal, + viewOnly: false, } ); expect( fetchMock ).toHaveFetchedTimes( 1, reportEndpoint ); @@ -120,6 +121,7 @@ describe( 'DashboardPopularKeywordsWidget getPDFData', () => { registry, dates: DATES, signal: new AbortController().signal, + viewOnly: false, } ); // Build the expected link from the same selector the loader uses, so the @@ -139,6 +141,22 @@ describe( 'DashboardPopularKeywordsWidget getPDFData', () => { } ); } ); + it( 'does not generate query links on a view-only dashboard', async () => { + fetchMock.getOnce( reportEndpoint, { body: REPORT, status: 200 } ); + + const result = await getPDFData( { + registry, + dates: DATES, + signal: new AbortController().signal, + viewOnly: true, + } ); + + // The dashboard widget shows a view-only user each query as plain text, + // so the PDF gets the rows with no link to render. + expect( result.data?.rows ).toEqual( REPORT ); + expect( result.data?.links ).toEqual( {} ); + } ); + it( 'requests the report with the query dimension, a limit of 10 rows, and the date range', async () => { fetchMock.getOnce( reportEndpoint, { body: REPORT, status: 200 } ); @@ -146,6 +164,7 @@ describe( 'DashboardPopularKeywordsWidget getPDFData', () => { registry, dates: DATES, signal: new AbortController().signal, + viewOnly: false, } ); const [ [ requestURL ] ] = fetchMock.calls( reportEndpoint ); @@ -162,7 +181,12 @@ describe( 'DashboardPopularKeywordsWidget getPDFData', () => { const { signal } = new AbortController(); - await getPDFData( { registry, dates: DATES, signal } ); + await getPDFData( { + registry, + dates: DATES, + signal, + viewOnly: false, + } ); // The registry starts resolver runs from a timeout. Wait for those // timeouts to finish, so any extra request shows up in the call count @@ -185,6 +209,7 @@ describe( 'DashboardPopularKeywordsWidget getPDFData', () => { registry, dates: DATES, signal: controller.signal, + viewOnly: false, } ); expect( result ).toEqual( { data: null } ); @@ -198,6 +223,7 @@ describe( 'DashboardPopularKeywordsWidget getPDFData', () => { registry, dates: DATES, signal: new AbortController().signal, + viewOnly: false, } ); // Null data tells the report document to skip the widget, so the PDF @@ -225,6 +251,7 @@ describe( 'DashboardPopularKeywordsWidget getPDFData', () => { registry, dates: DATES, signal: controller.signal, + viewOnly: false, } ); // Wait for the report request to dispatch before aborting. @@ -267,6 +294,7 @@ describe( 'DashboardPopularKeywordsWidget getPDFData', () => { registry, dates: DATES, signal: firstController.signal, + viewOnly: false, } ); while ( deferredResolvers.length < 1 ) { @@ -283,6 +311,7 @@ describe( 'DashboardPopularKeywordsWidget getPDFData', () => { registry, dates: DATES, signal: new AbortController().signal, + viewOnly: false, } ); expect( secondRun.data?.rows ).toEqual( REPORT ); diff --git a/assets/js/modules/search-console/components/dashboard/DashboardPopularKeywordsWidget/getPDFData.ts b/assets/js/modules/search-console/components/dashboard/DashboardPopularKeywordsWidget/getPDFData.ts index 5e0305ceea7..36d3e9259bf 100644 --- a/assets/js/modules/search-console/components/dashboard/DashboardPopularKeywordsWidget/getPDFData.ts +++ b/assets/js/modules/search-console/components/dashboard/DashboardPopularKeywordsWidget/getPDFData.ts @@ -40,11 +40,11 @@ export interface PopularKeywordRow { * Data the Top search queries PDF widget renders. */ export interface PopularKeywordsPDFData { - /** Report rows and the per-query links, or `null` when the export is canceled or the report has no rows. */ + /** Report rows and the per-query links, or `null` when the user cancels the export or the report has no rows. */ data: { /** The Top search queries report rows. */ rows: PopularKeywordRow[]; - /** Map of search query to its Search Console report link. */ + /** Map of search query to its Search Console report link. Empty on a view-only dashboard, where each query shows as plain text. */ links: Record< string, string >; } | null; } @@ -91,21 +91,26 @@ function getQueryLinkMap( * * Loads the Top search queries report and passes the abort signal to the * request. Builds a Search Console report link for each query, the same link the - * dashboard widget shows. Returns `{ data: null }` when the signal aborts or the - * report has no rows, so the report document skips this widget. + * dashboard widget shows. For a view-only user, the loader builds no links, + * because the dashboard widget shows the query as plain text. Returns + * `{ data: null }` when the signal aborts or the report has no rows, so the + * report document skips this widget. * * @since 1.183.0 + * @since n.e.x.t Leaves out the query links on a view-only dashboard. * - * @param params Loader parameters. - * @param params.registry WordPress data registry. - * @param params.dates Report date range. - * @param params.signal Cancellation signal. - * @return The report rows and the per-query links. + * @param {Object} params Loader parameters. + * @param {Object} params.registry WordPress data registry. + * @param {Object} params.dates Report date range. + * @param {AbortSignal} params.signal Cancellation signal. + * @param {boolean} params.viewOnly Whether the export runs on a view-only dashboard. + * @return {Promise} The report rows and the per-query links. */ export default async function getPDFData( { registry, dates, signal, + viewOnly, }: GetPDFDataParams ): Promise< PopularKeywordsPDFData > { if ( signal.aborted ) { return { data: null }; @@ -137,5 +142,10 @@ export default async function getPDFData( { return { data: null }; } - return { data: { rows, links: getQueryLinkMap( registry, dates, rows ) } }; + // A view-only user has no access to the Search Console report, so the + // dashboard widget shows them the query as plain text. Resolve no link, and + // the PDF renders the query as plain text too. + const links = viewOnly ? {} : getQueryLinkMap( registry, dates, rows ); + + return { data: { rows, links } }; }