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:
parent
a097f0e973
commit
ab1ca90779
@ -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>
|
||||
|
@ -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
|
||||
|
@ -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>
|
@ -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
|
||||
});
|
||||
}
|
||||
}
|
@ -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);
|
||||
|
@ -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) {
|
||||
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user