Commit 781ce294c04 for woocommerce
commit 781ce294c04854ab51dbb04236d3114257c02ab4
Author: louwie17 <lourensschep@gmail.com>
Date: Mon Oct 5 10:06:35 2026 +0200
Hide the legacy Reports menu on new stores by default (#69247)
* Hide the legacy Reports menu on new stores by default
* Add changelog entry for hiding the legacy Reports menu
* Update page-loads e2e test for the hidden legacy Reports menu
* Split the legacy Reports e2e test and skip the hidden-menu check on external sites
Target the menu link by href and assert it exists before checking it is
hidden, since role locators skip hidden elements and matched nothing.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GQJnWjuNUUkR7h2J4H5gHz
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
diff --git a/plugins/woocommerce/changelog/wooplug-7853-hide-legacy-reports-menu b/plugins/woocommerce/changelog/wooplug-7853-hide-legacy-reports-menu
new file mode 100644
index 00000000000..7f44297c97c
--- /dev/null
+++ b/plugins/woocommerce/changelog/wooplug-7853-hide-legacy-reports-menu
@@ -0,0 +1,4 @@
+Significance: minor
+Type: update
+
+Hide the legacy Reports menu on new stores; it still shows when Analytics is off or an extension adds legacy reports.
diff --git a/plugins/woocommerce/includes/admin/class-wc-admin-menus.php b/plugins/woocommerce/includes/admin/class-wc-admin-menus.php
index 9dd03b80fe9..1a47d24707e 100644
--- a/plugins/woocommerce/includes/admin/class-wc-admin-menus.php
+++ b/plugins/woocommerce/includes/admin/class-wc-admin-menus.php
@@ -6,6 +6,7 @@
* @version 2.5.0
*/
+use Automattic\WooCommerce\Internal\Admin\LegacyReportsMenu;
use Automattic\WooCommerce\Internal\Admin\Marketplace;
use Automattic\WooCommerce\Internal\Admin\Orders\COTRedirectionController;
use Automattic\WooCommerce\Internal\Admin\Orders\PageController as Custom_Orders_PageController;
@@ -112,6 +113,8 @@ class WC_Admin_Menus {
} else {
add_menu_page( __( 'Sales reports', 'woocommerce' ), __( 'Sales reports', 'woocommerce' ), 'view_woocommerce_reports', 'wc-reports', array( $this, 'reports_page' ), 'dashicons-chart-bar', '55.6' );
}
+
+ add_action( 'admin_head', array( wc_get_container()->get( LegacyReportsMenu::class ), 'handle_admin_head' ), PHP_INT_MAX );
}
/**
diff --git a/plugins/woocommerce/src/Internal/Admin/LegacyReportsMenu.php b/plugins/woocommerce/src/Internal/Admin/LegacyReportsMenu.php
new file mode 100644
index 00000000000..024ef197517
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Admin/LegacyReportsMenu.php
@@ -0,0 +1,183 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Internal\Admin;
+
+use Automattic\WooCommerce\Utilities\FeaturesUtil;
+use WC_Admin_Menus;
+use WC_Install;
+
+/**
+ * Decides whether the legacy WooCommerce > Reports menu item is shown, and hides it when it isn't.
+ *
+ * The page itself stays registered, so admin.php?page=wc-reports keeps working either way.
+ *
+ * @internal
+ *
+ * @since 11.3.0
+ */
+final class LegacyReportsMenu {
+
+ /**
+ * Stores first installed on this version or later hide the menu by default.
+ */
+ private const NEW_STORE_VERSION = '11.3.0-dev';
+
+ /**
+ * The report groups and reports that WC_Admin_Reports::get_reports() returns without extensions.
+ */
+ private const CORE_REPORTS = array(
+ 'orders' => array( 'sales_by_date', 'sales_by_product', 'sales_by_category', 'coupon_usage', 'downloads' ),
+ 'customers' => array( 'customers', 'customer_list' ),
+ 'stock' => array( 'low_in_stock', 'out_of_stock', 'most_stocked' ),
+ 'taxes' => array( 'taxes_by_code', 'taxes_by_date' ),
+ );
+
+ /**
+ * The callback every core legacy report uses.
+ */
+ private const CORE_CALLBACK = array( 'WC_Admin_Reports', 'get_report' );
+
+ /**
+ * Result of should_show(), cached for the request.
+ *
+ * @var bool|null
+ */
+ private ?bool $should_show = null;
+
+ /**
+ * Hide the Reports menu item right before the admin menu is printed.
+ *
+ * Runs this late so that the initial installed version has been recorded and extensions
+ * have had every chance to register their report filters.
+ *
+ * @internal
+ */
+ public function handle_admin_head(): void {
+ if ( ! current_user_can( 'view_woocommerce_reports' ) || $this->should_show() ) {
+ return;
+ }
+
+ global $menu, $submenu;
+
+ if ( ! empty( $submenu['woocommerce'] ) && is_array( $submenu['woocommerce'] ) ) {
+ $first_index = array_key_first( $submenu['woocommerce'] );
+ foreach ( $submenu['woocommerce'] as $index => $item ) {
+ // WordPress links the WooCommerce parent item to its first submenu entry, hidden or not.
+ if ( 'wc-reports' === ( $item[2] ?? null ) && $index !== $first_index ) {
+ $submenu['woocommerce'][ $index ][4] = self::add_hide_class( $item[4] ?? '' ); // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
+ }
+ }
+ }
+
+ if ( ! empty( $menu ) && is_array( $menu ) ) {
+ foreach ( $menu as $index => $item ) {
+ if ( 'wc-reports' === ( $item[2] ?? null ) ) {
+ $menu[ $index ][4] = self::add_hide_class( $item[4] ?? '' ); // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
+ }
+ }
+ }
+ }
+
+ /**
+ * Whether the legacy Reports menu item should be shown.
+ *
+ * @return bool
+ */
+ private function should_show(): bool {
+ if ( null !== $this->should_show ) {
+ return $this->should_show;
+ }
+
+ $show = ! $this->is_new_store()
+ || ! FeaturesUtil::feature_is_enabled( 'analytics' )
+ || $this->has_extension_reports();
+
+ /**
+ * Filters whether to show the legacy WooCommerce > Reports menu item.
+ *
+ * The page stays reachable at admin.php?page=wc-reports either way. By default the item is hidden on
+ * stores first installed on WooCommerce 11.3.0 or later, unless Analytics is disabled or an extension
+ * adds legacy reports.
+ *
+ * @since 11.3.0
+ *
+ * @param bool $show Whether to show the menu item.
+ */
+ $filtered = apply_filters( 'woocommerce_show_legacy_reports_menu', $show );
+
+ $filtered = is_scalar( $filtered ) ? filter_var( $filtered, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE ) : null;
+ $this->should_show = $filtered ?? $show;
+
+ return $this->should_show;
+ }
+
+ /**
+ * Whether the store was first installed on the version that hides the menu, or later.
+ *
+ * A missing or malformed initial version (e.g. stores installed before 9.2.0) counts as an existing store.
+ *
+ * @return bool
+ */
+ private function is_new_store(): bool {
+ $initial_version = get_option( WC_Install::INITIAL_INSTALLED_VERSION );
+
+ if ( ! is_string( $initial_version ) || ! preg_match( '/^\d+\.\d+\.\d+(-(dev|alpha|beta|rc)(\.?\d+)?)?$/i', $initial_version ) ) {
+ return false;
+ }
+
+ return version_compare( $initial_version, self::NEW_STORE_VERSION, '>=' );
+ }
+
+ /**
+ * Whether an extension adds to or changes the legacy reports.
+ *
+ * Analytics also applies woocommerce_admin_reports, so hooking it isn't enough on its own: the filtered
+ * legacy reports must contain a report core doesn't define, or a core report with a different callback.
+ *
+ * @return bool
+ */
+ private function has_extension_reports(): bool {
+ if ( has_action( 'wc_reports_tabs' ) || has_filter( 'wc_admin_reports_path' ) ) {
+ return true;
+ }
+
+ if ( ! has_filter( 'woocommerce_admin_reports' ) && ! has_filter( 'woocommerce_reports_charts' ) ) {
+ return false;
+ }
+
+ if ( ! class_exists( 'WC_Admin_Reports', false ) ) {
+ return false;
+ }
+
+ foreach ( \WC_Admin_Reports::get_reports() as $group_key => $group ) {
+ if ( ! is_array( $group ) || ! is_array( $group['reports'] ?? null ) ) {
+ continue;
+ }
+
+ foreach ( $group['reports'] as $report_key => $report ) {
+ if ( ! in_array( $report_key, self::CORE_REPORTS[ $group_key ] ?? array(), true ) ) {
+ return true;
+ }
+
+ if ( ! is_array( $report ) || self::CORE_CALLBACK !== ( $report['callback'] ?? null ) ) {
+ return true;
+ }
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Append the class WordPress uses to hide admin elements when JavaScript is enabled.
+ *
+ * @param mixed $classes Existing menu item classes.
+ * @return string
+ */
+ private static function add_hide_class( $classes ): string {
+ $classes = is_string( $classes ) ? trim( $classes ) : '';
+
+ return '' === $classes ? WC_Admin_Menus::HIDE_CSS_CLASS : $classes . ' ' . WC_Admin_Menus::HIDE_CSS_CLASS;
+ }
+}
diff --git a/plugins/woocommerce/tests/e2e/tests/basic/page-loads.spec.ts b/plugins/woocommerce/tests/e2e/tests/basic/page-loads.spec.ts
index e9243efd8f9..e9806b5ae8a 100644
--- a/plugins/woocommerce/tests/e2e/tests/basic/page-loads.spec.ts
+++ b/plugins/woocommerce/tests/e2e/tests/basic/page-loads.spec.ts
@@ -9,7 +9,7 @@ import {
/**
* Internal dependencies
*/
-import { test, expect } from '../../fixtures/fixtures';
+import { test, expect, tags } from '../../fixtures/fixtures';
import { getFakeProduct } from '../../utils/data';
import { ADMIN_STATE_PATH } from '../../playwright.config';
@@ -38,12 +38,6 @@ const wcPages = [
element: '.woocommerce-dropdown-button__labels',
text: 'All Customers',
},
- {
- name: 'Reports',
- heading: 'Reports',
- element: '.nav-tab-wrapper > .nav-tab-active',
- text: 'Orders',
- },
{
name: 'Settings',
heading: 'Settings',
@@ -290,3 +284,30 @@ for ( const currentPage of wcPages ) {
}
} );
}
+
+// External sites were installed before the menu was hidden, so Reports stays visible there.
+test(
+ 'hides the legacy Reports menu item on a new store',
+ { tag: [ tags.SKIP_ON_EXTERNAL_ENV ] },
+ async ( { page } ) => {
+ await page.goto( 'wp-admin/admin.php?page=wc-settings' );
+
+ // Target by href: role locators skip hidden elements, so they would match nothing here.
+ const reportsLink = page.locator(
+ 'li.wp-menu-open > ul.wp-submenu a[href="admin.php?page=wc-reports"]'
+ );
+ await expect( reportsLink ).toHaveCount( 1 );
+ await expect( reportsLink ).toBeHidden();
+ }
+);
+
+test( 'can load the legacy Reports page directly', async ( { page } ) => {
+ await page.goto( 'wp-admin/admin.php?page=wc-reports' );
+
+ await expect(
+ page.getByRole( 'heading', { name: 'Reports' } ).first()
+ ).toBeVisible();
+ await expect(
+ page.locator( '.nav-tab-wrapper > .nav-tab-active' )
+ ).toContainText( 'Orders' );
+} );
diff --git a/plugins/woocommerce/tests/php/src/Internal/Admin/LegacyReportsMenuTest.php b/plugins/woocommerce/tests/php/src/Internal/Admin/LegacyReportsMenuTest.php
new file mode 100644
index 00000000000..15a0e164408
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Admin/LegacyReportsMenuTest.php
@@ -0,0 +1,513 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\Admin;
+
+use Automattic\WooCommerce\Internal\Admin\LegacyReportsMenu;
+use WC_Admin_Menus;
+use WC_Admin_Reports;
+use WC_Install;
+use WC_Unit_Test_Case;
+
+/**
+ * Tests for the LegacyReportsMenu class.
+ */
+class LegacyReportsMenuTest extends WC_Unit_Test_Case {
+
+ /**
+ * Admin menu globals that the tests mutate.
+ */
+ private const MENU_GLOBALS = array( 'menu', 'submenu', 'admin_page_hooks', '_registered_pages', '_parent_pages', '_wp_menu_nopriv', '_wp_submenu_nopriv', 'pagenow', 'plugin_page', 'parent_file' );
+
+ /**
+ * The System Under Test.
+ *
+ * @var LegacyReportsMenu
+ */
+ private $sut;
+
+ /**
+ * Backup of the admin menu globals.
+ *
+ * @var array
+ */
+ private $globals_backup = array();
+
+ /**
+ * Set up test fixtures.
+ */
+ public function setUp(): void {
+ parent::setUp();
+
+ foreach ( self::MENU_GLOBALS as $name ) {
+ $this->globals_backup[ $name ] = $GLOBALS[ $name ] ?? null;
+ }
+
+ $GLOBALS['menu'] = array(); // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
+ $GLOBALS['submenu'] = array(); // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
+
+ update_option( 'woocommerce_analytics_enabled', 'yes' );
+ update_option( WC_Install::INITIAL_INSTALLED_VERSION, '11.3.0' );
+ wp_set_current_user( $this->factory->user->create( array( 'role' => 'administrator' ) ) );
+
+ $this->sut = new LegacyReportsMenu();
+ }
+
+ /**
+ * Tear down test fixtures.
+ */
+ public function tearDown(): void {
+ try {
+ foreach ( $this->globals_backup as $name => $value ) {
+ $GLOBALS[ $name ] = $value; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
+ }
+ // The container's LegacyReportsMenu caches its decision for the request.
+ $this->reset_container_resolutions();
+ } finally {
+ parent::tearDown();
+ }
+ }
+
+ /**
+ * @testdox Should treat the store as new only when the initial installed version is a valid 11.3.0 pre-release or later.
+ *
+ * @testWith ["11.3.0", false]
+ * ["11.3.0-dev", false]
+ * ["11.3.0-beta.1", false]
+ * ["11.3.0-RC1", false]
+ * ["11.4.0", false]
+ * ["12.0.0", false]
+ * ["11.2.9", true]
+ * ["11.2.0-rc.1", true]
+ * ["9.2.0", true]
+ * ["", true]
+ * ["abc", true]
+ * ["99.0garbage", true]
+ * ["12.0", true]
+ * ["12.0.0-foo", true]
+ *
+ * @param string $initial_version The stored initial installed version.
+ * @param bool $expected_show Whether the menu is expected to be shown.
+ */
+ public function test_new_store_detection( string $initial_version, bool $expected_show ): void {
+ update_option( WC_Install::INITIAL_INSTALLED_VERSION, $initial_version );
+
+ $this->assertSame( $expected_show, $this->is_menu_shown(), "Unexpected result for initial version '{$initial_version}'" );
+ }
+
+ /**
+ * @testdox Should treat a missing or non-string initial installed version as an existing store.
+ */
+ public function test_missing_or_invalid_version_type_shows_menu(): void {
+ delete_option( WC_Install::INITIAL_INSTALLED_VERSION );
+ $this->assertTrue( $this->is_menu_shown(), 'Missing option should count as an existing store' );
+
+ update_option( WC_Install::INITIAL_INSTALLED_VERSION, array( '11.3.0' ) );
+ $this->assertTrue( $this->is_menu_shown(), 'Array value should count as an existing store' );
+ }
+
+ /**
+ * @testdox Should hide the menu on a new store without extensions.
+ */
+ public function test_hidden_on_new_store_without_extensions(): void {
+ $this->assertFalse( has_filter( 'woocommerce_admin_reports' ), 'Precondition: core does not hook the legacy reports filter' );
+
+ $this->assertFalse( $this->is_menu_shown() );
+ }
+
+ /**
+ * @testdox Should show the menu on a new store when Analytics is disabled.
+ */
+ public function test_shown_on_new_store_when_analytics_disabled(): void {
+ update_option( 'woocommerce_analytics_enabled', 'no' );
+
+ $this->assertTrue( $this->is_menu_shown() );
+ }
+
+ /**
+ * @testdox Should show the menu when an extension adds a legacy report group.
+ */
+ public function test_shown_when_legacy_group_added(): void {
+ add_filter(
+ 'woocommerce_admin_reports',
+ function ( $reports ) {
+ $reports['subscriptions'] = array(
+ 'title' => 'Subscriptions',
+ 'reports' => array(
+ 'subscription_events_by_date' => array(
+ 'title' => 'Subscription events',
+ 'callback' => '__return_empty_string',
+ ),
+ ),
+ );
+ return $reports;
+ }
+ );
+
+ $this->assertTrue( $this->is_menu_shown() );
+ }
+
+ /**
+ * @testdox Should show the menu when an extension adds a report to a core group.
+ */
+ public function test_shown_when_report_added_to_core_group(): void {
+ add_filter(
+ 'woocommerce_admin_reports',
+ function ( $reports ) {
+ $reports['stock']['reports']['insufficient_stock'] = array(
+ 'title' => 'Insufficient stock',
+ 'callback' => '__return_empty_string',
+ );
+ return $reports;
+ }
+ );
+
+ $this->assertTrue( $this->is_menu_shown() );
+ }
+
+ /**
+ * @testdox Should show the menu when an extension adds reports with the legacy charts and function keys.
+ */
+ public function test_shown_when_legacy_charts_keys_used(): void {
+ add_filter(
+ 'woocommerce_reports_charts',
+ function ( $reports ) {
+ $reports['legacy'] = array(
+ 'title' => 'Legacy',
+ 'charts' => array(
+ 'chart' => array(
+ 'title' => 'Chart',
+ 'function' => '__return_empty_string',
+ ),
+ ),
+ );
+ return $reports;
+ }
+ );
+
+ $this->assertTrue( $this->is_menu_shown() );
+ }
+
+ /**
+ * @testdox Should show the menu when an extension replaces the callback of a core report.
+ *
+ * @testWith ["callback"]
+ * ["function"]
+ *
+ * @param string $key The report key used to replace the callback.
+ */
+ public function test_shown_when_core_report_callback_replaced( string $key ): void {
+ add_filter(
+ 'woocommerce_admin_reports',
+ function ( $reports ) use ( $key ) {
+ $reports['orders']['reports']['sales_by_date'][ $key ] = '__return_empty_string';
+ return $reports;
+ }
+ );
+
+ $this->assertTrue( $this->is_menu_shown() );
+ }
+
+ /**
+ * @testdox Should keep the menu hidden when an extension only adds an Analytics report.
+ */
+ public function test_hidden_when_only_analytics_report_added(): void {
+ add_filter(
+ 'woocommerce_admin_reports',
+ function ( $reports ) {
+ $reports[] = array(
+ 'slug' => 'my-extension/stats',
+ 'description' => 'Stats from my extension.',
+ 'path' => '/my-extension/v1/reports/stats',
+ );
+ return $reports;
+ }
+ );
+
+ $this->assertFalse( $this->is_menu_shown() );
+ }
+
+ /**
+ * @testdox Should show the menu when a callback adds both an Analytics report and a legacy report group.
+ */
+ public function test_shown_when_callback_returns_both_shapes(): void {
+ add_filter(
+ 'woocommerce_admin_reports',
+ function ( $reports ) {
+ $reports[] = array(
+ 'slug' => 'my-extension/stats',
+ 'description' => 'Stats from my extension.',
+ );
+ $reports['my_group'] = array(
+ 'title' => 'My group',
+ 'reports' => array(
+ 'my_report' => array(
+ 'title' => 'My report',
+ 'callback' => '__return_empty_string',
+ ),
+ ),
+ );
+ return $reports;
+ }
+ );
+
+ $this->assertTrue( $this->is_menu_shown() );
+ }
+
+ /**
+ * @testdox Should keep the menu hidden when an extension only removes reports.
+ */
+ public function test_hidden_when_reports_only_removed(): void {
+ add_filter(
+ 'woocommerce_admin_reports',
+ function ( $reports ) {
+ unset( $reports['customers'], $reports['orders']['reports']['downloads'] );
+ return $reports;
+ }
+ );
+
+ $this->assertFalse( $this->is_menu_shown() );
+ }
+
+ /**
+ * @testdox Should show the menu when an extension hooks the legacy report tabs or report path.
+ *
+ * @testWith ["wc_reports_tabs"]
+ * ["wc_admin_reports_path"]
+ *
+ * @param string $hook The hook an extension uses.
+ */
+ public function test_shown_when_legacy_report_hooks_used( string $hook ): void {
+ add_filter( $hook, '__return_null' );
+
+ $this->assertTrue( $this->is_menu_shown() );
+ }
+
+ /**
+ * @testdox Should let the override filter show or hide the menu, coercing its return value.
+ *
+ * @testWith [true, "11.3.0", true]
+ * [false, "11.2.0", false]
+ * ["yes", "11.3.0", true]
+ * ["no", "11.2.0", false]
+ * ["off", "11.2.0", false]
+ * [1, "11.3.0", true]
+ * [0, "11.2.0", false]
+ * ["1", "11.3.0", true]
+ * ["", "11.2.0", false]
+ *
+ * @param mixed $filtered The value returned by the filter.
+ * @param string $initial_version The stored initial installed version.
+ * @param bool $expected Whether the menu is expected to be shown.
+ */
+ public function test_override_filter( $filtered, string $initial_version, bool $expected ): void {
+ update_option( WC_Install::INITIAL_INSTALLED_VERSION, $initial_version );
+ add_filter(
+ 'woocommerce_show_legacy_reports_menu',
+ function () use ( $filtered ) {
+ return $filtered;
+ }
+ );
+
+ $this->assertSame( $expected, $this->is_menu_shown() );
+ }
+
+ /**
+ * @testdox Should fall back to the default when the override filter returns an invalid value.
+ *
+ * @testWith ["11.3.0", false]
+ * ["11.2.0", true]
+ *
+ * @param string $initial_version The stored initial installed version.
+ * @param bool $expected Whether the menu is expected to be shown.
+ */
+ public function test_override_filter_invalid_values_fall_back_to_default( string $initial_version, bool $expected ): void {
+ update_option( WC_Install::INITIAL_INSTALLED_VERSION, $initial_version );
+
+ foreach ( array( null, array( true ), new \stdClass(), 'maybe', 2 ) as $invalid ) {
+ $callback = function () use ( $invalid ) {
+ return $invalid;
+ };
+ add_filter( 'woocommerce_show_legacy_reports_menu', $callback );
+
+ $this->assertSame( $expected, $this->is_menu_shown(), 'Invalid value: ' . wp_json_encode( $invalid ) );
+
+ remove_filter( 'woocommerce_show_legacy_reports_menu', $callback );
+ }
+ }
+
+ /**
+ * @testdox Should keep the core report list in sync with the unfiltered legacy reports.
+ */
+ public function test_core_reports_list_matches_legacy_reports(): void {
+ update_option( 'woocommerce_calc_taxes', 'yes' );
+
+ $expected = array();
+ foreach ( WC_Admin_Reports::get_reports() as $group_key => $group ) {
+ $expected[ $group_key ] = array_keys( $group['reports'] );
+ }
+
+ $core_reports = ( new \ReflectionClassConstant( LegacyReportsMenu::class, 'CORE_REPORTS' ) )->getValue();
+
+ $this->assertSame( $expected, $core_reports, 'LegacyReportsMenu::CORE_REPORTS must list every core legacy report' );
+ }
+
+ /**
+ * @testdox Should register the container instance on admin_head from reports_menu() so production hides the item.
+ */
+ public function test_reports_menu_registers_admin_head_handler(): void {
+ $this->reset_container_resolutions();
+ $this->register_woocommerce_menu();
+
+ $handler = array( wc_get_container()->get( LegacyReportsMenu::class ), 'handle_admin_head' );
+ $this->assertSame( PHP_INT_MAX, has_action( 'admin_head', $handler ), 'reports_menu() must hook the handler late on admin_head' );
+
+ call_user_func( $handler );
+
+ $this->assertStringContainsString( WC_Admin_Menus::HIDE_CSS_CLASS, $this->get_menu_item_classes( 'submenu', 'wc-reports' ) );
+ }
+
+ /**
+ * @testdox Should hide the WooCommerce > Reports item on a new store but keep the page accessible.
+ */
+ public function test_admin_head_hides_submenu_item_and_keeps_page_accessible(): void {
+ $this->register_woocommerce_menu();
+
+ $this->sut->handle_admin_head();
+
+ $this->assertStringContainsString( WC_Admin_Menus::HIDE_CSS_CLASS, $this->get_menu_item_classes( 'submenu', 'wc-reports' ) );
+ $this->assertStringNotContainsString( WC_Admin_Menus::HIDE_CSS_CLASS, $this->get_menu_item_classes( 'submenu', 'wc-settings' ), 'Other items must stay visible' );
+ $this->assertTrue( $this->can_access_reports_page(), 'The reports page must stay accessible by URL' );
+ }
+
+ /**
+ * @testdox Should not hide the WooCommerce > Reports item on an existing store.
+ */
+ public function test_admin_head_keeps_submenu_item_on_existing_store(): void {
+ update_option( WC_Install::INITIAL_INSTALLED_VERSION, '11.2.0' );
+ $this->register_woocommerce_menu();
+
+ $this->sut->handle_admin_head();
+
+ $this->assertStringNotContainsString( WC_Admin_Menus::HIDE_CSS_CLASS, $this->get_menu_item_classes( 'submenu', 'wc-reports' ) );
+ }
+
+ /**
+ * @testdox Should not hide Reports when it is the first WooCommerce submenu item, since WordPress links the parent to it.
+ */
+ public function test_admin_head_keeps_reports_when_first_submenu_item(): void {
+ $GLOBALS['submenu']['woocommerce'] = array( array( 'Reports', 'view_woocommerce_reports', 'wc-reports', 'Reports' ) ); // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
+
+ $this->sut->handle_admin_head();
+
+ $this->assertStringNotContainsString( WC_Admin_Menus::HIDE_CSS_CLASS, $this->get_menu_item_classes( 'submenu', 'wc-reports' ) );
+ }
+
+ /**
+ * @testdox Should not recreate a Reports item that another plugin removed.
+ */
+ public function test_admin_head_does_not_recreate_removed_item(): void {
+ $this->register_woocommerce_menu();
+ remove_submenu_page( 'woocommerce', 'wc-reports' );
+ $submenu_before = $GLOBALS['submenu'];
+
+ $this->sut->handle_admin_head();
+
+ $this->assertSame( $submenu_before, $GLOBALS['submenu'] );
+ }
+
+ /**
+ * @testdox Should hide the top-level Sales reports item for users who can view reports but not the WooCommerce menu.
+ */
+ public function test_admin_head_hides_top_level_item_for_reports_only_user(): void {
+ wp_set_current_user( $this->create_reports_only_user() );
+ $this->assertFalse( WC_Admin_Menus::can_view_woocommerce_menu_item(), 'Precondition: user cannot see the WooCommerce menu' );
+
+ ( new WC_Admin_Menus() )->reports_menu();
+ $this->sut->handle_admin_head();
+
+ $this->assertStringContainsString( WC_Admin_Menus::HIDE_CSS_CLASS, $this->get_menu_item_classes( 'menu', 'wc-reports' ) );
+ $this->assertTrue( $this->can_access_reports_page(), 'The reports page must stay accessible by URL' );
+ }
+
+ /**
+ * @testdox Should keep denying the reports page to users without the reports capability.
+ */
+ public function test_reports_page_denied_without_capability(): void {
+ wp_set_current_user( $this->factory->user->create( array( 'role' => 'subscriber' ) ) );
+
+ ( new WC_Admin_Menus() )->reports_menu();
+ $this->sut->handle_admin_head();
+
+ $this->assertFalse( $this->can_access_reports_page() );
+ }
+
+ /**
+ * Build the WooCommerce menu, run the admin_head handler on a fresh instance, and report whether Reports is visible.
+ *
+ * @return bool
+ */
+ private function is_menu_shown(): bool {
+ $GLOBALS['menu'] = array(); // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
+ $GLOBALS['submenu'] = array(); // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
+ $this->register_woocommerce_menu();
+
+ ( new LegacyReportsMenu() )->handle_admin_head();
+
+ return false === strpos( $this->get_menu_item_classes( 'submenu', 'wc-reports' ), WC_Admin_Menus::HIDE_CSS_CLASS );
+ }
+
+ /**
+ * Register the WooCommerce menu with Reports and Settings items, as admin_menu does.
+ */
+ private function register_woocommerce_menu(): void {
+ $menus = new WC_Admin_Menus();
+ $menus->admin_menu();
+ $menus->reports_menu();
+ $menus->settings_menu();
+ }
+
+ /**
+ * Create a user that can view reports but cannot see the WooCommerce menu.
+ *
+ * @return int
+ */
+ private function create_reports_only_user(): int {
+ $user_id = $this->factory->user->create( array( 'role' => 'subscriber' ) );
+ ( new \WP_User( $user_id ) )->add_cap( 'view_woocommerce_reports' );
+
+ return $user_id;
+ }
+
+ /**
+ * Get the CSS classes of the wc-* item in $menu or $submenu['woocommerce'].
+ *
+ * @param string $menu_global The global to search: 'menu' or 'submenu'.
+ * @param string $slug The menu slug.
+ * @return string
+ */
+ private function get_menu_item_classes( string $menu_global, string $slug ): string {
+ $items = 'menu' === $menu_global ? $GLOBALS['menu'] : ( $GLOBALS['submenu']['woocommerce'] ?? array() );
+
+ foreach ( $items as $item ) {
+ if ( $slug === $item[2] ) {
+ return (string) ( $item[4] ?? '' );
+ }
+ }
+
+ $this->fail( "Menu item {$slug} not found in \${$menu_global}" );
+ }
+
+ /**
+ * Whether the current user can open admin.php?page=wc-reports.
+ *
+ * @return bool
+ */
+ private function can_access_reports_page(): bool {
+ $GLOBALS['pagenow'] = 'admin.php'; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
+ $GLOBALS['plugin_page'] = 'wc-reports'; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
+ $GLOBALS['parent_file'] = null; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
+
+ return user_can_access_admin_page();
+ }
+}