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:
Alan Guo Xiang Tan 2023-05-08 14:04:47 +08:00 committed by GitHub
parent be1cbc7082
commit 963bb3406e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 180 additions and 69 deletions

View File

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

View File

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

View File

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