From f4d75388fd1332299d2edfc92fb3afa857039faa Mon Sep 17 00:00:00 2001 From: Simon Backx Date: Fri, 7 Apr 2023 11:48:14 +0200 Subject: [PATCH] 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. --- .../components/posts-list/context-menu.hbs | 42 ++++ .../app/components/posts-list/context-menu.js | 72 +++++++ .../components/posts-list/list-item-old.hbs | 200 ++++++++++++++++++ .../components/posts-list/list-item-old.js | 46 ++++ .../app/components/posts-list/list-item.hbs | 3 + .../admin/app/components/posts-list/list.hbs | 37 +--- ghost/admin/app/components/posts-list/list.js | 5 - ghost/admin/app/templates/pages.hbs | 2 +- ghost/admin/app/templates/posts.hbs | 2 +- ghost/admin/app/utils/selection-list.js | 32 +++ ghost/core/core/server/api/endpoints/posts.js | 31 +++ .../utils/serializers/output/posts.js | 16 ++ .../server/web/api/endpoints/admin/routes.js | 1 + ghost/posts-service/lib/PostsService.js | 29 ++- 14 files changed, 475 insertions(+), 43 deletions(-) create mode 100644 ghost/admin/app/components/posts-list/context-menu.hbs create mode 100644 ghost/admin/app/components/posts-list/context-menu.js create mode 100644 ghost/admin/app/components/posts-list/list-item-old.hbs create mode 100644 ghost/admin/app/components/posts-list/list-item-old.js diff --git a/ghost/admin/app/components/posts-list/context-menu.hbs b/ghost/admin/app/components/posts-list/context-menu.hbs new file mode 100644 index 0000000000..2d3ba2b027 --- /dev/null +++ b/ghost/admin/app/components/posts-list/context-menu.hbs @@ -0,0 +1,42 @@ + diff --git a/ghost/admin/app/components/posts-list/context-menu.js b/ghost/admin/app/components/posts-list/context-menu.js new file mode 100644 index 0000000000..260357a671 --- /dev/null +++ b/ghost/admin/app/components/posts-list/context-menu.js @@ -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(); + } +} diff --git a/ghost/admin/app/components/posts-list/list-item-old.hbs b/ghost/admin/app/components/posts-list/list-item-old.hbs new file mode 100644 index 0000000000..83533a2c53 --- /dev/null +++ b/ghost/admin/app/components/posts-list/list-item-old.hbs @@ -0,0 +1,200 @@ +{{!-- template-lint-disable no-invalid-interactive --}} +
  • + + {{!-- Title column --}} + {{#if (and this.session.user.isContributor @post.isPublished)}} + + {{else}} + +

    + {{@post.title}} + {{#if @post.lexical}} + L + {{/if}} +

    + {{#unless @hideAuthor }} + +

    + {{#if @post.isScheduled}} + + + Scheduled + {{#if this.isHovered}} + + {{#if @post.emailOnly}} + to be sent + {{else}} + to be published {{if @post.newsletter "and sent "}} + {{/if}} + {{this.scheduledText}} to {{humanize-recipient-filter @post.emailSegment}} + + {{/if}} + + {{/if}} + + {{#if @post.isDraft}} + + + Draft + + {{/if}} + + {{#if @post.isPublished}} + + Published + {{#if @post.didEmailFail}} + but failed to send newsletter + {{else if @post.hasBeenEmailed}} + and sent + {{#if this.isHovered}} + to {{gh-pluralize @post.email.emailCount "member"}} + {{/if}} + {{/if}} + + {{/if}} + + {{#if @post.isSent}} + + {{#if @post.didEmailFail}} + Failed to send newsletter + {{else}} + Sent + {{#if this.isHovered}} + to {{gh-pluralize @post.email.emailCount "member"}} + {{/if}} + {{/if}} + + {{/if}} +

    + {{/unless}} +
    + {{/if}} + + {{!-- Opened / Signups column --}} + {{#if (and @post.showEmailOpenAnalytics @post.showEmailClickAnalytics) }} + + + {{#if this.isHovered}} + {{format-number @post.email.openedCount}} + {{else}} + {{@post.email.openRate}}% + {{/if}} + + + opened + + + {{else}} + + {{!-- Empty on purpose --}} + + {{/if}} + + {{!-- Clicked / Conversions column --}} + {{#if @post.showEmailClickAnalytics }} + + + {{#if this.isHovered}} + {{format-number @post.count.clicks}} + {{else}} + {{@post.clickRate}}% + {{/if}} + + + clicked + + + {{else}} + {{#if @post.showEmailOpenAnalytics }} + + + {{#if this.isHovered}} + {{format-number @post.email.openedCount}} + {{else}} + {{@post.email.openRate}}% + {{/if}} + + + opened + + + {{else}} + + {{!-- Empty on purpose --}} + + {{/if}} + {{/if}} + + {{!-- Button column --}} + {{#if @post.hasAnalyticsPage }} + + + {{svg-jar "stats" title="Go to Analytics"}} + + + {{else}} + + + {{svg-jar "pen" title="Go to Editor"}} + + + {{/if}} +
  • diff --git a/ghost/admin/app/components/posts-list/list-item-old.js b/ghost/admin/app/components/posts-list/list-item-old.js new file mode 100644 index 0000000000..b237f0fd6b --- /dev/null +++ b/ghost/admin/app/components/posts-list/list-item-old.js @@ -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; + } +} diff --git a/ghost/admin/app/components/posts-list/list-item.hbs b/ghost/admin/app/components/posts-list/list-item.hbs index 83533a2c53..546742be5d 100644 --- a/ghost/admin/app/components/posts-list/list-item.hbs +++ b/ghost/admin/app/components/posts-list/list-item.hbs @@ -51,6 +51,9 @@ {{#if @post.lexical}} L {{/if}} + {{#if @post.featured}} + + {{/if}} {{#unless @hideAuthor }}

    diff --git a/ghost/admin/app/components/posts-list/list.hbs b/ghost/admin/app/components/posts-list/list.hbs index d169c3b0fb..7115ac5ffe 100644 --- a/ghost/admin/app/components/posts-list/list.hbs +++ b/ghost/admin/app/components/posts-list/list.hbs @@ -12,40 +12,7 @@ {{!-- The currently selected item or items are passed to the context menu --}} -

    + diff --git a/ghost/admin/app/components/posts-list/list.js b/ghost/admin/app/components/posts-list/list.js index 8640f533e1..acf7d30687 100644 --- a/ghost/admin/app/components/posts-list/list.js +++ b/ghost/admin/app/components/posts-list/list.js @@ -4,9 +4,4 @@ export default class PostsList extends Component { get list() { return this.args.list; } - - deletePosts(menu) { - alert('Deleting posts not yet supported.'); - menu.close(); - } } diff --git a/ghost/admin/app/templates/pages.hbs b/ghost/admin/app/templates/pages.hbs index 4efd8b74a8..32f6c619a8 100644 --- a/ghost/admin/app/templates/pages.hbs +++ b/ghost/admin/app/templates/pages.hbs @@ -30,7 +30,7 @@
      {{#each this.postsInfinityModel as |page|}} - diff --git a/ghost/admin/app/templates/posts.hbs b/ghost/admin/app/templates/posts.hbs index fcbaf22dc3..e0c66231fb 100644 --- a/ghost/admin/app/templates/posts.hbs +++ b/ghost/admin/app/templates/posts.hbs @@ -38,7 +38,7 @@
        {{#each this.postsInfinityModel as |post|}} - diff --git a/ghost/admin/app/utils/selection-list.js b/ghost/admin/app/utils/selection-list.js index c732f296ca..fa4ab33c23 100644 --- a/ghost/admin/app/utils/selection-list.js +++ b/ghost/admin/app/utils/selection-list.js @@ -12,6 +12,38 @@ export default class SelectionList { 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 */ diff --git a/ghost/core/core/server/api/endpoints/posts.js b/ghost/core/core/server/api/endpoints/posts.js index ccaa2e90ad..3da7ccdb71 100644 --- a/ghost/core/core/server/api/endpoints/posts.js +++ b/ghost/core/core/server/api/endpoints/posts.js @@ -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: { statusCode: 204, headers: { diff --git a/ghost/core/core/server/api/endpoints/utils/serializers/output/posts.js b/ghost/core/core/server/api/endpoints/utils/serializers/output/posts.js index e84b0fee4a..4e4e0711b7 100644 --- a/ghost/core/core/server/api/endpoints/utils/serializers/output/posts.js +++ b/ghost/core/core/server/api/endpoints/utils/serializers/output/posts.js @@ -37,5 +37,21 @@ module.exports = { exportCSV(models, apiConfig, frame) { 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 + } + } + }; } }; diff --git a/ghost/core/core/server/web/api/endpoints/admin/routes.js b/ghost/core/core/server/web/api/endpoints/admin/routes.js index dc91d6e5ed..ea28fd3ee4 100644 --- a/ghost/core/core/server/web/api/endpoints/admin/routes.js +++ b/ghost/core/core/server/web/api/endpoints/admin/routes.js @@ -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.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)); router.put('/posts/:id', mw.authAdminApi, http(api.posts.edit)); diff --git a/ghost/posts-service/lib/PostsService.js b/ghost/posts-service/lib/PostsService.js index a4a8e65b65..f1d8a55a7b 100644 --- a/ghost/posts-service/lib/PostsService.js +++ b/ghost/posts-service/lib/PostsService.js @@ -1,10 +1,12 @@ const nql = require('@tryghost/nql'); const {BadRequestError} = require('@tryghost/errors'); const tpl = require('@tryghost/tpl'); +const errors = require('@tryghost/errors'); const messages = { 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 { @@ -58,10 +60,35 @@ class PostsService { 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) { 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) { try { const allProducts = await this.models.Product.findAll();