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();
+ }
+ );
+ }
+);