FEATURE: Mixed case tagging (#6454)

- By default, behaviour is not changed: tags are made lowercase upon creation and edit.

- If force_lowercase_tags is disabled, then mixed case tags are allowed.

- Tags must remain case-insensitively unique. This is enforced by ActiveRecord and Postgres.

- A migration is added to provide a `UNIQUE` index on `lower(name)`. Migration includes a safety to correct any current tags that do not meet the criteria.

- A `where_name` scope is added to `models/tag.rb`, to allow easy case-insensitive lookups. This is used instead of `Tag.where(name: "blah")`.

- URLs remain lowercase. Mixed case URLs are functional, but have the lowercase equivalent as the canonical.
This commit is contained in:
David Taylor 2018-10-05 10:23:52 +01:00 committed by GitHub
parent 8430ea927e
commit 9bf522f227
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
23 changed files with 137 additions and 43 deletions

View File

@ -1,6 +1,7 @@
export default function renderTag(tag, params) { export default function renderTag(tag, params) {
params = params || {}; params = params || {};
tag = Handlebars.Utils.escapeExpression(tag); const visibleName = Handlebars.Utils.escapeExpression(tag);
tag = visibleName.toLowerCase();
const classes = ["discourse-tag"]; const classes = ["discourse-tag"];
const tagName = params.tagName || "a"; const tagName = params.tagName || "a";
let path; let path;
@ -29,7 +30,7 @@ export default function renderTag(tag, params) {
" class='" + " class='" +
classes.join(" ") + classes.join(" ") +
"'>" + "'>" +
tag + visibleName +
"</" + "</" +
tagName + tagName +
">"; ">";

View File

@ -49,10 +49,12 @@ export default Discourse.Route.extend({
if (tag && tag.get("id") !== "none" && this.get("currentUser")) { if (tag && tag.get("id") !== "none" && this.get("currentUser")) {
// If logged in, we should get the tag's user settings // If logged in, we should get the tag's user settings
return this.store.find("tagNotification", tag.get("id")).then(tn => { return this.store
this.set("tagNotification", tn); .find("tagNotification", tag.get("id").toLowerCase())
return tag; .then(tn => {
}); this.set("tagNotification", tn);
return tag;
});
} }
return tag; return tag;
@ -67,7 +69,7 @@ export default Discourse.Route.extend({
const categorySlug = this.get("categorySlug"); const categorySlug = this.get("categorySlug");
const parentCategorySlug = this.get("parentCategorySlug"); const parentCategorySlug = this.get("parentCategorySlug");
const filter = this.get("navMode"); const filter = this.get("navMode");
const tag_id = tag ? tag.id : "none"; const tag_id = tag ? tag.id.toLowerCase() : "none";
if (categorySlug) { if (categorySlug) {
var category = Discourse.Category.findBySlug( var category = Discourse.Category.findBySlug(
@ -100,6 +102,7 @@ export default Discourse.Route.extend({
params, params,
{} {}
).then(list => { ).then(list => {
tag.set("id", list.topic_list.tags[0].name); // Update name of tag (case might be different)
controller.setProperties({ controller.setProperties({
list: list, list: list,
canCreateTopic: list.get("can_create_topic"), canCreateTopic: list.get("can_create_topic"),

View File

@ -225,7 +225,6 @@ export default ComboBox.extend(TagsMixin, {
case "string": case "string":
// See lib/discourse_tagging#clean_tag. // See lib/discourse_tagging#clean_tag.
return content return content
.toLowerCase()
.trim() .trim()
.replace(/\s+/, "-") .replace(/\s+/, "-")
.replace(/[\/\?#\[\]@!\$&'\(\)\*\+,;=\.%\\`^\s|\{\}"<>]+/, "") .replace(/[\/\?#\[\]@!\$&'\(\)\*\+,;=\.%\\`^\s|\{\}"<>]+/, "")

View File

@ -145,7 +145,7 @@ export default ComboBoxComponent.extend(TagsMixin, {
if (this.get("currentCategory")) { if (this.get("currentCategory")) {
url += this.get("currentCategory.url"); url += this.get("currentCategory.url");
} }
url = `${url}/${tagId}`; url = `${url}/${tagId.toLowerCase()}`;
DiscourseURL.routeTo(url); DiscourseURL.routeTo(url);
}, },

View File

@ -52,12 +52,16 @@ export default Ember.Mixin.create({
return false; return false;
} }
const toLowerCaseOrUndefined = string => {
return string === undefined ? undefined : string.toLowerCase();
};
const inCollection = this.get("collectionComputedContent") const inCollection = this.get("collectionComputedContent")
.map(c => get(c, "id")) .map(c => toLowerCaseOrUndefined(get(c, "id")))
.includes(term); .includes(term);
const inSelection = this.get("selection") const inSelection = this.get("selection")
.map(s => get(s, "value").toLowerCase()) .map(s => toLowerCaseOrUndefined(get(s, "value")))
.includes(term); .includes(term);
if (inCollection || inSelection) { if (inCollection || inSelection) {
return false; return false;

View File

@ -89,7 +89,7 @@ class TagsController < ::ApplicationController
path_name = url_method(params.slice(:category, :parent_category)) path_name = url_method(params.slice(:category, :parent_category))
canonical_url "#{Discourse.base_url_no_prefix}#{public_send(path_name, *(params.slice(:parent_category, :category, :tag_id).values.map { |t| t.force_encoding("UTF-8") }))}" canonical_url "#{Discourse.base_url_no_prefix}#{public_send(path_name, *(params.slice(:parent_category, :category, :tag_id).values.map { |t| t.force_encoding("UTF-8") }))}"
if @list.topics.size == 0 && params[:tag_id] != 'none' && !Tag.where(name: @tag_id).exists? if @list.topics.size == 0 && params[:tag_id] != 'none' && !Tag.where_name(@tag_id).exists?
raise Discourse::NotFound.new("tag not found", check_permalinks: true) raise Discourse::NotFound.new("tag not found", check_permalinks: true)
else else
respond_with_list(@list) respond_with_list(@list)
@ -162,7 +162,7 @@ class TagsController < ::ApplicationController
json_response = { results: tags } json_response = { results: tags }
if Tag.where(name: params[:q]).exists? && !tags.find { |h| h[:id] == params[:q] } if Tag.where_name(params[:q]).exists? && !tags.find { |h| h[:id] == params[:q] }
# filter_allowed_tags determined that the tag entered is not allowed # filter_allowed_tags determined that the tag entered is not allowed
json_response[:forbidden] = params[:q] json_response[:forbidden] = params[:q]
end end
@ -171,7 +171,7 @@ class TagsController < ::ApplicationController
end end
def notifications def notifications
tag = Tag.find_by_name(params[:tag_id]) tag = Tag.where_name(params[:tag_id]).first
raise Discourse::NotFound unless tag raise Discourse::NotFound unless tag
level = tag.tag_users.where(user: current_user).first.try(:notification_level) || TagUser.notification_levels[:regular] level = tag.tag_users.where(user: current_user).first.try(:notification_level) || TagUser.notification_levels[:regular]
render json: { tag_notification: { id: tag.name, notification_level: level.to_i } } render json: { tag_notification: { id: tag.name, notification_level: level.to_i } }
@ -186,9 +186,7 @@ class TagsController < ::ApplicationController
end end
def check_hashtag def check_hashtag
tag_values = params[:tag_values].each(&:downcase!) valid_tags = Tag.where_name(params[:tag_values]).map do |tag|
valid_tags = Tag.where(name: tag_values).map do |tag|
{ value: tag.name, url: tag.full_url } { value: tag.name, url: tag.full_url }
end.compact end.compact

View File

@ -2,7 +2,12 @@ class Tag < ActiveRecord::Base
include Searchable include Searchable
include HasDestroyedWebHook include HasDestroyedWebHook
validates :name, presence: true, uniqueness: true validates :name, presence: true, uniqueness: { case_sensitive: false }
scope :where_name, ->(name) do
name = Array(name).map(&:downcase)
where("lower(name) IN (?)", name)
end
has_many :tag_users # notification settings has_many :tag_users # notification settings
@ -59,6 +64,10 @@ class Tag < ActiveRecord::Base
SQL SQL
end end
def self.find_by_name(name)
self.find_by('lower(name) = ?', name.downcase)
end
def self.top_tags(limit_arg: nil, category: nil, guardian: nil) def self.top_tags(limit_arg: nil, category: nil, guardian: nil)
limit = limit_arg || SiteSetting.max_tags_in_filter_list limit = limit_arg || SiteSetting.max_tags_in_filter_list
scope_category_ids = (guardian || Guardian.new).allowed_category_ids scope_category_ids = (guardian || Guardian.new).allowed_category_ids

View File

@ -19,7 +19,7 @@ class TagUser < ActiveRecord::Base
records = TagUser.where(user: user, notification_level: notification_levels[level]) records = TagUser.where(user: user, notification_level: notification_levels[level])
old_ids = records.pluck(:tag_id) old_ids = records.pluck(:tag_id)
tag_ids = tags.empty? ? [] : Tag.where('name in (?)', tags).pluck(:id) tag_ids = tags.empty? ? [] : Tag.where_name(tags).pluck(:id)
remove = (old_ids - tag_ids) remove = (old_ids - tag_ids)
if remove.present? if remove.present?

View File

@ -105,7 +105,7 @@ class SearchIndexer
end end
def self.update_tags_index(tag_id, name) def self.update_tags_index(tag_id, name)
update_index(table: 'tag', id: tag_id, raw_data: [name]) update_index(table: 'tag', id: tag_id, raw_data: [name.downcase])
end end
def self.queue_post_reindex(topic_id) def self.queue_post_reindex(topic_id)

View File

@ -1798,6 +1798,7 @@ en:
min_trust_level_to_tag_topics: "Minimum trust level required to tag topics" min_trust_level_to_tag_topics: "Minimum trust level required to tag topics"
suppress_overlapping_tags_in_list: "If tags match exact words in topic titles, don't show the tag" suppress_overlapping_tags_in_list: "If tags match exact words in topic titles, don't show the tag"
remove_muted_tags_from_latest: "Don't show topics tagged with muted tags in the latest topic list." remove_muted_tags_from_latest: "Don't show topics tagged with muted tags in the latest topic list."
force_lowercase_tags: "Force all new tags to be entirely lowercase."
company_short_name: "Company Name (short)" company_short_name: "Company Name (short)"
company_full_name: "Company Name (full)" company_full_name: "Company Name (full)"

View File

@ -1788,3 +1788,5 @@ tags:
client: true client: true
remove_muted_tags_from_latest: remove_muted_tags_from_latest:
default: false default: false
force_lowercase_tags:
default: true

View File

@ -0,0 +1,17 @@
class AddIndexToTags < ActiveRecord::Migration[5.2]
def up
# Append ID to any tags that already have duplicate names
# Super rare case, as this is not possible to do via the UI
# Might affect some imports
execute <<~SQL
UPDATE tags
SET name = name || id
WHERE EXISTS(SELECT * FROM tags t WHERE lower(t.name) = lower(tags.name) AND t.id < tags.id)
SQL
add_index :tags, 'lower(name)', unique: true
end
def down
raise ActiveRecord::IrreversibleMigration
end
end

View File

@ -39,7 +39,7 @@ module DiscourseTagging
# guardian is explicitly nil cause we don't want to strip all # guardian is explicitly nil cause we don't want to strip all
# staff tags that already passed validation # staff tags that already passed validation
tags = filter_allowed_tags( tags = filter_allowed_tags(
Tag.where(name: tag_names), Tag.where_name(tag_names),
nil, # guardian nil, # guardian
for_topic: true, for_topic: true,
category: category, category: category,
@ -48,7 +48,7 @@ module DiscourseTagging
if tags.size < tag_names.size && (category.nil? || (category.tags.count == 0 && category.tag_groups.count == 0)) if tags.size < tag_names.size && (category.nil? || (category.tags.count == 0 && category.tag_groups.count == 0))
tag_names.each do |name| tag_names.each do |name|
unless Tag.where(name: name).exists? unless Tag.where_name(name).exists?
tags << Tag.create(name: name) tags << Tag.create(name: name)
end end
end end
@ -82,8 +82,7 @@ module DiscourseTagging
# for_topic: results are for tagging a topic # for_topic: results are for tagging a topic
# selected_tags: an array of tag names that are in the current selection # selected_tags: an array of tag names that are in the current selection
def self.filter_allowed_tags(query, guardian, opts = {}) def self.filter_allowed_tags(query, guardian, opts = {})
selected_tag_ids = opts[:selected_tags] ? Tag.where_name(opts[:selected_tags]).pluck(:id) : []
selected_tag_ids = opts[:selected_tags] ? Tag.where(name: opts[:selected_tags]).pluck(:id) : []
if !opts[:for_topic] && !selected_tag_ids.empty? if !opts[:for_topic] && !selected_tag_ids.empty?
query = query.where('tags.id NOT IN (?)', selected_tag_ids) query = query.where('tags.id NOT IN (?)', selected_tag_ids)
@ -92,8 +91,8 @@ module DiscourseTagging
term = opts[:term] term = opts[:term]
if term.present? if term.present?
term.gsub!("_", "\\_") term.gsub!("_", "\\_")
term = clean_tag(term) term = clean_tag(term).downcase
query = query.where('tags.name like ?', "%#{term}%") query = query.where('lower(tags.name) like ?', "%#{term}%")
end end
# Filters for category-specific tags: # Filters for category-specific tags:
@ -203,7 +202,8 @@ module DiscourseTagging
end end
def self.clean_tag(tag) def self.clean_tag(tag)
tag.downcase.strip tag.downcase! if SiteSetting.force_lowercase_tags
tag.strip
.gsub(/\s+/, '-').squeeze('-') .gsub(/\s+/, '-').squeeze('-')
.gsub(TAGS_FILTER_REGEXP, '')[0...SiteSetting.max_tag_length] .gsub(TAGS_FILTER_REGEXP, '')[0...SiteSetting.max_tag_length]
end end
@ -212,7 +212,7 @@ module DiscourseTagging
return [] unless guardian.can_tag_topics? && tags_arg.present? return [] unless guardian.can_tag_topics? && tags_arg.present?
tag_names = Tag.where(name: tags_arg).pluck(:name) tag_names = Tag.where_name(tags_arg).pluck(:name)
if guardian.can_create_tag? if guardian.can_create_tag?
tag_names += (tags_arg - tag_names).map { |t| clean_tag(t) } tag_names += (tags_arg - tag_names).map { |t| clean_tag(t) }
@ -226,7 +226,7 @@ module DiscourseTagging
def self.add_or_create_tags_by_name(taggable, tag_names_arg, opts = {}) def self.add_or_create_tags_by_name(taggable, tag_names_arg, opts = {})
tag_names = DiscourseTagging.tags_for_saving(tag_names_arg, Guardian.new(Discourse.system_user), opts) || [] tag_names = DiscourseTagging.tags_for_saving(tag_names_arg, Guardian.new(Discourse.system_user), opts) || []
if taggable.tags.pluck(:name).sort != tag_names.sort if taggable.tags.pluck(:name).sort != tag_names.sort
taggable.tags = Tag.where(name: tag_names).all taggable.tags = Tag.where_name(tag_names).all
if taggable.tags.size < tag_names.size if taggable.tags.size < tag_names.size
new_tag_names = tag_names - taggable.tags.map(&:name) new_tag_names = tag_names - taggable.tags.map(&:name)
new_tag_names.each do |name| new_tag_names.each do |name|

View File

@ -407,7 +407,7 @@ class Search
posts.where("topics.category_id IN (?)", category_ids) posts.where("topics.category_id IN (?)", category_ids)
else else
# try a possible tag match # try a possible tag match
tag_id = Tag.where(name: slug[0]).pluck(:id).first tag_id = Tag.where_name(slug[0]).pluck(:id).first
if (tag_id) if (tag_id)
posts.where("topics.id IN ( posts.where("topics.id IN (
SELECT DISTINCT(tt.topic_id) SELECT DISTINCT(tt.topic_id)
@ -496,7 +496,7 @@ class Search
def search_tags(posts, match, positive:) def search_tags(posts, match, positive:)
return if match.nil? return if match.nil?
match.downcase!
modifier = positive ? "" : "NOT" modifier = positive ? "" : "NOT"
if match.include?('+') if match.include?('+')
@ -507,7 +507,7 @@ class Search
FROM topic_tags tt, tags FROM topic_tags tt, tags
WHERE tt.tag_id = tags.id WHERE tt.tag_id = tags.id
GROUP BY tt.topic_id GROUP BY tt.topic_id
HAVING to_tsvector(#{default_ts_config}, array_to_string(array_agg(tags.name), ' ')) @@ to_tsquery(#{default_ts_config}, ?) HAVING to_tsvector(#{default_ts_config}, array_to_string(array_agg(lower(tags.name)), ' ')) @@ to_tsquery(#{default_ts_config}, ?)
)", tags.join('&')) )", tags.join('&'))
else else
tags = match.split(",") tags = match.split(",")
@ -515,7 +515,7 @@ class Search
posts.where("topics.id #{modifier} IN ( posts.where("topics.id #{modifier} IN (
SELECT DISTINCT(tt.topic_id) SELECT DISTINCT(tt.topic_id)
FROM topic_tags tt, tags FROM topic_tags tt, tags
WHERE tt.tag_id = tags.id AND tags.name IN (?) WHERE tt.tag_id = tags.id AND lower(tags.name) IN (?)
)", tags) )", tags)
end end
end end

View File

@ -634,11 +634,12 @@ class TopicQuery
result = result.preload(:tags) result = result.preload(:tags)
if @options[:tags] && @options[:tags].size > 0 if @options[:tags] && @options[:tags].size > 0
@options[:tags].each { |t| t.downcase! if t.is_a? String }
if @options[:match_all_tags] if @options[:match_all_tags]
# ALL of the given tags: # ALL of the given tags:
tags_count = @options[:tags].length tags_count = @options[:tags].length
@options[:tags] = Tag.where(name: @options[:tags]).pluck(:id) unless @options[:tags][0].is_a?(Integer) @options[:tags] = Tag.where_name(@options[:tags]).pluck(:id) unless @options[:tags][0].is_a?(Integer)
if tags_count == @options[:tags].length if tags_count == @options[:tags].length
@options[:tags].each_with_index do |tag, index| @options[:tags].each_with_index do |tag, index|
@ -654,7 +655,7 @@ class TopicQuery
if @options[:tags][0].is_a?(Integer) if @options[:tags][0].is_a?(Integer)
result = result.where("tags.id in (?)", @options[:tags]) result = result.where("tags.id in (?)", @options[:tags])
else else
result = result.where("tags.name in (?)", @options[:tags]) result = result.where("lower(tags.name) in (?)", @options[:tags])
end end
end end
elsif @options[:no_tags] elsif @options[:no_tags]

View File

@ -271,7 +271,7 @@ class BulkImport::DiscourseMerger < BulkImport::Base
@raw_connection.copy_data(sql, @encoder) do @raw_connection.copy_data(sql, @encoder) do
source_raw_connection.exec("SELECT #{columns.map { |c| "\"#{c}\"" }.join(', ')} FROM tags").each do |row| source_raw_connection.exec("SELECT #{columns.map { |c| "\"#{c}\"" }.join(', ')} FROM tags").each do |row|
if existing = Tag.where(name: row['name']).first if existing = Tag.where_name(row['name']).first
@tags[row['id']] = existing.id @tags[row['id']] = existing.id
next next
end end

View File

@ -13,7 +13,7 @@ describe DiscourseTagging do
let!(:tag1) { Fabricate(:tag, name: "fun") } let!(:tag1) { Fabricate(:tag, name: "fun") }
let!(:tag2) { Fabricate(:tag, name: "fun2") } let!(:tag2) { Fabricate(:tag, name: "fun2") }
let!(:tag3) { Fabricate(:tag, name: "fun3") } let!(:tag3) { Fabricate(:tag, name: "Fun3") }
before do before do
SiteSetting.tagging_enabled = true SiteSetting.tagging_enabled = true
@ -186,7 +186,8 @@ describe DiscourseTagging do
it "returns only existing tag names" do it "returns only existing tag names" do
Fabricate(:tag, name: 'oldtag') Fabricate(:tag, name: 'oldtag')
expect(described_class.tags_for_saving(['newtag', 'oldtag'], guardian).try(:sort)).to eq(['oldtag']) Fabricate(:tag, name: 'oldTag2')
expect(described_class.tags_for_saving(['newtag', 'oldtag', 'oldtag2'], guardian)).to contain_exactly('oldtag', 'oldTag2')
end end
end end
@ -205,5 +206,13 @@ describe DiscourseTagging do
expect(described_class.tags_for_saving(['math=fun', 'fun*2@gmail.com'], guardian).try(:sort)).to eq(['math=fun', 'fun2gmailcom'].sort) expect(described_class.tags_for_saving(['math=fun', 'fun*2@gmail.com'], guardian).try(:sort)).to eq(['math=fun', 'fun2gmailcom'].sort)
end end
end end
describe "clean_tag" do
it "downcases new tags if setting enabled" do
expect(DiscourseTagging.clean_tag("HeLlO")).to eq("hello")
SiteSetting.force_lowercase_tags = false
expect(DiscourseTagging.clean_tag("HeLlO")).to eq("HeLlO")
end
end
end end
end end

View File

@ -407,6 +407,7 @@ describe Search do
end end
let!(:tag) { Fabricate(:tag) } let!(:tag) { Fabricate(:tag) }
let!(:uppercase_tag) { Fabricate(:tag, name: "HeLlO") }
let(:tag_group) { Fabricate(:tag_group) } let(:tag_group) { Fabricate(:tag_group) }
let(:category) { Fabricate(:category) } let(:category) { Fabricate(:category) }
@ -415,7 +416,7 @@ describe Search do
SiteSetting.tagging_enabled = true SiteSetting.tagging_enabled = true
post = Fabricate(:post, raw: 'I am special post') post = Fabricate(:post, raw: 'I am special post')
DiscourseTagging.tag_topic_by_names(post.topic, Guardian.new(Fabricate.build(:admin)), [tag.name]) DiscourseTagging.tag_topic_by_names(post.topic, Guardian.new(Fabricate.build(:admin)), [tag.name, uppercase_tag.name])
post.topic.save post.topic.save
# we got to make this index (it is deferred) # we got to make this index (it is deferred)
@ -424,6 +425,9 @@ describe Search do
result = Search.execute(tag.name) result = Search.execute(tag.name)
expect(result.posts.length).to eq(1) expect(result.posts.length).to eq(1)
result = Search.execute("hElLo")
expect(result.posts.length).to eq(1)
SiteSetting.tagging_enabled = false SiteSetting.tagging_enabled = false
result = Search.execute(tag.name) result = Search.execute(tag.name)
@ -822,9 +826,10 @@ describe Search do
expect(Search.execute("sams post #sub-category").posts.length).to eq(1) expect(Search.execute("sams post #sub-category").posts.length).to eq(1)
# tags # tags
topic.tags = [Fabricate(:tag, name: 'alpha'), Fabricate(:tag, name: 'привет')] topic.tags = [Fabricate(:tag, name: 'alpha'), Fabricate(:tag, name: 'привет'), Fabricate(:tag, name: 'HeLlO')]
expect(Search.execute('this is a test #alpha').posts.map(&:id)).to eq([post.id]) expect(Search.execute('this is a test #alpha').posts.map(&:id)).to eq([post.id])
expect(Search.execute('this is a test #привет').posts.map(&:id)).to eq([post.id]) expect(Search.execute('this is a test #привет').posts.map(&:id)).to eq([post.id])
expect(Search.execute('this is a test #hElLo').posts.map(&:id)).to eq([post.id])
expect(Search.execute('this is a test #beta').posts.size).to eq(0) expect(Search.execute('this is a test #beta').posts.size).to eq(0)
end end

View File

@ -160,6 +160,7 @@ describe TopicQuery do
context 'tag filter' do context 'tag filter' do
let(:tag) { Fabricate(:tag) } let(:tag) { Fabricate(:tag) }
let(:other_tag) { Fabricate(:tag) } let(:other_tag) { Fabricate(:tag) }
let(:uppercase_tag) { Fabricate(:tag, name: "HeLlO") }
before do before do
SiteSetting.tagging_enabled = true SiteSetting.tagging_enabled = true
@ -169,6 +170,7 @@ describe TopicQuery do
let!(:tagged_topic1) { Fabricate(:topic, tags: [tag]) } let!(:tagged_topic1) { Fabricate(:topic, tags: [tag]) }
let!(:tagged_topic2) { Fabricate(:topic, tags: [other_tag]) } let!(:tagged_topic2) { Fabricate(:topic, tags: [other_tag]) }
let!(:tagged_topic3) { Fabricate(:topic, tags: [tag, other_tag]) } let!(:tagged_topic3) { Fabricate(:topic, tags: [tag, other_tag]) }
let!(:tagged_topic4) { Fabricate(:topic, tags: [uppercase_tag]) }
let!(:no_tags_topic) { Fabricate(:topic) } let!(:no_tags_topic) { Fabricate(:topic) }
it "returns topics with the tag when filtered to it" do it "returns topics with the tag when filtered to it" do
@ -186,6 +188,9 @@ describe TopicQuery do
expect(TopicQuery.new(moderator, tags: [tag.id, other_tag.id]).list_latest.topics) expect(TopicQuery.new(moderator, tags: [tag.id, other_tag.id]).list_latest.topics)
.to contain_exactly(tagged_topic1, tagged_topic2, tagged_topic3) .to contain_exactly(tagged_topic1, tagged_topic2, tagged_topic3)
expect(TopicQuery.new(moderator, tags: ["hElLo"]).list_latest.topics)
.to contain_exactly(tagged_topic4)
end end
it "can return topics with all specified tags" do it "can return topics with all specified tags" do

View File

@ -27,6 +27,12 @@ describe Tag do
expect(event[:event_name]).to eq(:tag_created) expect(event[:event_name]).to eq(:tag_created)
expect(event[:params].first).to eq(subject) expect(event[:params].first).to eq(subject)
end end
it 'prevents case-insensitive duplicates' do
Fabricate.build(:tag, name: "hello").save!
expect { Fabricate.build(:tag, name: "hElLo").save! }.to raise_error(ActiveRecord::RecordInvalid)
end
end end
describe 'destroy' do describe 'destroy' do

View File

@ -32,7 +32,7 @@ QUnit.test("list the tags in groups", async assert => {
id: 2, id: 2,
name: "Ford Cars", name: "Ford Cars",
tags: [ tags: [
{ id: "escort", text: "escort", count: 1, pm_count: 0 }, { id: "Escort", text: "Escort", count: 1, pm_count: 0 },
{ id: "focus", text: "focus", count: 3, pm_count: 0 } { id: "focus", text: "focus", count: 3, pm_count: 0 }
] ]
}, },
@ -79,7 +79,16 @@ QUnit.test("list the tags in groups", async assert => {
.map(i => { .map(i => {
return $(i).text(); return $(i).text();
}), }),
["focus", "escort"], ["focus", "Escort"],
"shows the tags in default sort (by count)" "shows the tags in default sort (by count)"
); );
assert.deepEqual(
$(".tag-list:first .discourse-tag")
.toArray()
.map(i => {
return $(i).attr("href");
}),
["/tags/focus", "/tags/escort"],
"always uses lowercase URLs for mixed case tags"
);
}); });

View File

@ -28,7 +28,7 @@ componentTest("default", {
}); });
} }
if (params.queryParams.q === "joffrey" || params.queryParams.q === "invalid'tag" || params.queryParams.q === "01234567890123456789012345") { if (params.queryParams.q.toLowerCase() === "joffrey" || params.queryParams.q === "invalid'tag" || params.queryParams.q === "01234567890123456789012345") {
return response({results: []}); return response({results: []});
} }
@ -74,6 +74,16 @@ componentTest("default", {
"it creates the tag" "it creates the tag"
); );
await this.get("subject").expand();
await this.get("subject").fillInFilter("Joffrey");
await this.get("subject").keyboard("enter");
await this.get("subject").collapse();
assert.deepEqual(
this.get("tags"),
["jeff", "neil", "arpit", "régis", "joffrey"],
"it does not allow case insensitive duplicate tags"
);
await this.get("subject").expand(); await this.get("subject").expand();
await this.get("subject").fillInFilter("invalid'tag"); await this.get("subject").fillInFilter("invalid'tag");
await this.get("subject").keyboard("enter"); await this.get("subject").keyboard("enter");

View File

@ -1,4 +1,5 @@
import componentTest from "helpers/component-test"; import componentTest from "helpers/component-test";
import DiscourseURL from "discourse/lib/url";
moduleForComponent("tag-drop", { moduleForComponent("tag-drop", {
integration: true, integration: true,
@ -26,6 +27,12 @@ componentTest("default", {
{ "id": "régis", "name": "régis", "count": 2, "pm_count": 0 } { "id": "régis", "name": "régis", "count": 2, "pm_count": 0 }
] ]
}); });
}else if (params.queryParams.q === "dav") {
return response({
"results": [
{ "id": "David", "name": "David", "count": 2, "pm_count": 0 }
]
});
} }
}); });
}, },
@ -66,5 +73,13 @@ componentTest("default", {
"jeff", "jeff",
"it returns top tags for an empty search" "it returns top tags for an empty search"
); );
sandbox.stub(DiscourseURL, "routeTo");
await this.get("subject").fillInFilter("dav");
await this.get("subject").keyboard("enter");
assert.ok(
DiscourseURL.routeTo.calledWith("/tags/david"),
"it uses lowercase URLs for tags"
);
} }
}); });