mirror of
https://github.com/TryGhost/Ghost.git
synced 2024-12-24 19:33:02 +03:00
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:
parent
eaf6e3c7e5
commit
854f616f70
@ -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: {
|
||||
|
@ -6,6 +6,8 @@
|
||||
data-test-post-id={{post.id}}
|
||||
/>
|
||||
</list.item>
|
||||
{{else}}
|
||||
{{yield}}
|
||||
{{/each}}
|
||||
</MultiList::List>
|
||||
|
||||
|
@ -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
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
||||
|
@ -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
|
||||
|
@ -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">
|
||||
|
||||
|
@ -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();
|
||||
|
@ -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: {
|
||||
|
@ -216,8 +216,7 @@ module.exports = {
|
||||
validation: {
|
||||
data: {
|
||||
action: {
|
||||
required: true,
|
||||
values: ['feature', 'unfeature', 'addTag']
|
||||
required: true
|
||||
}
|
||||
},
|
||||
options: {
|
||||
|
@ -189,5 +189,13 @@ module.exports = {
|
||||
|
||||
defaultFormat(frame);
|
||||
defaultRelations(frame);
|
||||
},
|
||||
|
||||
bulkEdit(apiConfig, frame) {
|
||||
forcePageFilter(frame);
|
||||
},
|
||||
|
||||
bulkDestroy(apiConfig, frame) {
|
||||
forcePageFilter(frame);
|
||||
}
|
||||
};
|
||||
|
@ -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;
|
||||
}
|
||||
};
|
||||
|
@ -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));
|
||||
|
Loading…
Reference in New Issue
Block a user