diff --git a/ghost/admin/app/components/posts-list/context-menu.js b/ghost/admin/app/components/posts-list/context-menu.js index 47435afa69..a3257532bf 100644 --- a/ghost/admin/app/components/posts-list/context-menu.js +++ b/ghost/admin/app/components/posts-list/context-menu.js @@ -1,11 +1,15 @@ import Component from '@glimmer/component'; +import DeletePostsModal from './modals/delete-posts'; import {action} from '@ember/object'; import {inject as service} from '@ember/service'; +import {task} from 'ember-concurrency'; export default class PostsContextMenu extends Component { @service ajax; @service ghostPaths; @service session; + @service infinity; + @service modals; get menu() { return this.args.menu; @@ -16,10 +20,34 @@ export default class PostsContextMenu extends Component { } @action - deletePosts() { - // Use filter in menu.selectionList.filter - alert('Deleting posts not yet supported.'); + async deletePosts() { this.menu.close(); + await this.modals.open(DeletePostsModal, { + isSingle: this.selectionList.isSingle, + count: this.selectionList.count, + confirm: this.deletePostsTask + }); + } + + @task + *deletePostsTask(close) { + const deletedModels = this.selectionList.availableModels; + yield this.performBulkDestroy(); + const remainingModels = this.selectionList.infinityModel.content.filter((model) => { + return !deletedModels.includes(model); + }); + // Deleteobjects method from infintiymodel is broken for all models except the first page, so we cannot use this + this.infinity.replace(this.selectionList.infinityModel, remainingModels); + this.selectionList.clearSelection(); + close(); + + return true; + } + + async performBulkDestroy() { + const filter = this.selectionList.filter; + let bulkUpdateUrl = this.ghostPaths.url.api(`posts`) + `?filter=${encodeURIComponent(filter)}`; + return await this.ajax.delete(bulkUpdateUrl); } async performBulkEdit(_action, meta = {}) { diff --git a/ghost/admin/app/components/posts-list/modals/delete-posts.hbs b/ghost/admin/app/components/posts-list/modals/delete-posts.hbs new file mode 100644 index 0000000000..8a38224ae3 --- /dev/null +++ b/ghost/admin/app/components/posts-list/modals/delete-posts.hbs @@ -0,0 +1,28 @@ +
diff --git a/ghost/admin/app/utils/selection-list.js b/ghost/admin/app/utils/selection-list.js index f8ca5c7dcf..912662ede6 100644 --- a/ghost/admin/app/utils/selection-list.js +++ b/ghost/admin/app/utils/selection-list.js @@ -71,6 +71,13 @@ export default class SelectionList { return this.selectedIds.size === 1 && !this.inverted; } + get count() { + if (!this.inverted) { + return this.selectedIds.size; + } + return Math.max((this.infinityModel.meta?.pagination?.total ?? 0) - this.selectedIds.size, 1); + } + isSelected(id) { if (this.inverted) { return !this.selectedIds.has(id); diff --git a/ghost/core/core/server/api/endpoints/posts.js b/ghost/core/core/server/api/endpoints/posts.js index 3da7ccdb71..7a90d61ee9 100644 --- a/ghost/core/core/server/api/endpoints/posts.js +++ b/ghost/core/core/server/api/endpoints/posts.js @@ -234,6 +234,20 @@ module.exports = { } }, + bulkDestroy: { + statusCode: 200, + headers: {}, + options: [ + 'filter' + ], + permissions: { + method: 'destroy' + }, + async query(frame) { + return await postsService.bulkDestroy(frame.options); + } + }, + destroy: { statusCode: 204, headers: { diff --git a/ghost/core/core/server/api/endpoints/utils/serializers/output/posts.js b/ghost/core/core/server/api/endpoints/utils/serializers/output/posts.js index 4e4e0711b7..7129b16ffe 100644 --- a/ghost/core/core/server/api/endpoints/utils/serializers/output/posts.js +++ b/ghost/core/core/server/api/endpoints/utils/serializers/output/posts.js @@ -53,5 +53,9 @@ module.exports = { } } }; + }, + + bulkDestroy(bulkActionResult, _apiConfig, frame) { + frame.response = bulkActionResult; } }; diff --git a/ghost/core/core/server/models/base/plugins/bulk-operations.js b/ghost/core/core/server/models/base/plugins/bulk-operations.js index c270913589..33808c243b 100644 --- a/ghost/core/core/server/models/base/plugins/bulk-operations.js +++ b/ghost/core/core/server/models/base/plugins/bulk-operations.js @@ -18,6 +18,9 @@ function createBulkOperation(singular, multiple) { await multiple(knex, table, chunkedData, options); result.successful += chunkedData.length; } catch (errToIgnore) { + if (options.throwErrors) { + throw errToIgnore; + } for (const singularData of chunkedData) { try { await singular(knex, table, singularData, options); @@ -54,7 +57,11 @@ async function editMultiple(knex, table, chunk, options) { async function delSingle(knex, table, id, options) { try { - await knex(table).where(options.column ?? 'id', id).del(); + let k = knex(table); + if (options.transacting) { + k = k.transacting(options.transacting); + } + await k.where(options.column ?? 'id', id).del(); } catch (err) { const importError = new errors.DataImportError({ message: `Failed to remove entry from ${table}`, @@ -67,7 +74,11 @@ async function delSingle(knex, table, id, options) { } async function delMultiple(knex, table, chunk, options) { - await knex(table).whereIn(options.column ?? 'id', chunk).del(); + let k = knex(table); + if (options.transacting) { + k = k.transacting(options.transacting); + } + await k.whereIn(options.column ?? 'id', chunk).del(); } const insert = createBulkOperation(insertSingle, insertMultiple); @@ -85,23 +96,23 @@ module.exports = function (Bookshelf) { return insert(Bookshelf.knex, tableName, data); }, - bulkEdit: function bulkEdit(data, tableName, options) { + bulkEdit: async function bulkEdit(data, tableName, options) { tableName = tableName || this.prototype.tableName; - return edit(Bookshelf.knex, tableName, data, options); + return await edit(Bookshelf.knex, tableName, data, options); }, /** - * + * * @param {string[]} data List of ids to delete - * @param {*} tableName - * @param {Object} [options] + * @param {*} tableName + * @param {Object} [options] * @param {string} [options.column] Delete the rows where this column equals the ids in `data` (defaults to 'id') - * @returns + * @returns */ - bulkDestroy: function bulkDestroy(data, tableName, options = {}) { + bulkDestroy: async function bulkDestroy(data, tableName, options = {}) { tableName = tableName || this.prototype.tableName; - return del(Bookshelf.knex, tableName, data, options); + return await del(Bookshelf.knex, tableName, data, options); } }); }; diff --git a/ghost/core/core/server/web/api/endpoints/admin/routes.js b/ghost/core/core/server/web/api/endpoints/admin/routes.js index ea28fd3ee4..5cae7b4201 100644 --- a/ghost/core/core/server/web/api/endpoints/admin/routes.js +++ b/ghost/core/core/server/web/api/endpoints/admin/routes.js @@ -29,6 +29,7 @@ module.exports = function apiRoutes() { router.get('/posts/export', mw.authAdminApi, http(api.posts.exportCSV)); router.post('/posts', mw.authAdminApi, http(api.posts.add)); + router.del('/posts', mw.authAdminApi, http(api.posts.bulkDestroy)); router.put('/posts/bulk', mw.authAdminApi, http(api.posts.bulkEdit)); router.get('/posts/:id', mw.authAdminApi, http(api.posts.read)); router.get('/posts/slug/:slug', mw.authAdminApi, http(api.posts.read)); diff --git a/ghost/posts-service/lib/PostsService.js b/ghost/posts-service/lib/PostsService.js index f1d8a55a7b..cd232b4e9d 100644 --- a/ghost/posts-service/lib/PostsService.js +++ b/ghost/posts-service/lib/PostsService.js @@ -72,6 +72,75 @@ class PostsService { }); } + async bulkDestroy(options) { + if (!options.transacting) { + return await this.models.Post.transaction(async (transacting) => { + return await this.bulkDestroy({ + ...options, + transacting + }); + }); + } + + const postRows = await this.models.Post.getFilteredCollectionQuery({ + filter: options.filter, + status: 'all' + }).leftJoin('emails', 'posts.id', 'emails.post_id').select('posts.id', 'emails.id as email_id'); + const deleteIds = postRows.map(row => row.id); + + // We also need to collect the email ids because the email relation doesn't have cascase, and we need to delete the related relations of the post + const deleteEmailIds = postRows.map(row => row.email_id).filter(id => !!id); + + const postTablesToDelete = [ + 'posts_authors', + 'posts_tags', + 'posts_meta', + 'mobiledoc_revisions', + 'post_revisions', + 'posts_products' + ]; + const emailTablesToDelete = [ + 'email_recipients', + 'email_recipient_failures', + 'email_batches', + 'email_spam_complaint_events' + ]; + + // Don't clear, but set relation to null + const emailTablesToSetNull = [ + 'suppressions' + ]; + + for (const table of postTablesToDelete) { + await this.models.Post.bulkDestroy(deleteIds, table, { + column: 'post_id', + transacting: options.transacting, + throwErrors: true + }); + } + + for (const table of emailTablesToDelete) { + await this.models.Post.bulkDestroy(deleteEmailIds, table, { + column: 'email_id', + transacting: options.transacting, + throwErrors: true + }); + } + + for (const table of emailTablesToSetNull) { + await this.models.Post.bulkEdit(deleteEmailIds, table, { + data: {email_id: null}, + column: 'email_id', + transacting: options.transacting, + throwErrors: true + }); + } + + // Posts and emails + await this.models.Post.bulkDestroy(deleteEmailIds, 'emails', {transacting: options.transacting, throwErrors: true}); + return await this.models.Post.bulkDestroy(deleteIds, 'posts', {transacting: options.transacting, throwErrors: true}); + } + async export(frame) { return await this.postsExporter.export(frame.options); }