Added bulk tag addition to post context menu
refs https://github.com/TryGhost/Team/issues/2922 Add multiple tags to multiple posts at once. --------- Co-authored-by: Simon Backx <simon@ghost.org>
This commit is contained in:
parent
07785c8ed9
commit
82393fa99d
@ -23,7 +23,7 @@
|
||||
{{/if}}
|
||||
{{/if}}
|
||||
<li>
|
||||
<button class="mr2" type="button" disabled {{on "click" @menu.close}}>
|
||||
<button class="mr2" type="button" {{on "click" this.addTagToPosts}}>
|
||||
<span>{{svg-jar "tag"}}Add a tag</span>
|
||||
</button>
|
||||
</li>
|
||||
|
@ -1,3 +1,4 @@
|
||||
import AddPostTagsModal from './modals/add-tag';
|
||||
import Component from '@glimmer/component';
|
||||
import DeletePostsModal from './modals/delete-posts';
|
||||
import EditPostsAccessModal from './modals/edit-posts-access';
|
||||
@ -28,6 +29,10 @@ const messages = {
|
||||
accessUpdated: {
|
||||
single: 'Post access successfully updated',
|
||||
multiple: 'Post access successfully updated for {count} posts'
|
||||
},
|
||||
tagsAdded: {
|
||||
single: 'Tags added successfully',
|
||||
multiple: 'Tags added successfully to {count} posts'
|
||||
}
|
||||
};
|
||||
|
||||
@ -64,6 +69,14 @@ export default class PostsContextMenu extends Component {
|
||||
this.menu.performTask(this.unfeaturePostsTask);
|
||||
}
|
||||
|
||||
@action
|
||||
async addTagToPosts() {
|
||||
await this.menu.openModal(AddPostTagsModal, {
|
||||
selectionList: this.selectionList,
|
||||
confirm: this.addTagToPostsTask
|
||||
});
|
||||
}
|
||||
|
||||
@action
|
||||
async deletePosts() {
|
||||
this.menu.openModal(DeletePostsModal, {
|
||||
@ -88,6 +101,51 @@ export default class PostsContextMenu extends Component {
|
||||
});
|
||||
}
|
||||
|
||||
@task
|
||||
*addTagToPostsTask(tags) {
|
||||
const updatedModels = this.selectionList.availableModels;
|
||||
|
||||
yield this.performBulkEdit('addTag', {tags: tags.map(tag => tag.id)});
|
||||
|
||||
this.notifications.showNotification(this.#getToastMessage('tagsAdded'), {type: 'success'});
|
||||
|
||||
// Update the models on the client side
|
||||
for (const post of updatedModels) {
|
||||
const newTags = post.tags.toArray().map((t) => {
|
||||
return {
|
||||
...t.serialize({includeId: true}),
|
||||
type: 'tag'
|
||||
};
|
||||
});
|
||||
for (const tag of tags) {
|
||||
if (!newTags.find(t => t.id === tag.id)) {
|
||||
newTags.push({
|
||||
...tag.serialize({includeId: true}),
|
||||
type: 'tag'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// We need to do it this way to prevent marking the model as dirty
|
||||
this.store.push({
|
||||
data: {
|
||||
id: post.id,
|
||||
type: 'post',
|
||||
relationships: {
|
||||
tags: {
|
||||
data: newTags
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Remove posts that no longer match the filter
|
||||
this.updateFilteredPosts();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@task
|
||||
*deletePostsTask() {
|
||||
const deletedModels = this.selectionList.availableModels;
|
||||
|
39
ghost/admin/app/components/posts-list/modals/add-tag.hbs
Normal file
39
ghost/admin/app/components/posts-list/modals/add-tag.hbs
Normal file
@ -0,0 +1,39 @@
|
||||
<div class="modal-content" data-test-modal="add-tags">
|
||||
<header class="modal-header">
|
||||
<h1>What tags do you want to add?</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">
|
||||
<p>
|
||||
<GhTokenInput
|
||||
@extra={{hash
|
||||
tokenComponent=(component "gh-token-input/tag-token")
|
||||
}}
|
||||
@onChange={{this.handleChange}}
|
||||
@onCreate={{this.handleCreate}}
|
||||
@options={{this.availableTags}}
|
||||
@renderInPlace={{true}}
|
||||
@selected={{this.selectedTags}}
|
||||
@showCreateWhen={{this.shouldAllowCreate}}
|
||||
@triggerId={{this.triggerId}}
|
||||
@placeholder="Select one or more tags"
|
||||
/>
|
||||
</p>
|
||||
</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="Add"
|
||||
@runningText="Adding"
|
||||
@showSuccess={{false}}
|
||||
@task={{@data.confirm}}
|
||||
@taskArgs={{this.selectedTags}}
|
||||
@class="gh-btn gh-btn-green gh-btn-icon"
|
||||
data-test-button="confirm"
|
||||
/>
|
||||
|
||||
</div>
|
||||
</div>
|
74
ghost/admin/app/components/posts-list/modals/add-tag.js
Normal file
74
ghost/admin/app/components/posts-list/modals/add-tag.js
Normal file
@ -0,0 +1,74 @@
|
||||
import Component from '@glimmer/component';
|
||||
import {action} from '@ember/object';
|
||||
import {inject as service} from '@ember/service';
|
||||
import {tracked} from '@glimmer/tracking';
|
||||
|
||||
export default class AddTag extends Component {
|
||||
@service store;
|
||||
|
||||
#availableTags = null;
|
||||
|
||||
@tracked
|
||||
selectedTags = [];
|
||||
|
||||
get availableTags() {
|
||||
return this.#availableTags || [];
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super(...arguments);
|
||||
// perform a background query to fetch all users and set `availableTags`
|
||||
// to a live-query that will be immediately populated with what's in the
|
||||
// store and be updated when the above query returns
|
||||
this.store.query('tag', {limit: 'all'});
|
||||
this.#availableTags = this.store.peekAll('tag');
|
||||
}
|
||||
|
||||
@action
|
||||
handleChange(newTags) {
|
||||
this.selectedTags.forEach((tag) => {
|
||||
if (!newTags.includes(tag) && tag.isNew) {
|
||||
tag.destroyRecord();
|
||||
}
|
||||
});
|
||||
this.selectedTags = newTags;
|
||||
}
|
||||
|
||||
@action
|
||||
handleCreate(nameInput) {
|
||||
let potentialTagName = nameInput.trim();
|
||||
|
||||
let isAlreadySelected = !!this.#findTagByName(potentialTagName, this.selectedTags);
|
||||
|
||||
if (isAlreadySelected) {
|
||||
return;
|
||||
}
|
||||
|
||||
let tagToAdd = this.#findTagByName(potentialTagName, this.#availableTags);
|
||||
|
||||
if (!tagToAdd) {
|
||||
tagToAdd = this.store.createRecord('tag', {
|
||||
name: potentialTagName
|
||||
});
|
||||
|
||||
tagToAdd.updateVisibility();
|
||||
}
|
||||
|
||||
this.selectedTags = this.selectedTags.concat(tagToAdd);
|
||||
}
|
||||
|
||||
@action
|
||||
shouldAllowCreate() {
|
||||
return false;
|
||||
|
||||
// This is not supported by the backend yet
|
||||
// return !this.#findTagByName(nameInput.trim(), this.#availableTags);
|
||||
}
|
||||
|
||||
#findTagByName(name, tags) {
|
||||
let withMatchingName = function (tag) {
|
||||
return tag.name.toLowerCase() === name.toLowerCase();
|
||||
};
|
||||
return tags.find(withMatchingName);
|
||||
}
|
||||
}
|
@ -217,7 +217,7 @@ module.exports = {
|
||||
data: {
|
||||
action: {
|
||||
required: true,
|
||||
values: ['feature', 'unfeature']
|
||||
values: ['feature', 'unfeature', 'addTag']
|
||||
}
|
||||
},
|
||||
options: {
|
||||
|
@ -94,11 +94,77 @@ class PostsService {
|
||||
}
|
||||
return await this.#updatePosts({visibility: data.meta.visibility, tiers}, {filter: options.filter});
|
||||
}
|
||||
if (data.action === 'addTag') {
|
||||
return await this.#bulkAddTags({tags: data.meta.tags}, {filter: options.filter});
|
||||
}
|
||||
throw new errors.IncorrectUsageError({
|
||||
message: tpl(messages.unsupportedBulkAction)
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object} data
|
||||
* @param {string[]} data.tags - Array of tag ids to add to the post
|
||||
* @param {object} options
|
||||
* @param {string} options.filter - An NQL Filter
|
||||
*/
|
||||
async #bulkAddTags(data, options) {
|
||||
if (!options.transacting) {
|
||||
return await this.models.Post.transaction(async (transacting) => {
|
||||
return await this.#bulkAddTags(data, {
|
||||
...options,
|
||||
transacting
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const postRows = await this.models.Post.getFilteredCollectionQuery({
|
||||
filter: options.filter,
|
||||
status: 'all'
|
||||
}).select('posts.id');
|
||||
|
||||
const postTags = data.tags.reduce((pt, tagId) => {
|
||||
return pt.concat(postRows.map((post) => {
|
||||
return {
|
||||
id: (new ObjectId()).toHexString(),
|
||||
post_id: post.id,
|
||||
tag_id: tagId,
|
||||
sort_order: 0
|
||||
};
|
||||
}));
|
||||
}, []);
|
||||
|
||||
await options.transacting('posts_tags').insert(postTags);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object} data
|
||||
* @param {string[]} data.tags - Array of tag ids to add to the post
|
||||
* @param {object} options
|
||||
* @param {string} options.filter - An NQL Filter
|
||||
*/
|
||||
async #bulkRemoveTags(data, options) {
|
||||
if (!options.transacting) {
|
||||
return await this.models.Post.transaction(async (transacting) => {
|
||||
return await this.#bulkRemoveTags(data, {
|
||||
...options,
|
||||
transacting
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const postRows = await this.models.Post.getFilteredCollectionQuery({
|
||||
filter: options.filter,
|
||||
status: 'all'
|
||||
}).select('posts.id');
|
||||
|
||||
await options.transacting('posts_tags').whereIn('post_id', postRows.map(post => post.id)).whereIn('tag_id', data.tags).del();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async bulkDestroy(options) {
|
||||
if (!options.transacting) {
|
||||
return await this.models.Post.transaction(async (transacting) => {
|
||||
|
Loading…
Reference in New Issue
Block a user