mirror of
https://github.com/TryGhost/Ghost.git
synced 2024-11-28 05:37:34 +03:00
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:
parent
ed1ae60bec
commit
f4d75388fd
42
ghost/admin/app/components/posts-list/context-menu.hbs
Normal file
42
ghost/admin/app/components/posts-list/context-menu.hbs
Normal 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>
|
72
ghost/admin/app/components/posts-list/context-menu.js
Normal file
72
ghost/admin/app/components/posts-list/context-menu.js
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
200
ghost/admin/app/components/posts-list/list-item-old.hbs
Normal file
200
ghost/admin/app/components/posts-list/list-item-old.hbs
Normal 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>
|
46
ghost/admin/app/components/posts-list/list-item-old.js
Normal file
46
ghost/admin/app/components/posts-list/list-item-old.js
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
@ -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">
|
||||||
|
@ -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>
|
||||||
|
@ -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();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@ -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}}
|
||||||
/>
|
/>
|
||||||
|
@ -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}}
|
||||||
/>
|
/>
|
||||||
|
@ -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
|
||||||
*/
|
*/
|
||||||
|
@ -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: {
|
||||||
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
@ -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));
|
||||||
|
@ -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();
|
||||||
|
Loading…
Reference in New Issue
Block a user