Commit a0754eadc8d for woocommerce

commit a0754eadc8d8eec735f0c72599f313e4d7d206ea
Author: Tung Du <dinhtungdu@gmail.com>
Date:   Mon Nov 9 15:10:10 2026 +0700

    Add a catalog visibility filter to the products list (#70684)

    * Add catalog visibility filter to the products list

    Amp-Thread-ID: https://ampcode.com/threads/T-01a1a9b3-3f57-712a-9cb5-c3b33121dc7b
    Co-authored-by: Amp <amp@ampcode.com>

    * Add changelog entry for the catalog visibility filter

    Amp-Thread-ID: https://ampcode.com/threads/T-01a1a9b3-3f57-712a-9cb5-c3b33121dc7b
    Co-authored-by: Amp <amp@ampcode.com>

    * Only offer catalog visibility values the filter can match

    Amp-Thread-ID: https://ampcode.com/threads/T-01a1a9b3-3f57-712a-9cb5-c3b33121dc7b

    * Create catalog visibility test products once

    Amp-Thread-ID: https://ampcode.com/threads/T-01a1a9b3-3f57-712a-9cb5-c3b33121dc7b
    Co-authored-by: Amp <amp@ampcode.com>

    ---------

    Co-authored-by: Amp <amp@ampcode.com>

diff --git a/plugins/woocommerce/changelog/70260-products-list-catalog-visibility-filter b/plugins/woocommerce/changelog/70260-products-list-catalog-visibility-filter
new file mode 100644
index 00000000000..33c607dae14
--- /dev/null
+++ b/plugins/woocommerce/changelog/70260-products-list-catalog-visibility-filter
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Add a "Filter by visibility" dropdown to Products > All Products, so you can find hidden products.
diff --git a/plugins/woocommerce/includes/admin/list-tables/class-wc-admin-list-table-products.php b/plugins/woocommerce/includes/admin/list-tables/class-wc-admin-list-table-products.php
index 34187e7c324..e769532edc6 100644
--- a/plugins/woocommerce/includes/admin/list-tables/class-wc-admin-list-table-products.php
+++ b/plugins/woocommerce/includes/admin/list-tables/class-wc-admin-list-table-products.php
@@ -6,6 +6,7 @@
  * @version  3.3.0
  */

+use Automattic\WooCommerce\Enums\CatalogVisibility;
 use Automattic\WooCommerce\Enums\ProductType;
 use Automattic\WooCommerce\Enums\ProductStockStatus;
 use Automattic\WooCommerce\Internal\CostOfGoodsSold\CostOfGoodsSoldController;
@@ -494,9 +495,10 @@ class WC_Admin_List_Table_Products extends WC_Admin_List_Table {
 		$filters = apply_filters(
 			'woocommerce_products_admin_list_table_filters',
 			array(
-				'product_category' => array( $this, 'render_products_category_filter' ),
-				'product_type'     => array( $this, 'render_products_type_filter' ),
-				'stock_status'     => array( $this, 'render_products_stock_status_filter' ),
+				'product_category'   => array( $this, 'render_products_category_filter' ),
+				'product_type'       => array( $this, 'render_products_type_filter' ),
+				'stock_status'       => array( $this, 'render_products_stock_status_filter' ),
+				'catalog_visibility' => array( $this, 'render_products_catalog_visibility_filter' ),
 			)
 		);

@@ -596,6 +598,54 @@ class WC_Admin_List_Table_Products extends WC_Admin_List_Table {
 		echo $output; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- $output contains only fixed markup and escaped option values.
 	}

+	/**
+	 * Render the catalog visibility filter for the list table.
+	 *
+	 * @since 11.4.0
+	 */
+	private function render_products_catalog_visibility_filter(): void {
+		$current = $this->get_requested_catalog_visibility();
+		$output  = '<select name="catalog_visibility"><option value="">' . esc_html__( 'Filter by visibility', 'woocommerce' ) . '</option>';
+
+		foreach ( $this->get_catalog_visibility_filter_options() as $value => $label ) {
+			$output .= '<option ' . selected( $value, $current, false ) . ' value="' . esc_attr( $value ) . '">' . esc_html( $label ) . '</option>';
+		}
+
+		$output .= '</select>';
+		echo $output; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- $output contains only fixed markup and escaped option values.
+	}
+
+	/**
+	 * Get the catalog visibility the merchant picked in the filter.
+	 *
+	 * Returns an empty string when the request has no value or an unknown value. The list then shows all products.
+	 *
+	 * @return string
+	 */
+	private function get_requested_catalog_visibility(): string {
+		$value = wp_unslash( $_GET['catalog_visibility'] ?? '' ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Read-only filter. The code below checks the value against a fixed list.
+		if ( is_array( $value ) ) {
+			$value = reset( $value ); // The dropdown sends one value, so use the first one if the request has several.
+		}
+
+		return is_string( $value ) && array_key_exists( $value, $this->get_catalog_visibility_filter_options() ) ? $value : '';
+	}
+
+	/**
+	 * Get the catalog visibility options the filter can list products by.
+	 *
+	 * Keeps the labels and removals from the woocommerce_product_visibility_options filter, but drops values that
+	 * extensions add. The product data store only saves the four core values, so the query cannot match any other value.
+	 *
+	 * @return array<string, string> Option labels keyed by catalog visibility value.
+	 */
+	private function get_catalog_visibility_filter_options(): array {
+		$options   = wc_get_product_visibility_options();
+		$supported = array( CatalogVisibility::VISIBLE, CatalogVisibility::CATALOG, CatalogVisibility::SEARCH, CatalogVisibility::HIDDEN );
+
+		return is_array( $options ) ? array_intersect_key( $options, array_flip( $supported ) ) : array();
+	}
+
 	/**
 	 * Search by SKU or ID for products.
 	 *
@@ -698,6 +748,29 @@ class WC_Admin_List_Table_Products extends WC_Admin_List_Table {
 			add_filter( 'posts_clauses', array( $this, 'filter_stock_status_post_clauses' ) );
 		}

+		// Catalog visibility filter. The product data store saves each visibility as two terms, so check both to match one visibility exactly.
+		$catalog_visibility = $this->get_requested_catalog_visibility();
+		if ( '' !== $catalog_visibility ) {
+			$excluded_from_catalog = in_array( $catalog_visibility, array( CatalogVisibility::SEARCH, CatalogVisibility::HIDDEN ), true );
+			$excluded_from_search  = in_array( $catalog_visibility, array( CatalogVisibility::CATALOG, CatalogVisibility::HIDDEN ), true );
+
+			$query_vars['tax_query'][] = array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_tax_query
+				'relation' => 'AND',
+				array(
+					'taxonomy' => 'product_visibility',
+					'field'    => 'slug',
+					'terms'    => 'exclude-from-catalog',
+					'operator' => $excluded_from_catalog ? 'IN' : 'NOT IN',
+				),
+				array(
+					'taxonomy' => 'product_visibility',
+					'field'    => 'slug',
+					'terms'    => 'exclude-from-search',
+					'operator' => $excluded_from_search ? 'IN' : 'NOT IN',
+				),
+			);
+		}
+
 		// Search using CRUD.
 		if ( ! empty( $query_vars['s'] ) ) {
 			$search_term                  = wc_clean( wp_unslash( $query_vars['s'] ) );
diff --git a/plugins/woocommerce/tests/php/includes/admin/list-tables/class-wc-admin-list-table-products-test.php b/plugins/woocommerce/tests/php/includes/admin/list-tables/class-wc-admin-list-table-products-test.php
index ba86d76fe0f..f6378f88e96 100644
--- a/plugins/woocommerce/tests/php/includes/admin/list-tables/class-wc-admin-list-table-products-test.php
+++ b/plugins/woocommerce/tests/php/includes/admin/list-tables/class-wc-admin-list-table-products-test.php
@@ -7,6 +7,7 @@

 declare( strict_types = 1 );

+use Automattic\WooCommerce\Enums\CatalogVisibility;
 use Automattic\WooCommerce\Enums\ProductStockStatus;

 require_once WC_ABSPATH . 'includes/admin/list-tables/class-wc-admin-list-table-products.php';
@@ -712,6 +713,41 @@ class WC_Admin_List_Table_Products_Test extends WC_Unit_Test_Case {
 		);
 	}

+	/**
+	 * @testdox Products list catalog visibility filter lists only products with exactly the chosen visibility.
+	 */
+	public function test_catalog_visibility_filter_matches_exact_visibility(): void {
+		$visibilities = array( CatalogVisibility::VISIBLE, CatalogVisibility::CATALOG, CatalogVisibility::SEARCH, CatalogVisibility::HIDDEN );
+		$product_ids  = array();
+		foreach ( $visibilities as $visibility ) {
+			$product = WC_Helper_Product::create_simple_product();
+			$product->set_catalog_visibility( $visibility );
+			$product_ids[ $visibility ] = $product->save();
+		}
+
+		$original_get = $_GET; // phpcs:ignore WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Test cleanup restores the raw original request data.
+
+		try {
+			foreach ( $visibilities as $visibility ) {
+				$_GET['catalog_visibility'] = $visibility;
+
+				$query_vars = $this->sut->request_query(
+					array(
+						'post_type'      => 'product',
+						'post_status'    => 'any',
+						'posts_per_page' => -1,
+						'fields'         => 'ids',
+					)
+				);
+				$results    = array_map( 'intval', ( new WP_Query( $query_vars ) )->get_posts() );
+
+				$this->assertSame( array( $product_ids[ $visibility ] ), $results, "Filtering by \"{$visibility}\" lists the wrong products." );
+			}
+		} finally {
+			$_GET = $original_get; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
+		}
+	}
+
 	/**
 	 * @testdox Products list shipping-class filter lists a variable product whose variation uses the class.
 	 */