mirror of
https://github.com/TryGhost/Ghost.git
synced 2024-12-25 03:44:29 +03:00
Added bulk destroy posts api (#16587)
fixes https://github.com/TryGhost/Team/issues/2921 Adds the bulk destroy API for posts, and implemented it in the admin UI.
This commit is contained in:
parent
1ce4e4c522
commit
66b353ed97
@ -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 = {}) {
|
||||
|
@ -0,0 +1,28 @@
|
||||
<div class="modal-content" data-test-modal="delete-posts">
|
||||
<header class="modal-header">
|
||||
<h1>Are you sure you want to delete {{if @data.isSingle 'this post' (concat @data.count ' posts')}}?</h1>
|
||||
</header>
|
||||
<button type="button" class="close" title="Close" {{on "click" (fn @close false)}} data-test-button="close">{{svg-jar "close"}}<span class="hidden">Close</span></button>
|
||||
|
||||
<div class="modal-body">
|
||||
<p>
|
||||
You're about to delete {{if @data.isSingle 'this post' (concat @data.count ' posts')}}.
|
||||
This is irreversible.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="modal-footer">
|
||||
<button class="gh-btn" data-test-button="cancel" type="button" {{on "click" (fn @close false)}}><span>Cancel</span></button>
|
||||
|
||||
<GhTaskButton
|
||||
@buttonText="Delete"
|
||||
@runningText="Deleting"
|
||||
@showSuccess={{false}}
|
||||
@task={{@data.confirm}}
|
||||
@taskArgs={{@close}}
|
||||
@class="gh-btn gh-btn-red gh-btn-icon"
|
||||
data-test-button="confirm"
|
||||
/>
|
||||
|
||||
</div>
|
||||
</div>
|
@ -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);
|
||||
|
@ -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: {
|
||||
|
@ -53,5 +53,9 @@ module.exports = {
|
||||
}
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
bulkDestroy(bulkActionResult, _apiConfig, frame) {
|
||||
frame.response = bulkActionResult;
|
||||
}
|
||||
};
|
||||
|
@ -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);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
@ -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));
|
||||
|
@ -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);
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user