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.
*