mirror of
https://github.com/NodeBB/NodeBB.git
synced 2026-02-26 16:41:21 +01:00
feat: allow tag editing from topic tools
closes #7536 closes #7465 closes #11538
This commit is contained in:
@@ -100,10 +100,10 @@
|
|||||||
"nodebb-plugin-ntfy": "1.0.15",
|
"nodebb-plugin-ntfy": "1.0.15",
|
||||||
"nodebb-plugin-spam-be-gone": "2.0.6",
|
"nodebb-plugin-spam-be-gone": "2.0.6",
|
||||||
"nodebb-rewards-essentials": "0.2.3",
|
"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-lavender": "7.0.9",
|
||||||
"nodebb-theme-peace": "2.0.21",
|
"nodebb-theme-peace": "2.0.21",
|
||||||
"nodebb-theme-persona": "13.0.58",
|
"nodebb-theme-persona": "13.0.59",
|
||||||
"nodebb-widget-essentials": "7.0.11",
|
"nodebb-widget-essentials": "7.0.11",
|
||||||
"nodemailer": "6.9.1",
|
"nodemailer": "6.9.1",
|
||||||
"nprogress": "0.2.0",
|
"nprogress": "0.2.0",
|
||||||
|
|||||||
@@ -105,6 +105,7 @@
|
|||||||
"already-posting": "You are already posting",
|
"already-posting": "You are already posting",
|
||||||
"tag-too-short": "Please enter a longer tag. Tags should contain at least %1 character(s)",
|
"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-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)",
|
"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)",
|
"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.",
|
"cant-use-system-tag": "You can not use this system tag.",
|
||||||
|
|||||||
@@ -6,5 +6,6 @@
|
|||||||
"enter_tags_here": "Enter tags here, between %1 and %2 characters each.",
|
"enter_tags_here": "Enter tags here, between %1 and %2 characters each.",
|
||||||
"enter_tags_here_short": "Enter tags...",
|
"enter_tags_here_short": "Enter tags...",
|
||||||
"no_tags": "There are no tags yet.",
|
"no_tags": "There are no tags yet.",
|
||||||
"select_tags": "Select Tags"
|
"select_tags": "Select Tags",
|
||||||
|
"tag-whitelist": "Tag Whitelist"
|
||||||
}
|
}
|
||||||
@@ -115,6 +115,7 @@
|
|||||||
"thread_tools.change_owner": "Change Owner",
|
"thread_tools.change_owner": "Change Owner",
|
||||||
"thread_tools.select_category": "Select Category",
|
"thread_tools.select_category": "Select Category",
|
||||||
"thread_tools.fork": "Fork Topic",
|
"thread_tools.fork": "Fork Topic",
|
||||||
|
"thread_tools.tag": "Tag Topic",
|
||||||
"thread_tools.delete": "Delete Topic",
|
"thread_tools.delete": "Delete Topic",
|
||||||
"thread_tools.delete-posts": "Delete Posts",
|
"thread_tools.delete-posts": "Delete Posts",
|
||||||
"thread_tools.delete_confirm": "Are you sure you want to delete this topic?",
|
"thread_tools.delete_confirm": "Are you sure you want to delete this topic?",
|
||||||
|
|||||||
@@ -1,4 +1,46 @@
|
|||||||
put:
|
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:
|
tags:
|
||||||
- topics
|
- topics
|
||||||
summary: adds tags to a topic
|
summary: adds tags to a topic
|
||||||
@@ -35,8 +77,11 @@ put:
|
|||||||
status:
|
status:
|
||||||
$ref: ../../../components/schemas/Status.yaml#/Status
|
$ref: ../../../components/schemas/Status.yaml#/Status
|
||||||
response:
|
response:
|
||||||
type: object
|
type: array
|
||||||
properties: {}
|
description: 'The current tags of the topic'
|
||||||
|
items:
|
||||||
|
type: object
|
||||||
|
example: [{}, {}]
|
||||||
delete:
|
delete:
|
||||||
tags:
|
tags:
|
||||||
- topics
|
- topics
|
||||||
|
|||||||
@@ -6,6 +6,16 @@
|
|||||||
[component="category-selector-selected"] span {
|
[component="category-selector-selected"] span {
|
||||||
display: inline-flex!important;
|
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) {
|
@include media-breakpoint-down(md) {
|
||||||
@@ -17,6 +27,6 @@
|
|||||||
|
|
||||||
@include media-breakpoint-up(md) {
|
@include media-breakpoint-up(md) {
|
||||||
.tool-modal {
|
.tool-modal {
|
||||||
max-width: 400px;
|
max-width: 500px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
CategoryTools.removeListeners();
|
||||||
socket.on('event:topic_deleted', setDeleteState);
|
socket.on('event:topic_deleted', setDeleteState);
|
||||||
socket.on('event:topic_restored', setDeleteState);
|
socket.on('event:topic_restored', setDeleteState);
|
||||||
|
|||||||
@@ -159,12 +159,8 @@ define('forum/topic/events', [
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (data.topic.tags && data.topic.tagsupdated) {
|
if (data.topic.tags && data.topic.tagsupdated) {
|
||||||
Benchpress.render('partials/topic/tags', { tags: data.topic.tags }).then(function (html) {
|
require(['forum/topic/tag'], function (tag) {
|
||||||
const tags = $('[data-pid="' + data.post.pid + '"] .tags');
|
tag.updateTopicTags([data.topic]);
|
||||||
tags.fadeOut(250, function () {
|
|
||||||
tags.toggleClass('hidden', data.topic.tags.length === 0);
|
|
||||||
tags.html(html).fadeIn(250);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
120
public/src/client/topic/tag.js
Normal file
120
public/src/client/topic/tag.js
Normal file
@@ -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;
|
||||||
|
});
|
||||||
@@ -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 () {
|
topicContainer.on('click', '[component="topic/move-posts"]', function () {
|
||||||
require(['forum/topic/move-post'], function (movePosts) {
|
require(['forum/topic/move-post'], function (movePosts) {
|
||||||
movePosts.init();
|
movePosts.init();
|
||||||
|
|||||||
@@ -4,21 +4,21 @@ define('autocomplete', ['api', 'alerts'], function (api, alerts) {
|
|||||||
const module = {};
|
const module = {};
|
||||||
const _default = {
|
const _default = {
|
||||||
delay: 200,
|
delay: 200,
|
||||||
|
appendTo: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
module.init = (params) => {
|
module.init = (params) => {
|
||||||
const { input, source, onSelect, delay } = { ..._default, ...params };
|
const acParams = { ..._default, ...params };
|
||||||
|
const { input, onSelect } = acParams;
|
||||||
app.loadJQueryUI(function () {
|
app.loadJQueryUI(function () {
|
||||||
input.autocomplete({
|
input.autocomplete({
|
||||||
delay,
|
...acParams,
|
||||||
open: function () {
|
open: function () {
|
||||||
$(this).autocomplete('widget').css('z-index', 100005);
|
$(this).autocomplete('widget').css('z-index', 100005);
|
||||||
},
|
},
|
||||||
select: function (event, ui) {
|
select: function (event, ui) {
|
||||||
handleOnSelect(input, onSelect, event, ui);
|
handleOnSelect(input, onSelect, event, ui);
|
||||||
},
|
},
|
||||||
source,
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -173,6 +173,17 @@ topicsAPI.unfollow = async function (caller, data) {
|
|||||||
await topics.unfollow(data.tid, caller.uid);
|
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 }) => {
|
topicsAPI.addTags = async (caller, { tid, tags }) => {
|
||||||
if (!await privileges.topics.canEdit(tid, caller.uid)) {
|
if (!await privileges.topics.canEdit(tid, caller.uid)) {
|
||||||
throw new Error('[[error:no-privileges]]');
|
throw new Error('[[error:no-privileges]]');
|
||||||
@@ -180,9 +191,10 @@ topicsAPI.addTags = async (caller, { tid, tags }) => {
|
|||||||
|
|
||||||
const cid = await topics.getTopicField(tid, 'cid');
|
const cid = await topics.getTopicField(tid, 'cid');
|
||||||
await topics.validateTags(tags, cid, caller.uid, tid);
|
await topics.validateTags(tags, cid, caller.uid, tid);
|
||||||
tags = await topics.filterTags(tags);
|
tags = await topics.filterTags(tags, cid);
|
||||||
|
|
||||||
await topics.addTags(tags, [tid]);
|
await topics.addTags(tags, [tid]);
|
||||||
|
return await topics.getTopicTagsObjects(tid);
|
||||||
};
|
};
|
||||||
|
|
||||||
topicsAPI.deleteTags = async (caller, { tid }) => {
|
topicsAPI.deleteTags = async (caller, { tid }) => {
|
||||||
|
|||||||
@@ -100,13 +100,21 @@ Topics.unfollow = async (req, res) => {
|
|||||||
helpers.formatApiResponse(200, 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) => {
|
Topics.addTags = async (req, res) => {
|
||||||
await api.topics.addTags(req, {
|
const payload = await api.topics.addTags(req, {
|
||||||
tid: req.params.tid,
|
tid: req.params.tid,
|
||||||
tags: req.body.tags,
|
tags: req.body.tags,
|
||||||
});
|
});
|
||||||
|
|
||||||
helpers.formatApiResponse(200, res);
|
helpers.formatApiResponse(200, res, payload);
|
||||||
};
|
};
|
||||||
|
|
||||||
Topics.deleteTags = async (req, res) => {
|
Topics.deleteTags = async (req, res) => {
|
||||||
|
|||||||
@@ -32,7 +32,8 @@ module.exports = function () {
|
|||||||
setupApiRoute(router, 'put', '/:tid/ignore', [...middlewares, middleware.assert.topic], controllers.write.topics.ignore);
|
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, '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, 'delete', '/:tid/tags', [...middlewares, middleware.assert.topic], controllers.write.topics.deleteTags);
|
||||||
|
|
||||||
setupApiRoute(router, 'get', '/:tid/thumbs', [], controllers.write.topics.getThumbs);
|
setupApiRoute(router, 'get', '/:tid/thumbs', [], controllers.write.topics.getThumbs);
|
||||||
|
|||||||
27
src/views/modals/tag-topic.tpl
Normal file
27
src/views/modals/tag-topic.tpl
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
<div class="card tool-modal shadow">
|
||||||
|
<h5 class="card-header">
|
||||||
|
[[topic:thread_tools.tag]]
|
||||||
|
</h5>
|
||||||
|
<div class="card-body d-flex flex-column gap-2">
|
||||||
|
<div class="d-flex flex-column gap-1">
|
||||||
|
{{{ if tagWhitelist }}}
|
||||||
|
<span>[[tags:tag-whitelist]]</span>
|
||||||
|
<div>
|
||||||
|
{{{ each tagWhitelist }}}
|
||||||
|
<span class="badge bg-info">{@value}</span>
|
||||||
|
{{{ end }}}
|
||||||
|
</div>
|
||||||
|
{{{ end }}}
|
||||||
|
</div>
|
||||||
|
{{{ each topics }}}
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label" for="fork-title"><strong>{./title}</strong></label>
|
||||||
|
<input class="tags" type="text" placeholder="[[tags:enter_tags_here, {config.minimumTagLength}, {config.maximumTagLength}]]" />
|
||||||
|
</div>
|
||||||
|
{{{ end }}}
|
||||||
|
</div>
|
||||||
|
<div class="card-footer text-end">
|
||||||
|
<button class="btn btn-link btn-sm" id="tag-topic-cancel">[[global:buttons.close]]</button>
|
||||||
|
<button class="btn btn-primary btn-sm" id="tag-topic-commit">[[global:save]]</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -72,6 +72,7 @@ describe('API', async () => {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
patch: {},
|
||||||
delete: {
|
delete: {
|
||||||
'/users/{uid}/tokens/{token}': [
|
'/users/{uid}/tokens/{token}': [
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user