Commit d7bec0c309e for woocommerce

commit d7bec0c309e0927b638c0daeca6aa4907899c899
Author: Luigi Teschio <gigitux@gmail.com>
Date:   Thu Nov 5 09:45:39 2026 +0100

    Fix removing product categories and brands through bulk editing (#70226)

    * Fix removing product taxonomies through bulk editing

    * Add changelog entries for bulk taxonomy editing

    * Limit bulk taxonomy editing to categories and brands

    * Update changelogs for category and brand editing

    * clean up unit test

    * add e2e test

    * restore test

    * clean up code

diff --git a/plugins/woocommerce/changelog/fix-bulk-edit-taxonomy-removal b/plugins/woocommerce/changelog/fix-bulk-edit-taxonomy-removal
new file mode 100644
index 00000000000..e65167f50d3
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-bulk-edit-taxonomy-removal
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Allow removing categories and brands from products in bulk.
diff --git a/plugins/woocommerce/client/legacy/changelog/fix-bulk-edit-taxonomy-removal b/plugins/woocommerce/client/legacy/changelog/fix-bulk-edit-taxonomy-removal
new file mode 100644
index 00000000000..67d85cde30f
--- /dev/null
+++ b/plugins/woocommerce/client/legacy/changelog/fix-bulk-edit-taxonomy-removal
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Show shared and mixed categories and brands when editing products in bulk.
diff --git a/plugins/woocommerce/client/legacy/css/admin.scss b/plugins/woocommerce/client/legacy/css/admin.scss
index 6b6d06c8ff8..93c9a5f109b 100644
--- a/plugins/woocommerce/client/legacy/css/admin.scss
+++ b/plugins/woocommerce/client/legacy/css/admin.scss
@@ -938,6 +938,20 @@ table.wc_status_table--tools {
 	}
 }

+// Adapted from WordPress Core's indeterminate checkbox styles.
+#bulk-edit ul.product_cat-checklist input[type="checkbox"]:indeterminate::before,
+#bulk-edit ul.product_brand-checklist input[type="checkbox"]:indeterminate::before {
+	content: "";
+	border-block-start: 2px solid #808080;
+	width: 65%;
+	height: 2px;
+	position: absolute;
+	inset-block-start: calc(50% + 1px);
+	inset-inline: 0;
+	margin-inline: auto;
+	transform: translateY(-50%);
+}
+
 #woocommerce-fields.inline-edit-col {
 	clear: left;

