Commit 1725a8dc888 for woocommerce

commit 1725a8dc8882ca14fc5be34ffe1876840c4afa61
Author: Taha Paksu <3295+tpaksu@users.noreply.github.com>
Date:   Fri Oct 16 12:28:02 2026 +0300

    List variable products when filtering products by shipping class (#69877)

diff --git a/plugins/woocommerce/changelog/69877-wooplug-1537-shipping-class-count-link b/plugins/woocommerce/changelog/69877-wooplug-1537-shipping-class-count-link
new file mode 100644
index 00000000000..5dcb6f734be
--- /dev/null
+++ b/plugins/woocommerce/changelog/69877-wooplug-1537-shipping-class-count-link
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Show variable products in the admin products list when filtering by a shipping class set on a variation.
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 7613c7481b1..0b3da324c6d 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
@@ -698,16 +698,6 @@ class WC_Admin_List_Table_Products extends WC_Admin_List_Table {
 			add_filter( 'posts_clauses', array( $this, 'filter_stock_status_post_clauses' ) );
 		}

-		// Shipping class taxonomy.
-		if ( ! empty( $_GET['product_shipping_class'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
-			$query_vars['tax_query'][] = array(
-				'taxonomy' => 'product_shipping_class',
-				'field'    => 'slug',
-				'terms'    => sanitize_title( wp_unslash( $_GET['product_shipping_class'] ) ),
-				'operator' => 'IN',
-			);
-		}
-
 		// Search using CRUD.
 		if ( ! empty( $query_vars['s'] ) ) {
 			$search_term                  = wc_clean( wp_unslash( $query_vars['s'] ) );
@@ -723,6 +713,22 @@ class WC_Admin_List_Table_Products extends WC_Admin_List_Table {
 			unset( $query_vars['s'] );
 		}

+		// Shipping class taxonomy. Drop the query var so core adds no direct-product tax condition (which
+		// would exclude a variable product whose variation alone uses the class), and carry the slug on a
+		// private marker so the replacement clause applies only to this query, via a subquery that also
+		// matches those parents.
+		if ( ! empty( $_GET['product_shipping_class'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
+			unset( $query_vars['product_shipping_class'] );
+
+			$shipping_class = wp_unslash( $_GET['product_shipping_class'] ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
+			if ( is_array( $shipping_class ) ) {
+				$shipping_class = reset( $shipping_class ); // Single-select filter: the first value wins.
+			}
+
+			$query_vars['wc_shipping_class_filter'] = is_string( $shipping_class ) ? sanitize_title( $shipping_class ) : '';
+			add_filter( 'posts_clauses', array( $this, 'filter_shipping_class_post_clauses' ), 10, 2 );
+		}
+
 		return $query_vars;
 	}

@@ -987,6 +993,7 @@ class WC_Admin_List_Table_Products extends WC_Admin_List_Table {
 		remove_filter( 'posts_clauses', array( $this, 'filter_downloadable_post_clauses' ) );
 		remove_filter( 'posts_clauses', array( $this, 'filter_virtual_post_clauses' ) );
 		remove_filter( 'posts_clauses', array( $this, 'filter_stock_status_post_clauses' ) );
+		remove_filter( 'posts_clauses', array( $this, 'filter_shipping_class_post_clauses' ) );
 		if ( $this->use_cogs_lookup_column ) {
 			remove_filter( 'posts_clauses', array( $this, 'order_by_cogs_value_asc_post_clauses' ) );
 			remove_filter( 'posts_clauses', array( $this, 'order_by_cogs_value_desc_post_clauses' ) );
@@ -1179,6 +1186,63 @@ class WC_Admin_List_Table_Products extends WC_Admin_List_Table {
 		return $args;
 	}

+	/**
+	 * Restrict the products list to a shipping class, including variable products whose variations use it.
+	 *
+	 * The taxonomy applies to both products and variations, but this list shows only products, so a matching
+	 * variation is resolved to its parent. Only published variations qualify their parent, matching the
+	 * catalog's out-of-stock filter. The slug is read from the query's own marker so the clause applies only
+	 * to the marked list query and not to any other query that runs while the filter is registered.
+	 *
+	 * @since 11.3.0
+	 * @param array    $args  SQL clause pieces (join, where, etc).
+	 * @param WP_Query $query The query being filtered.
+	 * @return array
+	 */
+	public function filter_shipping_class_post_clauses( $args, $query ) {
+		global $wpdb;
+
+		if ( ! isset( $query->query_vars['wc_shipping_class_filter'] ) ) {
+			return $args;
+		}
+
+		$shipping_class = $query->query_vars['wc_shipping_class_filter'];
+		if ( ! is_string( $shipping_class ) || '' === $shipping_class ) {
+			// Marked query with an empty or invalid slug: keep the filter active but match nothing.
+			$args['where'] .= ' AND 0 = 1 ';
+			return $args;
+		}
+
+		// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+		$args['where'] .= $wpdb->prepare(
+			" AND {$wpdb->posts}.ID IN (
+				SELECT DISTINCT
+					CASE
+						WHEN shipping_class_posts.post_type = 'product_variation' THEN shipping_class_posts.post_parent
+						ELSE shipping_class_posts.ID
+					END
+				FROM {$wpdb->term_relationships} shipping_class_tr
+				INNER JOIN {$wpdb->term_taxonomy} shipping_class_tt
+					ON shipping_class_tt.term_taxonomy_id = shipping_class_tr.term_taxonomy_id
+					AND shipping_class_tt.taxonomy = 'product_shipping_class'
+				INNER JOIN {$wpdb->terms} shipping_class_terms
+					ON shipping_class_terms.term_id = shipping_class_tt.term_id
+					AND shipping_class_terms.slug = %s
+				INNER JOIN {$wpdb->posts} shipping_class_posts
+					ON shipping_class_posts.ID = shipping_class_tr.object_id
+				WHERE shipping_class_posts.post_type = 'product'
+					OR (
+						shipping_class_posts.post_type = 'product_variation'
+						AND shipping_class_posts.post_status = 'publish'
+					)
+			) ",
+			$shipping_class
+		);
+		// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+
+		return $args;
+	}
+
 	/**
 	 * Join wc_product_meta_lookup to posts if not already joined.
 	 *
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 ca367273f25..ba86d76fe0f 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
@@ -712,6 +712,166 @@ class WC_Admin_List_Table_Products_Test extends WC_Unit_Test_Case {
 		);
 	}

+	/**
+	 * @testdox Products list shipping-class filter lists a variable product whose variation uses the class.
+	 */
+	public function test_shipping_class_filter_includes_variable_product_with_matching_variation(): void {
+		list( $term_id, $slug ) = $this->create_shipping_class();
+
+		$simple_in_class = WC_Helper_Product::create_simple_product();
+		wp_set_object_terms( $simple_in_class->get_id(), array( $term_id ), 'product_shipping_class' );
+
+		$variable = new WC_Product_Variable();
+		$variable->save();
+		$variation = new WC_Product_Variation();
+		$variation->set_parent_id( $variable->get_id() );
+		$variation->set_status( 'publish' );
+		$variation->set_regular_price( '10' );
+		$variation->save();
+		wp_set_object_terms( $variation->get_id(), array( $term_id ), 'product_shipping_class' );
+
+		$unrelated = WC_Helper_Product::create_simple_product();
+
+		$results = $this->query_product_ids_for_shipping_class( $slug );
+
+		$this->assertContains( $simple_in_class->get_id(), $results, 'A product directly in the shipping class should be listed.' );
+		$this->assertContains( $variable->get_id(), $results, 'A variable product whose variation uses the class should be listed via its parent.' );
+		$this->assertNotContains( $unrelated->get_id(), $results, 'A product not in the class should not be listed.' );
+		$this->assertNotContains( $variation->get_id(), $results, 'The variation itself is never a row in the products list.' );
+	}
+
+	/**
+	 * @testdox Products list shipping-class filter does not surface the parent of a non-published variation.
+	 */
+	public function test_shipping_class_filter_ignores_non_published_variation_parent(): void {
+		list( $term_id, $slug ) = $this->create_shipping_class();
+
+		$variable = new WC_Product_Variable();
+		$variable->save();
+		$variation = new WC_Product_Variation();
+		$variation->set_parent_id( $variable->get_id() );
+		$variation->set_status( 'private' );
+		$variation->set_regular_price( '10' );
+		$variation->save();
+		wp_set_object_terms( $variation->get_id(), array( $term_id ), 'product_shipping_class' );
+
+		$results = $this->query_product_ids_for_shipping_class( $slug );
+
+		$this->assertNotContains( $variable->get_id(), $results, 'A class used only by a non-published variation should not surface its parent.' );
+	}
+
+	/**
+	 * @testdox Products list shipping-class filter returns no products for an unused class.
+	 */
+	public function test_shipping_class_filter_returns_nothing_for_unused_class(): void {
+		list( , $slug ) = $this->create_shipping_class();
+
+		$product = WC_Helper_Product::create_simple_product();
+
+		$results = $this->query_product_ids_for_shipping_class( $slug );
+
+		$this->assertNotContains( $product->get_id(), $results, 'A class with no products assigned should match nothing.' );
+		$this->assertSame( array(), $results, 'An unused shipping class should return an empty list, not every product.' );
+	}
+
+	/**
+	 * @testdox Products list shipping-class filter matches nothing for an array-shaped request instead of fataling or listing everything.
+	 * @testWith ["heavy"]
+	 *           ["!!!"]
+	 *
+	 * @param string $value Single value supplied as an array-shaped request ("!!!" sanitizes to an empty slug).
+	 */
+	public function test_shipping_class_filter_handles_array_shaped_request( string $value ): void {
+		$product = WC_Helper_Product::create_simple_product();
+		$request = array( $value );
+
+		$original_get                   = $_GET; // phpcs:ignore WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Test cleanup restores the raw original request data.
+		$_GET['product_shipping_class'] = $request;
+
+		try {
+			$query_vars = $this->sut->request_query(
+				array(
+					'post_type'              => 'product',
+					'post_status'            => 'any',
+					'posts_per_page'         => -1,
+					'fields'                 => 'ids',
+					'product_shipping_class' => $request,
+				)
+			);
+			$results    = array_map( 'intval', ( new WP_Query( $query_vars ) )->get_posts() );
+		} finally {
+			$_GET = $original_get; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
+			remove_filter( 'posts_clauses', array( $this->sut, 'filter_shipping_class_post_clauses' ) );
+		}
+
+		$this->assertNotContains( $product->get_id(), $results, 'An array-shaped request with no real class should not list unrelated products.' );
+		$this->assertSame( array(), $results, 'An invalid shipping-class request should match nothing, not the whole catalog.' );
+	}
+
+	/**
+	 * @testdox Products list shipping-class filter drops the core taxonomy query var.
+	 */
+	public function test_shipping_class_filter_removes_core_taxonomy_query_var(): void {
+		list( , $slug ) = $this->create_shipping_class();
+
+		$original_get                   = $_GET; // phpcs:ignore WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Test cleanup restores the raw original request data.
+		$_GET['product_shipping_class'] = $slug;
+
+		try {
+			$query_vars = $this->sut->request_query(
+				array(
+					'post_type'              => 'product',
+					'product_shipping_class' => $slug,
+				)
+			);
+		} finally {
+			$_GET = $original_get; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
+			remove_filter( 'posts_clauses', array( $this->sut, 'filter_shipping_class_post_clauses' ) );
+		}
+
+		$this->assertArrayNotHasKey( 'product_shipping_class', $query_vars, 'The direct-product taxonomy query var must be removed so core does not exclude variation parents.' );
+	}
+
+	/**
+	 * @testdox Products list shipping-class filter combined with a non-matching search returns no products.
+	 */
+	public function test_shipping_class_filter_with_non_matching_search_returns_nothing(): void {
+		list( $term_id, $slug ) = $this->create_shipping_class();
+
+		$in_class = WC_Helper_Product::create_simple_product();
+		$in_class->set_name( 'Bulky lamp ' . wp_generate_password( 8, false ) );
+		$in_class->save();
+		wp_set_object_terms( $in_class->get_id(), array( $term_id ), 'product_shipping_class' );
+
+		$out_of_class = WC_Helper_Product::create_simple_product();
+		$search_token = wp_generate_password( 12, false );
+		$out_of_class->set_name( 'Unrelated ' . $search_token );
+		$out_of_class->save();
+
+		$original_get                   = $_GET; // phpcs:ignore WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Test cleanup restores the raw original request data.
+		$_GET['product_shipping_class'] = $slug;
+
+		try {
+			$query_vars = $this->sut->request_query(
+				array(
+					'post_type'              => 'product',
+					'post_status'            => 'any',
+					'posts_per_page'         => -1,
+					'fields'                 => 'ids',
+					'product_shipping_class' => $slug,
+					's'                      => $search_token,
+				)
+			);
+			$results    = array_map( 'intval', ( new WP_Query( $query_vars ) )->get_posts() );
+		} finally {
+			$_GET = $original_get; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
+			remove_filter( 'posts_clauses', array( $this->sut, 'filter_shipping_class_post_clauses' ) );
+		}
+
+		$this->assertNotContains( $out_of_class->get_id(), $results, 'A search match outside the shipping class must not appear.' );
+		$this->assertSame( array(), $results, 'A search with no product in the shipping class should return nothing, not the whole catalog.' );
+	}
+
 	/**
 	 * Products list out-of-stock filter includes variable products with out-of-stock variations.
 	 *
@@ -1075,6 +1235,49 @@ class WC_Admin_List_Table_Products_Test extends WC_Unit_Test_Case {
 		}
 	}

+	/**
+	 * Create a product shipping class term with a unique slug.
+	 *
+	 * @return array{int, string} The term ID and its slug.
+	 */
+	private function create_shipping_class(): array {
+		$slug = sanitize_title( 'ship ' . wp_generate_password( 8, false, false ) );
+		$term = wp_insert_term( 'Shipping ' . $slug, 'product_shipping_class', array( 'slug' => $slug ) );
+
+		return array( (int) $term['term_id'], $slug );
+	}
+
+	/**
+	 * Query the products list filtered by a shipping class slug.
+	 *
+	 * @param string $shipping_class Shipping class slug.
+	 * @return int[]
+	 */
+	private function query_product_ids_for_shipping_class( string $shipping_class ): array {
+		$original_get                   = $_GET; // phpcs:ignore WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Test cleanup restores the raw original request data.
+		$_GET['product_shipping_class'] = $shipping_class;
+
+		try {
+			// Pass the query var too, mirroring the admin request where the taxonomy query var is active.
+			$query_vars = $this->sut->request_query(
+				array(
+					'post_type'              => 'product',
+					'post_status'            => 'any',
+					'posts_per_page'         => -1,
+					'fields'                 => 'ids',
+					'product_shipping_class' => $shipping_class,
+				)
+			);
+
+			$query = new WP_Query( $query_vars );
+
+			return array_map( 'intval', $query->get_posts() );
+		} finally {
+			$_GET = $original_get; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
+			remove_filter( 'posts_clauses', array( $this->sut, 'filter_shipping_class_post_clauses' ) );
+		}
+	}
+
 	/**
 	 * Create title and content-only search matches.
 	 *