Added bulk edit pages API and admin UI (#16633)

refs https://github.com/TryGhost/Team/issues/2677

- This extends the bulk editing UI to pages.
- New endpoints for editing pages in bulk
- Support for type in bulk edit UI
- Fixed empty messages for lists
- Minor bugfixes (e.g. save button when adding tags became red because
task didn't return true)

---

This pull request adds support for bulk editing and deleting of pages in
the admin UI and the API. It refactors the context menu component and
the list templates to handle different types of content (posts or pages)
dynamically. It also updates the selection list utility and the no posts
box component to work with the new feature. It modifies the `posts.js`
and `pages.js` API files and the corresponding input and output
serializers and routes.
This commit is contained in:
Simon Backx 2023-04-14 12:16:15 +02:00 committed by GitHub
parent eaf6e3c7e5
commit 854f616f70
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 176 additions and 37 deletions

View File

@ -5,6 +5,7 @@ import EditPostsAccessModal from './modals/edit-posts-access';
import UnpublishPostsModal from './modals/unpublish-posts';
import nql from '@tryghost/nql';
import {action} from '@ember/object';
import {capitalizeFirstLetter} from 'ghost-admin/helpers/capitalize-first-letter';
import {inject as service} from '@ember/service';
import {task} from 'ember-concurrency';
@ -20,24 +21,24 @@ function tpl(str, data) {
const messages = {
deleted: {
single: 'Post deleted successfully',
multiple: '{count} posts deleted successfully'
single: '{Type} deleted successfully',
multiple: '{count} {type}s deleted successfully'
},
unpublished: {
single: 'Post successfully reverted to a draft',
multiple: '{count} posts successfully reverted to drafts'
single: '{Type} successfully reverted to a draft',
multiple: '{count} {type}s successfully reverted to drafts'
},
accessUpdated: {
single: 'Post access successfully updated',
multiple: 'Post access successfully updated for {count} posts'
single: '{Type} access successfully updated',
multiple: '{Type} access successfully updated for {count} {type}s'
},
tagsAdded: {
single: 'Tags added successfully',
multiple: 'Tags added successfully to {count} posts'
multiple: 'Tags added successfully to {count} {type}s'
},
tagAdded: {
single: 'Tag added successfully',
multiple: 'Tag added successfully to {count} posts'
multiple: 'Tag added successfully to {count} {type}s'
}
};
@ -58,11 +59,15 @@ export default class PostsContextMenu extends Component {
return this.menu.selectionList;
}
get type() {
return this.selectionList.first?.displayName === 'page' ? 'page' : 'post';
}
#getToastMessage(type) {
if (this.selectionList.isSingle) {
return messages[type].single;
return tpl(messages[type].single, {count: this.selectionList.count, type: this.type, Type: capitalizeFirstLetter(this.type)});
}
return tpl(messages[type].multiple, {count: this.selectionList.count});
return tpl(messages[type].multiple, {count: this.selectionList.count, type: this.type, Type: capitalizeFirstLetter(this.type)});
}
@action
@ -78,6 +83,7 @@ export default class PostsContextMenu extends Component {
@action
async addTagToPosts() {
await this.menu.openModal(AddPostTagsModal, {
type: this.type,
selectionList: this.selectionList,
confirm: this.addTagToPostsTask
});
@ -86,6 +92,7 @@ export default class PostsContextMenu extends Component {
@action
async deletePosts() {
this.menu.openModal(DeletePostsModal, {
type: this.type,
selectionList: this.selectionList,
confirm: this.deletePostsTask
});
@ -94,6 +101,7 @@ export default class PostsContextMenu extends Component {
@action
async unpublishPosts() {
await this.menu.openModal(UnpublishPostsModal, {
type: this.type,
selectionList: this.selectionList,
confirm: this.unpublishPostsTask
});
@ -102,6 +110,7 @@ export default class PostsContextMenu extends Component {
@action
async editPostsAccess() {
this.menu.openModal(EditPostsAccessModal, {
type: this.type,
selectionList: this.selectionList,
confirm: this.editPostsAccessTask
});
@ -176,7 +185,7 @@ export default class PostsContextMenu extends Component {
this.store.push({
data: {
id: post.id,
type: 'post',
type: this.type,
relationships: {
tags: {
data: newTags
@ -203,7 +212,7 @@ export default class PostsContextMenu extends Component {
});
// 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();
this.selectionList.clearSelection({force: true});
return true;
}
@ -220,7 +229,7 @@ export default class PostsContextMenu extends Component {
this.store.push({
data: {
id: post.id,
type: 'post',
type: this.type,
attributes: {
status: 'draft'
}
@ -248,6 +257,8 @@ export default class PostsContextMenu extends Component {
});
// 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.clearUnavailableItems();
}
@task
@ -262,7 +273,7 @@ export default class PostsContextMenu extends Component {
this.store.push({
data: {
id: post.id,
type: 'post',
type: this.type,
attributes: {
visibility
},
@ -292,7 +303,7 @@ export default class PostsContextMenu extends Component {
this.store.push({
data: {
id: post.id,
type: 'post',
type: this.type,
attributes: {
featured: true
}
@ -317,7 +328,7 @@ export default class PostsContextMenu extends Component {
this.store.push({
data: {
id: post.id,
type: 'post',
type: this.type,
attributes: {
featured: false
}
@ -333,13 +344,13 @@ export default class PostsContextMenu extends Component {
async performBulkDestroy() {
const filter = this.selectionList.filter;
let bulkUpdateUrl = this.ghostPaths.url.api(`posts`) + `?filter=${encodeURIComponent(filter)}`;
let bulkUpdateUrl = this.ghostPaths.url.api(this.type === 'post' ? 'posts' : 'pages') + `?filter=${encodeURIComponent(filter)}`;
return await this.ajax.delete(bulkUpdateUrl);
}
async performBulkEdit(_action, meta = {}) {
const filter = this.selectionList.filter;
let bulkUpdateUrl = this.ghostPaths.url.api(`posts/bulk`) + `?filter=${encodeURIComponent(filter)}`;
let bulkUpdateUrl = this.ghostPaths.url.api(this.type === 'post' ? 'posts/bulk' : 'pages/bulk') + `?filter=${encodeURIComponent(filter)}`;
return await this.ajax.put(bulkUpdateUrl, {
data: {
bulk: {

View File

@ -6,6 +6,8 @@
data-test-post-id={{post.id}}
/>
</list.item>
{{else}}
{{yield}}
{{/each}}
</MultiList::List>

View File

@ -58,7 +58,7 @@ export default class AddTag extends Component {
return;
}
this.errors.clear();
yield this.args.data.confirm.perform(this.selectedTags);
return yield this.args.data.confirm.perform(this.selectedTags);
}
@action

View File

@ -1,12 +1,12 @@
<div class="modal-content" data-test-modal="delete-posts">
<header class="modal-header">
<h1>Are you sure you want to delete {{if @data.selectionList.isSingle 'this post' 'these posts'}}?</h1>
<h1>Are you sure you want to delete {{if @data.selectionList.isSingle (concat 'this ' @data.type) (concat 'these ' @data.type 's')}}?</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 mb9">
<p>
You're about to delete <strong>{{if @data.selectionList.isSingle (concat '"' @data.selectionList.first.title '"') (concat @data.selectionList.count ' posts')}}</strong>.
You're about to delete <strong>{{if @data.selectionList.isSingle (concat '"' @data.selectionList.first.title '"') (concat @data.selectionList.count ' ' @data.type 's')}}</strong>.
This is permanent! We warned you, k?
</p>
</div>

View File

@ -1,12 +1,12 @@
<div class="modal-content" data-test-modal="unpublish-posts">
<header class="modal-header">
<h1>Are you sure you want to unpublish {{if @data.selectionList.isSingle 'this post' 'these posts'}}?</h1>
<h1>Are you sure you want to unpublish {{if @data.selectionList.isSingle (concat 'this ' @data.type) (concat 'these ' @data.type 's')}}?</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 mb9">
<p>
You're about to revert <strong>{{if @data.selectionList.isSingle (concat '"' @data.selectionList.first.title '"') (concat @data.selectionList.count ' posts')}}</strong> to a private draft.
You're about to revert <strong>{{if @data.selectionList.isSingle (concat '"' @data.selectionList.first.title '"') (concat @data.selectionList.count ' ' @data.type 's')}}</strong> to a private draft.
</p>
</div>

View File

@ -28,13 +28,11 @@
<section class="view-container content-list">
<div class="{{if this.feature.memberAttribution 'gh-list-sticky'}}">
<ol class="pages-list gh-list {{unless this.postsInfinityModel "no-posts"}} {{if this.feature.memberAttribution 'feature-memberAttribution'}}">
{{#each this.postsInfinityModel as |page|}}
<PostsList::ListItemOld
@post={{page}}
data-test-page-id={{page.id}}
/>
{{else}}
{{#if (feature "makingItRain")}}
<PostsList::List
@model={{this.postsInfinityModel}}
@list={{this.selectionList}}
>
<li class="no-posts-box">
<div class="no-posts">
{{#if this.showingAll}}
@ -51,8 +49,34 @@
{{/if}}
</div>
</li>
{{/each}}
</ol>
</PostsList::List>
{{else}}
<ol class="pages-list gh-list {{unless this.postsInfinityModel "no-posts"}} {{if this.feature.memberAttribution 'feature-memberAttribution'}}">
{{#each this.postsInfinityModel as |page|}}
<PostsList::ListItemOld
@post={{page}}
data-test-page-id={{page.id}}
/>
{{else}}
<li class="no-posts-box">
<div class="no-posts">
{{#if this.showingAll}}
{{svg-jar "pages-placeholder" class="gh-pages-placeholder"}}
<h4>Tell the world about yourself.</h4>
<LinkTo @route="editor.new" @model="page" class="gh-btn gh-btn-green">
<span>Create a new page</span>
</LinkTo>
{{else}}
<h4>No pages match the current filter</h4>
<LinkTo @route="pages" @query={{hash type=null author=null tag=null}} class="gh-btn">
<span>Show all pages</span>
</LinkTo>
{{/if}}
</div>
</li>
{{/each}}
</ol>
{{/if}}
</div>
<GhInfinityLoader

View File

@ -33,7 +33,24 @@
<PostsList::List
@model={{this.postsInfinityModel}}
@list={{this.selectionList}}
/>
>
<li class="no-posts-box" data-test-no-posts-box>
<div class="no-posts">
{{#if this.showingAll}}
{{svg-jar "posts-placeholder" class="gh-posts-placeholder"}}
<h4>Start creating content.</h4>
<LinkTo @route="editor.new" @model="post" class="gh-btn gh-btn-green" data-test-link="write-a-new-post">
<span>Write a new post</span>
</LinkTo>
{{else}}
<h4>No posts match the current filter</h4>
<LinkTo @route="posts" @query={{hash type=null author=null tag=null}} class="gh-btn" data-test-link="show-all">
<span>Show all posts</span>
</LinkTo>
{{/if}}
</div>
</li>
</PostsList::List>
{{else}}
<ol class="posts-list gh-list {{unless this.postsInfinityModel "no-posts"}} feature-memberAttribution">

View File

@ -145,6 +145,16 @@ export default class SelectionList {
this.selectedIds = this.selectedIds;
}
clearUnavailableItems() {
const newSelection = new Set();
for (const item of this.infinityModel.content) {
if (this.selectedIds.has(item.id)) {
newSelection.add(item.id);
}
}
this.selectedIds = newSelection;
}
/**
* Select all items between the last selection or the first one if none
*/
@ -220,8 +230,8 @@ export default class SelectionList {
this.lastSelectedId = null;
}
clearSelection() {
if (this.#frozen) {
clearSelection(options = {}) {
if (this.#frozen && !options.force) {
return;
}
this.selectedIds = new Set();

View File

@ -162,6 +162,52 @@ module.exports = {
}
},
bulkEdit: {
statusCode: 200,
headers: {},
options: [
'filter'
],
data: [
'action',
'meta'
],
validation: {
data: {
action: {
required: true
}
},
options: {
filter: {
required: true
}
}
},
permissions: {
docName: 'posts',
method: 'edit'
},
async query(frame) {
return await postsService.bulkEdit(frame.data.bulk, frame.options);
}
},
bulkDestroy: {
statusCode: 200,
headers: {},
options: [
'filter'
],
permissions: {
docName: 'posts',
method: 'destroy'
},
async query(frame) {
return await postsService.bulkDestroy(frame.options);
}
},
destroy: {
statusCode: 204,
headers: {

View File

@ -216,8 +216,7 @@ module.exports = {
validation: {
data: {
action: {
required: true,
values: ['feature', 'unfeature', 'addTag']
required: true
}
},
options: {

View File

@ -189,5 +189,13 @@ module.exports = {
defaultFormat(frame);
defaultRelations(frame);
},
bulkEdit(apiConfig, frame) {
forcePageFilter(frame);
},
bulkDestroy(apiConfig, frame) {
forcePageFilter(frame);
}
};

View File

@ -33,5 +33,25 @@ module.exports = {
frame.response = {
pages: [page]
};
},
bulkEdit(bulkActionResult, _apiConfig, frame) {
frame.response = {
bulk: {
action: frame.data.action,
meta: {
stats: {
successful: bulkActionResult.successful,
unsuccessful: bulkActionResult.unsuccessful
},
errors: bulkActionResult.errors,
unsuccessfulData: bulkActionResult.unsuccessfulData
}
}
};
},
bulkDestroy(bulkActionResult, _apiConfig, frame) {
frame.response = bulkActionResult;
}
};

View File

@ -42,6 +42,8 @@ module.exports = function apiRoutes() {
// ## Pages
router.get('/pages', mw.authAdminApi, http(api.pages.browse));
router.del('/pages', mw.authAdminApi, http(api.pages.bulkDestroy));
router.put('/pages/bulk', mw.authAdminApi, http(api.pages.bulkEdit));
router.post('/pages', mw.authAdminApi, http(api.pages.add));
router.get('/pages/:id', mw.authAdminApi, http(api.pages.read));
router.get('/pages/slug/:slug', mw.authAdminApi, http(api.pages.read));