diff --git a/plugins/woocommerce/client/legacy/js/admin/quick-edit.js b/plugins/woocommerce/client/legacy/js/admin/quick-edit.js
index d050f67674e..bb57208f498 100644
--- a/plugins/woocommerce/client/legacy/js/admin/quick-edit.js
+++ b/plugins/woocommerce/client/legacy/js/admin/quick-edit.js
@@ -1,6 +1,70 @@
 /*global inlineEditPost, woocommerce_admin, woocommerce_quick_edit */
 jQuery(
 	function( $ ) {
+		function init_bulk_edit_taxonomies() {
+			const bulk_row = $( '#bulk-edit.inline-editor' );
+			const checkedProducts = $( '#the-list .check-column input[name="post[]"]:checked' );
+			if ( ! bulk_row.length || ! checkedProducts.length ) {
+				return;
+			}
+
+			[ 'product_cat', 'product_brand' ].forEach( function( taxonomy ) {
+				const list = $( 'ul.' + taxonomy + '-checklist', bulk_row );
+				const checkboxes = list.find( 'input[type="checkbox"]' );
+				if ( ! checkboxes.length ) {
+					return;
+				}
+
+				const taxonomy_field = 'wc_bulk_edit_taxonomies[' + taxonomy + ']';
+				const mixed_field = 'wc_bulk_edit_indeterminate[' + taxonomy + '][]';
+				list.find( 'input[name="' + taxonomy_field + '"], input[name="' + mixed_field + '"]' ).remove();
+				checkboxes.prop( 'checked', false ).prop( 'indeterminate', false ).removeAttr( 'aria-label' );
+
+				const categories = {};
+				let has_data = true;
+				checkedProducts.each( function() {
+					const id = $( this ).val();
+					const terms = $( '#' + taxonomy + '_' + id );
+					if ( ! terms.length ) {
+						has_data = false;
+						return;
+					}
+					const checked = terms.text().split( ',' );
+					checked.map( function( cid ) {
+						categories[ cid ] || ( categories[ cid ] = 0 );
+						categories[ cid ]++;
+					} );
+				} );
+				if ( ! has_data ) {
+					return;
+				}
+
+				checkboxes.each( function() {
+					if ( categories[ $( this ).val() ] == checkedProducts.length ) {
+						$( this ).prop( 'checked', true );
+					} else if ( categories[ $( this ).val() ] > 0 ) {
+						$( this ).prop( 'indeterminate', true );
+						if ( ! $( this ).parent().find( 'input[name="' + mixed_field + '"]' ).length ) {
+							const label = $( this ).parent().text();
+							$( this ).after( '<input type="hidden" name="' + mixed_field + '" value="' + $( this ).val() + '">' )
+								.attr( 'aria-label', label.trim() + ': ' + woocommerce_quick_edit.strings.some_products_have_term );
+						}
+					}
+				} );
+
+				$( 'input[name="tax_input[' + taxonomy + '][]"]:indeterminate', bulk_row ).on( 'change', function() {
+					$( this ).removeAttr( 'aria-label' ).parent().find( 'input[name="' + mixed_field + '"]' ).remove();
+				} );
+
+				$( '.inline-edit-save button', bulk_row ).on( 'click', function() {
+					checkboxes.prop( 'indeterminate', false );
+				} );
+
+				const seen = checkboxes.map( function() { return $( this ).val(); } ).get().join( ',' );
+				checkboxes.first().after( '<input type="hidden" name="' + taxonomy_field + '" value="' + seen + '">' );
+			} );
+		}
+
 		function init_sale_datepickers( $row ) {
 			if ( ! $.fn.datepicker ) {
 				return;
@@ -179,6 +243,7 @@ jQuery(
 				$( 'input.text', '.inline-edit-row' ).val( '' );
 				$( '#woocommerce-fields' ).find( 'select' ).prop( 'selectedIndex', 0 );
 				$( '#woocommerce-fields-bulk' ).find( '.inline-edit-group .change-input' ).hide();
+				init_bulk_edit_taxonomies();
 			}
 		);

diff --git a/plugins/woocommerce/includes/admin/class-wc-admin-assets.php b/plugins/woocommerce/includes/admin/class-wc-admin-assets.php
index 55960fac6c6..0b0e47a83b5 100644
--- a/plugins/woocommerce/includes/admin/class-wc-admin-assets.php
+++ b/plugins/woocommerce/includes/admin/class-wc-admin-assets.php
@@ -537,7 +537,8 @@ if ( ! class_exists( 'WC_Admin_Assets', false ) ) :

 				$params = array(
 					'strings' => array(
-						'allow_reviews' => esc_js( __( 'Enable reviews', 'woocommerce' ) ),
+						'allow_reviews'           => esc_js( __( 'Enable reviews', 'woocommerce' ) ),
+						'some_products_have_term' => esc_js( __( 'Some selected products have this term', 'woocommerce' ) ),
 					),
 				);

diff --git a/plugins/woocommerce/includes/admin/class-wc-admin-post-types.php b/plugins/woocommerce/includes/admin/class-wc-admin-post-types.php
index ee3cf0bc856..73914643934 100644
--- a/plugins/woocommerce/includes/admin/class-wc-admin-post-types.php
+++ b/plugins/woocommerce/includes/admin/class-wc-admin-post-types.php
@@ -557,6 +557,8 @@ class WC_Admin_Post_Types {

 		$data_store = $product->get_data_store();

+		$this->apply_bulk_edit_taxonomy_changes( $product, $request_data );
+
 		if ( ! empty( $request_data['change_weight'] ) && isset( $request_data['_weight'] ) ) {
 			$product->set_weight( wc_clean( wp_unslash( $request_data['_weight'] ) ) );
 		}
@@ -1021,6 +1023,54 @@ class WC_Admin_Post_Types {
 		return $_REQUEST;
 	}

+	/**
+	 * Apply category and brand changes through the product's CRUD properties.
+	 *
+	 * @since 11.4.0
+	 *
+	 * @param WC_Product $product Product being edited.
+	 * @param array      $request_data Bulk edit request fields.
+	 * @return void
+	 */
+	private function apply_bulk_edit_taxonomy_changes( WC_Product $product, array $request_data ): void {
+		$taxonomies = $request_data['wc_bulk_edit_taxonomies'] ?? array();
+		$tax_input  = $request_data['tax_input'] ?? array();
+		$mixed      = $request_data['wc_bulk_edit_indeterminate'] ?? array();
+		if ( ! is_array( $taxonomies ) || ! is_array( $tax_input ) || ! is_array( $mixed ) ) {
+			return;
+		}
+
+		// Taxonomy names differ from CRUD property names; setters keep the product object in sync.
+		$taxonomy_properties = array(
+			'product_cat'   => 'category_ids',
+			'product_brand' => 'brand_ids',
+		);
+		foreach ( $taxonomy_properties as $taxonomy_name => $property ) {
+			$taxonomy      = get_taxonomy( $taxonomy_name );
+			$seen          = $taxonomies[ $taxonomy_name ] ?? null;
+			$checked       = $tax_input[ $taxonomy_name ] ?? array();
+			$indeterminate = $mixed[ $taxonomy_name ] ?? array();
+			if ( ! $taxonomy || ! is_string( $seen ) || ! is_array( $checked ) || ! is_array( $indeterminate ) || ! $taxonomy->hierarchical || ! $taxonomy->show_in_quick_edit || ! current_user_can( $taxonomy->cap->assign_terms ) ) {
+				continue;
+			}
+
+			$seen          = wp_parse_id_list( wp_unslash( $seen ) );
+			$checked       = wp_parse_id_list( array_filter( wp_unslash( $checked ), 'is_scalar' ) );
+			$indeterminate = wp_parse_id_list( array_filter( wp_unslash( $indeterminate ), 'is_scalar' ) );
+			$current       = $product->{"get_$property"}( 'edit' );
+
+			// Match Core's mixed-state handling while preserving terms omitted from the checklist.
+			$editable  = array_diff( $seen, $indeterminate );
+			$preserved = array_diff( $current, $editable );
+			$terms     = array_values( array_unique( array_merge( $preserved, array_intersect( $checked, $editable ) ) ) );
+			if ( ! array_diff( $current, $terms ) && ! array_diff( $terms, $current ) ) {
+				continue;
+			}
+
+			$product->{"set_$property"}( $terms );
+		}
+	}
+
 	/**
 	 * Update the Cost of Goods Sold value coming from a bulk edit for a product.
 	 *
diff --git a/plugins/woocommerce/tests/e2e/tests/product/product-bulk-edit-taxonomies.spec.ts b/plugins/woocommerce/tests/e2e/tests/product/product-bulk-edit-taxonomies.spec.ts
new file mode 100644
index 00000000000..f91825ab01c
--- /dev/null
+++ b/plugins/woocommerce/tests/e2e/tests/product/product-bulk-edit-taxonomies.spec.ts
@@ -0,0 +1,191 @@
+/**
+ * External dependencies
+ */
+import { faker } from '@faker-js/faker';
+import { WC_API_PATH } from '@woocommerce/e2e-utils-playwright';
+import type { Page } from '@playwright/test';
+
+/**
+ * Internal dependencies
+ */
+import { test as baseTest, expect } from '../../fixtures/fixtures';
+import { ADMIN_STATE_PATH } from '../../playwright.config';
+
+interface Term {
+	id: number;
+	name: string;
+}
+
+interface BulkEditData {
+	search: string;
+	products: { id: number; name: string }[];
+	terms: Record< 'shared' | 'retained' | 'mixed' | 'unassigned', Term >;
+}
+
+async function saveAndVerifyAssignments(
+	page: Page,
+	{ products, terms }: BulkEditData,
+	expected: Term[][]
+) {
+	await page
+		.locator( '#bulk-edit' )
+		.getByRole( 'button', { name: 'Update' } )
+		.click();
+	await expect( page.locator( '#message' ) ).toContainText(
+		'2 products updated'
+	);
+	await page.reload();
+
+	for ( const [ index, product ] of products.entries() ) {
+		const row = page.locator( `#post-${ product.id }` );
+		await expect( row ).toBeVisible();
+		for ( const term of Object.values( terms ) ) {
+			await expect(
+				row.getByRole( 'link', { name: term.name, exact: true } )
+			).toHaveCount( expected[ index ].includes( term ) ? 1 : 0 );
+		}
+	}
+}
+
+for ( const { field, taxonomy } of [
+	{ field: 'categories', taxonomy: 'product_cat' },
+	{ field: 'brands', taxonomy: 'product_brand' },
+] ) {
+	const test = baseTest.extend< { bulkEditData: BulkEditData } >( {
+		storageState: ADMIN_STATE_PATH,
+		bulkEditData: async ( { restApi }, provide ) => {
+			const search = `bulk-taxonomy-${ faker.string.uuid() }`;
+			const products: BulkEditData[ 'products' ] = [];
+			const terms = {} as BulkEditData[ 'terms' ];
+
+			try {
+				for ( const name of [
+					'shared',
+					'retained',
+					'mixed',
+					'unassigned',
+				] as const ) {
+					const { data } = await restApi.post< Term >(
+						`${ WC_API_PATH }/products/${ field }`,
+						{ name: `${ search }-${ name }` }
+					);
+					terms[ name ] = data;
+				}
+
+				for ( let i = 0; i < 2; i++ ) {
+					const assignments = [ terms.shared, terms.retained ];
+					if ( i === 0 ) {
+						assignments.push( terms.mixed );
+					}
+					const { data } = await restApi.post<
+						BulkEditData[ 'products' ][ number ]
+					>( `${ WC_API_PATH }/products`, {
+						name: `${ search } product ${ i + 1 }`,
+						type: 'simple',
+						regular_price: '10',
+						[ field ]: assignments.map( ( { id } ) => ( { id } ) ),
+					} );
+					products.push( data );
+				}
+
+				await provide( { search, products, terms } );
+			} finally {
+				for ( const product of products ) {
+					await restApi.delete(
+						`${ WC_API_PATH }/products/${ product.id }`,
+						{ force: true }
+					);
+				}
+				for ( const term of Object.values( terms ) ) {
+					await restApi.delete(
+						`${ WC_API_PATH }/products/${ field }/${ term.id }`,
+						{ force: true }
+					);
+				}
+			}
+		},
+	} );
+
+	test.describe( `Bulk edit product ${ field }`, () => {
+		test.beforeEach( async ( { page, bulkEditData } ) => {
+			const { search, products, terms } = bulkEditData;
+			await page.goto(
+				`wp-admin/edit.php?post_type=product&s=${ encodeURIComponent( search ) }`
+			);
+			for ( const product of products ) {
+				await page
+					.locator( `#post-${ product.id }` )
+					.getByRole( 'checkbox' )
+					.check();
+			}
+			await page
+				.locator( '#bulk-action-selector-top' )
+				.selectOption( 'edit' );
+			await page.locator( '#doaction' ).click();
+			await expect( page.locator( '#bulk-titles-list li' ) ).toHaveCount(
+				2
+			);
+
+			const checklist = page.locator(
+				`#bulk-edit .${ taxonomy }-checklist`
+			);
+			await expect(
+				checklist.getByLabel( terms.shared.name, { exact: true } )
+			).toBeChecked();
+			await expect(
+				checklist.getByLabel( terms.retained.name, { exact: true } )
+			).toBeChecked();
+			await expect(
+				checklist.getByLabel( terms.unassigned.name, { exact: true } )
+			).not.toBeChecked();
+		} );
+
+		test( 'can remove a shared term from both products', async ( {
+			page,
+			bulkEditData,
+		} ) => {
+			const { shared, retained, mixed } = bulkEditData.terms;
+			const checkbox = page
+				.locator( '#bulk-edit' )
+				.getByLabel( shared.name, { exact: true } );
+			await checkbox.uncheck();
+			await expect( checkbox ).not.toBeChecked();
+			await saveAndVerifyAssignments( page, bulkEditData, [
+				[ retained, mixed ],
+				[ retained ],
+			] );
+		} );
+
+		test( 'preserves mixed assignments when saved without changes', async ( {
+			page,
+			bulkEditData,
+		} ) => {
+			const { shared, retained, mixed } = bulkEditData.terms;
+			await expect(
+				page
+					.locator( '#bulk-edit' )
+					.getByRole( 'checkbox', { name: mixed.name } )
+			).toBeChecked( { indeterminate: true } );
+			await saveAndVerifyAssignments( page, bulkEditData, [
+				[ shared, retained, mixed ],
+				[ shared, retained ],
+			] );
+		} );
+
+		test( 'can add an unassigned term to both products', async ( {
+			page,
+			bulkEditData,
+		} ) => {
+			const { shared, retained, mixed, unassigned } = bulkEditData.terms;
+			const checkbox = page
+				.locator( '#bulk-edit' )
+				.getByLabel( unassigned.name, { exact: true } );
+			await checkbox.check();
+			await expect( checkbox ).toBeChecked();
+			await saveAndVerifyAssignments( page, bulkEditData, [
+				[ shared, retained, mixed, unassigned ],
+				[ shared, retained, unassigned ],
+			] );
+		} );
+	} );
+}
diff --git a/plugins/woocommerce/tests/php/includes/admin/class-wc-admin-post-types-test.php b/plugins/woocommerce/tests/php/includes/admin/class-wc-admin-post-types-test.php
index 499ad769f5e..be6bc59f6f8 100644
--- a/plugins/woocommerce/tests/php/includes/admin/class-wc-admin-post-types-test.php
+++ b/plugins/woocommerce/tests/php/includes/admin/class-wc-admin-post-types-test.php
@@ -334,6 +334,44 @@ class WC_Admin_Post_Types_Test extends WC_Unit_Test_Case {
 		);
 	}

+	/**
+	 * @testdox Bulk Edit updates categories and brands, preserving mixed and omitted terms.
+	 *
+	 * @testWith ["product_cat", "category_ids"]
+	 *           ["product_brand", "brand_ids"]
+	 * @param string $taxonomy Product taxonomy.
+	 * @param string $property Product property.
+	 */
+	public function test_bulk_edit_taxonomy_scope( string $taxonomy, string $property ): void {
+		$terms = array();
+		foreach ( array( 'Removed', 'Mixed', 'Omitted', 'Added' ) as $name ) {
+			$terms[] = $this->factory->term->create(
+				array(
+					'taxonomy' => $taxonomy,
+					'name'     => $name,
+				)
+			);
+		}
+		$product = WC_Helper_Product::create_simple_product();
+		$product->{"set_$property"}( array( $terms[0], $terms[1], $terms[2] ) );
+		$product->save();
+
+		$this->bulk_edit(
+			array( $product ),
+			array(
+				'wc_bulk_edit_taxonomies'    => array( $taxonomy => implode( ',', array( $terms[0], $terms[1], $terms[3] ) ) ),
+				'wc_bulk_edit_indeterminate' => array( $taxonomy => array( $terms[1] ) ),
+				'tax_input'                  => array( $taxonomy => array( $terms[3] ) ),
+			)
+		);
+
+		$this->assertEqualsCanonicalizing(
+			array( $terms[1], $terms[2], $terms[3] ),
+			wp_get_object_terms( $product->get_id(), $taxonomy, array( 'fields' => 'ids' ) ),
+			'Category and brand assignments should change, with mixed and omitted terms preserved.'
+		);
+	}
+
 	/**
 	 * @testdox Bulk Edit applies percentage price and stock changes to each edited product.
 	 *