From 788aa34c8b0560ed0744121318cb3e761ed8bdad Mon Sep 17 00:00:00 2001 From: Simon Backx Date: Thu, 13 Apr 2023 17:05:42 +0200 Subject: [PATCH] Added support for creating new tags when bulk editing posts refs https://github.com/TryGhost/Team/issues/2922 --- .../app/components/posts-list/context-menu.js | 50 ++++++++++++++++--- .../components/posts-list/modals/add-tag.js | 14 ++++-- ghost/posts-service/lib/PostsService.js | 30 ++++++++++- 3 files changed, 80 insertions(+), 14 deletions(-) diff --git a/ghost/admin/app/components/posts-list/context-menu.js b/ghost/admin/app/components/posts-list/context-menu.js index 7639a34348..1435a087e1 100644 --- a/ghost/admin/app/components/posts-list/context-menu.js +++ b/ghost/admin/app/components/posts-list/context-menu.js @@ -105,10 +105,49 @@ export default class PostsContextMenu extends Component { *addTagToPostsTask(tags) { const updatedModels = this.selectionList.availableModels; - yield this.performBulkEdit('addTag', {tags: tags.map(tag => tag.id)}); - + yield this.performBulkEdit('addTag', { + tags: tags.map((t) => { + return { + id: t.id, + name: t.name, + slug: t.slug + }; + }) + }); this.notifications.showNotification(this.#getToastMessage('tagsAdded'), {type: 'success'}); + const serializedTags = tags.toArray().map((t) => { + return { + ...t.serialize({includeId: true}), + type: 'tag' + }; + }); + + // Destroy unsaved new tags (otherwise we could select them again) + this.store.peekAll('tag').forEach((tag) => { + if (tag.isNew) { + tag.destroyRecord(); + } + }); + + // For new tags, attach the id to it, so we can link the new tag to the post + let allTags = null; + + for (const tag of serializedTags) { + if (!tag.id) { + if (!allTags) { + // Update tags on the client side (we could have created new tags) + yield this.store.query('tag', {limit: 'all'}); + allTags = this.store.peekAll('tag').toArray(); + } + const createdTag = allTags.find(t => t.name === tag.name && t.id); + if (createdTag) { + tag.id = createdTag.id; + tag.slug = createdTag.slug; + } + } + } + // Update the models on the client side for (const post of updatedModels) { const newTags = post.tags.toArray().map((t) => { @@ -117,12 +156,9 @@ export default class PostsContextMenu extends Component { type: 'tag' }; }); - for (const tag of tags) { + for (const tag of serializedTags) { if (!newTags.find(t => t.id === tag.id)) { - newTags.push({ - ...tag.serialize({includeId: true}), - type: 'tag' - }); + newTags.push(tag); } } diff --git a/ghost/admin/app/components/posts-list/modals/add-tag.js b/ghost/admin/app/components/posts-list/modals/add-tag.js index 190dcfde60..50f888a0eb 100644 --- a/ghost/admin/app/components/posts-list/modals/add-tag.js +++ b/ghost/admin/app/components/posts-list/modals/add-tag.js @@ -22,6 +22,13 @@ export default class AddTag extends Component { // store and be updated when the above query returns this.store.query('tag', {limit: 'all'}); this.#availableTags = this.store.peekAll('tag'); + + // Destroy unsaved new tags (otherwise we could select them again -> create them again) + this.#availableTags.forEach((tag) => { + if (tag.isNew) { + tag.destroyRecord(); + } + }); } @action @@ -58,11 +65,8 @@ export default class AddTag extends Component { } @action - shouldAllowCreate() { - return false; - - // This is not supported by the backend yet - // return !this.#findTagByName(nameInput.trim(), this.#availableTags); + shouldAllowCreate(nameInput) { + return !this.#findTagByName(nameInput.trim(), this.#availableTags); } #findTagByName(name, tags) { diff --git a/ghost/posts-service/lib/PostsService.js b/ghost/posts-service/lib/PostsService.js index 6aa37780f3..9b32eaf9e7 100644 --- a/ghost/posts-service/lib/PostsService.js +++ b/ghost/posts-service/lib/PostsService.js @@ -8,6 +8,7 @@ const messages = { invalidVisibilityFilter: 'Invalid visibility filter.', invalidVisibility: 'Invalid visibility value.', invalidTiers: 'Invalid tiers value.', + invalidTags: 'Invalid tags value.', invalidEmailSegment: 'The email segment parameter doesn\'t contain a valid filter', unsupportedBulkAction: 'Unsupported bulk action' }; @@ -95,6 +96,23 @@ class PostsService { return await this.#updatePosts({visibility: data.meta.visibility, tiers}, {filter: options.filter}); } if (data.action === 'addTag') { + if (!Array.isArray(data.meta.tags)) { + throw new errors.IncorrectUsageError({ + message: tpl(messages.invalidTags) + }); + } + for (const tag of data.meta.tags) { + if (typeof tag !== 'object') { + throw new errors.IncorrectUsageError({ + message: tpl(messages.invalidTags) + }); + } + if (!tag.id && !tag.name) { + throw new errors.IncorrectUsageError({ + message: tpl(messages.invalidTags) + }); + } + } return await this.#bulkAddTags({tags: data.meta.tags}, {filter: options.filter}); } throw new errors.IncorrectUsageError({ @@ -118,17 +136,25 @@ class PostsService { }); } + // Create tags that don't exist + for (const tag of data.tags) { + if (!tag.id) { + const createdTag = await this.models.Tag.add(tag, {transacting: options.transacting}); + tag.id = createdTag.id; + } + } + const postRows = await this.models.Post.getFilteredCollectionQuery({ filter: options.filter, status: 'all' }).select('posts.id'); - const postTags = data.tags.reduce((pt, tagId) => { + const postTags = data.tags.reduce((pt, tag) => { return pt.concat(postRows.map((post) => { return { id: (new ObjectId()).toHexString(), post_id: post.id, - tag_id: tagId, + tag_id: tag.id, sort_order: 0 }; }));