Added post bulk edit api (#16576)

fixes https://github.com/TryGhost/Team/issues/2919

This pull request implements a new feature that allows bulk editing of
posts by a filter. It adds a new `bulkEdit` endpoint to the posts API
and new `PostsService` methods to handle the bulk actions.

The posts list component is duplicated, so we can keep working in a
copied version without affecting the old version without a flag. It
temporarily adds a star icon to indicate featured posts in the posts
list.
This commit is contained in:
Simon Backx 2023-04-07 11:48:14 +02:00 committed by GitHub
parent ed1ae60bec
commit f4d75388fd
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 475 additions and 43 deletions

View File

@ -0,0 +1,42 @@
<ul class="gh-posts-context-menu dropdown-menu dropdown-triangle-top-left">
{{#if this.selectionList.isSingle}}
<li>
<button class="mr2" type="button" disabled {{on "click" @menu.close}}>
<span>Duplicate</span>
</button>
</li>
{{/if}}
<li>
<button class="mr2" type="button" disabled {{on "click" @menu.close}}>
<span>Unpublish</span>
</button>
</li>
{{#if this.shouldFeatureSelection }}
<li>
<button class="mr2" type="button" {{on "click" this.featurePosts}}>
<span>Feature</span>
</button>
</li>
{{else}}
<li>
<button class="mr2" type="button" {{on "click" this.unfeaturePosts}}>
<span>Unfeature</span>
</button>
</li>
{{/if}}
<li>
<button class="mr2" type="button" disabled {{on "click" @menu.close}}>
<span>Add tag...</span>
</button>
</li>
<li>
<button class="mr2" type="button" disabled {{on "click" @menu.close}}>
<span>Post access...</span>
</button>
</li>
<li>
<button class="mr2" type="button" {{on "click" this.deletePosts}}>
<span>Delete</span>
</button>
</li>
</ul>

View File

@ -0,0 +1,72 @@
import Component from '@glimmer/component';
import {action} from '@ember/object';
import {inject as service} from '@ember/service';
export default class PostsContextMenu extends Component {
@service ajax;
@service ghostPaths;
get menu() {
return this.args.menu;
}
get selectionList() {
return this.menu.selectionList;
}
@action
deletePosts() {
// Use filter in menu.selectionList.filter
alert('Deleting posts not yet supported.');
this.menu.close();
}
async performBulkEdit(_action, meta = {}) {
const filter = this.selectionList.filter;
let bulkUpdateUrl = this.ghostPaths.url.api(`posts/bulk`) + `?filter=${encodeURIComponent(filter)}`;
return await this.ajax.put(bulkUpdateUrl, {
data: {
bulk: {
action: _action,
meta
}
}
});
}
get shouldFeatureSelection() {
const firstPost = this.selectionList.availableModels[0];
if (!firstPost) {
return true;
}
return !firstPost.featured;
}
@action
async featurePosts() {
const updatedModels = this.selectionList.availableModels;
await this.performBulkEdit('feature');
// Update the models on the client side
for (const post of updatedModels) {
post.set('featured', true);
}
// Close the menu
this.menu.close();
}
@action
async unfeaturePosts() {
const updatedModels = this.selectionList.availableModels;
await this.performBulkEdit('unfeature');
// Update the models on the client side
for (const post of updatedModels) {
post.set('featured', false);
}
// Close the menu
this.menu.close();
}
}

View File

@ -0,0 +1,200 @@
{{!-- template-lint-disable no-invalid-interactive --}}
<li class="gh-list-row gh-posts-list-item gh-post-list-plain-status"
{{on "mouseover" this.mouseOver}}
{{on "mouseleave" this.mouseLeave}}
...attributes
>
{{!-- Title column --}}
{{#if (and this.session.user.isContributor @post.isPublished)}}
<a href={{@post.url}} class="permalink gh-list-data gh-post-list-title" target="_blank" rel="noopener noreferrer">
<h3 class="gh-content-entry-title">
{{@post.title}} {{svg-jar "external" class="gh-post-list-external"}}
</h3>
{{#unless @hideAuthor }}
<p class="gh-content-entry-meta">
<span class="gh-content-entry-author">
By {{post-author-names @post}}
{{#if @post.primaryTag}}
in <span class="midgrey-l2 fw5">{{@post.primaryTag.name}}</span>
{{/if}}
-
</span>
<span class="gh-content-entry-date">
{{#if this.isHovered}}
{{gh-format-post-time @post.updatedAtUTC format="D MMM YYYY"}}
{{else}}
{{gh-format-post-time @post.updatedAtUTC draft=true}}
{{/if}}
</span>
</p>
<p class="gh-content-entry-status">
<span class="published">
Published
{{#if @post.hasEmail}}
{{#if this.isHovered}}
and sent to {{gh-pluralize @post.email.emailCount "member"}}
{{else}}
and sent
{{/if}}
{{/if}}
</span>
</p>
{{/unless}}
</a>
{{else}}
<LinkTo @route="editor.edit" @models={{array this.post.displayName this.post.id}} class="permalink gh-list-data gh-post-list-title">
<h3 class="gh-content-entry-title">
{{@post.title}}
{{#if @post.lexical}}
<span class="gh-lexical-indicator">L</span>
{{/if}}
</h3>
{{#unless @hideAuthor }}
<p class="gh-content-entry-meta">
<span class="gh-content-entry-author">
By {{post-author-names @post}}
{{#if @post.primaryTag}}
in <span class="midgrey-l2 fw5">{{@post.primaryTag.name}}</span>
{{/if}}
-
</span>
<span class="gh-content-entry-date" {{on "mouseover" (fn (mut this.isDateHovered) true)}} {{on "mouseleave" (fn (mut this.isDateHovered) false)}}>
{{gh-format-post-time @post.updatedAtUTC draft=true}}
{{#if this.isDateHovered}}
<span {{css-transition "anim-fade-in-scale"}}>on {{gh-format-post-time @post.updatedAtUTC format="D MMM YYYY"}}</span>
{{/if}}
</span>
{{!-- {{#if @post.lexical}}
<span class="gh-content-entry-date"> Lexical</span>
{{/if}} --}}
</p>
<p class="gh-content-entry-status">
{{#if @post.isScheduled}}
<span class="scheduled">
<span class="status-dot"></span>
Scheduled
{{#if this.isHovered}}
<span class="schedule-details" {{css-transition "anim-fade-in-scale"}}>
{{#if @post.emailOnly}}
to be sent
{{else}}
to be published {{if @post.newsletter "and sent "}}
{{/if}}
{{this.scheduledText}} to {{humanize-recipient-filter @post.emailSegment}}
</span>
{{/if}}
</span>
{{/if}}
{{#if @post.isDraft}}
<span class="draft">
<span class="status-dot"></span>
Draft
</span>
{{/if}}
{{#if @post.isPublished}}
<span class="published {{this.errorClass}}">
Published
{{#if @post.didEmailFail}}
but failed to send newsletter
{{else if @post.hasBeenEmailed}}
and sent
{{#if this.isHovered}}
<span {{css-transition "anim-fade-in-scale"}}>to {{gh-pluralize @post.email.emailCount "member"}}</span>
{{/if}}
{{/if}}
</span>
{{/if}}
{{#if @post.isSent}}
<span class="sent {{this.errorClass}}">
{{#if @post.didEmailFail}}
Failed to send newsletter
{{else}}
Sent
{{#if this.isHovered}}
<span {{css-transition "anim-fade-in-scale"}}>to {{gh-pluralize @post.email.emailCount "member"}}</span>
{{/if}}
{{/if}}
</span>
{{/if}}
</p>
{{/unless}}
</LinkTo>
{{/if}}
{{!-- Opened / Signups column --}}
{{#if (and @post.showEmailOpenAnalytics @post.showEmailClickAnalytics) }}
<LinkTo @route="members" @query={{hash filterParam=(concat "opened_emails.post_id:" @post.id) }} class="permalink gh-list-data gh-post-list-metrics">
<span class="gh-content-email-stats-value">
{{#if this.isHovered}}
{{format-number @post.email.openedCount}}
{{else}}
{{@post.email.openRate}}<sup>%</sup>
{{/if}}
</span>
<span class="gh-content-email-stats">
opened
</span>
</LinkTo>
{{else}}
<LinkTo @route="editor.edit" @models={{array this.post.displayName this.post.id}} class="permalink gh-list-data">
{{!-- Empty on purpose --}}
</LinkTo>
{{/if}}
{{!-- Clicked / Conversions column --}}
{{#if @post.showEmailClickAnalytics }}
<LinkTo @route="members" @query={{hash filterParam=(concat "clicked_links.post_id:" @post.id) }} class="permalink gh-list-data gh-post-list-metrics">
<span class="gh-content-email-stats-value">
{{#if this.isHovered}}
{{format-number @post.count.clicks}}
{{else}}
{{@post.clickRate}}<sup>%</sup>
{{/if}}
</span>
<span class="gh-content-email-stats">
clicked
</span>
</LinkTo>
{{else}}
{{#if @post.showEmailOpenAnalytics }}
<LinkTo @route="members" @query={{hash filterParam=(concat "opened_emails.post_id:" @post.id) }} class="permalink gh-list-data gh-post-list-metrics">
<span class="gh-content-email-stats-value">
{{#if this.isHovered}}
{{format-number @post.email.openedCount}}
{{else}}
{{@post.email.openRate}}<sup>%</sup>
{{/if}}
</span>
<span class="gh-content-email-stats">
opened
</span>
</LinkTo>
{{else}}
<LinkTo @route="editor.edit" @models={{array this.post.displayName this.post.id}} class="permalink gh-list-data">
{{!-- Empty on purpose --}}
</LinkTo>
{{/if}}
{{/if}}
{{!-- Button column --}}
{{#if @post.hasAnalyticsPage }}
<LinkTo @route="posts.analytics" @model={{@post.id}} class="permalink gh-list-data gh-post-list-button" title="">
<span class="gh-post-list-cta stats {{if this.isHovered "is-hovered"}}" title="Go to Analytics">
{{svg-jar "stats" title="Go to Analytics"}}
</span>
</LinkTo>
{{else}}
<LinkTo @route="editor.edit" @models={{array this.post.displayName this.post.id}} class="permalink gh-list-data gh-post-list-button" title="">
<span class="gh-post-list-cta edit {{if this.isHovered "is-hovered"}}" title="Go to Editor">
{{svg-jar "pen" title="Go to Editor"}}
</span>
</LinkTo>
{{/if}}
</li>

View File

@ -0,0 +1,46 @@
import Component from '@glimmer/component';
import {action} from '@ember/object';
import {formatPostTime} from 'ghost-admin/helpers/gh-format-post-time';
import {inject as service} from '@ember/service';
import {tracked} from '@glimmer/tracking';
export default class PostsListItemClicks extends Component {
@service feature;
@service session;
@service settings;
@tracked isHovered = false;
get post() {
return this.args.post;
}
get errorClass() {
if (this.post.didEmailFail) {
return 'error';
}
return '';
}
get scheduledText() {
let text = [];
let formattedTime = formatPostTime(
this.post.publishedAtUTC,
{timezone: this.settings.timezone, scheduled: true}
);
text.push(formattedTime);
return text.join(' ');
}
@action
mouseOver() {
this.isHovered = true;
}
@action
mouseLeave() {
this.isHovered = false;
}
}

View File

@ -51,6 +51,9 @@
{{#if @post.lexical}} {{#if @post.lexical}}
<span class="gh-lexical-indicator">L</span> <span class="gh-lexical-indicator">L</span>
{{/if}} {{/if}}
{{#if @post.featured}}
<span class="gh-lexical-indicator">★</span>
{{/if}}
</h3> </h3>
{{#unless @hideAuthor }} {{#unless @hideAuthor }}
<p class="gh-content-entry-meta"> <p class="gh-content-entry-meta">

View File

@ -12,40 +12,7 @@
{{!-- The currently selected item or items are passed to the context menu --}} {{!-- The currently selected item or items are passed to the context menu --}}
<GhContextMenu <GhContextMenu
@name="context-menu" @name="context-menu"
as |menu selectionList| as |menu|
> >
<ul class="gh-posts-context-menu dropdown-menu dropdown-triangle-top-left"> <PostsList::ContextMenu @menu={{menu}} />
{{#if selectionList.isSingle}}
<li>
<button class="mr2" type="button" disabled {{on "click" menu.close}}>
<span>Duplicate</span>
</button>
</li>
{{/if}}
<li>
<button class="mr2" type="button" disabled {{on "click" menu.close}}>
<span>Unpublish</span>
</button>
</li>
<li>
<button class="mr2" type="button" disabled {{on "click" menu.close}}>
<span>Feature</span>
</button>
</li>
<li>
<button class="mr2" type="button" disabled {{on "click" menu.close}}>
<span>Add tag...</span>
</button>
</li>
<li>
<button class="mr2" type="button" disabled {{on "click" menu.close}}>
<span>Post access...</span>
</button>
</li>
<li>
<button class="mr2" type="button" {{on "click" (fn this.deletePosts menu)}}>
<span>Delete</span>
</button>
</li>
</ul>
</GhContextMenu> </GhContextMenu>

View File

@ -4,9 +4,4 @@ export default class PostsList extends Component {
get list() { get list() {
return this.args.list; return this.args.list;
} }
deletePosts(menu) {
alert('Deleting posts not yet supported.');
menu.close();
}
} }

View File

@ -30,7 +30,7 @@
<div class="{{if this.feature.memberAttribution 'gh-list-sticky'}}"> <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'}}"> <ol class="pages-list gh-list {{unless this.postsInfinityModel "no-posts"}} {{if this.feature.memberAttribution 'feature-memberAttribution'}}">
{{#each this.postsInfinityModel as |page|}} {{#each this.postsInfinityModel as |page|}}
<PostsList::ListItem <PostsList::ListItemOld
@post={{page}} @post={{page}}
data-test-page-id={{page.id}} data-test-page-id={{page.id}}
/> />

View File

@ -38,7 +38,7 @@
<ol class="posts-list gh-list {{unless this.postsInfinityModel "no-posts"}} feature-memberAttribution"> <ol class="posts-list gh-list {{unless this.postsInfinityModel "no-posts"}} feature-memberAttribution">
{{#each this.postsInfinityModel as |post|}} {{#each this.postsInfinityModel as |post|}}
<PostsList::ListItem <PostsList::ListItemOld
@post={{post}} @post={{post}}
data-test-post-id={{post.id}} data-test-post-id={{post.id}}
/> />

View File

@ -12,6 +12,38 @@ export default class SelectionList {
this.infinityModel = infinityModel ?? {content: []}; this.infinityModel = infinityModel ?? {content: []};
} }
/**
* Returns an NQL filter for all items, not the selection
*/
get allFilter() {
return this.infinityModel.extraParams?.filter ?? '';
}
/**
* Returns an NQL filter for the current selection
*/
get filter() {
if (this.inverted) {
if (this.allFilter) {
if (this.selectedIds.size === 0) {
return this.allFilter;
}
return `(${this.allFilter})+id:-['${[...this.selectedIds].join('\',\'')}']`;
}
if (this.selectedIds.size === 0) {
// Select all
return '';
}
return `id:-['${[...this.selectedIds].join('\',\'')}']`;
}
if (this.selectedIds.size === 0) {
// Select nothing
return 'id:nothing';
}
// Only based on the ids
return `id:['${[...this.selectedIds].join('\',\'')}']`;
}
/** /**
* Create an empty copy * Create an empty copy
*/ */

View File

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

View File

@ -37,5 +37,21 @@ module.exports = {
exportCSV(models, apiConfig, frame) { exportCSV(models, apiConfig, frame) {
frame.response = papaparse.unparse(models.data); frame.response = papaparse.unparse(models.data);
},
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
}
}
};
} }
}; };

View File

@ -29,6 +29,7 @@ module.exports = function apiRoutes() {
router.get('/posts/export', mw.authAdminApi, http(api.posts.exportCSV)); router.get('/posts/export', mw.authAdminApi, http(api.posts.exportCSV));
router.post('/posts', mw.authAdminApi, http(api.posts.add)); router.post('/posts', mw.authAdminApi, http(api.posts.add));
router.put('/posts/bulk', mw.authAdminApi, http(api.posts.bulkEdit));
router.get('/posts/:id', mw.authAdminApi, http(api.posts.read)); router.get('/posts/:id', mw.authAdminApi, http(api.posts.read));
router.get('/posts/slug/:slug', mw.authAdminApi, http(api.posts.read)); router.get('/posts/slug/:slug', mw.authAdminApi, http(api.posts.read));
router.put('/posts/:id', mw.authAdminApi, http(api.posts.edit)); router.put('/posts/:id', mw.authAdminApi, http(api.posts.edit));

View File

@ -1,10 +1,12 @@
const nql = require('@tryghost/nql'); const nql = require('@tryghost/nql');
const {BadRequestError} = require('@tryghost/errors'); const {BadRequestError} = require('@tryghost/errors');
const tpl = require('@tryghost/tpl'); const tpl = require('@tryghost/tpl');
const errors = require('@tryghost/errors');
const messages = { const messages = {
invalidVisibilityFilter: 'Invalid visibility filter.', invalidVisibilityFilter: 'Invalid visibility filter.',
invalidEmailSegment: 'The email segment parameter doesn\'t contain a valid filter' invalidEmailSegment: 'The email segment parameter doesn\'t contain a valid filter',
unsupportedBulkAction: 'Unsupported bulk action'
}; };
class PostsService { class PostsService {
@ -58,10 +60,35 @@ class PostsService {
return model; return model;
} }
async bulkEdit(data, options) {
if (data.action === 'feature') {
return await this.#updatePosts({featured: true}, {filter: options.filter});
}
if (data.action === 'unfeature') {
return await this.#updatePosts({featured: false}, {filter: options.filter});
}
throw new errors.IncorrectUsageError({
message: tpl(messages.unsupportedBulkAction)
});
}
async export(frame) { async export(frame) {
return await this.postsExporter.export(frame.options); return await this.postsExporter.export(frame.options);
} }
async #updatePosts(data, options) {
const postRows = await this.models.Post.getFilteredCollectionQuery({
filter: options.filter,
status: 'all'
}).select('posts.id');
const editIds = postRows.map(row => row.id);
return await this.models.Post.bulkEdit(editIds, 'posts', {
data
});
}
async getProductsFromVisibilityFilter(visibilityFilter) { async getProductsFromVisibilityFilter(visibilityFilter) {
try { try {
const allProducts = await this.models.Product.findAll(); const allProducts = await this.models.Product.findAll();