diff --git a/app/assets/javascripts/discourse/app/components/d-navigation.hbs b/app/assets/javascripts/discourse/app/components/d-navigation.hbs
index 9a33a368930..c328d2349f8 100644
--- a/app/assets/javascripts/discourse/app/components/d-navigation.hbs
+++ b/app/assets/javascripts/discourse/app/components/d-navigation.hbs
@@ -1,19 +1,36 @@
-
Your new topics will appear here. By default, topics are considered new and will show a indicator if they were created in the last 2 days.
Visit your preferences to change this.
' unread: 'Your unread topics appear here.
By default, topics are considered unread and will show unread counts 1 if you:
Or if you have explicitly set the topic to Tracked or Watched via the 🔔 in each topic.
Visit your preferences to change this.
' @@ -2885,6 +2886,7 @@ en: tag: "There are no more %{tag} topics." top: "There are no more top topics." bookmarks: "There are no more bookmarked topics." + filter: "There are no more topics." topic: filter_to: @@ -3931,6 +3933,10 @@ en: filters: with_topics: "%{filter} topics" with_category: "%{filter} %{category} topics" + filter: + title: "Filter" + button: + label: "Filter" latest: title: "Latest" title_with_count: diff --git a/config/routes.rb b/config/routes.rb index 225b535393b..d1aa0b5b9b0 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1207,6 +1207,8 @@ Discourse::Application.routes.draw do Discourse.filters.each { |filter| get "#{filter}" => "list##{filter}" } + get "filter" => "list#filter" + get "search/query" => "search#query" get "search" => "search#show" post "search/click" => "search#click" diff --git a/config/site_settings.yml b/config/site_settings.yml index ed6505d2799..8a5082f32fc 100644 --- a/config/site_settings.yml +++ b/config/site_settings.yml @@ -2080,6 +2080,10 @@ developer: default: "" allow_any: false refresh: true + experimental_topics_filter: + client: true + default: false + hidden: true navigation: navigation_menu: diff --git a/lib/topic_query.rb b/lib/topic_query.rb index 7c364e53d6a..fe5dc2ff500 100644 --- a/lib/topic_query.rb +++ b/lib/topic_query.rb @@ -260,6 +260,10 @@ class TopicQuery create_list(:latest, {}, latest_results) end + def list_filter + list_latest + end + def list_read create_list(:read, unordered: true) do |topics| topics.where("tu.last_visited_at IS NOT NULL").order("tu.last_visited_at DESC") @@ -663,7 +667,7 @@ class TopicQuery end # Start with a list of all topics - result = Topic.unscoped.includes(:category) + result = Topic.includes(:category) if @user result = @@ -821,8 +825,6 @@ class TopicQuery ) end - require_deleted_clause = true - if before = options[:before] if (before = before.to_i) > 0 result = result.where("topics.created_at < ?", before.to_i.days.ago) @@ -836,24 +838,17 @@ class TopicQuery end if status = options[:status] - case status - when "open" - result = result.where("NOT topics.closed AND NOT topics.archived") - when "closed" - result = result.where("topics.closed") - when "archived" - result = result.where("topics.archived") - when "listed" - result = result.where("topics.visible") - when "unlisted" - result = result.where("NOT topics.visible") - when "deleted" - category = Category.find_by(id: options[:category]) - if @guardian.can_see_deleted_topics?(category) - result = result.where("topics.deleted_at IS NOT NULL") - require_deleted_clause = false - end - end + options[:q] ||= +"" + options[:q] << " status:#{status}" + end + + if options[:q].present? + result = + TopicsFilter.new( + scope: result, + guardian: @guardian, + category_id: options[:category], + ).filter(options[:q]) end if (filter = (options[:filter] || options[:f])) && @user @@ -876,7 +871,6 @@ class TopicQuery result = TopicQuery.tracked_filter(result, @user.id) if filter == "tracked" end - result = result.where("topics.deleted_at IS NULL") if require_deleted_clause result = result.where("topics.posts_count <= ?", options[:max_posts]) if options[ :max_posts ].present? diff --git a/lib/topics_filter.rb b/lib/topics_filter.rb new file mode 100644 index 00000000000..af80382e12f --- /dev/null +++ b/lib/topics_filter.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +class TopicsFilter + def self.register_filter(matcher, &block) + self.filters[matcher] = block + end + + def self.filters + @@filters ||= {} + end + + register_filter(/\Astatus:([a-zA-Z]+)\z/i) do |topics, match| + case match + when "open" + topics.where("NOT topics.closed AND NOT topics.archived") + when "closed" + topics.where("topics.closed") + when "archived" + topics.where("topics.archived") + when "deleted" + if @guardian.can_see_deleted_topics?(@category) + topics.unscope(where: :deleted_at).where("topics.deleted_at IS NOT NULL") + end + end + end + + def initialize(guardian:, scope: Topic, category_id: nil) + @guardian = guardian + @scope = scope + @category = category_id.present? ? Category.find_by(id: category_id) : nil + end + + def filter(input) + input + .to_s + .scan(/(([^" \t\n\x0B\f\r]+)?(("[^"]+")?))/) + .to_a + .map do |(word, _)| + next if word.blank? + + self.class.filters.each do |matcher, block| + cleaned = word.gsub(/["']/, "") + + new_scope = instance_exec(@scope, $1, &block) if cleaned =~ matcher + @scope = new_scope if !new_scope.nil? + end + end + + @scope + end +end diff --git a/spec/lib/topics_filter_spec.rb b/spec/lib/topics_filter_spec.rb new file mode 100644 index 00000000000..778bbbb9fc0 --- /dev/null +++ b/spec/lib/topics_filter_spec.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +RSpec.describe TopicsFilter do + fab!(:admin) { Fabricate(:admin) } + fab!(:topic) { Fabricate(:topic) } + fab!(:closed_topic) { Fabricate(:topic, closed: true) } + fab!(:archived_topic) { Fabricate(:topic, archived: true) } + fab!(:deleted_topic_id) { Fabricate(:topic, deleted_at: Time.zone.now).id } + + describe "#filter" do + it "should return all topics when input is blank" do + expect(TopicsFilter.new(guardian: Guardian.new).filter("").pluck(:id)).to contain_exactly( + topic.id, + closed_topic.id, + archived_topic.id, + ) + end + + it "should return all topics when input does not match any filters" do + expect( + TopicsFilter.new(guardian: Guardian.new).filter("randomstring").pluck(:id), + ).to contain_exactly(topic.id, closed_topic.id, archived_topic.id) + end + + it "should only return topics that have not been closed or archived when input is `status:open`" do + expect( + TopicsFilter.new(guardian: Guardian.new).filter("status:open").pluck(:id), + ).to contain_exactly(topic.id) + end + + it "should only return topics that have been deleted when input is `status:deleted` and user can see deleted topics" do + expect( + TopicsFilter.new(guardian: Guardian.new(admin)).filter("status:deleted").pluck(:id), + ).to contain_exactly(deleted_topic_id) + end + + it "should status filter when input is `status:deleted` and user cannot see deleted topics" do + expect( + TopicsFilter.new(guardian: Guardian.new).filter("status:deleted").pluck(:id), + ).to contain_exactly(topic.id, closed_topic.id, archived_topic.id) + end + + it "should only return topics that have been archived when input is `status:archived`" do + expect( + TopicsFilter.new(guardian: Guardian.new).filter("status:archived").pluck(:id), + ).to contain_exactly(archived_topic.id) + end + end +end diff --git a/spec/requests/list_controller_spec.rb b/spec/requests/list_controller_spec.rb index 547e0573139..29c66262ebe 100644 --- a/spec/requests/list_controller_spec.rb +++ b/spec/requests/list_controller_spec.rb @@ -1084,4 +1084,23 @@ RSpec.describe ListController do expect(parsed["topic_list"]["topics"].first["id"]).to eq(welcome_topic.id) end end + + describe "#filter" do + it "should respond with 403 response code for an anonymous user" do + SiteSetting.experimental_topics_filter = true + + get "/filter.json" + + expect(response.status).to eq(403) + end + + it "should respond with 404 response code when `experimental_topics_filter` site setting has not been enabled" do + SiteSetting.experimental_topics_filter = false + sign_in(user) + + get "/filter.json" + + expect(response.status).to eq(404) + end + end end diff --git a/spec/system/filtering_topics_spec.rb b/spec/system/filtering_topics_spec.rb new file mode 100644 index 00000000000..fcc8513a411 --- /dev/null +++ b/spec/system/filtering_topics_spec.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +describe "Filtering topics", type: :system, js: true do + fab!(:user) { Fabricate(:user) } + fab!(:topic) { Fabricate(:topic) } + fab!(:closed_topic) { Fabricate(:topic, closed: true) } + let(:topic_list) { PageObjects::Components::TopicList.new } + + before { SiteSetting.experimental_topics_filter = true } + + it "should allow users to enter a custom query string to filter through topics" do + sign_in(user) + + visit("/filter") + + expect(topic_list).to have_topic(topic) + expect(topic_list).to have_topic(closed_topic) + + topic_query_filter = PageObjects::Components::TopicQueryFilter.new + topic_query_filter.fill_in("status:open") + + expect(topic_list).to have_topic(topic) + expect(topic_list).to have_no_topic(closed_topic) + expect(page).to have_current_path("/filter?q=status%3Aopen") + + topic_query_filter.fill_in("status:closed") + + expect(topic_list).to have_no_topic(topic) + expect(topic_list).to have_topic(closed_topic) + expect(page).to have_current_path("/filter?q=status%3Aclosed") + end + + it "should filter topics when 'q' query params is present" do + sign_in(user) + + visit("/filter?q=status:open") + + expect(topic_list).to have_topic(topic) + expect(topic_list).to have_no_topic(closed_topic) + end +end diff --git a/spec/system/page_objects/components/topic_list.rb b/spec/system/page_objects/components/topic_list.rb index 83be6a93c27..1c1b86c74ff 100644 --- a/spec/system/page_objects/components/topic_list.rb +++ b/spec/system/page_objects/components/topic_list.rb @@ -3,13 +3,29 @@ module PageObjects module Components class TopicList < PageObjects::Components::Base + TOPIC_LIST_BODY_CLASS = ".topic-list-body" + def topic_list - ".topic-list-body" + TOPIC_LIST_BODY_CLASS + end + + def has_topic?(topic) + page.has_css?(topic_list_item_class(topic)) + end + + def has_no_topic?(topic) + page.has_no_css?(topic_list_item_class(topic)) end def visit_topic_with_title(title) find(".topic-list-body a", text: title).click end + + private + + def topic_list_item_class(topic) + "#{TOPIC_LIST_BODY_CLASS} .topic-list-item[data-topic-id='#{topic.id}']" + end end end end diff --git a/spec/system/page_objects/components/topic_query_filter.rb b/spec/system/page_objects/components/topic_query_filter.rb new file mode 100644 index 00000000000..75a9b3c5a7e --- /dev/null +++ b/spec/system/page_objects/components/topic_query_filter.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module PageObjects + module Components + class TopicQueryFilter < PageObjects::Components::Base + def fill_in(text) + page.fill_in(class: "topic-query-filter__input", with: text) + + page.click_button( + I18n.t("js.filters.filter.button.label"), + class: "topic-query-filter__button", + ) + end + end + end +end