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