From e3551d80d7079a71d8e79c397e129c2406357831 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bar=C4=B1=C5=9F=20Soner=20U=C5=9Fakl=C4=B1?= Date: Sun, 30 Apr 2023 21:17:25 -0400 Subject: [PATCH] feat: allow tag editing from topic tools closes #7536 closes #7465 closes #11538 --- install/package.json | 4 +- public/language/en-GB/error.json | 1 + public/language/en-GB/tags.json | 3 +- public/language/en-GB/topic.json | 1 + public/openapi/write/topics/tid/tags.yaml | 49 ++++++++- public/scss/modals.scss | 12 ++- public/src/client/category/tools.js | 8 ++ public/src/client/topic/events.js | 8 +- public/src/client/topic/tag.js | 120 ++++++++++++++++++++++ public/src/client/topic/threadTools.js | 6 ++ public/src/modules/autocomplete.js | 8 +- src/api/topics.js | 14 ++- src/controllers/write/topics.js | 12 ++- src/routes/write/topics.js | 3 +- src/views/modals/tag-topic.tpl | 27 +++++ test/api.js | 1 + 16 files changed, 257 insertions(+), 20 deletions(-) create mode 100644 public/src/client/topic/tag.js create mode 100644 src/views/modals/tag-topic.tpl diff --git a/install/package.json b/install/package.json index b7e39b2b76..8a23d51f05 100644 --- a/install/package.json +++ b/install/package.json @@ -100,10 +100,10 @@ "nodebb-plugin-ntfy": "1.0.15", "nodebb-plugin-spam-be-gone": "2.0.6", "nodebb-rewards-essentials": "0.2.3", - "nodebb-theme-harmony": "1.0.6", + "nodebb-theme-harmony": "1.0.7", "nodebb-theme-lavender": "7.0.9", "nodebb-theme-peace": "2.0.21", - "nodebb-theme-persona": "13.0.58", + "nodebb-theme-persona": "13.0.59", "nodebb-widget-essentials": "7.0.11", "nodemailer": "6.9.1", "nprogress": "0.2.0", diff --git a/public/language/en-GB/error.json b/public/language/en-GB/error.json index eb7dfd4b33..fa9fa6e319 100644 --- a/public/language/en-GB/error.json +++ b/public/language/en-GB/error.json @@ -105,6 +105,7 @@ "already-posting": "You are already posting", "tag-too-short": "Please enter a longer tag. Tags should contain at least %1 character(s)", "tag-too-long": "Please enter a shorter tag. Tags can't be longer than %1 character(s)", + "tag-not-allowed": "Tag not allowed", "not-enough-tags": "Not enough tags. Topics must have at least %1 tag(s)", "too-many-tags": "Too many tags. Topics can't have more than %1 tag(s)", "cant-use-system-tag": "You can not use this system tag.", diff --git a/public/language/en-GB/tags.json b/public/language/en-GB/tags.json index f126a70e6b..7159d4f542 100644 --- a/public/language/en-GB/tags.json +++ b/public/language/en-GB/tags.json @@ -6,5 +6,6 @@ "enter_tags_here": "Enter tags here, between %1 and %2 characters each.", "enter_tags_here_short": "Enter tags...", "no_tags": "There are no tags yet.", - "select_tags": "Select Tags" + "select_tags": "Select Tags", + "tag-whitelist": "Tag Whitelist" } \ No newline at end of file diff --git a/public/language/en-GB/topic.json b/public/language/en-GB/topic.json index d360fff733..7cda91c8a3 100644 --- a/public/language/en-GB/topic.json +++ b/public/language/en-GB/topic.json @@ -115,6 +115,7 @@ "thread_tools.change_owner": "Change Owner", "thread_tools.select_category": "Select Category", "thread_tools.fork": "Fork Topic", + "thread_tools.tag": "Tag Topic", "thread_tools.delete": "Delete Topic", "thread_tools.delete-posts": "Delete Posts", "thread_tools.delete_confirm": "Are you sure you want to delete this topic?", diff --git a/public/openapi/write/topics/tid/tags.yaml b/public/openapi/write/topics/tid/tags.yaml index 9f229d9707..d145f27374 100644 --- a/public/openapi/write/topics/tid/tags.yaml +++ b/public/openapi/write/topics/tid/tags.yaml @@ -1,4 +1,46 @@ put: + tags: + - topics + summary: update the tags of a topic + description: This operation updates the tags of the topic to the array of tags sent in the request + parameters: + - in: path + name: tid + schema: + type: string + required: true + description: a valid topic id + example: 1 + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + tags: + type: array + description: 'An array of tags' + items: + type: string + example: [test, foobar] + responses: + '200': + description: Topic tags successfully updated + content: + application/json: + schema: + type: object + properties: + status: + $ref: ../../../components/schemas/Status.yaml#/Status + response: + type: array + description: 'The current tags of the topic' + items: + type: object + example: [{}, {}] +patch: tags: - topics summary: adds tags to a topic @@ -35,8 +77,11 @@ put: status: $ref: ../../../components/schemas/Status.yaml#/Status response: - type: object - properties: {} + type: array + description: 'The current tags of the topic' + items: + type: object + example: [{}, {}] delete: tags: - topics diff --git a/public/scss/modals.scss b/public/scss/modals.scss index dcdd2ad465..8422c318a2 100644 --- a/public/scss/modals.scss +++ b/public/scss/modals.scss @@ -6,6 +6,16 @@ [component="category-selector-selected"] span { display: inline-flex!important; } + .bootstrap-tagsinput { + input { + width: 100%; + } + .ui-autocomplete { + max-height: 350px; + overflow-x: hidden; + overflow-y: auto; + } + } } @include media-breakpoint-down(md) { @@ -17,6 +27,6 @@ @include media-breakpoint-up(md) { .tool-modal { - max-width: 400px; + max-width: 500px; } } diff --git a/public/src/client/category/tools.js b/public/src/client/category/tools.js index 3d95a87e27..4c214d5067 100644 --- a/public/src/client/category/tools.js +++ b/public/src/client/category/tools.js @@ -117,6 +117,14 @@ define('forum/category/tools', [ }); }); + components.get('topic/tag').on('click', async function () { + const tids = topicSelect.getSelectedTids(); + const topics = await Promise.all(tids.map(tid => api.get(`/topics/${tid}`))); + require(['forum/topic/tag'], function (tag) { + tag.init(topics, ajaxify.data.tagWhitelist, onCommandComplete); + }); + }); + CategoryTools.removeListeners(); socket.on('event:topic_deleted', setDeleteState); socket.on('event:topic_restored', setDeleteState); diff --git a/public/src/client/topic/events.js b/public/src/client/topic/events.js index 5e0c311f6b..fe795ea044 100644 --- a/public/src/client/topic/events.js +++ b/public/src/client/topic/events.js @@ -159,12 +159,8 @@ define('forum/topic/events', [ } if (data.topic.tags && data.topic.tagsupdated) { - Benchpress.render('partials/topic/tags', { tags: data.topic.tags }).then(function (html) { - const tags = $('[data-pid="' + data.post.pid + '"] .tags'); - tags.fadeOut(250, function () { - tags.toggleClass('hidden', data.topic.tags.length === 0); - tags.html(html).fadeIn(250); - }); + require(['forum/topic/tag'], function (tag) { + tag.updateTopicTags([data.topic]); }); } diff --git a/public/src/client/topic/tag.js b/public/src/client/topic/tag.js new file mode 100644 index 0000000000..232835b338 --- /dev/null +++ b/public/src/client/topic/tag.js @@ -0,0 +1,120 @@ +'use strict'; + + +define('forum/topic/tag', [ + 'alerts', 'autocomplete', 'api', 'benchpress', +], function (alerts, autocomplete, api, Benchpress) { + const Tag = {}; + let tagModal; + let tagCommit; + let topics; + let tagWhitelist; + Tag.init = function (_topics, _tagWhitelist, onComplete) { + if (tagModal) { + return; + } + topics = _topics; + tagWhitelist = _tagWhitelist || []; + + app.parseAndTranslate('modals/tag-topic', { + topics: topics, + tagWhitelist: tagWhitelist, + }, function (html) { + tagModal = html; + + tagCommit = tagModal.find('#tag-topic-commit'); + + $('body').append(tagModal); + + tagModal.find('#tag-topic-cancel').on('click', closeTagModal); + + tagCommit.on('click', async () => { + await tagTopics(); + if (onComplete) { + onComplete(); + } + }); + + tagModal.find('.tags').each((index, el) => { + const tagEl = $(el); + const tagsinputEl = tagEl.tagsinput({ + tagClass: 'badge bg-info', + confirmKeys: [13, 44], + trimValue: true, + }); + const input = tagsinputEl[0].$input; + + const topic = topics[index]; + topic.tags.forEach(tag => tagEl.tagsinput('add', tag.value)); + + tagEl.on('itemAdded', function (event) { + if (tagWhitelist.length && !tagWhitelist.includes(event.item)) { + tagEl.tagsinput('remove', event.item); + alerts.error('[[error:tag-not-allowed]]'); + } + if (input.length) { + input.autocomplete('close'); + } + }); + + initAutocomplete({ + input, + container: tagsinputEl[0].$container, + }); + }); + }); + }; + + function initAutocomplete(params) { + autocomplete.init({ + input: params.input, + position: { my: 'left bottom', at: 'left top', collision: 'flip' }, + appendTo: params.container, + source: async (request, response) => { + socket.emit('topics.autocompleteTags', { + query: request.term, + }, function (err, tags) { + if (err) { + return alerts.error(err); + } + if (tags) { + response(tags); + } + }); + }, + }); + } + + async function tagTopics() { + await Promise.all(tagModal.find('.tags').map(async (index, el) => { + const topic = topics[index]; + const tagEl = $(el); + topic.tags = await api.put(`/topics/${topic.tid}/tags`, { tags: tagEl.tagsinput('items') }); + Tag.updateTopicTags([topic]); + })); + closeTagModal(); + } + + Tag.updateTopicTags = function (topics) { + topics.forEach((topic) => { + // render "partials/category/tags" or "partials/topic/tags" + const tpl = ajaxify.data.template.topic ? 'partials/topic/tags' : 'partials/category/tags'; + Benchpress.render(tpl, { tags: topic.tags }).then(function (html) { + const tags = $(`[data-tid="${topic.tid}"][component="topic/tags"]`); + tags.fadeOut(250, function () { + tags.toggleClass('hidden', topic.tags.length === 0); + tags.html(html).fadeIn(250); + }); + }); + }); + }; + + function closeTagModal() { + if (tagModal) { + tagModal.remove(); + tagModal = null; + } + } + + return Tag; +}); diff --git a/public/src/client/topic/threadTools.js b/public/src/client/topic/threadTools.js index 4a44418273..dc401dfd39 100644 --- a/public/src/client/topic/threadTools.js +++ b/public/src/client/topic/threadTools.js @@ -133,6 +133,12 @@ define('forum/topic/threadTools', [ }); }); + topicContainer.on('click', '[component="topic/tag"]', function () { + require(['forum/topic/tag'], function (tag) { + tag.init([ajaxify.data], ajaxify.data.tagWhitelist); + }); + }); + topicContainer.on('click', '[component="topic/move-posts"]', function () { require(['forum/topic/move-post'], function (movePosts) { movePosts.init(); diff --git a/public/src/modules/autocomplete.js b/public/src/modules/autocomplete.js index a8f1e832e4..ae435e72bd 100644 --- a/public/src/modules/autocomplete.js +++ b/public/src/modules/autocomplete.js @@ -4,21 +4,21 @@ define('autocomplete', ['api', 'alerts'], function (api, alerts) { const module = {}; const _default = { delay: 200, + appendTo: null, }; module.init = (params) => { - const { input, source, onSelect, delay } = { ..._default, ...params }; - + const acParams = { ..._default, ...params }; + const { input, onSelect } = acParams; app.loadJQueryUI(function () { input.autocomplete({ - delay, + ...acParams, open: function () { $(this).autocomplete('widget').css('z-index', 100005); }, select: function (event, ui) { handleOnSelect(input, onSelect, event, ui); }, - source, }); }); }; diff --git a/src/api/topics.js b/src/api/topics.js index d8910b1098..08fa75ed04 100644 --- a/src/api/topics.js +++ b/src/api/topics.js @@ -173,6 +173,17 @@ topicsAPI.unfollow = async function (caller, data) { await topics.unfollow(data.tid, caller.uid); }; +topicsAPI.updateTags = async (caller, { tid, tags }) => { + if (!await privileges.topics.canEdit(tid, caller.uid)) { + throw new Error('[[error:no-privileges]]'); + } + + const cid = await topics.getTopicField(tid, 'cid'); + await topics.validateTags(tags, cid, caller.uid, tid); + await topics.updateTopicTags(tid, tags); + return await topics.getTopicTagsObjects(tid); +}; + topicsAPI.addTags = async (caller, { tid, tags }) => { if (!await privileges.topics.canEdit(tid, caller.uid)) { throw new Error('[[error:no-privileges]]'); @@ -180,9 +191,10 @@ topicsAPI.addTags = async (caller, { tid, tags }) => { const cid = await topics.getTopicField(tid, 'cid'); await topics.validateTags(tags, cid, caller.uid, tid); - tags = await topics.filterTags(tags); + tags = await topics.filterTags(tags, cid); await topics.addTags(tags, [tid]); + return await topics.getTopicTagsObjects(tid); }; topicsAPI.deleteTags = async (caller, { tid }) => { diff --git a/src/controllers/write/topics.js b/src/controllers/write/topics.js index 920f331b9a..83c021c5e3 100644 --- a/src/controllers/write/topics.js +++ b/src/controllers/write/topics.js @@ -100,13 +100,21 @@ Topics.unfollow = async (req, res) => { helpers.formatApiResponse(200, res); }; +Topics.updateTags = async (req, res) => { + const payload = await api.topics.updateTags(req, { + tid: req.params.tid, + tags: req.body.tags, + }); + helpers.formatApiResponse(200, res, payload); +}; + Topics.addTags = async (req, res) => { - await api.topics.addTags(req, { + const payload = await api.topics.addTags(req, { tid: req.params.tid, tags: req.body.tags, }); - helpers.formatApiResponse(200, res); + helpers.formatApiResponse(200, res, payload); }; Topics.deleteTags = async (req, res) => { diff --git a/src/routes/write/topics.js b/src/routes/write/topics.js index 3a0c7c306e..c3e4739940 100644 --- a/src/routes/write/topics.js +++ b/src/routes/write/topics.js @@ -32,7 +32,8 @@ module.exports = function () { setupApiRoute(router, 'put', '/:tid/ignore', [...middlewares, middleware.assert.topic], controllers.write.topics.ignore); setupApiRoute(router, 'delete', '/:tid/ignore', [...middlewares, middleware.assert.topic], controllers.write.topics.unfollow); // intentional, unignore == unfollow - setupApiRoute(router, 'put', '/:tid/tags', [...middlewares, middleware.checkRequired.bind(null, ['tags']), middleware.assert.topic], controllers.write.topics.addTags); + setupApiRoute(router, 'put', '/:tid/tags', [...middlewares, middleware.checkRequired.bind(null, ['tags']), middleware.assert.topic], controllers.write.topics.updateTags); + setupApiRoute(router, 'patch', '/:tid/tags', [...middlewares, middleware.checkRequired.bind(null, ['tags']), middleware.assert.topic], controllers.write.topics.addTags); setupApiRoute(router, 'delete', '/:tid/tags', [...middlewares, middleware.assert.topic], controllers.write.topics.deleteTags); setupApiRoute(router, 'get', '/:tid/thumbs', [], controllers.write.topics.getThumbs); diff --git a/src/views/modals/tag-topic.tpl b/src/views/modals/tag-topic.tpl new file mode 100644 index 0000000000..56636fa364 --- /dev/null +++ b/src/views/modals/tag-topic.tpl @@ -0,0 +1,27 @@ +
+
+ [[topic:thread_tools.tag]] +
+
+
+ {{{ if tagWhitelist }}} + [[tags:tag-whitelist]] +
+ {{{ each tagWhitelist }}} + {@value} + {{{ end }}} +
+ {{{ end }}} +
+ {{{ each topics }}} +
+ + +
+ {{{ end }}} +
+ +
\ No newline at end of file diff --git a/test/api.js b/test/api.js index e76ec8a216..c0d85f2f25 100644 --- a/test/api.js +++ b/test/api.js @@ -72,6 +72,7 @@ describe('API', async () => { }, ], }, + patch: {}, delete: { '/users/{uid}/tokens/{token}': [ {