FEATURE: ability to add description to tags (#15125)

Ability to add description to tags, which will be displayed on hover.
This commit is contained in:
Krzysztof Kotlarek 2021-12-01 09:18:56 +11:00 committed by GitHub
parent 78723345c0
commit 9cabd3721b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
23 changed files with 190 additions and 85 deletions

View File

@ -6,7 +6,7 @@ import bootbox from "bootbox";
import discourseComputed from "discourse-common/utils/decorators";
import { isEmpty } from "@ember/utils";
import { popupAjaxError } from "discourse/lib/ajax-error";
import showModal from "discourse/lib/show-modal";
import { inject as service } from "@ember/service";
export default Component.extend({
tagName: "",
@ -16,6 +16,10 @@ export default Component.extend({
showEditControls: false,
canAdminTag: reads("currentUser.staff"),
editSynonymsMode: and("canAdminTag", "showEditControls"),
editing: false,
newTagName: null,
newTagDescription: null,
router: service(),
@discourseComputed("tagInfo.tag_group_names")
tagGroupsInfo(tagGroupNames) {
@ -41,6 +45,13 @@ export default Component.extend({
return isEmpty(tagGroupNames) && isEmpty(categories) && isEmpty(synonyms);
},
@discourseComputed("newTagName")
updateDisabled(newTagName) {
const filterRegexp = new RegExp(this.site.tags_filter_regexp, "g");
newTagName = newTagName ? newTagName.replace(filterRegexp, "").trim() : "";
return newTagName.length === 0;
},
didInsertElement() {
this._super(...arguments);
this.loadTagInfo();
@ -69,8 +80,29 @@ export default Component.extend({
this.toggleProperty("showEditControls");
},
renameTag() {
showModal("rename-tag", { model: this.tag });
edit() {
this.setProperties({
editing: true,
newTagName: this.tag.id,
newTagDescription: this.tagInfo.description,
});
},
cancelEditing() {
this.set("editing", false);
},
finishedEditing() {
this.tag
.update({ id: this.newTagName, description: this.newTagDescription })
.then((result) => {
this.set("editing", false);
this.tagInfo.set("description", this.newTagDescription);
if (result.payload) {
this.router.transitionTo("tag.show", result.payload.id);
}
})
.catch(popupAjaxError);
},
deleteTag() {

View File

@ -1,33 +0,0 @@
import { action } from "@ember/object";
import BufferedContent from "discourse/mixins/buffered-content";
import Controller from "@ember/controller";
import ModalFunctionality from "discourse/mixins/modal-functionality";
import discourseComputed from "discourse-common/utils/decorators";
import { extractError } from "discourse/lib/ajax-error";
export default Controller.extend(ModalFunctionality, BufferedContent, {
newTag: null,
@discourseComputed("newTag", "model.id")
renameDisabled(newTag, currentTag) {
const filterRegexp = new RegExp(this.site.tags_filter_regexp, "g");
newTag = newTag ? newTag.replace(filterRegexp, "").trim() : "";
return newTag.length === 0 || newTag === currentTag;
},
@action
performRename() {
this.model
.update({ id: this.newTag })
.then((result) => {
this.send("closeModal");
if (result.responseJson.tag) {
this.transitionToRoute("tag.show", result.responseJson.tag.id);
} else {
this.flash(extractError(result.responseJson.errors[0]), "error");
}
})
.catch((error) => this.flash(extractError(error), "error"));
},
});

View File

@ -44,6 +44,7 @@ export function defaultRenderTag(tag, params) {
href +
" data-tag-name=" +
tag +
(params.description ? ' title="' + params.description + '" ' : "") +
" class='" +
classes.join(" ") +
"'>" +

View File

@ -55,7 +55,13 @@ export default function (topic, params) {
if (tags) {
for (let i = 0; i < tags.length; i++) {
buffer +=
renderTag(tags[i], { isPrivateMessage, tagsForUser, tagName }) + " ";
renderTag(tags[i], {
description:
topic.tags_descriptions && topic.tags_descriptions[tags[i]],
isPrivateMessage,
tagsForUser,
tagName,
}) + " ";
}
}

View File

@ -1,16 +1,41 @@
<section class="tag-info">
{{#if tagInfo}}
<div class="tag-name">
{{discourse-tag tagInfo.name tagName="div" size="large"}}
{{#if editing}}
<div class="edit-tag-wrapper">
{{text-field id="edit-name" value=(readonly tagInfo.name) maxlength=siteSettings.max_tag_length input=(action (mut newTagName) value="target.value") autofocus="true"}}
{{text-field id="edit-description" value=(readonly tagInfo.description) placeholder=(i18n "tagging.description") maxlength=280 input=(action (mut newTagDescription) value="target.value") autofocus="true"}}
<div class="edit-controls">
{{#unless updateDisabled}}
{{d-button action=(action "finishedEditing") class="btn-primary submit-edit" icon="check" ariaLabel="tagging.save"}}
{{/unless}}
{{d-button action=(action "cancelEditing") class="btn-default cancel-edit" icon="times" ariaLabel="cancel"}}
</div>
</div>
{{else}}
<div class="tag-name-wrapper">
{{discourse-tag tagInfo.name tagName="div" size="large"}}
{{#if canAdminTag}}
<a href {{action "edit"}} id="edit-tag" title={{i18n "tagging.edit_tag"}}>{{d-icon "pencil-alt"}}</a>
{{/if}}
</div>
{{#if canAdminTag}}
<div class="tag-description-wrapper">
{{tagInfo.description}}
</div>
{{/if}}
{{/if}}
{{#if canAdminTag}}
{{plugin-outlet name="tag-custom-settings" args=(hash tag=tagInfo) connectorTagName="" tagName="section"}}
{{d-button class="btn-default" action=(action "renameTag") icon="pencil-alt" label="tagging.rename_tag" id="rename-tag"}}
{{d-button class="btn-default" action=(action "toggleEditControls") icon="cog" label="tagging.edit_synonyms" id="edit-synonyms"}}
{{#if deleteAction}}
{{d-button class="btn-danger delete-tag" action=(action "deleteTag") icon="far-trash-alt" label="tagging.delete_tag" id="delete-tag"}}
{{/if}}
<div class="tag-actions">
{{d-button class="btn-default" action=(action "toggleEditControls") icon="cog" label="tagging.edit_synonyms" id="edit-synonyms"}}
{{#if deleteAction}}
{{d-button class="btn-danger delete-tag" action=(action "deleteTag") icon="far-trash-alt" label="tagging.delete_tag" id="delete-tag"}}
{{/if}}
</div>
{{/if}}
</div>
<div class="tag-associations">

View File

@ -9,7 +9,7 @@
{{/if}}
{{#each sortedTags as |tag|}}
<div class="tag-box">
{{discourse-tag tag.id isPrivateMessage=isPrivateMessage pmOnly=tag.pmOnly tagsForUser=tagsForUser}} {{#if tag.pmOnly}}{{d-icon "far-envelope"}}{{/if}}{{#if tag.totalCount}} <span class="tag-count">x {{tag.totalCount}}</span>{{/if}}
{{discourse-tag tag.id description=tag.description isPrivateMessage=isPrivateMessage pmOnly=tag.pmOnly tagsForUser=tagsForUser}} {{#if tag.pmOnly}}{{d-icon "far-envelope"}}{{/if}}{{#if tag.totalCount}} <span class="tag-count">x {{tag.totalCount}}</span>{{/if}}
</div>
{{/each}}
<div class="clearfix"></div>

View File

@ -1,21 +0,0 @@
{{#d-modal-body title="tagging.rename_tag"}}
<label class="control-label">
{{i18n "tagging.rename_instructions"}}
</label>
<div class="controls">
{{input
value=(readonly model.id)
maxlength=siteSettings.max_tag_length
input=(action (mut newTag) value="target.value")
}}
</div>
{{/d-modal-body}}
<div class="modal-footer">
{{d-button
class="btn-primary"
action=(action "performRename")
label="tagging.rename_tag"
disabled=renameDisabled
}}
</div>

View File

@ -4,6 +4,7 @@ import {
count,
exists,
invisible,
query,
queryAll,
updateCurrentUser,
} from "discourse/tests/helpers/qunit-helpers";
@ -64,6 +65,7 @@ acceptance("Tags", function (needs) {
bookmarked: false,
liked: true,
tags: ["test"],
tags_descriptions: { test: "test description" },
views: 42,
like_count: 42,
has_summary: false,
@ -355,6 +357,7 @@ acceptance("Tag info", function (needs) {
tag_info: {
id: 13,
name: "happy-monkey",
description: "happy monkey description",
topic_count: 1,
staff: false,
synonyms: [],
@ -429,6 +432,28 @@ acceptance("Tag info", function (needs) {
);
});
test("edit tag is showing input for name and description", async function (assert) {
updateCurrentUser({ moderator: false, admin: true });
await visit("/tag/happy-monkey");
assert.strictEqual(count("#show-tag-info"), 1);
await click("#show-tag-info");
assert.ok(exists(".tag-info .tag-name"), "show tag");
await click("#edit-tag");
assert.strictEqual(
query("#edit-name").value,
"happy-monkey",
"it displays original tag name"
);
assert.strictEqual(
query("#edit-description").value,
"happy monkey description",
"it displays original tag description"
);
});
test("can filter tags page by category", async function (assert) {
await visit("/tag/planters");
@ -445,7 +470,7 @@ acceptance("Tag info", function (needs) {
assert.strictEqual(count("#show-tag-info"), 1);
await click("#show-tag-info");
assert.ok(exists("#rename-tag"), "can rename tag");
assert.ok(exists("#edit-tag"), "can rename tag");
assert.ok(exists("#edit-synonyms"), "can edit synonyms");
assert.ok(exists("#delete-tag"), "can delete tag");

View File

@ -6405,6 +6405,7 @@ export default {
bookmarked: false,
liked: false,
tags: ["test", "test-tag"],
tags_description: { test: "test description", "test-tag": "test tag description" },
views: 6,
like_count: 0,
has_summary: false,

View File

@ -540,6 +540,10 @@ export default {
pinned_globally: false,
posters: [],
tags: ["dev", "slow"],
tags_descriptions: {
"dev": "dev description",
"slow": "slow description",
}
},
{
id: 14727,

View File

@ -99,7 +99,11 @@ export default MultiSelectComponent.extend(TagsMixin, {
return results
.filter((r) => !makeArray(context.tags).includes(r.id))
.map((result) => {
return { id: result.text, name: result.text, count: result.count };
return {
id: result.text,
name: result.description,
count: result.count,
};
});
},
});

View File

@ -338,9 +338,32 @@ section.tag-info {
margin-right: 0.5em;
}
.edit-tag-wrapper {
display: flex;
input {
margin-right: 0.5em;
}
}
.tag-name-wrapper,
.tag-description-wrapper {
display: flex;
}
.tag-name-wrapper a {
color: var(--primary-high);
margin-left: 0.5em;
}
.tag-name-wrapper a {
font-size: var(--font-up-3);
}
.tag-name .discourse-tag {
display: block;
margin-bottom: 0.75em;
}
.tag-description-wrapper {
margin-bottom: 1em;
}
.synonyms-list,

View File

@ -24,6 +24,7 @@
@import "reviewables";
@import "ring";
@import "search";
@import "tagging";
@import "topic-list";
@import "topic-post";
@import "topic";

View File

@ -0,0 +1,9 @@
.edit-tag-wrapper {
flex-direction: column;
.edit-controls {
margin-bottom: 0.5em;
}
}
.tag-info .tag-actions {
display: flex;
}

View File

@ -135,11 +135,14 @@ class TagsController < ::ApplicationController
tag = Tag.find_by_name(params[:tag_id])
raise Discourse::NotFound if tag.nil?
new_tag_name = DiscourseTagging.clean_tag(params[:tag][:id])
tag.name = new_tag_name
if (params[:tag][:id].present?)
new_tag_name = DiscourseTagging.clean_tag(params[:tag][:id])
tag.name = new_tag_name
end
tag.description = params[:tag][:description] if params[:tag]&.has_key?(:description)
if tag.save
StaffActionLogger.new(current_user).log_custom('renamed_tag', previous_value: params[:tag_id], new_value: new_tag_name)
render json: { tag: { id: new_tag_name } }
render json: { tag: { id: tag.name, description: tag.description } }
else
render_json_error tag.errors.full_messages
end
@ -353,6 +356,8 @@ class TagsController < ::ApplicationController
{
id: t.name,
text: t.name,
name: t.name,
description: t.description,
count: t.topic_count,
pm_count: show_pm_tags ? t.pm_topic_count : 0,
target_tag: t.target_tag_id ? target_tags.find { |x| x.id == t.target_tag_id }&.name : nil

View File

@ -15,6 +15,7 @@ class Tag < ActiveRecord::Base
validate :target_tag_validator, if: Proc.new { |t| t.new_record? || t.will_save_change_to_target_tag_id? }
validate :name_validator
validates :description, length: { maximum: 280 }
scope :where_name, ->(name) do
name = Array(name).map(&:downcase)
@ -215,6 +216,7 @@ end
# updated_at :datetime not null
# pm_topic_count :integer default(0), not null
# target_tag_id :integer
# description :string
#
# Indexes
#

View File

@ -3,6 +3,7 @@
module TopicTagsMixin
def self.included(klass)
klass.attributes :tags
klass.attributes :tags_descriptions
end
def include_tags?
@ -10,16 +11,26 @@ module TopicTagsMixin
end
def tags
# Calling method `pluck` or `order` along with `includes` causing N+1 queries
tags = (SiteSetting.tags_sort_alphabetically ? topic.tags.sort_by(&:name) : topic.tags.sort_by(&:topic_count).reverse).map(&:name)
if scope.is_staff?
tags
else
tags - scope.hidden_tag_names
end
all_tags.map(&:name)
end
def tags_descriptions
all_tags.each.with_object({}) { |tag, acc| acc[tag.name] = tag.description }.compact
end
def topic
object.is_a?(Topic) ? object : object.topic
end
private
def all_tags
return @tags if defined?(@tags)
# Calling method `pluck` or `order` along with `includes` causing N+1 queries
tags = (SiteSetting.tags_sort_alphabetically ? topic.tags.sort_by(&:name) : topic.tags.sort_by(&:topic_count).reverse)
if !scope.is_staff?
tags = tags.reject { |tag| scope.hidden_tag_names.include?(tag[:name]) }
end
@tags = tags
end
end

View File

@ -1,7 +1,7 @@
# frozen_string_literal: true
class TagSerializer < ApplicationSerializer
attributes :id, :name, :topic_count, :staff
attributes :id, :name, :topic_count, :staff, :description
def staff
DiscourseTagging.staff_tag_names.include?(name)

View File

@ -3796,6 +3796,7 @@ en:
category_restricted: "This tag is restricted to categories you don't have permission to access."
synonyms: "Synonyms"
synonyms_description: "When the following tags are used, they will be replaced with <b>%{base_tag_name}</b>."
save: "Save name and description of the tag"
tag_groups_info:
one: 'This tag belongs to the group "%{tag_groups}".'
other: "This tag belongs to these groups: %{tag_groups}."
@ -3819,8 +3820,8 @@ en:
delete_confirm_synonyms:
one: "Its synonym will also be deleted."
other: "Its %{count} synonyms will also be deleted."
rename_tag: "Rename Tag"
rename_instructions: "Choose a new name for the tag:"
edit_tag: "Edit tag name and description"
description: "Description"
sort_by: "Sort by:"
sort_by_count: "count"
sort_by_name: "name"

View File

@ -0,0 +1,7 @@
# frozen_string_literal: true
class AddDescriptionToTags < ActiveRecord::Migration[6.1]
def change
add_column :tags, :description, :string
end
end

View File

@ -256,7 +256,7 @@ module DiscourseTagging
end
sql << <<~SQL
SELECT #{distinct_clause} t.id, t.name, t.topic_count, t.pm_topic_count,
SELECT #{distinct_clause} t.id, t.name, t.topic_count, t.pm_topic_count, t.description,
tgr.tgm_id as tgm_id, tgr.tag_group_id as tag_group_id, tgr.parent_tag_id as parent_tag_id,
tgr.one_per_topic as one_per_topic, t.target_tag_id
FROM tags t

View File

@ -236,9 +236,9 @@ describe TopicViewSerializer do
end
describe 'tags order' do
fab!(:tag1) { Fabricate(:tag, name: 'ctag', topic_count: 5) }
fab!(:tag2) { Fabricate(:tag, name: 'btag', topic_count: 9) }
fab!(:tag3) { Fabricate(:tag, name: 'atag', topic_count: 3) }
fab!(:tag1) { Fabricate(:tag, name: 'ctag', description: "c description", topic_count: 5) }
fab!(:tag2) { Fabricate(:tag, name: 'btag', description: "b description", topic_count: 9) }
fab!(:tag3) { Fabricate(:tag, name: 'atag', description: "a description", topic_count: 3) }
before do
topic.tags << tag1
@ -249,6 +249,7 @@ describe TopicViewSerializer do
it 'tags are automatically sorted by tag popularity' do
json = serialize_topic(topic, user)
expect(json[:tags]).to eq(%w(btag ctag atag))
expect(json[:tags_descriptions]).to eq({ btag: "b description", ctag: "c description", atag: "a description" })
end
it 'tags can be sorted alphabetically' do

View File

@ -50,6 +50,7 @@ RSpec.describe WebHookTopicViewSerializer do
created_by
last_poster
tags
tags_descriptions
thumbnails
}