From 82393fa99d15cffd74bc01d17ab3ca979595c161 Mon Sep 17 00:00:00 2001 From: Fabien 'egg' O'Carroll Date: Thu, 13 Apr 2023 21:17:36 +0700 Subject: [PATCH] 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 --- .../components/posts-list/context-menu.hbs | 2 +- .../app/components/posts-list/context-menu.js | 58 +++++++++++++++ .../components/posts-list/modals/add-tag.hbs | 39 ++++++++++ .../components/posts-list/modals/add-tag.js | 74 +++++++++++++++++++ ghost/core/core/server/api/endpoints/posts.js | 2 +- ghost/posts-service/lib/PostsService.js | 66 +++++++++++++++++ 6 files changed, 239 insertions(+), 2 deletions(-) create mode 100644 ghost/admin/app/components/posts-list/modals/add-tag.hbs create mode 100644 ghost/admin/app/components/posts-list/modals/add-tag.js diff --git a/ghost/admin/app/components/posts-list/context-menu.hbs b/ghost/admin/app/components/posts-list/context-menu.hbs index da82c57288..e50aa348c7 100644 --- a/ghost/admin/app/components/posts-list/context-menu.hbs +++ b/ghost/admin/app/components/posts-list/context-menu.hbs @@ -23,7 +23,7 @@ {{/if}} {{/if}}
  • -
  • diff --git a/ghost/admin/app/components/posts-list/context-menu.js b/ghost/admin/app/components/posts-list/context-menu.js index 60f9148a46..0aa61ebee8 100644 --- a/ghost/admin/app/components/posts-list/context-menu.js +++ b/ghost/admin/app/components/posts-list/context-menu.js @@ -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; diff --git a/ghost/admin/app/components/posts-list/modals/add-tag.hbs b/ghost/admin/app/components/posts-list/modals/add-tag.hbs new file mode 100644 index 0000000000..45fb235d21 --- /dev/null +++ b/ghost/admin/app/components/posts-list/modals/add-tag.hbs @@ -0,0 +1,39 @@ + diff --git a/ghost/admin/app/components/posts-list/modals/add-tag.js b/ghost/admin/app/components/posts-list/modals/add-tag.js new file mode 100644 index 0000000000..190dcfde60 --- /dev/null +++ b/ghost/admin/app/components/posts-list/modals/add-tag.js @@ -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); + } +} diff --git a/ghost/core/core/server/api/endpoints/posts.js b/ghost/core/core/server/api/endpoints/posts.js index 7a90d61ee9..8b52f8f83a 100644 --- a/ghost/core/core/server/api/endpoints/posts.js +++ b/ghost/core/core/server/api/endpoints/posts.js @@ -217,7 +217,7 @@ module.exports = { data: { action: { required: true, - values: ['feature', 'unfeature'] + values: ['feature', 'unfeature', 'addTag'] } }, options: { diff --git a/ghost/posts-service/lib/PostsService.js b/ghost/posts-service/lib/PostsService.js index 0b34b3aa9e..c57fa7b0cc 100644 --- a/ghost/posts-service/lib/PostsService.js +++ b/ghost/posts-service/lib/PostsService.js @@ -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) => {