Commit b50e93d92ba for woocommerce

commit b50e93d92baf998c0b50c707966969e86749df1f
Author: Faisal Ahammad <faisalahammad24@gmail.com>
Date:   Thu Jul 23 23:52:26 2026 +0600

    fix(admin): show Product Categories, Tags, and Brands nav menu boxes by default (#65990)

    * fix(admin): make WC taxonomy nav-menu boxes visible by default

    - On first visit to Appearance > Menus, WordPress hides every meta box except
      page, post, custom-links, and category. This included Product Categories,
      Product Tags, and Brands.
    - Hook get_user_option_metaboxhidden_nav-menus on load-nav-menus.php to return
      a default hidden list that excludes the three WC taxonomy boxes while keeping
      all other boxes in their default-hidden state.
    - Existing users who already saved a preference keep their setting unchanged.

    Fixes #65698

    * fix(admin): address review feedback - global notation, doc comment, test coverage

    - Switch to global $wp_meta_boxes per WPCS convention
    - Document intentional recomputation on each Menus page visit
    - Rename test for clarity and add direct guard-coverage test

    Addresses PR review feedback.

    Refs #65990

    * fix(admin): address CodeRabbit feedback on nav menu meta boxes

    - Remove unused $context key from foreach in filter_default_nav_menu_hidden_meta_boxes.
    - Restore product_brand taxonomy via register_taxonomy in test tearDown instead of direct global assignment.
    - Remove .claudeignore from PR branch.

    Refs #65990.

    * fix(admin): fix PHPCS errors in nav-menu meta-box test

    - Replace direct $wp_meta_boxes assignment with $GLOBALS + phpcs:ignore
    - Align array double-arrow spacing in allowed-list fixture
    - Add phpcs:ignore comments for intentional global overrides

    Addresses PHPCS review from PanosSynetos on PR #65990.

    * fix(admin): restore taxonomy state directly in test teardown

    - Swap wp_taxonomies global directly instead of round-tripping through
      register_taxonomy(), which drops real capabilities on cast
    - Save and restore current user ID to prevent state leak across tests

    Addresses PR feedback.

    Refs #65990

    * fix(admin): memoize nav-menu hidden boxes and keep endpoints box visible

    Address review feedback on #65990: the metaboxhidden_nav-menus option is
    read twice on the Menus page (before admin_head and again inside
    do_accordion_sections after admin_head-nav-menus.php). Without memoization,
    the second read swept our own endpoints box into the hidden list because it
    was registered late. Compute the list once per request and cache it on the
    instance, restoring core's snapshot timing and fixing the general case for
    third-party boxes. Also add woocommerce_endpoints_nav_link to the visible
    list as an explicit safeguard, correct the @since tags to 11.1.0, and fix
    the docblock to reflect that Brands visibility is conditional on
    taxonomy_exists().

    * Restore taxonomy rewrite rules and hooks in test tearDown

diff --git a/plugins/woocommerce/changelog/fix-65698-show-wc-taxonomies-in-nav-menus-by-default b/plugins/woocommerce/changelog/fix-65698-show-wc-taxonomies-in-nav-menus-by-default
new file mode 100644
index 00000000000..73e0e8a84c1
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-65698-show-wc-taxonomies-in-nav-menus-by-default
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Ensure Product Categories, Product Tags, and Brands menu meta boxes are visible by default on the Appearance > Menus screen.
diff --git a/plugins/woocommerce/includes/admin/class-wc-admin-menus.php b/plugins/woocommerce/includes/admin/class-wc-admin-menus.php
index da9f10ee596..c910afa8527 100644
--- a/plugins/woocommerce/includes/admin/class-wc-admin-menus.php
+++ b/plugins/woocommerce/includes/admin/class-wc-admin-menus.php
@@ -31,6 +31,22 @@ class WC_Admin_Menus {
 	 */
 	const HIDE_CSS_CLASS = 'hide-if-js';

+	/**
+	 * Cached hidden meta box IDs from the first read of this request.
+	 *
+	 * The metaboxhidden_nav-menus option is read more than once per page load:
+	 * once before admin_head fires and again inside do_accordion_sections()
+	 * after admin_head-nav-menus.php has fired. Memoizing the computed list
+	 * keeps the value stable across those reads, so a box registered late
+	 * (e.g. the "WooCommerce endpoints" box) is not swept into the hidden list
+	 * on the second read. This mirrors how core persists the snapshot before
+	 * admin_head, and fixes the general case for third-party boxes too.
+	 *
+	 * @since 11.1.0
+	 * @var string[]|null
+	 */
+	private $last_computed_hidden_boxes = null;
+
 	/**
 	 * Hook in tabs.
 	 */
@@ -66,6 +82,9 @@ class WC_Admin_Menus {
 		// Add endpoints custom URLs in Appearance > Menus > Pages.
 		add_action( 'admin_head-nav-menus.php', array( $this, 'add_nav_menu_meta_boxes' ) );

+		// Ensure WC taxonomy meta boxes are visible by default on the first visit to Appearance > Menus.
+		add_action( 'load-nav-menus.php', array( $this, 'register_default_nav_menu_meta_boxes_filter' ), 9 );
+
 		// Admin bar menus.
 		if ( apply_filters( 'woocommerce_show_admin_bar_visit_store', true ) ) {
 			add_action( 'admin_bar_menu', array( $this, 'admin_bar_menus' ), 31 );
@@ -426,6 +445,89 @@ class WC_Admin_Menus {
 		add_meta_box( 'woocommerce_endpoints_nav_link', __( 'WooCommerce endpoints', 'woocommerce' ), array( $this, 'nav_menu_links' ), 'nav-menus', 'side', 'low' );
 	}

+	/**
+	 * Ensure the Product Categories, Product Tags, and Brands meta boxes
+	 * are visible by default on the Appearance > Menus screen.
+	 *
+	 * WordPress's wp_initial_nav_menu_meta_boxes() hides every box except page,
+	 * post, custom-links, and category on a user's first visit. We intercept
+	 * the empty user option and return a hidden list that excludes the WC
+	 * taxonomy boxes, so WP's existing-user guard short-circuits and leaves
+	 * them visible.
+	 *
+	 * The hidden list is computed once per request and cached on the instance,
+	 * so every read of the option within the same page load returns the same
+	 * value. This keeps late-registered boxes out of the hidden list and restores
+	 * the snapshot timing core relied on before admin_head.
+	 *
+	 * @since 11.1.0
+	 *
+	 * @return void
+	 */
+	public function register_default_nav_menu_meta_boxes_filter() {
+		add_filter( 'get_user_option_metaboxhidden_nav-menus', array( $this, 'filter_default_nav_menu_hidden_meta_boxes' ), 10, 3 );
+	}
+
+	/**
+	 * Filter the default hidden meta boxes for the Appearance > Menus screen.
+	 *
+	 * Only applies when the user has no saved preference for the nav-menus
+	 * screen. Returns a list of every registered nav-menus meta box except the
+	 * four core visible boxes, the "WooCommerce endpoints" box, and the WC
+	 * taxonomy boxes. Product Brands is visible only when its taxonomy exists.
+	 *
+	 * @since 11.1.0
+	 *
+	 * @param mixed   $result Value for the user's option, or false if not set.
+	 * @param string  $option Name of the option being retrieved.
+	 * @param WP_User $user   WP_User object of the user whose option is being retrieved.
+	 * @return mixed The filtered option value.
+	 */
+	public function filter_default_nav_menu_hidden_meta_boxes( $result, $option, $user ) {
+		global $wp_meta_boxes;
+
+		if ( false !== $result ) {
+			return $result;
+		}
+
+		if ( ! $user || ! isset( $wp_meta_boxes['nav-menus'] ) || ! is_array( $wp_meta_boxes['nav-menus'] ) ) {
+			return $result;
+		}
+
+		if ( null !== $this->last_computed_hidden_boxes ) {
+			return $this->last_computed_hidden_boxes;
+		}
+
+		$visible = array(
+			'add-post-type-page',
+			'add-post-type-post',
+			'add-custom-links',
+			'add-category',
+			'add-product_cat',
+			'add-product_tag',
+			'woocommerce_endpoints_nav_link',
+		);
+
+		if ( taxonomy_exists( 'product_brand' ) ) {
+			$visible[] = 'add-product_brand';
+		}
+
+		$hidden = array();
+		foreach ( $wp_meta_boxes['nav-menus'] as $priorities ) {
+			foreach ( (array) $priorities as $priority => $boxes ) {
+				foreach ( (array) $boxes as $box ) {
+					if ( isset( $box['id'] ) && ! in_array( $box['id'], $visible, true ) ) {
+						$hidden[] = $box['id'];
+					}
+				}
+			}
+		}
+
+		$this->last_computed_hidden_boxes = $hidden;
+
+		return $hidden;
+	}
+
 	/**
 	 * Output menu links.
 	 *
diff --git a/plugins/woocommerce/tests/php/includes/class-wc-admin-menus-test.php b/plugins/woocommerce/tests/php/includes/class-wc-admin-menus-test.php
new file mode 100644
index 00000000000..8e1554d7538
--- /dev/null
+++ b/plugins/woocommerce/tests/php/includes/class-wc-admin-menus-test.php
@@ -0,0 +1,232 @@
+<?php
+/**
+ * WC_Admin_Menus unit tests.
+ *
+ * @package WooCommerce
+ */
+
+declare( strict_types = 1 );
+
+/**
+ * WC_Admin_Menus_Test
+ */
+class WC_Admin_Menus_Test extends WC_Unit_Test_Case {
+
+	/**
+	 * Holds the original $wp_meta_boxes global state.
+	 *
+	 * @var array|null
+	 */
+	private $wp_meta_boxes_backup = null;
+
+	/**
+	 * Holds the original product_brand taxonomy object when a test unregisters it.
+	 *
+	 * @var WP_Taxonomy|false|null
+	 */
+	private $brand_taxonomy_backup = null;
+
+	/**
+	 * Holds the original current user ID when a test changes it.
+	 *
+	 * @var int|null
+	 */
+	private $current_user_backup = null;
+
+	/**
+	 * Set up test data.
+	 */
+	public function setUp(): void {
+		parent::setUp();
+		global $wp_meta_boxes;
+		$this->wp_meta_boxes_backup = isset( $wp_meta_boxes ) ? $wp_meta_boxes : array();
+		$this->current_user_backup  = get_current_user_id();
+	}
+
+	/**
+	 * Tear down test data.
+	 */
+	public function tearDown(): void {
+		if ( $this->brand_taxonomy_backup instanceof WP_Taxonomy ) {
+			// Restore the original registration directly; register_taxonomy() takes a 'capabilities'
+			// array arg but WP_Taxonomy only exposes a 'cap' object, so casting the object back
+			// would silently drop the real capabilities.
+			$GLOBALS['wp_taxonomies']['product_brand'] = $this->brand_taxonomy_backup; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
+
+			// unregister_taxonomy() also called remove_rewrite_rules() and remove_hooks(),
+			// which mutate $wp_rewrite and $wp_filter. Undo both to fully restore state.
+			$this->brand_taxonomy_backup->add_rewrite_rules();
+			$this->brand_taxonomy_backup->add_hooks();
+		}
+		$GLOBALS['wp_meta_boxes'] = $this->wp_meta_boxes_backup;  // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
+		wp_set_current_user( $this->current_user_backup );
+		parent::tearDown();
+	}
+
+	/**
+	 * Build a fake nav-menus meta box registry for testing.
+	 *
+	 * @return array
+	 */
+	private function get_nav_meta_boxes() {
+		return array(
+			'nav-menus' => array(
+				'side' => array(
+					'default' => array(
+						'add-post-type-page' => array(
+							'id'    => 'add-post-type-page',
+							'title' => 'Pages',
+						),
+						'add-post-type-post' => array(
+							'id'    => 'add-post-type-post',
+							'title' => 'Posts',
+						),
+						'add-custom-links'   => array(
+							'id'    => 'add-custom-links',
+							'title' => 'Custom Links',
+						),
+						'add-category'       => array(
+							'id'    => 'add-category',
+							'title' => 'Categories',
+						),
+						'add-product_cat'    => array(
+							'id'    => 'add-product_cat',
+							'title' => 'Product Categories',
+						),
+						'add-product_tag'    => array(
+							'id'    => 'add-product_tag',
+							'title' => 'Product Tags',
+						),
+						'add-product_brand'  => array(
+							'id'    => 'add-product_brand',
+							'title' => 'Brands',
+						),
+						'add-post-type-news' => array(
+							'id'    => 'add-post-type-news',
+							'title' => 'News',
+						),
+					),
+				),
+			),
+		);
+	}
+
+	/**
+	 * First-time users should see the WC taxonomy boxes on the nav-menus screen
+	 * while third-party boxes remain hidden by default.
+	 */
+	public function test_filter_returns_wc_taxonomy_boxes_visible_for_first_time_user() {
+		$GLOBALS['wp_meta_boxes'] = $this->get_nav_meta_boxes();  // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
+		$user_id                  = $this->factory->user->create();
+
+		$menus = new WC_Admin_Menus();
+		$menus->register_default_nav_menu_meta_boxes_filter();
+
+		$hidden = get_user_option( 'metaboxhidden_nav-menus', $user_id );
+
+		$this->assertIsArray( $hidden );
+		$this->assertContains( 'add-post-type-news', $hidden );
+		$this->assertNotContains( 'add-post-type-page', $hidden );
+		$this->assertNotContains( 'add-post-type-post', $hidden );
+		$this->assertNotContains( 'add-custom-links', $hidden );
+		$this->assertNotContains( 'add-category', $hidden );
+		$this->assertNotContains( 'add-product_cat', $hidden );
+		$this->assertNotContains( 'add-product_tag', $hidden );
+		$this->assertNotContains( 'add-product_brand', $hidden );
+		$this->assertNotContains( 'woocommerce_endpoints_nav_link', $hidden );
+	}
+
+	/**
+	 * Existing users who already saved a preference should keep it unchanged.
+	 */
+	public function test_filter_preserves_existing_user_preference() {
+		$user_id = $this->factory->user->create();
+		update_user_option( $user_id, 'metaboxhidden_nav-menus', array( 'add-product_cat' ) );
+
+		$menus = new WC_Admin_Menus();
+		$menus->register_default_nav_menu_meta_boxes_filter();
+
+		$hidden = get_user_option( 'metaboxhidden_nav-menus', $user_id );
+
+		$this->assertSame( array( 'add-product_cat' ), $hidden );
+	}
+
+	/**
+	 * When the user ID is invalid, get_user_option short-circuits before the
+	 * filter is reached, so the default value stays unchanged.
+	 */
+	public function test_filter_does_not_reach_callback_when_user_not_logged_in() {
+		wp_set_current_user( 0 );
+
+		$menus = new WC_Admin_Menus();
+		$menus->register_default_nav_menu_meta_boxes_filter();
+
+		$hidden = get_user_option( 'metaboxhidden_nav-menus' );
+
+		// get_user_option returns false for an invalid user ID, so the default filter should not be reached.
+		$this->assertFalse( $hidden );
+	}
+
+	/**
+	 * The callback should return the default value unchanged when no user object is passed.
+	 */
+	public function test_filter_returns_default_value_when_no_user_object() {
+		$menus  = new WC_Admin_Menus();
+		$hidden = $menus->filter_default_nav_menu_hidden_meta_boxes( false, 'metaboxhidden_nav-menus', null );
+
+		$this->assertFalse( $hidden );
+	}
+
+	/**
+	 * When the Brands taxonomy is not registered, the brand box should not be treated as visible by default.
+	 */
+	public function test_filter_keeps_brand_box_hidden_when_brand_taxonomy_unregistered() {
+		$this->brand_taxonomy_backup = get_taxonomy( 'product_brand' );
+		unregister_taxonomy( 'product_brand' );
+
+		$GLOBALS['wp_meta_boxes'] = $this->get_nav_meta_boxes();  // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
+		$user_id                  = $this->factory->user->create();
+
+		$menus = new WC_Admin_Menus();
+		$menus->register_default_nav_menu_meta_boxes_filter();
+
+		$hidden = get_user_option( 'metaboxhidden_nav-menus', $user_id );
+
+		$this->assertIsArray( $hidden );
+		$this->assertContains( 'add-product_brand', $hidden );
+		$this->assertNotContains( 'add-product_cat', $hidden );
+		$this->assertNotContains( 'add-product_tag', $hidden );
+	}
+
+	/**
+	 * A box registered after admin_head-nav-menus.php fires (the "WooCommerce
+	 * endpoints" box) must stay visible. The option is read twice on the same
+	 * page load: once before admin_head and again inside do_accordion_sections()
+	 * after admin_head-nav-menus.php. Memoization keeps the hidden list stable
+	 * across both reads so the late-registered box is not swept into it.
+	 */
+	public function test_filter_keeps_endpoints_box_visible_after_late_registration() {
+		$GLOBALS['wp_meta_boxes'] = $this->get_nav_meta_boxes();  // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
+		$user_id                  = $this->factory->user->create();
+
+		$menus = new WC_Admin_Menus();
+		$menus->register_default_nav_menu_meta_boxes_filter();
+
+		// First read happens before admin_head-nav-menus.php, so the endpoints box is not yet registered.
+		$hidden_before = get_user_option( 'metaboxhidden_nav-menus', $user_id );
+
+		// Simulate admin_head-nav-menus.php registering the WooCommerce endpoints box.
+		$GLOBALS['wp_meta_boxes']['nav-menus']['side']['low']['woocommerce_endpoints_nav_link'] = array( // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
+			'id'    => 'woocommerce_endpoints_nav_link',
+			'title' => 'WooCommerce endpoints',
+		);
+
+		// Second read happens inside do_accordion_sections(), after the box was registered.
+		$hidden_after = get_user_option( 'metaboxhidden_nav-menus', $user_id );
+
+		$this->assertIsArray( $hidden_before );
+		$this->assertIsArray( $hidden_after );
+		$this->assertSame( $hidden_before, $hidden_after );
+		$this->assertNotContains( 'woocommerce_endpoints_nav_link', $hidden_after );
+	}
+}