From ab1ca9077969aa2c6f2825f13ad9fb1c95a48141 Mon Sep 17 00:00:00 2001 From: Simon Backx Date: Wed, 12 Apr 2023 11:58:46 +0200 Subject: [PATCH] Implemented bulk editing post access (#16617) fixes https://github.com/TryGhost/Team/issues/2924 This change adds a new bulk edit action for posts to update their visibility. It also implements a modal to change the post access level for multiple posts at once using this new API. It also fixes a pattern that was used when modifying the Ember models in memory. They previously were marked as dirty, this is fixed now. So when going to the editor after modifying posts, you won't get a confirmation dialog any longer. --- .../components/posts-list/context-menu.hbs | 2 +- .../app/components/posts-list/context-menu.js | 63 +++++++++++++++- .../posts-list/modals/edit-posts-access.hbs | 39 ++++++++++ .../posts-list/modals/edit-posts-access.js | 55 ++++++++++++++ .../models/base/plugins/bulk-operations.js | 34 ++++++--- ghost/posts-service/lib/PostsService.js | 72 ++++++++++++++++++- ghost/posts-service/package.json | 3 +- 7 files changed, 253 insertions(+), 15 deletions(-) create mode 100644 ghost/admin/app/components/posts-list/modals/edit-posts-access.hbs create mode 100644 ghost/admin/app/components/posts-list/modals/edit-posts-access.js diff --git a/ghost/admin/app/components/posts-list/context-menu.hbs b/ghost/admin/app/components/posts-list/context-menu.hbs index 077359982e..e607f61922 100644 --- a/ghost/admin/app/components/posts-list/context-menu.hbs +++ b/ghost/admin/app/components/posts-list/context-menu.hbs @@ -25,7 +25,7 @@
  • -
  • diff --git a/ghost/admin/app/components/posts-list/context-menu.js b/ghost/admin/app/components/posts-list/context-menu.js index a3257532bf..094274e119 100644 --- a/ghost/admin/app/components/posts-list/context-menu.js +++ b/ghost/admin/app/components/posts-list/context-menu.js @@ -1,5 +1,6 @@ import Component from '@glimmer/component'; import DeletePostsModal from './modals/delete-posts'; +import EditPostsAccessModal from './modals/edit-posts-access'; import {action} from '@ember/object'; import {inject as service} from '@ember/service'; import {task} from 'ember-concurrency'; @@ -10,6 +11,7 @@ export default class PostsContextMenu extends Component { @service session; @service infinity; @service modals; + @service store; get menu() { return this.args.menu; @@ -29,6 +31,16 @@ export default class PostsContextMenu extends Component { }); } + @action + async editPostsAccess() { + this.menu.close(); + await this.modals.open(EditPostsAccessModal, { + isSingle: this.selectionList.isSingle, + count: this.selectionList.count, + confirm: this.editPostsAccessTask + }); + } + @task *deletePostsTask(close) { const deletedModels = this.selectionList.availableModels; @@ -44,6 +56,35 @@ export default class PostsContextMenu extends Component { return true; } + @task + *editPostsAccessTask(close, {visibility, tiers}) { + const updatedModels = this.selectionList.availableModels; + yield this.performBulkEdit('access', {visibility, tiers}); + + // Update the models on the client side + for (const post of updatedModels) { + // We need to do it this way to prevent marking the model as dirty + this.store.push({ + data: { + id: post.id, + type: 'post', + attributes: { + visibility + }, + relationships: { + links: { + data: tiers + } + } + } + }); + } + + close(); + + return true; + } + async performBulkDestroy() { const filter = this.selectionList.filter; let bulkUpdateUrl = this.ghostPaths.url.api(`posts`) + `?filter=${encodeURIComponent(filter)}`; @@ -85,7 +126,16 @@ export default class PostsContextMenu extends Component { // Update the models on the client side for (const post of updatedModels) { - post.set('featured', true); + // We need to do it this way to prevent marking the model as dirty + this.store.push({ + data: { + id: post.id, + type: 'post', + attributes: { + featured: true + } + } + }); } // Close the menu @@ -99,7 +149,16 @@ export default class PostsContextMenu extends Component { // Update the models on the client side for (const post of updatedModels) { - post.set('featured', false); + // We need to do it this way to prevent marking the model as dirty + this.store.push({ + data: { + id: post.id, + type: 'post', + attributes: { + featured: false + } + } + }); } // Close the menu diff --git a/ghost/admin/app/components/posts-list/modals/edit-posts-access.hbs b/ghost/admin/app/components/posts-list/modals/edit-posts-access.hbs new file mode 100644 index 0000000000..52aaa730d2 --- /dev/null +++ b/ghost/admin/app/components/posts-list/modals/edit-posts-access.hbs @@ -0,0 +1,39 @@ + diff --git a/ghost/admin/app/components/posts-list/modals/edit-posts-access.js b/ghost/admin/app/components/posts-list/modals/edit-posts-access.js new file mode 100644 index 0000000000..80747151df --- /dev/null +++ b/ghost/admin/app/components/posts-list/modals/edit-posts-access.js @@ -0,0 +1,55 @@ +import Component from '@glimmer/component'; +import {action} from '@ember/object'; +import {inject as service} from '@ember/service'; +import {task} from 'ember-concurrency'; +import {tracked} from '@glimmer/tracking'; + +export default class EditPostsAccessModal extends Component { + @service store; + + // We createa new post model to use the same validations as the post model + @tracked post = this.store.createRecord('post', { + visibility: 'public', + tiers: [] + }); + + async validate() { + // Mark as not new + this.post.set('currentState.parentState.isNew', false); + await this.post.validate({property: 'visibility'}); + await this.post.validate({property: 'tiers'}); + } + + @action + async setVisibility(segment) { + this.post.set('tiers', segment); + try { + await this.validate(); + } catch (e) { + if (!e) { + // validation error + return; + } + + throw e; + } + } + + @task + *save() { + // First validate + try { + yield this.validate(); + } catch (e) { + if (!e) { + // validation error + return; + } + throw e; + } + return yield this.args.data.confirm.perform(this.args.close, { + visibility: this.post.visibility, + tiers: this.post.tiers + }); + } +} diff --git a/ghost/core/core/server/models/base/plugins/bulk-operations.js b/ghost/core/core/server/models/base/plugins/bulk-operations.js index 33808c243b..644569acc6 100644 --- a/ghost/core/core/server/models/base/plugins/bulk-operations.js +++ b/ghost/core/core/server/models/base/plugins/bulk-operations.js @@ -39,20 +39,36 @@ function createBulkOperation(singular, multiple) { }; } -async function insertSingle(knex, table, record) { - await knex(table).insert(record); +async function insertSingle(knex, table, record, options) { + let k = knex(table); + if (options.transacting) { + k = k.transacting(options.transacting); + } + await k.insert(record); } -async function insertMultiple(knex, table, chunk) { - await knex(table).insert(chunk); +async function insertMultiple(knex, table, chunk, options) { + let k = knex(table); + if (options.transacting) { + k = k.transacting(options.transacting); + } + await k.insert(chunk); } async function editSingle(knex, table, id, options) { - await knex(table).where('id', id).update(options.data); + let k = knex(table); + if (options.transacting) { + k = k.transacting(options.transacting); + } + await k.where('id', id).update(options.data); } async function editMultiple(knex, table, chunk, options) { - await knex(table).whereIn('id', chunk).update(options.data); + let k = knex(table); + if (options.transacting) { + k = k.transacting(options.transacting); + } + await k.whereIn('id', chunk).update(options.data); } async function delSingle(knex, table, id, options) { @@ -90,13 +106,13 @@ const del = createBulkOperation(delSingle, delMultiple); */ module.exports = function (Bookshelf) { Bookshelf.Model = Bookshelf.Model.extend({}, { - bulkAdd: function bulkAdd(data, tableName) { + bulkAdd: function bulkAdd(data, tableName, options = {}) { tableName = tableName || this.prototype.tableName; - return insert(Bookshelf.knex, tableName, data); + return insert(Bookshelf.knex, tableName, data, options); }, - bulkEdit: async function bulkEdit(data, tableName, options) { + bulkEdit: async function bulkEdit(data, tableName, options = {}) { tableName = tableName || this.prototype.tableName; return await edit(Bookshelf.knex, tableName, data, options); diff --git a/ghost/posts-service/lib/PostsService.js b/ghost/posts-service/lib/PostsService.js index cdfd65d866..cbf780a1d3 100644 --- a/ghost/posts-service/lib/PostsService.js +++ b/ghost/posts-service/lib/PostsService.js @@ -2,9 +2,12 @@ const nql = require('@tryghost/nql'); const {BadRequestError} = require('@tryghost/errors'); const tpl = require('@tryghost/tpl'); const errors = require('@tryghost/errors'); +const ObjectId = require('bson-objectid').default; const messages = { invalidVisibilityFilter: 'Invalid visibility filter.', + invalidVisibility: 'Invalid visibility value.', + invalidTiers: 'Invalid tiers value.', invalidEmailSegment: 'The email segment parameter doesn\'t contain a valid filter', unsupportedBulkAction: 'Unsupported bulk action' }; @@ -67,6 +70,23 @@ class PostsService { if (data.action === 'unfeature') { return await this.#updatePosts({featured: false}, {filter: options.filter}); } + if (data.action === 'access') { + if (!['public', 'members', 'paid', 'tiers'].includes(data.meta.visibility)) { + throw new errors.IncorrectUsageError({ + message: tpl(messages.invalidVisibility) + }); + } + let tiers = undefined; + if (data.meta.visibility === 'tiers') { + if (!Array.isArray(data.meta.tiers)) { + throw new errors.IncorrectUsageError({ + message: tpl(messages.invalidTiers) + }); + } + tiers = data.meta.tiers; + } + return await this.#updatePosts({visibility: data.meta.visibility, tiers}, {filter: options.filter}); + } throw new errors.IncorrectUsageError({ message: tpl(messages.unsupportedBulkAction) }); @@ -146,6 +166,15 @@ class PostsService { } async #updatePosts(data, options) { + if (!options.transacting) { + return await this.models.Post.transaction(async (transacting) => { + return await this.#updatePosts(data, { + ...options, + transacting + }); + }); + } + const postRows = await this.models.Post.getFilteredCollectionQuery({ filter: options.filter, status: 'all' @@ -153,9 +182,48 @@ class PostsService { const editIds = postRows.map(row => row.id); - return await this.models.Post.bulkEdit(editIds, 'posts', { - data + let tiers = undefined; + if (data.tiers) { + tiers = data.tiers; + delete data.tiers; + } + + const result = await this.models.Post.bulkEdit(editIds, 'posts', { + data, + transacting: options.transacting, + throwErrors: true }); + + // Update tiers + if (tiers) { + // First delete all + await this.models.Post.bulkDestroy(editIds, 'posts_products', { + column: 'post_id', + transacting: options.transacting, + throwErrors: true + }); + + // Then add again + const toInsert = []; + for (const postId of editIds) { + for (const [index, tier] of tiers.entries()) { + if (typeof tier.id === 'string') { + toInsert.push({ + id: ObjectId().toHexString(), + post_id: postId, + product_id: tier.id, + sort_order: index + }); + } + } + } + await this.models.Post.bulkAdd(toInsert, 'posts_products', { + transacting: options.transacting, + throwErrors: true + }); + } + + return result; } async getProductsFromVisibilityFilter(visibilityFilter) { diff --git a/ghost/posts-service/package.json b/ghost/posts-service/package.json index cb375e5244..cebd73671c 100644 --- a/ghost/posts-service/package.json +++ b/ghost/posts-service/package.json @@ -25,6 +25,7 @@ "dependencies": { "@tryghost/errors": "1.2.24", "@tryghost/nql": "0.11.0", - "@tryghost/tpl": "0.1.24" + "@tryghost/tpl": "0.1.24", + "bson-objectid": "2.0.4" } }