feat: allow tag editing from topic tools

closes #7536
closes #7465
closes #11538
isekai-main
Barış Soner Uşaklı 2 years ago
parent 0391876822
commit e3551d80d7

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

@ -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.",

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

@ -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?",

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

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

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

@ -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]);
});
}

@ -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 () {
require(['forum/topic/move-post'], function (movePosts) {
movePosts.init();

@ -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,
});
});
};

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

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

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

@ -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: {
'/users/{uid}/tokens/{token}': [
{

Loading…
Cancel
Save