Commit f5459b9f110 for woocommerce

commit f5459b9f11070e9ee5a0cc6d45eb8b8da6b848d3
Author: Albert Juhé Lluveras <contact@albertjuhe.com>
Date:   Tue Oct 6 11:24:14 2026 +0200

    Product Export: enable category filtering when exporting variable products and product variations together (#69264)

    * Add changelog

    * Product Export: enable category filtering when exporting variable products and product variations together

    * Make sure export percentages are correct

    * Harden test

diff --git a/plugins/woocommerce/changelog/update-allow-filtering-by-category-when-exporting-variations b/plugins/woocommerce/changelog/update-allow-filtering-by-category-when-exporting-variations
new file mode 100644
index 00000000000..aee8311875f
--- /dev/null
+++ b/plugins/woocommerce/changelog/update-allow-filtering-by-category-when-exporting-variations
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Product Export: enable category filtering when exporting variable products and product variations together
diff --git a/plugins/woocommerce/client/legacy/js/admin/wc-product-export.js b/plugins/woocommerce/client/legacy/js/admin/wc-product-export.js
index 69331654bd8..9740235f558 100644
--- a/plugins/woocommerce/client/legacy/js/admin/wc-product-export.js
+++ b/plugins/woocommerce/client/legacy/js/admin/wc-product-export.js
@@ -91,9 +91,11 @@
 	 * Handle fields per export type.
 	 */
 	productExportForm.prototype.exportTypeFields = function() {
-		var exportCategory = $( '.woocommerce-exporter-category' );
+		var exportCategory          = $( '.woocommerce-exporter-category' );
+		var exportTypes             = $( this ).val() || [];
+		var exportingVariationsOnly = -1 !== $.inArray( 'variation', exportTypes ) && -1 === $.inArray( 'variable', exportTypes );

-		if ( -1 !== $.inArray( 'variation', $( this ).val() ) ) {
+		if ( exportingVariationsOnly ) {
 			exportCategory.closest( 'tr' ).hide();
 			exportCategory.val( '' ).trigger( 'change' ); // Reset WooSelect selected value.
 		} else {
diff --git a/plugins/woocommerce/includes/export/class-wc-product-csv-exporter.php b/plugins/woocommerce/includes/export/class-wc-product-csv-exporter.php
index 3622df651cb..240d105e52c 100644
--- a/plugins/woocommerce/includes/export/class-wc-product-csv-exporter.php
+++ b/plugins/woocommerce/includes/export/class-wc-product-csv-exporter.php
@@ -63,6 +63,13 @@ class WC_Product_CSV_Exporter extends WC_CSV_Batch_Exporter {
 	 */
 	protected $product_ids_to_export = array();

+	/**
+	 * Rows on this page that the paginated query did not return.
+	 *
+	 * @var int
+	 */
+	protected $rows_outside_query = 0;
+
 	/**
 	 * Constructor.
 	 */
@@ -225,10 +232,11 @@ class WC_Product_CSV_Exporter extends WC_CSV_Batch_Exporter {

 		$products = wc_get_products( $args );

-		$this->total_rows   = $products->total;
-		$this->row_data     = array();
-		$variable_products  = array();
-		$include_variations = ! isset( $args['type'] ) || in_array( ProductType::VARIATION, (array) $args['type'], true );
+		$this->total_rows         = $products->total;
+		$this->row_data           = array();
+		$this->rows_outside_query = 0;
+		$variable_products        = array();
+		$include_variations       = ! isset( $args['type'] ) || in_array( ProductType::VARIATION, (array) $args['type'], true );

 		foreach ( $products->products as $product ) {
 			// Check if the product is variable and if either the include or category filter is active.
@@ -261,11 +269,30 @@ class WC_Product_CSV_Exporter extends WC_CSV_Batch_Exporter {

 				foreach ( $products as $product ) {
 					$this->row_data[] = $this->generate_row_data( $product );
+					++$this->rows_outside_query;
 				}
 			}
 		}
 	}

+	/**
+	 * Get total percentage complete.
+	 *
+	 * Variations appended after the paginated query are written on this page, but total_rows does not include them. Leave them out of this calculation so the export finishes at 100 instead of stopping early or requesting pages forever.
+	 *
+	 * @since 11.3.0
+	 * @return int
+	 */
+	public function get_percent_complete() {
+		if ( 0 === $this->total_rows ) {
+			return 100;
+		}
+
+		$exported = parent::get_total_exported() - $this->rows_outside_query;
+
+		return (int) floor( ( max( 0, $exported ) / $this->total_rows ) * 100 );
+	}
+
 	/**
 	 * Take a product and generate row data from it for export.
 	 *
diff --git a/plugins/woocommerce/tests/php/includes/exporter/class-wc-product-csv-exporter-test.php b/plugins/woocommerce/tests/php/includes/exporter/class-wc-product-csv-exporter-test.php
index e9ac1e7d7e3..926bc0163e0 100644
--- a/plugins/woocommerce/tests/php/includes/exporter/class-wc-product-csv-exporter-test.php
+++ b/plugins/woocommerce/tests/php/includes/exporter/class-wc-product-csv-exporter-test.php
@@ -58,10 +58,11 @@ class WC_Product_CSV_Exporter_Test extends \WC_Unit_Test_Case {
 	/**
 	 * Create a variable product assigned to a product category.
 	 *
-	 * @return array{product: WC_Product_Variable, category_slug: string}
+	 * @param string $category_name Category name.
+	 * @return array{product: WC_Product_Variable, category_slug: string, category_id: int}
 	 */
-	private function create_categorized_variation_product(): array {
-		$term = wp_insert_term( 'Export Test Category', 'product_cat' );
+	private function create_categorized_variation_product( string $category_name = 'Export Test Category' ): array {
+		$term = wp_insert_term( $category_name, 'product_cat' );
 		$this->assertIsArray( $term, 'Failed to create product category for export test.' );

 		$product = WC_Helper_Product::create_variation_product();
@@ -73,9 +74,23 @@ class WC_Product_CSV_Exporter_Test extends \WC_Unit_Test_Case {
 		return array(
 			'product'       => $product,
 			'category_slug' => $category->slug,
+			'category_id'   => (int) $category->term_id,
 		);
 	}

+	/**
+	 * Write the current page the way file generation does, so the completion percentage includes its rows.
+	 *
+	 * @param WC_Product_CSV_Exporter $exporter Exporter instance.
+	 */
+	private function export_current_page( WC_Product_CSV_Exporter $exporter ): void {
+		$exporter->prepare_data_to_export();
+
+		$export_rows = ( new ReflectionClass( WC_Product_CSV_Exporter::class ) )->getMethod( 'export_rows' );
+		$export_rows->setAccessible( true );
+		$export_rows->invoke( $exporter );
+	}
+
 	/**
 	 * @testdox variations should use draft status from parent product
 	 */
@@ -162,6 +177,50 @@ class WC_Product_CSV_Exporter_Test extends \WC_Unit_Test_Case {
 		}
 	}

+	/**
+	 * @testdox Category export includes variable products and their variations without changing the completion percentage.
+	 */
+	public function test_appended_variations_do_not_change_category_export_completion(): void {
+		$first  = $this->create_categorized_variation_product( 'Export Paged Progress Category' );
+		$second = WC_Helper_Product::create_variation_product();
+		$second->set_category_ids( array( $first['category_id'] ) );
+		$second->save();
+		$this->create_categorized_variation_product( 'Export Other Progress Category' );
+
+		$products    = array(
+			1 => $first['product'],
+			2 => $second,
+		);
+		$percentages = array(
+			1 => 50,
+			2 => 100,
+		);
+
+		foreach ( $products as $page => $product ) {
+			$exporter = new WC_Product_CSV_Exporter();
+			$exporter->set_limit( 1 );
+			$exporter->set_page( $page );
+			$exporter->set_product_types_to_export( array( ProductType::VARIABLE, ProductType::VARIATION ) );
+			$exporter->set_product_category_to_export( array( $first['category_slug'] ) );
+			$this->export_current_page( $exporter );
+
+			$exported_ids = array_map( 'intval', wp_list_pluck( $this->get_exported_data( $exporter ), 'id' ) );
+			$expected_ids = array_map(
+				'intval',
+				array_merge(
+					array( $product->get_id() ),
+					$product->get_children( 'edit' )
+				)
+			);
+			sort( $exported_ids );
+			sort( $expected_ids );
+
+			$this->assertGreaterThan( $exporter->get_limit(), count( $exported_ids ), 'The page should include the parent and its appended variations.' );
+			$this->assertSame( $expected_ids, $exported_ids, 'The page should include that variable product and its variations, and exclude the other category.' );
+			$this->assertSame( $percentages[ $page ], $exporter->get_percent_complete() );
+		}
+	}
+
 	/**
 	 * @testdox Selected product IDs restrict the export to those products and their variations.
 	 */