From e62982143b8ff0e8eeba9e2df0befc5840dcef04 Mon Sep 17 00:00:00 2001 From: Jaydeep Das Date: Fri, 3 Apr 2026 16:47:59 +0530 Subject: [PATCH 1/2] add: batch delete action for media grid Fixes: #54016 Current implementation of bulk attachment deletion in media (grid view) initiates an AJAX request per attachment. This patch implements a new AJAX action for bulk post deletion and rewrites the logic for bulk deletion in frontend. --- src/js/media/models/attachment.js | 48 +++++++++++++++++++- src/js/media/views/attachments/browser.js | 32 +++++++++----- src/wp-admin/admin-ajax.php | 1 + src/wp-admin/includes/ajax-actions.php | 53 +++++++++++++++++++++++ 4 files changed, 123 insertions(+), 11 deletions(-) diff --git a/src/js/media/models/attachment.js b/src/js/media/models/attachment.js index 267624b7d6c48..3c084447c4a84 100644 --- a/src/js/media/models/attachment.js +++ b/src/js/media/models/attachment.js @@ -163,7 +163,53 @@ Attachment = Backbone.Model.extend(/** @lends wp.media.model.Attachment.prototyp get: _.memoize( function( id, attachment ) { var Attachments = wp.media.model.Attachments; return Attachments.all.push( attachment || { id: id } ); - }) + }), + + /** + * Delete multiple attachments in a single batched AJAX request. + * + * Sends attachment IDs and their per-item nonces to the + * `delete-post-batch` endpoint, splitting into chunks of `batchSize`. + * On success, marks each successfully deleted model as destroyed and + * fires its `destroy` event so collections update automatically. + * + * @since 7.1.0 + * @static + * + * @param {wp.media.model.Attachment[]} models Array of Attachment models to delete. + * @param {number} [batchSize=50] Max items per request. + * @return {jQuery.Promise} Resolves with 0 or 1 for failure and success respectively. + */ + batchDestroy: function( models, batchSize ) { + batchSize = batchSize || 50; + + var promises = [], + i, slice, ids, nonces; + + for ( i = 0; i < models.length; i += batchSize ) { + slice = models.slice( i, i + batchSize ); + ids = []; + nonces = {}; + + _.each( slice, function( model ) { + ids.push( model.id ); + nonces[ model.id ] = model.get( 'nonces' )['delete']; + }); + + promises.push( wp.media.post( 'delete-post-batch', { + ids: ids, + nonces: nonces + }) ); + } + + return $.when.apply( null, promises ).then( function() { + _.each( models, function( model ) { + model.destroyed = true; + model.stopListening(); + model.trigger( 'destroy', model, model.collection ); + }); + }); + } }); module.exports = Attachment; diff --git a/src/js/media/views/attachments/browser.js b/src/js/media/views/attachments/browser.js index 65936efc186ef..b0cc69ac45d5d 100644 --- a/src/js/media/views/attachments/browser.js +++ b/src/js/media/views/attachments/browser.js @@ -234,7 +234,7 @@ AttachmentsBrowser = View.extend(/** @lends wp.media.view.AttachmentsBrowser.pro priority: -80 }).render() ); } - + var dateFilter, dateFilterLabel, dateFilterContainer; /* * Feels odd to bring the global media library switcher into the Attachment browser view. @@ -294,9 +294,10 @@ AttachmentsBrowser = View.extend(/** @lends wp.media.view.AttachmentsBrowser.pro controller: this.controller, priority: -80, click: function() { - var changed = [], removed = [], + var changed = [], removed = [], destroy = [], selection = this.controller.state().get( 'selection' ), - library = this.controller.state().get( 'library' ); + library = this.controller.state().get( 'library' ), + spinner = this.controller.content.get().toolbar.get( 'spinner' ); if ( ! selection.length ) { return; @@ -328,7 +329,7 @@ AttachmentsBrowser = View.extend(/** @lends wp.media.view.AttachmentsBrowser.pro changed.push( model.save() ); removed.push( model ); } else { - model.destroy({wait: true}); + destroy.push( model ); } } ); @@ -339,6 +340,14 @@ AttachmentsBrowser = View.extend(/** @lends wp.media.view.AttachmentsBrowser.pro library._requery( true ); this.controller.trigger( 'selection:action:done' ); }, this ) ); + } else if ( destroy.length ) { + this.controller.trigger( 'selection:action:done' ); + spinner.show(); + wp.media.model.Attachment.batchDestroy( destroy ).always( function() { + spinner.hide(); + }).fail( function() { + window.alert( l10n.errorDeleting ); + }); } else { this.controller.trigger( 'selection:action:done' ); } @@ -356,7 +365,8 @@ AttachmentsBrowser = View.extend(/** @lends wp.media.view.AttachmentsBrowser.pro click: function() { var removed = [], destroy = [], - selection = this.controller.state().get( 'selection' ); + selection = this.controller.state().get( 'selection' ), + spinner = this.controller.content.get().toolbar.get( 'spinner' ); if ( ! selection.length || ! window.confirm( l10n.warnBulkDelete ) ) { return; @@ -376,11 +386,13 @@ AttachmentsBrowser = View.extend(/** @lends wp.media.view.AttachmentsBrowser.pro } if ( destroy.length ) { - $.when.apply( null, destroy.map( function (item) { - return item.destroy(); - } ) ).then( _.bind( function() { - this.controller.trigger( 'selection:action:done' ); - }, this ) ); + this.controller.trigger( 'selection:action:done' ); + spinner.show(); + wp.media.model.Attachment.batchDestroy( destroy ).always( function() { + spinner.hide(); + }).fail( function() { + window.alert( l10n.errorDeleting ); + }); } } }).render() ); diff --git a/src/wp-admin/admin-ajax.php b/src/wp-admin/admin-ajax.php index 3ad60f95766e3..4f17cdffd6619 100644 --- a/src/wp-admin/admin-ajax.php +++ b/src/wp-admin/admin-ajax.php @@ -64,6 +64,7 @@ 'delete-link', 'delete-meta', 'delete-post', + 'delete-post-batch', 'trash-post', 'untrash-post', 'delete-page', diff --git a/src/wp-admin/includes/ajax-actions.php b/src/wp-admin/includes/ajax-actions.php index 2af08fba70af9..889401ac5351c 100644 --- a/src/wp-admin/includes/ajax-actions.php +++ b/src/wp-admin/includes/ajax-actions.php @@ -893,6 +893,59 @@ function wp_ajax_delete_post( $action ) { } } +/** + * Handles deleting multiple posts via a single AJAX request. + * + * Accepts an array of post IDs and their corresponding nonces, + * validates each individually, and deletes them. If any single + * deletion fails, it continues to the next post without stopping + * but returns a failure code. It returns a success code only if + * all deletions succeed. + * + * @since 7.1.0 + */ +function wp_ajax_delete_post_batch() { + $ids = isset( $_POST['ids'] ) ? array_map( 'intval', (array) $_POST['ids'] ) : array(); + $nonces = isset( $_POST['nonces'] ) ? (array) $_POST['nonces'] : array(); + + if ( empty( $ids ) ) { + wp_die( 0 ); + } + + $failed = false; + + foreach ( $ids as $id ) { + $id = (int) $id; + + if ( $id <= 0 ) { + continue; + } + + $nonce = isset( $nonces[ $id ] ) ? $nonces[ $id ] : ''; + if ( ! wp_verify_nonce( $nonce, "delete-post_$id" ) ) { + $failed = true; + continue; + } + + if ( ! current_user_can( 'delete_post', $id ) ) { + $failed = true; + continue; + } + + if ( get_post( $id ) ) { + if ( ! wp_delete_post( $id ) ) { + $failed = true; + } + } + } + + if ( $failed ) { + wp_die( 0 ); + } else { + wp_die( 1 ); + } +} + /** * Handles sending a post to the Trash via AJAX. * From c9601fd3ee14b4ee96adcfbf268b5562f3ad7aaa Mon Sep 17 00:00:00 2001 From: Jaydeep Das Date: Sat, 4 Apr 2026 19:54:42 +0530 Subject: [PATCH 2/2] fix: jshint lint errors --- src/js/media/models/attachment.js | 23 +++++++++-------------- 1 file changed, 9 insertions(+), 14 deletions(-) diff --git a/src/js/media/models/attachment.js b/src/js/media/models/attachment.js index 3c084447c4a84..921a4477765c2 100644 --- a/src/js/media/models/attachment.js +++ b/src/js/media/models/attachment.js @@ -184,22 +184,17 @@ Attachment = Backbone.Model.extend(/** @lends wp.media.model.Attachment.prototyp batchSize = batchSize || 50; var promises = [], - i, slice, ids, nonces; + i, slice, data; for ( i = 0; i < models.length; i += batchSize ) { - slice = models.slice( i, i + batchSize ); - ids = []; - nonces = {}; - - _.each( slice, function( model ) { - ids.push( model.id ); - nonces[ model.id ] = model.get( 'nonces' )['delete']; - }); - - promises.push( wp.media.post( 'delete-post-batch', { - ids: ids, - nonces: nonces - }) ); + slice = models.slice( i, i + batchSize ); + data = _.reduce( slice, function( acc, model ) { + acc.ids.push( model.id ); + acc.nonces[ model.id ] = model.get( 'nonces' )['delete']; + return acc; + }, { ids: [], nonces: {} } ); + + promises.push( wp.media.post( 'delete-post-batch', data ) ); } return $.when.apply( null, promises ).then( function() {