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:
Fabien 'egg' O'Carroll 2023-04-13 21:17:36 +07:00 committed by GitHub
parent 07785c8ed9
commit 82393fa99d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 239 additions and 2 deletions

View File

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

View File

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

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

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

View File

@ -217,7 +217,7 @@ module.exports = {
data: {
action: {
required: true,
values: ['feature', 'unfeature']
values: ['feature', 'unfeature', 'addTag']
}
},
options: {

View File

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