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.
This commit is contained in:
Simon Backx 2023-04-12 11:58:46 +02:00 committed by GitHub
parent a097f0e973
commit ab1ca90779
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 253 additions and 15 deletions

View File

@ -25,7 +25,7 @@
</button>
</li>
<li>
<button class="mr2" type="button" disabled {{on "click" @menu.close}}>
<button class="mr2" type="button" {{on "click" this.editPostsAccess}}>
<span>Post access...</span>
</button>
</li>

View File

@ -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

View File

@ -0,0 +1,39 @@
<div class="modal-content" data-test-modal="edit-posts-access">
<header class="modal-header">
<h1>Modify access for {{if @data.isSingle 'this post' (concat @data.count ' posts')}}</h1>
</header>
<button type="button" class="close" title="Close" {{on "click" (fn @close false)}} data-test-button="close">{{svg-jar "close"}}<span class="hidden">Close</span></button>
<div class="modal-body">
<GhFormGroup @errors={{this.post.errors}} @hasValidated={{this.post.hasValidated}} @property="visibility">
<label for="visibility-input">{{capitalize this.post.displayName}} access</label>
<GhPsmVisibilityInput @post={{this.post}} @triggerId="visibility-input" />
</GhFormGroup>
{{#if (eq this.post.visibility "tiers")}}
<GhFormGroup @errors={{this.post.errors}} @hasValidated={{this.post.hasValidated}} @property="tiers" class="nt3" data-test-visibility-segment-select>
<GhPostSettingsMenu::VisibilitySegmentSelect
@tiers={{this.post.tiers}}
@onChange={{this.setVisibility}}
@renderInPlace={{true}}
@hideOptionsWhenAllSelected={{true}}
/>
<GhErrorMessage @errors={{this.post.errors}} @property="tiers" data-test-error="tiers" />
</GhFormGroup>
{{/if}}
</div>
<div class="modal-footer">
<button class="gh-btn" data-test-button="cancel" type="button" {{on "click" (fn @close false)}}><span>Cancel</span></button>
<GhTaskButton
@buttonText="Save"
@runningText="Saving"
@showSuccess={{false}}
@task={{this.save}}
@class="gh-btn gh-btn-icon gh-btn-black"
data-test-button="confirm"
/>
</div>
</div>

View File

@ -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
});
}
}

View File

@ -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);

View File

@ -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) {

View File

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