DEV: Support excluding categories with the `category:` filter (#21432)
This commit adds support for excluding categories when using the `category:` filter with the `-` prefix. For example, `-category:category-slug` will exclude all topics that belong to the category with slug "category-slug" and all of its sub-categories. To only exclude a particular category and not all of its sub-categories, the `-` prefix can be used with the `=` prefix. For example, `-=category:category-slug` will only exclude topics that belong to the category with slug "category-slug". Topics in the sub-categories of "category-slug" will still be included.
This commit is contained in:
parent
be1cbc7082
commit
963bb3406e
|
@ -18,7 +18,7 @@ class TopicsFilter
|
|||
filters = {}
|
||||
|
||||
query_string.scan(
|
||||
/(?<key_prefix>[-=])?(?<key>[\w-]+):(?<value>[^\s]+)/,
|
||||
/(?<key_prefix>(?:-|=|-=|=-))?(?<key>[\w-]+):(?<value>[^\s]+)/,
|
||||
) do |key_prefix, key, value|
|
||||
key = FILTER_ALIASES[key] || key
|
||||
|
||||
|
@ -186,51 +186,80 @@ class TopicsFilter
|
|||
end
|
||||
|
||||
def filter_categories(values:)
|
||||
exclude_subcategories_category_slugs = []
|
||||
include_subcategories_category_slugs = []
|
||||
category_slugs = {
|
||||
include: {
|
||||
with_subcategories: [],
|
||||
without_subcategories: [],
|
||||
},
|
||||
exclude: {
|
||||
with_subcategories: [],
|
||||
without_subcategories: [],
|
||||
},
|
||||
}
|
||||
|
||||
values.each do |key_prefix, value|
|
||||
break if key_prefix && key_prefix != "="
|
||||
exclude_categories = key_prefix&.include?("-")
|
||||
exclude_subcategories = key_prefix&.include?("=")
|
||||
|
||||
value
|
||||
.scan(
|
||||
/\A(?<category_slugs>([a-zA-Z0-9\-:]+)(?<delimiter>[,])?([a-zA-Z0-9\-:]+)?(\k<delimiter>[a-zA-Z0-9\-:]+)*)\z/,
|
||||
)
|
||||
.each do |category_slugs, delimiter|
|
||||
(
|
||||
if key_prefix.presence
|
||||
exclude_subcategories_category_slugs
|
||||
else
|
||||
include_subcategories_category_slugs
|
||||
end
|
||||
).concat(category_slugs.split(delimiter))
|
||||
.each do |category_slugs_match, delimiter|
|
||||
slugs = category_slugs_match.split(delimiter)
|
||||
type = exclude_categories ? :exclude : :include
|
||||
subcategory_type = exclude_subcategories ? :without_subcategories : :with_subcategories
|
||||
category_slugs[type][subcategory_type].concat(slugs)
|
||||
end
|
||||
end
|
||||
|
||||
category_ids = []
|
||||
include_category_ids = []
|
||||
|
||||
if exclude_subcategories_category_slugs.present?
|
||||
category_ids =
|
||||
if category_slugs[:include][:without_subcategories].present?
|
||||
include_category_ids =
|
||||
get_category_ids_from_slugs(
|
||||
exclude_subcategories_category_slugs,
|
||||
category_slugs[:include][:without_subcategories],
|
||||
exclude_subcategories: true,
|
||||
)
|
||||
end
|
||||
|
||||
if include_subcategories_category_slugs.present?
|
||||
category_ids.concat(
|
||||
if category_slugs[:include][:with_subcategories].present?
|
||||
include_category_ids.concat(
|
||||
get_category_ids_from_slugs(
|
||||
include_subcategories_category_slugs,
|
||||
category_slugs[:include][:with_subcategories],
|
||||
exclude_subcategories: false,
|
||||
),
|
||||
)
|
||||
end
|
||||
|
||||
if category_ids.present?
|
||||
@scope = @scope.where("topics.category_id IN (?)", category_ids)
|
||||
elsif exclude_subcategories_category_slugs.present? ||
|
||||
include_subcategories_category_slugs.present?
|
||||
if include_category_ids.present?
|
||||
@scope = @scope.where("topics.category_id IN (?)", include_category_ids)
|
||||
elsif category_slugs[:include].values.flatten.present?
|
||||
@scope = @scope.none
|
||||
return
|
||||
end
|
||||
|
||||
exclude_category_ids = []
|
||||
|
||||
if category_slugs[:exclude][:without_subcategories].present?
|
||||
exclude_category_ids =
|
||||
get_category_ids_from_slugs(
|
||||
category_slugs[:exclude][:without_subcategories],
|
||||
exclude_subcategories: true,
|
||||
)
|
||||
end
|
||||
|
||||
if category_slugs[:exclude][:with_subcategories].present?
|
||||
exclude_category_ids.concat(
|
||||
get_category_ids_from_slugs(
|
||||
category_slugs[:exclude][:with_subcategories],
|
||||
exclude_subcategories: false,
|
||||
),
|
||||
)
|
||||
end
|
||||
|
||||
if exclude_category_ids.present?
|
||||
@scope = @scope.where("topics.category_id NOT IN (?)", exclude_category_ids)
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
@ -225,54 +225,10 @@ RSpec.describe TopicsFilter do
|
|||
Fabricate(:category, parent_category: category2, name: "category2 subcategory")
|
||||
end
|
||||
|
||||
fab!(:private_category) do
|
||||
Fabricate(:private_category, group: group, slug: "private-category")
|
||||
end
|
||||
|
||||
fab!(:topic_in_category) { Fabricate(:topic, category: category) }
|
||||
fab!(:topic_in_category_subcategory) { Fabricate(:topic, category: category_subcategory) }
|
||||
fab!(:topic_in_category2) { Fabricate(:topic, category: category2) }
|
||||
fab!(:topic_in_category2_subcategory) { Fabricate(:topic, category: category2_subcategory) }
|
||||
fab!(:topic_in_private_category) { Fabricate(:topic, category: private_category) }
|
||||
|
||||
describe "when query string is `-category:category`" do
|
||||
it "ignores the filter because the prefix is invalid" do
|
||||
expect(
|
||||
TopicsFilter
|
||||
.new(guardian: Guardian.new)
|
||||
.filter_from_query_string("-category:category")
|
||||
.pluck(:id),
|
||||
).to contain_exactly(
|
||||
topic_in_category.id,
|
||||
topic_in_category_subcategory.id,
|
||||
topic_in_category2.id,
|
||||
topic_in_category2_subcategory.id,
|
||||
topic_in_private_category.id,
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
describe "when query string is `category:private-category`" do
|
||||
it "should not return any topics when user does not have access to specified category" do
|
||||
expect(
|
||||
TopicsFilter
|
||||
.new(guardian: Guardian.new)
|
||||
.filter_from_query_string("category:private-category")
|
||||
.pluck(:id),
|
||||
).to eq([])
|
||||
end
|
||||
|
||||
it "should return topics from specified category when user has access to specified category" do
|
||||
group.add(user)
|
||||
|
||||
expect(
|
||||
TopicsFilter
|
||||
.new(guardian: Guardian.new(user))
|
||||
.filter_from_query_string("category:private-category")
|
||||
.pluck(:id),
|
||||
).to contain_exactly(topic_in_private_category.id)
|
||||
end
|
||||
end
|
||||
|
||||
describe "when query string is `category:category`" do
|
||||
it "should return topics from specified category and its subcategories" do
|
||||
|
@ -357,6 +313,65 @@ RSpec.describe TopicsFilter do
|
|||
end
|
||||
end
|
||||
|
||||
describe "when query string is `-category:category`" do
|
||||
it "should not return any topics from specified category or its subcategories" do
|
||||
expect(
|
||||
TopicsFilter
|
||||
.new(guardian: Guardian.new)
|
||||
.filter_from_query_string("-category:category")
|
||||
.pluck(:id),
|
||||
).to contain_exactly(topic_in_category2.id, topic_in_category2_subcategory.id)
|
||||
end
|
||||
end
|
||||
|
||||
describe "when query string is `-category:category2,category`" do
|
||||
it "should not return any topics from either specified categories or their subcategories" do
|
||||
expect(
|
||||
TopicsFilter
|
||||
.new(guardian: Guardian.new)
|
||||
.filter_from_query_string("-category:category2,category")
|
||||
.pluck(:id),
|
||||
).to eq([])
|
||||
end
|
||||
end
|
||||
|
||||
describe "when query string is `-category:category -category:category2-subcategory`" do
|
||||
it "should not return any topics from either specified category or their subcategories" do
|
||||
expect(
|
||||
TopicsFilter
|
||||
.new(guardian: Guardian.new)
|
||||
.filter_from_query_string("-category:category -category:category2-subcategory")
|
||||
.pluck(:id),
|
||||
).to contain_exactly(topic_in_category2.id)
|
||||
end
|
||||
end
|
||||
|
||||
describe "when query string is `-=category:category`" do
|
||||
it "should not return any topics from the specified category only" do
|
||||
expect(
|
||||
TopicsFilter
|
||||
.new(guardian: Guardian.new)
|
||||
.filter_from_query_string("-=category:category")
|
||||
.pluck(:id),
|
||||
).to contain_exactly(
|
||||
topic_in_category_subcategory.id,
|
||||
topic_in_category2.id,
|
||||
topic_in_category2_subcategory.id,
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
describe "when query string is `-=category:category,category2`" do
|
||||
it "should not return any topics from the specified categories only" do
|
||||
expect(
|
||||
TopicsFilter
|
||||
.new(guardian: Guardian.new)
|
||||
.filter_from_query_string("-=category:category,category2")
|
||||
.pluck(:id),
|
||||
).to contain_exactly(topic_in_category_subcategory.id, topic_in_category2_subcategory.id)
|
||||
end
|
||||
end
|
||||
|
||||
describe "when query string is `=category:category`" do
|
||||
it "should not return topics from subcategories`" do
|
||||
expect(
|
||||
|
@ -379,6 +394,46 @@ RSpec.describe TopicsFilter do
|
|||
end
|
||||
end
|
||||
|
||||
describe "when query string is `category:category2 -=category:category2-subcategory`" do
|
||||
it "should return topics from category2 and its subcategories but not from the category2-subcategory" do
|
||||
expect(
|
||||
TopicsFilter
|
||||
.new(guardian: Guardian.new)
|
||||
.filter_from_query_string(
|
||||
"category:category2 -=category:category2:category2-subcategory",
|
||||
)
|
||||
.pluck(:id),
|
||||
).to contain_exactly(topic_in_category2.id)
|
||||
end
|
||||
|
||||
describe "when max category nesting is 3" do
|
||||
fab!(:category2_subcategory_subcategory) do
|
||||
SiteSetting.max_category_nesting = 3
|
||||
Fabricate(:category, parent_category: category2_subcategory, name: "sub-subcategory")
|
||||
end
|
||||
|
||||
fab!(:topic_in_category2_subcategory_subcategory) do
|
||||
Fabricate(:topic, category: category2_subcategory_subcategory)
|
||||
end
|
||||
|
||||
before { SiteSetting.max_category_nesting = 3 }
|
||||
|
||||
it "should return topics from category2, category2's sub-categories and category2's sub-sub-categories but not from the category2-subcategory only" do
|
||||
expect(
|
||||
TopicsFilter
|
||||
.new(guardian: Guardian.new)
|
||||
.filter_from_query_string(
|
||||
"category:category2 -=category:category2:category2-subcategory",
|
||||
)
|
||||
.pluck(:id),
|
||||
).to contain_exactly(
|
||||
topic_in_category2.id,
|
||||
topic_in_category2_subcategory_subcategory.id,
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "when multiple categories have subcategories with the same name" do
|
||||
fab!(:category_subcategory) do
|
||||
Fabricate(:category, parent_category: category, name: "subcategory")
|
||||
|
|
|
@ -1130,10 +1130,10 @@ RSpec.describe ListController do
|
|||
end
|
||||
|
||||
describe "#filter" do
|
||||
fab!(:category) { Fabricate(:category) }
|
||||
fab!(:category) { Fabricate(:category, slug: "category-slug") }
|
||||
fab!(:tag) { Fabricate(:tag, name: "tag1") }
|
||||
fab!(:group) { Fabricate(:group) }
|
||||
fab!(:private_category) { Fabricate(:private_category, group: Fabricate(:group)) }
|
||||
fab!(:private_category) { Fabricate(:private_category, group:, slug: "private-category-slug") }
|
||||
fab!(:private_message_topic) { Fabricate(:private_message_topic) }
|
||||
fab!(:topic_in_private_category) { Fabricate(:topic, category: private_category) }
|
||||
|
||||
|
@ -1262,6 +1262,33 @@ RSpec.describe ListController do
|
|||
end
|
||||
end
|
||||
|
||||
describe "when filtering with the `category:<category_slug>` filter" do
|
||||
fab!(:topic_in_category) { Fabricate(:topic, category:) }
|
||||
|
||||
it "does not return any topics when `q` query param is `category:private-category-slug` and user is not allowed to see category" do
|
||||
sign_in(user)
|
||||
|
||||
get "/filter.json", params: { q: "category:private-category-slug" }
|
||||
|
||||
expect(response.status).to eq(200)
|
||||
expect(response.parsed_body["topic_list"]["topics"].map { |topic| topic["id"] }).to eq([])
|
||||
end
|
||||
|
||||
it "returns only topics in the category when `q` query param is `category:private-category-slug` and user can see category" do
|
||||
group.add(user)
|
||||
|
||||
sign_in(user)
|
||||
|
||||
get "/filter.json", params: { q: "category:private-category-slug" }
|
||||
|
||||
expect(response.status).to eq(200)
|
||||
|
||||
expect(
|
||||
response.parsed_body["topic_list"]["topics"].map { |topic| topic["id"] },
|
||||
).to contain_exactly(topic_in_private_category.id)
|
||||
end
|
||||
end
|
||||
|
||||
describe "when filtering with the `in:<topic_notification_level>` filter" do
|
||||
fab!(:user_muted_topic) do
|
||||
Fabricate(:topic).tap do |topic|
|
||||
|
|
Loading…
Reference in New Issue