diff --git a/src/js/_enqueues/admin/inline-edit-post.js b/src/js/_enqueues/admin/inline-edit-post.js
index e7d4496b88..962f421448 100644
--- a/src/js/_enqueues/admin/inline-edit-post.js
+++ b/src/js/_enqueues/admin/inline-edit-post.js
@@ -178,6 +178,8 @@ window.wp = window.wp || {};
*/
setBulk : function(){
var te = '', type = this.type, c = true;
+ var checkedPosts = $( 'tbody th.check-column input[type="checkbox"]:checked' );
+ var categories = {};
this.revert();
$( '#bulk-edit td' ).attr( 'colspan', $( 'th:visible, td:visible', '.widefat:first thead' ).length );
@@ -217,6 +219,44 @@ window.wp = window.wp || {};
// Populate the list of items to bulk edit.
$( '#bulk-titles' ).html( '
' );
+ // Gather up some statistics on which of these checked posts are in which categories.
+ checkedPosts.each( function() {
+ var id = $( this ).val();
+ var checked = $( '#category_' + id ).text().split( ',' );
+
+ checked.map( function( cid ) {
+ categories[ cid ] || ( categories[ cid ] = 0 );
+ // Just record that this category is checked.
+ categories[ cid ]++;
+ } );
+ } );
+
+ // Compute initial states.
+ $( '.inline-edit-categories input[name="post_category[]"]' ).each( function() {
+ if ( categories[ $( this ).val() ] == checkedPosts.length ) {
+ // If the number of checked categories matches the number of selected posts, then all posts are in this category.
+ $( this ).prop( 'checked', true );
+ } else if ( categories[ $( this ).val() ] > 0 ) {
+ // If the number is less than the number of selected posts, then it's indeterminate.
+ $( this ).prop( 'indeterminate', true );
+ if ( ! $( this ).parent().find( 'input[name="indeterminate_post_category[]"]' ).length ) {
+ // Get the term label text.
+ var label = $( this ).parent().text();
+ // Set indeterminate states for the backend. Add accessible text for indeterminate inputs.
+ $( this ).after( '' ).attr( 'aria-label', label.trim() + ': ' + wp.i18n.__( 'Some selected posts have this category' ) );
+ }
+ }
+ } );
+
+ $( '.inline-edit-categories input[name="post_category[]"]:indeterminate' ).on( 'change', function() {
+ // Remove accessible label text. Remove the indeterminate flags as there was a specific state change.
+ $( this ).removeAttr( 'aria-label' ).parent().find( 'input[name="indeterminate_post_category[]"]' ).remove();
+ } );
+
+ $( '.inline-edit-save button' ).on( 'click', function() {
+ $( '.inline-edit-categories input[name="post_category[]"]' ).prop( 'indeterminate', false );
+ } );
+
/**
* Binds on click events to handle the list of items to bulk edit.
*
diff --git a/src/wp-admin/css/list-tables.css b/src/wp-admin/css/list-tables.css
index 098c31a6d1..50c74b3a7e 100644
--- a/src/wp-admin/css/list-tables.css
+++ b/src/wp-admin/css/list-tables.css
@@ -1149,6 +1149,17 @@ ul.cat-checklist {
overflow-y: scroll;
}
+ul.cat-checklist input[name="post_category[]"]:indeterminate::before {
+ content: '';
+ border-top: 2px solid grey;
+ width: 65%;
+ height: 2px;
+ position: absolute;
+ top: calc( 50% + 1px );
+ left: 50%;
+ transform: translate( -50%, -50% );
+}
+
#bulk-titles .ntdelbutton,
#bulk-titles .ntdeltitle,
.inline-edit-row fieldset ul.cat-checklist label {
diff --git a/src/wp-admin/includes/post.php b/src/wp-admin/includes/post.php
index 90aaf2228e..a31332426b 100644
--- a/src/wp-admin/includes/post.php
+++ b/src/wp-admin/includes/post.php
@@ -649,8 +649,21 @@ function bulk_edit_posts( $post_data = null ) {
}
if ( isset( $new_cats ) && in_array( 'category', $tax_names, true ) ) {
- $cats = (array) wp_get_post_categories( $post_id );
- $post_data['post_category'] = array_unique( array_merge( $cats, $new_cats ) );
+ $cats = (array) wp_get_post_categories( $post_id );
+
+ if (
+ isset( $post_data['indeterminate_post_category'] )
+ && is_array( $post_data['indeterminate_post_category'] )
+ ) {
+ $indeterminate_post_category = $post_data['indeterminate_post_category'];
+ } else {
+ $indeterminate_post_category = array();
+ }
+
+ $indeterminate_cats = array_intersect( $cats, $indeterminate_post_category );
+ $determinate_cats = array_diff( $new_cats, $indeterminate_post_category );
+ $post_data['post_category'] = array_unique( array_merge( $indeterminate_cats, $determinate_cats ) );
+
unset( $post_data['tax_input']['category'] );
}
diff --git a/tests/phpunit/tests/admin/includesPost.php b/tests/phpunit/tests/admin/includesPost.php
index dcf0a4952f..0796b1012c 100644
--- a/tests/phpunit/tests/admin/includesPost.php
+++ b/tests/phpunit/tests/admin/includesPost.php
@@ -384,6 +384,131 @@ class Tests_Admin_IncludesPost extends WP_UnitTestCase {
}
}
+ /**
+ * @ticket 11302
+ */
+ public function test_bulk_edit_if_categories_unchanged() {
+ wp_set_current_user( self::$admin_id );
+
+ $post_ids = self::factory()->post->create_many( 3 );
+
+ wp_set_post_categories( $post_ids[0], array( 'test1', 'test2' ) );
+ wp_set_post_categories( $post_ids[1], array( 'test2', 'test3' ) );
+ wp_set_post_categories( $post_ids[2], array( 'test1', 'test3' ) );
+
+ $terms1 = wp_get_post_categories( $post_ids[0] );
+ $terms2 = wp_get_post_categories( $post_ids[1] );
+ $terms3 = wp_get_post_categories( $post_ids[2] );
+
+ $indeterminate_categories = array_merge( $terms1, $terms2, $terms3 );
+
+ $request = array(
+ '_status' => -1,
+ 'post' => $post_ids,
+ 'indeterminate_post_category' => $indeterminate_categories,
+ );
+
+ bulk_edit_posts( $request );
+
+ $updated_terms1 = wp_get_post_categories( $post_ids[0] );
+ $updated_terms2 = wp_get_post_categories( $post_ids[1] );
+ $updated_terms3 = wp_get_post_categories( $post_ids[2] );
+
+ $this->assertSame( $terms1, $updated_terms1, 'Post 1 should have terms 1 and 2.' );
+ $this->assertSame( $terms2, $updated_terms2, 'Post 2 should have terms 2 and 3.' );
+ $this->assertSame( $terms3, $updated_terms3, 'Post 3 should have terms 1 and 3.' );
+ }
+
+ /**
+ * @ticket 11302
+ */
+ public function test_bulk_edit_if_some_categories_added() {
+ wp_set_current_user( self::$admin_id );
+
+ $post_ids = self::factory()->post->create_many( 3 );
+ $term1 = wp_create_category( 'test1' );
+ $term2 = wp_create_category( 'test2' );
+ $term3 = wp_create_category( 'test3' );
+ $term4 = wp_create_category( 'test4' );
+
+ wp_set_post_categories( $post_ids[0], array( $term1, $term2 ) );
+ wp_set_post_categories( $post_ids[1], array( $term2, $term3 ) );
+ wp_set_post_categories( $post_ids[2], array( $term1, $term3 ) );
+
+ $terms1 = wp_get_post_categories( $post_ids[0], array( 'fields' => 'ids' ) );
+ $terms2 = wp_get_post_categories( $post_ids[1], array( 'fields' => 'ids' ) );
+ $terms3 = wp_get_post_categories( $post_ids[2], array( 'fields' => 'ids' ) );
+ // All existing categories are indeterminate.
+ $indeterminate = array_unique( array_merge( $terms1, $terms2, $terms3 ) );
+ // Add new category.
+ $categories[] = $term4;
+
+ $request = array(
+ '_status' => -1,
+ 'post' => $post_ids,
+ 'post_category' => $categories,
+ 'indeterminate_post_category' => $indeterminate,
+ );
+
+ bulk_edit_posts( $request );
+
+ $updated_terms1 = wp_get_post_categories( $post_ids[0], array( 'fields' => 'ids' ) );
+ $updated_terms2 = wp_get_post_categories( $post_ids[1], array( 'fields' => 'ids' ) );
+ $updated_terms3 = wp_get_post_categories( $post_ids[2], array( 'fields' => 'ids' ) );
+
+ // Each post should have the same categories as before and add term 4.
+ $this->assertSame( array( $term1, $term2, $term4 ), $updated_terms1, 'Post should have terms 1, 2, and 4.' );
+ $this->assertSame( array( $term2, $term3, $term4 ), $updated_terms2, 'Post should have terms 2, 3, and 4.' );
+ $this->assertSame( array( $term1, $term3, $term4 ), $updated_terms3, 'Post should have terms 1, 3, and 4.' );
+ }
+
+ /**
+ * @ticket 11302
+ */
+ public function test_bulk_edit_if_some_categories_removed() {
+ wp_set_current_user( self::$admin_id );
+
+ $post_ids = self::factory()->post->create_many( 3 );
+ $term1 = wp_create_category( 'test1' );
+ $term2 = wp_create_category( 'test2' );
+ $term3 = wp_create_category( 'test3' );
+
+ wp_set_post_categories( $post_ids[0], array( $term1, $term2 ) );
+ wp_set_post_categories( $post_ids[1], array( $term2, $term3 ) );
+ wp_set_post_categories( $post_ids[2], array( $term1, $term3 ) );
+
+ $terms1 = wp_get_post_categories( $post_ids[0], array( 'fields' => 'ids' ) );
+ $terms2 = wp_get_post_categories( $post_ids[1], array( 'fields' => 'ids' ) );
+ $terms3 = wp_get_post_categories( $post_ids[2], array( 'fields' => 'ids' ) );
+
+ // Terms 2 and 3 are in indeterminate state.
+ $indeterminate = array( $term2, $term3 );
+ // Remove term 1 from selected categories.
+ $categories = array_unique( array_merge( $terms1, $terms2, $terms3 ) );
+ $remove_key = array_search( $term1, $categories, true );
+ unset( $categories[ $remove_key ] );
+
+ $request = array(
+ '_status' => -1,
+ 'post' => $post_ids,
+ 'post_category' => $categories,
+ 'indeterminate_post_category' => $indeterminate,
+ );
+
+ bulk_edit_posts( $request );
+
+ $updated_terms1 = wp_get_post_categories( $post_ids[0], array( 'fields' => 'ids' ) );
+ $updated_terms2 = wp_get_post_categories( $post_ids[1], array( 'fields' => 'ids' ) );
+ $updated_terms3 = wp_get_post_categories( $post_ids[2], array( 'fields' => 'ids' ) );
+
+ // Post 1 should only have term 2.
+ $this->assertSame( $updated_terms1, array( $term2 ), 'Post 1 should only have term 2.' );
+ // Post 2 should be unchanged.
+ $this->assertSame( $terms2, $updated_terms2, 'Post 2 should be unchanged.' );
+ // Post 3 should only have term 3.
+ $this->assertSame( $updated_terms3, array( $term3 ), 'Post 3 should only have term 3.' );
+ }
+
/**
* Tests that `bulk_edit_posts()` fires the 'bulk_edit_posts' action.
*