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:
parent
78723345c0
commit
9cabd3721b
|
@ -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() {
|
||||
|
|
|
@ -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"));
|
||||
},
|
||||
});
|
|
@ -44,6 +44,7 @@ export function defaultRenderTag(tag, params) {
|
|||
href +
|
||||
" data-tag-name=" +
|
||||
tag +
|
||||
(params.description ? ' title="' + params.description + '" ' : "") +
|
||||
" class='" +
|
||||
classes.join(" ") +
|
||||
"'>" +
|
||||
|
|
|
@ -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,
|
||||
}) + " ";
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
|
@ -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");
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -540,6 +540,10 @@ export default {
|
|||
pinned_globally: false,
|
||||
posters: [],
|
||||
tags: ["dev", "slow"],
|
||||
tags_descriptions: {
|
||||
"dev": "dev description",
|
||||
"slow": "slow description",
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 14727,
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
});
|
||||
},
|
||||
});
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -24,6 +24,7 @@
|
|||
@import "reviewables";
|
||||
@import "ring";
|
||||
@import "search";
|
||||
@import "tagging";
|
||||
@import "topic-list";
|
||||
@import "topic-post";
|
||||
@import "topic";
|
||||
|
|
|
@ -0,0 +1,9 @@
|
|||
.edit-tag-wrapper {
|
||||
flex-direction: column;
|
||||
.edit-controls {
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
}
|
||||
.tag-info .tag-actions {
|
||||
display: flex;
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
#
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -0,0 +1,7 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class AddDescriptionToTags < ActiveRecord::Migration[6.1]
|
||||
def change
|
||||
add_column :tags, :description, :string
|
||||
end
|
||||
end
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -50,6 +50,7 @@ RSpec.describe WebHookTopicViewSerializer do
|
|||
created_by
|
||||
last_poster
|
||||
tags
|
||||
tags_descriptions
|
||||
thumbnails
|
||||
}
|
||||
|
||||
|
|
Loading…
Reference in New Issue