Skip to content

Commit ee8d73f

Browse files
committed
Merge branch 'develop' into enhancement/12874-km-setup-screen-inline-error.
2 parents af9bbe0 + e413735 commit ee8d73f

42 files changed

Lines changed: 1943 additions & 112 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
/**
2+
* Site Kit by Google, Copyright 2026 Google LLC
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
export const CHART_VERSION = '49';

assets/js/components/GoogleChart/index.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ import GatheringDataNotice, {
5252
import { useSelect, useDispatch } from 'googlesitekit-data';
5353
import GoogleChartErrorHandler from '@/js/components/GoogleChartErrorHandler';
5454
import DateMarker from './DateMarker';
55+
import { CHART_VERSION } from './constants';
5556
import { CORE_UI } from '@/js/googlesitekit/datastore/ui/constants';
5657
import useViewContext from '@/js/hooks/useViewContext';
5758
import { isSiteKitScreen } from '@/js/util/is-site-kit-screen';
@@ -402,7 +403,7 @@ export default function GoogleChart( props ) {
402403
chartEvents={ combinedChartEvents }
403404
chartLanguage={ getLocale() }
404405
chartType={ chartType }
405-
chartVersion="49"
406+
chartVersion={ CHART_VERSION }
406407
data={ filteredData }
407408
loader={ loader }
408409
height={ height }

assets/js/components/pdf-export/PDFExportOrchestrator.test.tsx

Lines changed: 73 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/**
2-
* PDFExportOrchestrator tests.
2+
* PDFExportOrchestrator component tests.
33
*
44
* Site Kit by Google, Copyright 2026 Google LLC
55
*
@@ -26,6 +26,7 @@ import { pdf } from '@react-pdf/renderer';
2626
*/
2727
import { VIEW_CONTEXT_MAIN_DASHBOARD } from '@/js/googlesitekit/constants';
2828
import { CORE_PDF } from '@/js/googlesitekit/datastore/pdf/constants';
29+
import { CORE_SITE } from '@/js/googlesitekit/datastore/site/constants';
2930
import { CORE_USER } from '@/js/googlesitekit/datastore/user/constants';
3031
import { CORE_WIDGETS } from '@/js/googlesitekit/widgets/datastore/constants';
3132
import { CONTEXT_MAIN_DASHBOARD_TRAFFIC } from '@/js/googlesitekit/widgets/default-contexts';
@@ -39,6 +40,13 @@ import {
3940
import { registerPDFFonts } from './pdf-fonts-react';
4041
import PDFExportOrchestrator from './PDFExportOrchestrator';
4142

43+
// `@react-pdf/renderer` is auto-mocked via `__mocks__/@react-pdf/renderer.js`,
44+
// which exports `pdf` as a `jest.fn()` returning a stub `toBlob()`. That lets
45+
// the orchestrator's BUILDING stage resolve instantly so we can capture the
46+
// element handed to `pdf()`, all without loading fontkit (which needs Node APIs
47+
// JSDOM lacks). The mock also renders the report primitives as host elements,
48+
// so `DashboardReport`/`PDFFooter` import cleanly.
49+
4250
// Stub the download trigger so the anchor click does not attempt a JSDOM
4351
// navigation; the filename helper stays real.
4452
jest.mock( './pdf-utils', () => ( {
@@ -55,14 +63,18 @@ function NullComponent() {
5563
}
5664

5765
describe( 'PDFExportOrchestrator', () => {
66+
const ADMIN_URL = 'http://example.com/wp-admin/';
5867
let registry: ReturnType< typeof createTestRegistry >;
5968
const OriginalAbortController = global.AbortController;
6069
const originalCreateObjectURL = global.URL.createObjectURL;
6170
const originalRevokeObjectURL = global.URL.revokeObjectURL;
6271

6372
beforeEach( () => {
6473
registry = createTestRegistry();
65-
provideSiteInfo( registry, { siteName: 'Example Site' } );
74+
provideSiteInfo( registry, {
75+
adminURL: ADMIN_URL,
76+
siteName: 'Example Site',
77+
} );
6678
provideUserInfo( registry );
6779
registry.dispatch( CORE_USER ).setReferenceDate( '2021-01-10' );
6880
registry.dispatch( CORE_USER ).setDateRange( 'last-28-days' );
@@ -113,6 +125,31 @@ describe( 'PDFExportOrchestrator', () => {
113125
} );
114126
}
115127

128+
/**
129+
* Renders the orchestrator and resolves with the React element passed to
130+
* the mocked `pdf()` once the BUILDING stage runs.
131+
*
132+
* @since n.e.x.t
133+
*
134+
* @return The captured `DashboardReport` element.
135+
*/
136+
async function renderAndCaptureReport() {
137+
const getData: jest.Mock = jest.fn( () =>
138+
Promise.resolve( { data: { totalUsers: 100 } } )
139+
);
140+
registerPDFWidget( 'trafficArea', 'trafficWidget', getData );
141+
registry.dispatch( CORE_PDF ).setSelection( {
142+
contextSlugs: [ CONTEXT_MAIN_DASHBOARD_TRAFFIC ],
143+
widgetSlugs: [],
144+
} );
145+
146+
renderOrchestrator();
147+
148+
await waitFor( () => expect( pdf ).toHaveBeenCalled() );
149+
150+
return ( pdf as jest.Mock ).mock.calls[ 0 ][ 0 ];
151+
}
152+
116153
// The orchestrator creates its own `AbortController` on mount and keeps it
117154
// private. To read that controller's signal in a test, replace the global
118155
// constructor with a subclass that records each new instance. The records
@@ -134,7 +171,35 @@ describe( 'PDFExportOrchestrator', () => {
134171
return controllers;
135172
}
136173

137-
it( 'loads the selected widget data with PDF-adjusted dates and builds the PDF', async () => {
174+
it( 'should pass the resolved dashboard, help center, and privacy policy URLs to DashboardReport', async () => {
175+
const reportElement = await renderAndCaptureReport();
176+
177+
expect( reportElement.props.dashboardURL ).toBe(
178+
registry.select( CORE_SITE ).getGoLinkURL( 'dashboard' )
179+
);
180+
expect( reportElement.props.helpCenterURL ).toBe(
181+
'https://sitekit.withgoogle.com/support/?doc=get-support'
182+
);
183+
expect( reportElement.props.privacyPolicyURL ).toBe(
184+
'https://policies.google.com/privacy'
185+
);
186+
} );
187+
188+
it( 'should build each URL via getGoLinkURL with the expected handler key', async () => {
189+
const reportElement = await renderAndCaptureReport();
190+
191+
expect( reportElement.props.dashboardURL ).toBe(
192+
`${ ADMIN_URL }index.php?action=googlesitekit_go&to=dashboard`
193+
);
194+
expect( reportElement.props.helpCenterURL ).toBe(
195+
'https://sitekit.withgoogle.com/support/?doc=get-support'
196+
);
197+
expect( reportElement.props.privacyPolicyURL ).toBe(
198+
'https://policies.google.com/privacy'
199+
);
200+
} );
201+
202+
it( 'should load the selected widget data with PDF-adjusted dates and build the PDF', async () => {
138203
const getData: jest.Mock = jest.fn( () =>
139204
Promise.resolve( { data: { totalUsers: 100 } } )
140205
);
@@ -161,7 +226,7 @@ describe( 'PDFExportOrchestrator', () => {
161226
expect( pdf ).toHaveBeenCalledTimes( 1 );
162227
} );
163228

164-
it( 'transitions to error and does not build a PDF when the only widget fails', async () => {
229+
it( 'should transition to error and not build a PDF when the only widget fails', async () => {
165230
const getData = jest.fn( () =>
166231
Promise.reject( new Error( 'report failed' ) )
167232
);
@@ -180,7 +245,7 @@ describe( 'PDFExportOrchestrator', () => {
180245
expect( pdf ).not.toHaveBeenCalled();
181246
} );
182247

183-
it( 'isolates a failing widget when another widget succeeds', async () => {
248+
it( 'should isolate a failing widget when another widget succeeds', async () => {
184249
const failing = jest.fn( () =>
185250
Promise.reject( new Error( 'report failed' ) )
186251
);
@@ -205,7 +270,7 @@ describe( 'PDFExportOrchestrator', () => {
205270
expect( pdf ).toHaveBeenCalledTimes( 1 );
206271
} );
207272

208-
it( 'passes the email reporting golink URL to the report document', async () => {
273+
it( 'should pass the email reporting golink URL to the report document', async () => {
209274
const getData: jest.Mock = jest.fn( () =>
210275
Promise.resolve( { data: { totalUsers: 100 } } )
211276
);
@@ -227,7 +292,7 @@ describe( 'PDFExportOrchestrator', () => {
227292
);
228293
} );
229294

230-
it( 'registers the PDF fonts before rendering the document', async () => {
295+
it( 'should register the PDF fonts before rendering the document', async () => {
231296
const getData: jest.Mock = jest.fn( () =>
232297
Promise.resolve( { data: { totalUsers: 100 } } )
233298
);
@@ -249,7 +314,7 @@ describe( 'PDFExportOrchestrator', () => {
249314
).toBeLessThan( ( pdf as jest.Mock ).mock.invocationCallOrder[ 0 ] );
250315
} );
251316

252-
it( 'transitions to error and does not build a PDF when font registration fails', async () => {
317+
it( 'should transition to error and not build a PDF when font registration fails', async () => {
253318
jest.mocked( registerPDFFonts ).mockImplementationOnce( () => {
254319
throw new Error( 'font registration failed' );
255320
} );

assets/js/components/pdf-export/PDFExportOrchestrator.tsx

Lines changed: 5 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -205,8 +205,8 @@ const PDFExportOrchestrator: FC< PDFExportOrchestratorProps > = ( {
205205
( select: Select ) => select( CORE_USER ).getDateRange(),
206206
[]
207207
);
208-
const userName = useSelect(
209-
( select: Select ) => select( CORE_USER ).getName(),
208+
const dashboardURL = useSelect(
209+
( select: Select ) => select( CORE_SITE ).getGoLinkURL( 'dashboard' ),
210210
[]
211211
);
212212
// A golink with this key opens the Site Kit dashboard with the email
@@ -453,10 +453,6 @@ const PDFExportOrchestrator: FC< PDFExportOrchestratorProps > = ( {
453453
} ),
454454
} )
455455
);
456-
457-
// Footer timestamp uses the real generation time, not the dashboard date range.
458-
// eslint-disable-next-line sitekit/no-direct-date
459-
const generatedAt = new Date().toLocaleString();
460456
const filename = getPDFFilename(
461457
referenceName,
462458
typeof dateRange === 'string' ? dateRange : undefined
@@ -470,10 +466,9 @@ const PDFExportOrchestrator: FC< PDFExportOrchestratorProps > = ( {
470466
? dateRange
471467
: undefined
472468
}
473-
userName={
474-
typeof userName === 'string' ? userName : undefined
475-
}
476-
generatedAt={ generatedAt }
469+
dashboardURL={ dashboardURL || '' }
470+
helpCenterURL="https://sitekit.withgoogle.com/support/?doc=get-support"
471+
privacyPolicyURL="https://policies.google.com/privacy"
477472
areas={ areas }
478473
emailReportingSetupURL={ emailReportingSetupURL }
479474
/>
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
/**
2+
* Site Kit by Google, Copyright 2026 Google LLC
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
/**
18+
* Internal dependencies
19+
*/
20+
import { CHART_VERSION } from '@/js/components/GoogleChart/constants';
21+
22+
const LOADER_SELECTOR =
23+
'script[src="https://www.gstatic.com/charts/loader.js"]';
24+
25+
type EnsureGoogleChartsLoaded =
26+
typeof import('./ensure-google-charts-loaded').default;
27+
28+
function setGoogle( value: unknown ) {
29+
( global as unknown as { google?: unknown } ).google = value;
30+
}
31+
32+
function getLoaderScript(): Element | null {
33+
return global.document.head.querySelector( LOADER_SELECTOR );
34+
}
35+
36+
describe( 'ensureGoogleChartsLoaded', () => {
37+
let ensureGoogleChartsLoaded: EnsureGoogleChartsLoaded;
38+
39+
beforeEach( async () => {
40+
// The loader memoises an in-flight promise at module scope, so reset the
41+
// module registry between tests to start from a clean cache each time.
42+
jest.resetModules();
43+
ensureGoogleChartsLoaded = (
44+
await import( './ensure-google-charts-loaded' )
45+
).default;
46+
setGoogle( undefined );
47+
} );
48+
49+
afterEach( () => {
50+
getLoaderScript()?.remove();
51+
setGoogle( undefined );
52+
} );
53+
54+
it( 'should resolve immediately when Google Charts is already available', async () => {
55+
setGoogle( { visualization: { DataTable: function DataTable() {} } } );
56+
57+
await expect( ensureGoogleChartsLoaded() ).resolves.toBeUndefined();
58+
59+
expect( getLoaderScript() ).toBeNull();
60+
} );
61+
62+
it( 'should inject the loader script and load the corechart package when not present', async () => {
63+
const load = jest.fn( () => Promise.resolve() );
64+
65+
const promise = ensureGoogleChartsLoaded();
66+
67+
const script = getLoaderScript();
68+
expect( script ).not.toBeNull();
69+
70+
// Simulate the CDN loader becoming available, then fire the load event.
71+
setGoogle( { charts: { load } } );
72+
script?.dispatchEvent( new Event( 'load' ) );
73+
74+
await expect( promise ).resolves.toBeUndefined();
75+
expect( load ).toHaveBeenCalledWith( CHART_VERSION, {
76+
packages: [ 'corechart' ],
77+
} );
78+
} );
79+
80+
it( 'should reuse a single in-flight promise across repeat calls', async () => {
81+
const load = jest.fn( () => Promise.resolve() );
82+
83+
const first = ensureGoogleChartsLoaded();
84+
const second = ensureGoogleChartsLoaded();
85+
86+
// Same promise reference and only one script injected.
87+
expect( first ).toBe( second );
88+
expect(
89+
global.document.head.querySelectorAll( LOADER_SELECTOR )
90+
).toHaveLength( 1 );
91+
92+
setGoogle( { charts: { load } } );
93+
getLoaderScript()?.dispatchEvent( new Event( 'load' ) );
94+
95+
await Promise.all( [ first, second ] );
96+
expect( load ).toHaveBeenCalledTimes( 1 );
97+
} );
98+
99+
it( 'should reject when the loader script fails to load', async () => {
100+
const promise = ensureGoogleChartsLoaded();
101+
102+
const script = getLoaderScript();
103+
expect( script ).not.toBeNull();
104+
105+
script?.dispatchEvent( new Event( 'error' ) );
106+
107+
await expect( promise ).rejects.toThrow(
108+
/failed to load the Google Charts loader script/i
109+
);
110+
} );
111+
112+
it( 'should throw when an incompatible Google Charts version is already present', async () => {
113+
// `google.charts` exists but `load` is not a function — another plugin's
114+
// incompatible build.
115+
setGoogle( { charts: {} } );
116+
117+
await expect( ensureGoogleChartsLoaded() ).rejects.toThrow(
118+
/google\.charts\.load/i
119+
);
120+
121+
expect( getLoaderScript() ).toBeNull();
122+
} );
123+
} );

0 commit comments

Comments
 (0)