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" } }