Commit 55596a6d1e0 for woocommerce

commit 55596a6d1e015c7c6a1b70d3d1aa03e6a5180087
Author: Peter Petrov <peter.petrov89@gmail.com>
Date:   Sat Oct 10 21:26:46 2026 +0300

    Point merchants to the Analytics Overview metrics menu with a one-time tour (#69097)

    * Point merchants to the Analytics Overview metrics menu with a one-time tour

    * Remember the Analytics Overview metrics tour per user

    * Dismiss the Analytics Overview metrics tour in the e2e site setup

    * Scroll the Analytics Overview metrics tour into view

    * Track views and dismissals of the Analytics Overview metrics tour

    * Reword the Analytics Overview metrics tour description

    * Add an e2e test for the Analytics Overview metrics tour

    * Stop the Analytics Overview metrics tour from scrolling back on every render

    * Skip the Analytics Overview tour e2e test on multisite

diff --git a/packages/js/data/changelog/wooa7s-1890-performance-metrics-tour b/packages/js/data/changelog/wooa7s-1890-performance-metrics-tour
new file mode 100644
index 00000000000..64a37920cba
--- /dev/null
+++ b/packages/js/data/changelog/wooa7s-1890-performance-metrics-tour
@@ -0,0 +1,3 @@
+Significance: patch
+Type: dev
+Comment: Add the Analytics Overview performance tour preference to the UserPreferences type. No runtime change.
diff --git a/packages/js/data/src/user/types.ts b/packages/js/data/src/user/types.ts
index 1cc6e5a2b7c..c1338e3e799 100644
--- a/packages/js/data/src/user/types.ts
+++ b/packages/js/data/src/user/types.ts
@@ -14,6 +14,7 @@ export type UserPreferences = {
 	dashboard_chart_interval?: string;
 	dashboard_chart_type?: string;
 	dashboard_leaderboard_rows?: string;
+	dashboard_performance_tour_shown?: 'yes' | 'no' | '';
 	dashboard_sections?: string;
 	homepage_layout?: string;
 	homepage_stats?: string;
diff --git a/plugins/woocommerce/changelog/wooa7s-1890-performance-metrics-tour b/plugins/woocommerce/changelog/wooa7s-1890-performance-metrics-tour
new file mode 100644
index 00000000000..0e0b0a799fc
--- /dev/null
+++ b/plugins/woocommerce/changelog/wooa7s-1890-performance-metrics-tour
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Show a one-time hint that points merchants to the metrics menu on the Analytics Overview page.
diff --git a/plugins/woocommerce/client/admin/client/dashboard/store-performance/index.js b/plugins/woocommerce/client/admin/client/dashboard/store-performance/index.js
index 4a51134c7c3..e69a1eabb80 100644
--- a/plugins/woocommerce/client/admin/client/dashboard/store-performance/index.js
+++ b/plugins/woocommerce/client/admin/client/dashboard/store-performance/index.js
@@ -25,6 +25,7 @@ import { CurrencyContext } from '@woocommerce/currency';
 import './style.scss';
 import { getIndicatorData, getIndicatorValues } from './utils';
 import { getAdminSetting } from '~/utils/admin-settings';
+import { PerformanceMetricsTour } from '~/guided-tours/performance-metrics-tour';

 const { performanceIndicators: indicators } = getAdminSetting(
 	'dataEndpoints',
@@ -34,6 +35,16 @@ const { performanceIndicators: indicators } = getAdminSetting(
 );

 class StorePerformance extends Component {
+	constructor( props ) {
+		super( props );
+		this.state = { hasOpenedMenu: false };
+		this.onToggleMenu = this.onToggleMenu.bind( this );
+	}
+
+	onToggleMenu() {
+		this.setState( { hasOpenedMenu: true } );
+	}
+
 	renderMenu() {
 		const {
 			hiddenBlocks,
@@ -50,6 +61,8 @@ class StorePerformance extends Component {

 		return (
 			<EllipsisMenu
+				className="woocommerce-dashboard__performance-menu"
+				onToggle={ this.onToggleMenu }
 				label={ __(
 					'Choose which analytics to display and the section name',
 					'woocommerce'
@@ -186,6 +199,9 @@ class StorePerformance extends Component {
 						{ this.renderList() }
 					</div>
 				) }
+				<PerformanceMetricsTour
+					hasOpenedMenu={ this.state.hasOpenedMenu }
+				/>
 			</Fragment>
 		);
 	}
diff --git a/plugins/woocommerce/client/admin/client/dashboard/store-performance/test/index.js b/plugins/woocommerce/client/admin/client/dashboard/store-performance/test/index.js
new file mode 100644
index 00000000000..9c7196d2437
--- /dev/null
+++ b/plugins/woocommerce/client/admin/client/dashboard/store-performance/test/index.js
@@ -0,0 +1,51 @@
+/**
+ * External dependencies
+ */
+import { fireEvent, render, screen } from '@testing-library/react';
+
+/**
+ * Internal dependencies
+ */
+import StorePerformance from '../index';
+
+jest.mock( '@wordpress/data', () => ( {
+	...jest.requireActual( '@wordpress/data' ),
+	withSelect: ( mapSelect ) => ( Component ) => ( props ) => (
+		<Component { ...props } { ...mapSelect( jest.fn(), props ) } />
+	),
+} ) );
+
+jest.mock( '@woocommerce/components', () => ( {
+	...jest.requireActual( '@woocommerce/components' ),
+	SectionHeader: ( { title, menu } ) => (
+		<div>
+			{ title }
+			{ menu }
+		</div>
+	),
+	EllipsisMenu: ( { onToggle } ) => (
+		<button onClick={ onToggle }>Open menu</button>
+	),
+} ) );
+
+jest.mock( '~/utils/admin-settings', () => ( {
+	getAdminSetting: () => ( { performanceIndicators: [] } ),
+} ) );
+
+jest.mock( '~/guided-tours/performance-metrics-tour', () => ( {
+	PerformanceMetricsTour: ( { hasOpenedMenu } ) => (
+		<div>{ hasOpenedMenu ? 'Menu opened' : 'Menu not opened' }</div>
+	),
+} ) );
+
+describe( 'StorePerformance tour', () => {
+	it( 'tells the tour when the menu is opened', () => {
+		render( <StorePerformance hiddenBlocks={ [] } query={ {} } /> );
+
+		expect( screen.getByText( 'Menu not opened' ) ).toBeInTheDocument();
+
+		fireEvent.click( screen.getByRole( 'button', { name: 'Open menu' } ) );
+
+		expect( screen.getByText( 'Menu opened' ) ).toBeInTheDocument();
+	} );
+} );
diff --git a/plugins/woocommerce/client/admin/client/guided-tours/performance-metrics-tour.tsx b/plugins/woocommerce/client/admin/client/guided-tours/performance-metrics-tour.tsx
new file mode 100644
index 00000000000..4edb626edd9
--- /dev/null
+++ b/plugins/woocommerce/client/admin/client/guided-tours/performance-metrics-tour.tsx
@@ -0,0 +1,108 @@
+/**
+ * External dependencies
+ */
+import { TourKit, TourKitTypes } from '@woocommerce/components';
+import { __ } from '@wordpress/i18n';
+import { useUserPreferences } from '@woocommerce/data';
+import { createElement, useEffect, useRef } from '@wordpress/element';
+import { recordEvent } from '@woocommerce/tracks';
+
+// Setting effects replaces the TourKit defaults, so they are repeated here
+// alongside autoScroll. Merchants can move the Performance section below the
+// fold, and the spotlight would then lock a page whose tour sits off-screen.
+// Kept outside the component because TourKit scrolls again whenever the
+// autoScroll object changes.
+const TOUR_OPTIONS: TourKitTypes.WooOptions = {
+	effects: {
+		spotlight: {
+			interactivity: {
+				enabled: true,
+				rootElementSelector: '#wpwrap',
+			},
+		},
+		arrowIndicator: true,
+		autoScroll: {
+			behavior: 'auto',
+			block: 'center',
+		},
+		liveResize: {
+			mutation: true,
+			resize: true,
+			rootElementSelector: '#wpwrap',
+		},
+	},
+};
+
+export const PerformanceMetricsTour = ( {
+	hasOpenedMenu,
+}: {
+	hasOpenedMenu: boolean;
+} ) => {
+	const {
+		updateUserPreferences,
+		isRequesting,
+		dashboard_performance_tour_shown: hasShownTour,
+	} = useUserPreferences();
+
+	const shouldShowTour = ! isRequesting && hasShownTour !== 'yes';
+	const isTourVisible = shouldShowTour && ! hasOpenedMenu;
+	const hasRecordedView = useRef( false );
+
+	const dismissTour = ( source: string ) => {
+		recordEvent( 'dash_indicators_tour_dismiss', { source } );
+		void updateUserPreferences( {
+			dashboard_performance_tour_shown: 'yes',
+		} );
+	};
+
+	useEffect( () => {
+		if ( isTourVisible && ! hasRecordedView.current ) {
+			hasRecordedView.current = true;
+			recordEvent( 'dash_indicators_tour_view' );
+		}
+	}, [ isTourVisible ] );
+
+	// Opening the menu the tour points at counts as seeing it.
+	useEffect( () => {
+		if ( hasOpenedMenu && shouldShowTour ) {
+			dismissTour( 'menu' );
+		}
+		// eslint-disable-next-line react-hooks/exhaustive-deps
+	}, [ hasOpenedMenu, shouldShowTour ] );
+
+	if ( ! isTourVisible ) {
+		return null;
+	}
+
+	const config: TourKitTypes.WooConfig = {
+		steps: [
+			{
+				referenceElements: {
+					desktop:
+						'.woocommerce-dashboard__performance-menu .woocommerce-ellipsis-menu__toggle',
+				},
+				meta: {
+					name: 'performance-metrics',
+					heading: __(
+						'Choose which metrics to display',
+						'woocommerce'
+					),
+					descriptions: {
+						desktop: __(
+							'Some are hidden by default. Add or remove them from this menu.',
+							'woocommerce'
+						),
+					},
+					primaryButton: {
+						text: __( 'Got it', 'woocommerce' ),
+					},
+				},
+			},
+		],
+		closeHandler: ( steps, currentStepIndex, source ) =>
+			dismissTour( source ),
+		options: TOUR_OPTIONS,
+	};
+
+	return <TourKit config={ config } />;
+};
diff --git a/plugins/woocommerce/client/admin/client/guided-tours/test/performance-metrics-tour.tsx b/plugins/woocommerce/client/admin/client/guided-tours/test/performance-metrics-tour.tsx
new file mode 100644
index 00000000000..badfe921242
--- /dev/null
+++ b/plugins/woocommerce/client/admin/client/guided-tours/test/performance-metrics-tour.tsx
@@ -0,0 +1,212 @@
+/**
+ * External dependencies
+ */
+import { render, screen } from '@testing-library/react';
+import { useUserPreferences } from '@woocommerce/data';
+import { recordEvent } from '@woocommerce/tracks';
+
+/**
+ * Internal dependencies
+ */
+import { PerformanceMetricsTour } from '../performance-metrics-tour';
+
+jest.mock( '@woocommerce/data', () => ( {
+	...jest.requireActual( '@woocommerce/data' ),
+	useUserPreferences: jest.fn(),
+} ) );
+
+jest.mock( '@woocommerce/tracks', () => ( {
+	recordEvent: jest.fn(),
+} ) );
+
+type MockTourConfig = {
+	steps: Array< { meta: { heading: string } } >;
+	closeHandler: (
+		steps: unknown[],
+		currentStepIndex: number,
+		source: string
+	) => void;
+	options?: { effects?: { autoScroll?: unknown } };
+};
+
+const mockRenderedConfigs: MockTourConfig[] = [];
+
+jest.mock( '@woocommerce/components', () => ( {
+	TourKit: ( { config }: { config: MockTourConfig } ) => {
+		mockRenderedConfigs.push( config );
+		return (
+			<div>
+				{ config.steps[ 0 ].meta.heading }
+				<button
+					onClick={ () =>
+						config.closeHandler( config.steps, 0, 'done-btn' )
+					}
+				>
+					Got it
+				</button>
+			</div>
+		);
+	},
+} ) );
+
+const updateUserPreferences = jest.fn();
+
+const mockPreferences = ( hasShownTour?: string, isRequesting = false ) => {
+	( useUserPreferences as jest.Mock ).mockReturnValue( {
+		updateUserPreferences,
+		isRequesting,
+		dashboard_performance_tour_shown: hasShownTour,
+	} );
+};
+
+describe( 'PerformanceMetricsTour', () => {
+	beforeEach( () => {
+		updateUserPreferences.mockClear();
+		( recordEvent as jest.Mock ).mockClear();
+		mockRenderedConfigs.length = 0;
+	} );
+
+	it( 'shows the tour when the user has not seen it', () => {
+		mockPreferences();
+
+		render( <PerformanceMetricsTour hasOpenedMenu={ false } /> );
+
+		expect(
+			screen.getByText( 'Choose which metrics to display' )
+		).toBeInTheDocument();
+	} );
+
+	it( 'does not show the tour once the user has seen it', () => {
+		mockPreferences( 'yes' );
+
+		const { container } = render(
+			<PerformanceMetricsTour hasOpenedMenu={ false } />
+		);
+
+		expect( container ).toBeEmptyDOMElement();
+	} );
+
+	it( 'does not show the tour while the user preferences are loading', () => {
+		mockPreferences( undefined, true );
+
+		const { container } = render(
+			<PerformanceMetricsTour hasOpenedMenu={ false } />
+		);
+
+		expect( container ).toBeEmptyDOMElement();
+	} );
+
+	it( 'keeps the same scroll options across re-renders', () => {
+		mockPreferences();
+
+		const { rerender } = render(
+			<PerformanceMetricsTour hasOpenedMenu={ false } />
+		);
+		rerender( <PerformanceMetricsTour hasOpenedMenu={ false } /> );
+
+		// TourKit scrolls to the menu again whenever autoScroll changes.
+		expect( mockRenderedConfigs ).toHaveLength( 2 );
+		expect( mockRenderedConfigs[ 1 ].options?.effects?.autoScroll ).toBe(
+			mockRenderedConfigs[ 0 ].options?.effects?.autoScroll
+		);
+	} );
+
+	it( 'remembers the tour for the user when it is closed', () => {
+		mockPreferences();
+
+		render( <PerformanceMetricsTour hasOpenedMenu={ false } /> );
+		screen.getByRole( 'button', { name: 'Got it' } ).click();
+
+		expect( updateUserPreferences ).toHaveBeenCalledWith( {
+			dashboard_performance_tour_shown: 'yes',
+		} );
+	} );
+
+	it( 'hides and remembers the tour once when the menu is opened', () => {
+		mockPreferences();
+
+		const { container, rerender } = render(
+			<PerformanceMetricsTour hasOpenedMenu={ false } />
+		);
+		rerender( <PerformanceMetricsTour hasOpenedMenu /> );
+		rerender( <PerformanceMetricsTour hasOpenedMenu /> );
+
+		expect( container ).toBeEmptyDOMElement();
+		expect( updateUserPreferences ).toHaveBeenCalledTimes( 1 );
+		expect( updateUserPreferences ).toHaveBeenCalledWith( {
+			dashboard_performance_tour_shown: 'yes',
+		} );
+	} );
+
+	it( 'does not save again when the menu is opened after the tour was seen', () => {
+		mockPreferences( 'yes' );
+
+		render( <PerformanceMetricsTour hasOpenedMenu /> );
+
+		expect( updateUserPreferences ).not.toHaveBeenCalled();
+	} );
+
+	it( 'records a view once while the tour is shown', () => {
+		mockPreferences();
+
+		const { rerender } = render(
+			<PerformanceMetricsTour hasOpenedMenu={ false } />
+		);
+		rerender( <PerformanceMetricsTour hasOpenedMenu={ false } /> );
+
+		expect( recordEvent ).toHaveBeenCalledTimes( 1 );
+		expect( recordEvent ).toHaveBeenCalledWith(
+			'dash_indicators_tour_view'
+		);
+	} );
+
+	it( 'does not record a view when the tour is not shown', () => {
+		mockPreferences( 'yes' );
+		render( <PerformanceMetricsTour hasOpenedMenu={ false } /> );
+
+		mockPreferences( undefined, true );
+		render( <PerformanceMetricsTour hasOpenedMenu={ false } /> );
+
+		expect( recordEvent ).not.toHaveBeenCalled();
+	} );
+
+	it( 'records how the tour was dismissed', () => {
+		mockPreferences();
+
+		render( <PerformanceMetricsTour hasOpenedMenu={ false } /> );
+		screen.getByRole( 'button', { name: 'Got it' } ).click();
+
+		expect( recordEvent ).toHaveBeenCalledWith(
+			'dash_indicators_tour_dismiss',
+			{ source: 'done-btn' }
+		);
+	} );
+
+	it( 'records a dismissal from the menu once', () => {
+		mockPreferences();
+
+		const { rerender } = render(
+			<PerformanceMetricsTour hasOpenedMenu={ false } />
+		);
+		rerender( <PerformanceMetricsTour hasOpenedMenu /> );
+		rerender( <PerformanceMetricsTour hasOpenedMenu /> );
+
+		expect( recordEvent ).toHaveBeenCalledWith(
+			'dash_indicators_tour_dismiss',
+			{ source: 'menu' }
+		);
+		expect(
+			( recordEvent as jest.Mock ).mock.calls.filter(
+				( [ name ] ) => name === 'dash_indicators_tour_dismiss'
+			)
+		).toHaveLength( 1 );
+	} );
+
+	it( 'records nothing when the menu is opened after the tour was seen', () => {
+		mockPreferences( 'yes' );
+
+		render( <PerformanceMetricsTour hasOpenedMenu /> );
+
+		expect( recordEvent ).not.toHaveBeenCalled();
+	} );
+} );
diff --git a/plugins/woocommerce/src/Internal/Admin/Analytics.php b/plugins/woocommerce/src/Internal/Admin/Analytics.php
index 62ce6b10534..cd00b168479 100644
--- a/plugins/woocommerce/src/Internal/Admin/Analytics.php
+++ b/plugins/woocommerce/src/Internal/Admin/Analytics.php
@@ -210,6 +210,7 @@ class Analytics {
 				'dashboard_chart_type',
 				'dashboard_chart_interval',
 				'dashboard_leaderboard_rows',
+				'dashboard_performance_tour_shown',
 				'order_attribution_install_banner_dismissed',
 				'scheduled_updates_promotion_notice_dismissed',
 			)
diff --git a/plugins/woocommerce/tests/e2e/fixtures/site.setup.ts b/plugins/woocommerce/tests/e2e/fixtures/site.setup.ts
index cc334517b6f..5fab9b67d8e 100644
--- a/plugins/woocommerce/tests/e2e/fixtures/site.setup.ts
+++ b/plugins/woocommerce/tests/e2e/fixtures/site.setup.ts
@@ -2,7 +2,7 @@
  * External dependencies
  */
 import { request } from '@playwright/test';
-import { WC_API_PATH } from '@woocommerce/e2e-utils-playwright';
+import { WC_API_PATH, WP_API_PATH } from '@woocommerce/e2e-utils-playwright';

 /**
  * Internal dependencies
@@ -99,6 +99,14 @@ setup( 'setup site', async ( { baseURL, restApi } ) => {
 		await skipOnboardingWizard();
 	} );

+	await setup.step( 'dismiss analytics overview metrics tour', async () => {
+		// The tour blocks clicks outside the menu it points at, which breaks
+		// any test that opens Analytics Overview and uses the rest of the page.
+		await restApi.post( `${ WP_API_PATH }/users/me`, {
+			woocommerce_meta: { dashboard_performance_tour_shown: 'yes' },
+		} );
+	} );
+
 	await setup.step( 'determine if multisite', async () => {
 		const response = await restApi.get( `${ WC_API_PATH }/system_status` );
 		const { environment } = response.data;
diff --git a/plugins/woocommerce/tests/e2e/tests/analytics/analytics-overview-tour.spec.ts b/plugins/woocommerce/tests/e2e/tests/analytics/analytics-overview-tour.spec.ts
new file mode 100644
index 00000000000..f771fec78ed
--- /dev/null
+++ b/plugins/woocommerce/tests/e2e/tests/analytics/analytics-overview-tour.spec.ts
@@ -0,0 +1,128 @@
+/**
+ * External dependencies
+ */
+import { WP_API_PATH } from '@woocommerce/e2e-utils-playwright';
+
+/**
+ * Internal dependencies
+ */
+import { test, expect, tags } from '../../fixtures/fixtures';
+import { getFakeUser } from '../../utils/data';
+import { logIn } from '../../utils/login';
+
+const OVERVIEW_URL =
+	'wp-admin/admin.php?page=wc-admin&path=%2Fanalytics%2Foverview';
+
+// The shared admin has the tour marked as seen in site setup. Clearing that for
+// this test would show the tour to parallel specs on the Overview page, and the
+// tour blocks clicks outside the menu it points at, so this test runs as its own
+// admin instead.
+const testAsNewAdmin = test.extend< {
+	newAdmin: { id: number; username: string; password: string };
+} >( {
+	storageState: { cookies: [], origins: [] },
+	newAdmin: async ( { restApi }, use ) => {
+		const userData = getFakeUser( 'administrator' );
+		const { data: user } = await restApi.post( `${ WP_API_PATH }/users`, {
+			...userData,
+			roles: [ 'administrator' ],
+		} );
+
+		await use( {
+			id: user.id,
+			username: userData.username,
+			password: userData.password,
+		} );
+
+		await restApi.delete( `${ WP_API_PATH }/users/${ user.id }`, {
+			force: true,
+			reassign: 1,
+		} );
+	},
+} );
+
+testAsNewAdmin.skip(
+	!! process.env.IS_MULTISITE,
+	'The REST API cannot delete users on multisite, so the new admin cannot be cleaned up'
+);
+
+testAsNewAdmin(
+	'points new admins at the Performance menu until they open it',
+	{ tag: [ tags.PAYMENTS, tags.SERVICES ] },
+	async ( { page, restApi, newAdmin } ) => {
+		const tourHeading = page.getByRole( 'heading', {
+			name: 'Choose which metrics to display',
+		} );
+		const performanceMenu = page
+			.locator( '.woocommerce-section-header', {
+				has: page.getByRole( 'heading', { name: 'Performance' } ),
+			} )
+			.getByRole( 'button', { name: 'Choose which' } );
+
+		await testAsNewAdmin.step( 'log in as the new admin', async () => {
+			await page.goto( 'wp-login.php' );
+			await logIn( page, newAdmin.username, newAdmin.password );
+		} );
+
+		await testAsNewAdmin.step(
+			'the tour shows next to the Performance menu',
+			async () => {
+				await page.goto( OVERVIEW_URL );
+				await expect( tourHeading ).toBeVisible();
+
+				const tourBox = await page
+					.locator( '.woocommerce-tour-kit-step' )
+					.boundingBox();
+				const menuBox = await performanceMenu.boundingBox();
+
+				// The tour sits beside the menu it points at. Without its target it
+				// renders elsewhere on the page and this fails.
+				expect(
+					Math.abs(
+						tourBox.y +
+							tourBox.height / 2 -
+							( menuBox.y + menuBox.height / 2 )
+					)
+				).toBeLessThan( tourBox.height );
+				expect(
+					menuBox.x - ( tourBox.x + tourBox.width )
+				).toBeLessThan( 60 );
+			}
+		);
+
+		await testAsNewAdmin.step(
+			'opening the menu dismisses the tour',
+			async () => {
+				await performanceMenu.click();
+				await expect( tourHeading ).toBeHidden();
+				await expect(
+					page.getByRole( 'menuitemcheckbox', {
+						name: 'Total sales',
+					} )
+				).toBeVisible();
+			}
+		);
+
+		await testAsNewAdmin.step(
+			'the tour stays dismissed for this admin',
+			async () => {
+				await expect
+					.poll( async () => {
+						const { data } = await restApi.get(
+							`${ WP_API_PATH }/users/${ newAdmin.id }`,
+							{ context: 'edit' }
+						);
+						return data.woocommerce_meta
+							.dashboard_performance_tour_shown;
+					} )
+					.toBe( 'yes' );
+
+				await page.reload();
+				await expect(
+					page.getByRole( 'heading', { name: 'Performance' } )
+				).toBeVisible();
+				await expect( tourHeading ).toBeHidden();
+			}
+		);
+	}
+);