DEV: Experimental /filter route to filter through topics (#20494)

This commit introduces an experimental `/filter` route which allows a
user to input a query string to filter through topics.

Internal Ref: /t/92833
This commit is contained in:
Alan Guo Xiang Tan 2023-03-03 09:46:21 +08:00 committed by GitHub
parent e022a7adec
commit 66c50547b4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 297 additions and 37 deletions

View File

@ -1,19 +1,36 @@
<BreadCrumbs {{#if this.isQueryFilterMode}}
@categories={{this.categories}} <div class="topic-query-filter">
@category={{this.category}} <Input
@noSubcategories={{this.noSubcategories}} class="topic-query-filter__input"
@tag={{this.tag}} @value={{this.queryString}}
@additionalTags={{this.additionalTags}} @enter={{route-action "changeQueryString" this.queryString}}
/> />
{{#unless this.additionalTags}} <DButton
{{! nav bar doesn't work with tag intersections }} @action={{route-action "changeQueryString" this.queryString}}
<NavigationBar @icon="filter"
@navItems={{this.navItems}} @class="btn-primary topic-query-filter__button"
@filterMode={{this.filterMode}} @label="filters.filter.button.label"
/>
</div>
{{else}}
<BreadCrumbs
@categories={{this.categories}}
@category={{this.category}} @category={{this.category}}
@noSubcategories={{this.noSubcategories}}
@tag={{this.tag}}
@additionalTags={{this.additionalTags}}
/> />
{{/unless}}
{{#unless this.additionalTags}}
{{! nav bar doesn't work with tag intersections }}
<NavigationBar
@navItems={{this.navItems}}
@filterMode={{this.filterMode}}
@category={{this.category}}
/>
{{/unless}}
{{/if}}
<div class="navigation-controls"> <div class="navigation-controls">
{{#if (and this.notCategoriesRoute this.site.mobileView this.canBulk)}} {{#if (and this.notCategoriesRoute this.site.mobileView this.canBulk)}}

View File

@ -6,6 +6,7 @@ import { NotificationLevels } from "discourse/lib/notification-levels";
import { getOwner } from "discourse-common/lib/get-owner"; import { getOwner } from "discourse-common/lib/get-owner";
import { htmlSafe } from "@ember/template"; import { htmlSafe } from "@ember/template";
import { inject as service } from "@ember/service"; import { inject as service } from "@ember/service";
import { alias, equal } from "@ember/object/computed";
export default Component.extend(FilterModeMixin, { export default Component.extend(FilterModeMixin, {
router: service(), router: service(),
@ -140,6 +141,9 @@ export default Component.extend(FilterModeMixin, {
return controller.canBulkSelect; return controller.canBulkSelect;
}, },
isQueryFilterMode: equal("filterMode", "filter"),
queryString: alias("router.currentRoute.queryParams.q"),
actions: { actions: {
changeCategoryNotificationLevel(notificationLevel) { changeCategoryNotificationLevel(notificationLevel) {
this.category.setNotification(notificationLevel); this.category.setNotification(notificationLevel);

View File

@ -34,6 +34,10 @@ controllerOpts.queryParams.forEach((p) => {
controllerOpts[p] = queryParams[p].default; controllerOpts[p] = queryParams[p].default;
}); });
export function changeQueryString(queryString) {
this.controller.set("q", queryString);
}
export function changeSort(sortBy) { export function changeSort(sortBy) {
let model = this.controllerFor("discovery.topics").model; let model = this.controllerFor("discovery.topics").model;

View File

@ -10,7 +10,18 @@ export default {
after: "inject-discourse-objects", after: "inject-discourse-objects",
name: "dynamic-route-builders", name: "dynamic-route-builders",
initialize(registry, app) { initialize(container, app) {
const siteSettings = container.lookup("service:site-settings");
if (siteSettings.experimental_topics_filter) {
app.register(
"controller:discovery.filter",
DiscoverySortableController.extend()
);
app.register("route:discovery.filter", buildTopicRoute("filter"));
}
app.register( app.register(
"controller:discovery.category", "controller:discovery.category",
DiscoverySortableController.extend() DiscoverySortableController.extend()

View File

@ -52,6 +52,8 @@ export default function () {
}); });
}); });
this.route("filter", { path: "/filter" });
this.route("categories"); this.route("categories");
// default filter for a category // default filter for a category

View File

@ -1,4 +1,5 @@
import { import {
changeQueryString,
changeSort, changeSort,
queryParams, queryParams,
resetParams, resetParams,
@ -146,6 +147,7 @@ export default function (filter, extras) {
}; };
this.controllerFor("discovery/topics").setProperties(topicOpts); this.controllerFor("discovery/topics").setProperties(topicOpts);
this.controllerFor("navigation/default").set( this.controllerFor("navigation/default").set(
"canCreateTopic", "canCreateTopic",
model.get("can_create_topic") model.get("can_create_topic")
@ -154,6 +156,7 @@ export default function (filter, extras) {
renderTemplate() { renderTemplate() {
this.render("navigation/default", { outlet: "navigation-bar" }); this.render("navigation/default", { outlet: "navigation-bar" });
this.render("discovery/topics", { this.render("discovery/topics", {
controller: "discovery/topics", controller: "discovery/topics",
outlet: "list-container", outlet: "list-container",
@ -165,6 +168,11 @@ export default function (filter, extras) {
changeSort.call(this, sortBy); changeSort.call(this, sortBy);
}, },
@action
changeQueryString(queryString) {
changeQueryString.call(this, queryString);
},
@action @action
resetParams(skipParams = []) { resetParams(skipParams = []) {
resetParams.call(this, skipParams); resetParams.call(this, skipParams);

View File

@ -30,6 +30,7 @@
@import "tap-tile"; @import "tap-tile";
@import "time-input"; @import "time-input";
@import "time-shortcut-picker"; @import "time-shortcut-picker";
@import "topic-query-filter";
@import "user-card"; @import "user-card";
@import "user-info"; @import "user-info";
@import "user-status-message"; @import "user-status-message";

View File

@ -0,0 +1,10 @@
.topic-query-filter {
display: flex;
flex-direction: row;
margin-right: auto;
margin-bottom: var(--nav-space);
.topic-query-filter__input {
margin: 0 0.5em 0 0;
}
}

View File

@ -117,6 +117,11 @@ class ListController < ApplicationController
end end
end end
def filter
raise Discourse::NotFound if !SiteSetting.experimental_topics_filter
latest
end
def category_default def category_default
canonical_url "#{Discourse.base_url_no_prefix}#{@category.url}" canonical_url "#{Discourse.base_url_no_prefix}#{@category.url}"
view_method = @category.default_view view_method = @category.default_view

View File

@ -2871,6 +2871,7 @@ en:
bookmarks: "You have no bookmarked topics yet." bookmarks: "You have no bookmarked topics yet."
category: "There are no %{category} topics." category: "There are no %{category} topics."
top: "There are no top topics." top: "There are no top topics."
filter: "There are no topics."
educate: educate:
new: '<p>Your new topics will appear here. By default, topics are considered new and will show a <span class="badge new-topic badge-notification" style="vertical-align:middle;line-height:inherit;"></span> indicator if they were created in the last 2 days.</p><p>Visit your <a href="%{userPrefsUrl}">preferences</a> to change this.</p>' new: '<p>Your new topics will appear here. By default, topics are considered new and will show a <span class="badge new-topic badge-notification" style="vertical-align:middle;line-height:inherit;"></span> indicator if they were created in the last 2 days.</p><p>Visit your <a href="%{userPrefsUrl}">preferences</a> to change this.</p>'
unread: '<p>Your unread topics appear here.</p><p>By default, topics are considered unread and will show unread counts <span class="badge unread-posts badge-notification">1</span> if you:</p><ul><li>Created the topic</li><li>Replied to the topic</li><li>Read the topic for more than 4 minutes</li></ul><p>Or if you have explicitly set the topic to Tracked or Watched via the 🔔 in each topic.</p><p>Visit your <a href="%{userPrefsUrl}">preferences</a> to change this.</p>' unread: '<p>Your unread topics appear here.</p><p>By default, topics are considered unread and will show unread counts <span class="badge unread-posts badge-notification">1</span> if you:</p><ul><li>Created the topic</li><li>Replied to the topic</li><li>Read the topic for more than 4 minutes</li></ul><p>Or if you have explicitly set the topic to Tracked or Watched via the 🔔 in each topic.</p><p>Visit your <a href="%{userPrefsUrl}">preferences</a> to change this.</p>'
@ -2885,6 +2886,7 @@ en:
tag: "There are no more %{tag} topics." tag: "There are no more %{tag} topics."
top: "There are no more top topics." top: "There are no more top topics."
bookmarks: "There are no more bookmarked topics." bookmarks: "There are no more bookmarked topics."
filter: "There are no more topics."
topic: topic:
filter_to: filter_to:
@ -3931,6 +3933,10 @@ en:
filters: filters:
with_topics: "%{filter} topics" with_topics: "%{filter} topics"
with_category: "%{filter} %{category} topics" with_category: "%{filter} %{category} topics"
filter:
title: "Filter"
button:
label: "Filter"
latest: latest:
title: "Latest" title: "Latest"
title_with_count: title_with_count:

View File

@ -1207,6 +1207,8 @@ Discourse::Application.routes.draw do
Discourse.filters.each { |filter| get "#{filter}" => "list##{filter}" } Discourse.filters.each { |filter| get "#{filter}" => "list##{filter}" }
get "filter" => "list#filter"
get "search/query" => "search#query" get "search/query" => "search#query"
get "search" => "search#show" get "search" => "search#show"
post "search/click" => "search#click" post "search/click" => "search#click"

View File

@ -2080,6 +2080,10 @@ developer:
default: "" default: ""
allow_any: false allow_any: false
refresh: true refresh: true
experimental_topics_filter:
client: true
default: false
hidden: true
navigation: navigation:
navigation_menu: navigation_menu:

View File

@ -260,6 +260,10 @@ class TopicQuery
create_list(:latest, {}, latest_results) create_list(:latest, {}, latest_results)
end end
def list_filter
list_latest
end
def list_read def list_read
create_list(:read, unordered: true) do |topics| create_list(:read, unordered: true) do |topics|
topics.where("tu.last_visited_at IS NOT NULL").order("tu.last_visited_at DESC") topics.where("tu.last_visited_at IS NOT NULL").order("tu.last_visited_at DESC")
@ -663,7 +667,7 @@ class TopicQuery
end end
# Start with a list of all topics # Start with a list of all topics
result = Topic.unscoped.includes(:category) result = Topic.includes(:category)
if @user if @user
result = result =
@ -821,8 +825,6 @@ class TopicQuery
) )
end end
require_deleted_clause = true
if before = options[:before] if before = options[:before]
if (before = before.to_i) > 0 if (before = before.to_i) > 0
result = result.where("topics.created_at < ?", before.to_i.days.ago) result = result.where("topics.created_at < ?", before.to_i.days.ago)
@ -836,24 +838,17 @@ class TopicQuery
end end
if status = options[:status] if status = options[:status]
case status options[:q] ||= +""
when "open" options[:q] << " status:#{status}"
result = result.where("NOT topics.closed AND NOT topics.archived") end
when "closed"
result = result.where("topics.closed") if options[:q].present?
when "archived" result =
result = result.where("topics.archived") TopicsFilter.new(
when "listed" scope: result,
result = result.where("topics.visible") guardian: @guardian,
when "unlisted" category_id: options[:category],
result = result.where("NOT topics.visible") ).filter(options[:q])
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
end end
if (filter = (options[:filter] || options[:f])) && @user if (filter = (options[:filter] || options[:f])) && @user
@ -876,7 +871,6 @@ class TopicQuery
result = TopicQuery.tracked_filter(result, @user.id) if filter == "tracked" result = TopicQuery.tracked_filter(result, @user.id) if filter == "tracked"
end end
result = result.where("topics.deleted_at IS NULL") if require_deleted_clause
result = result.where("topics.posts_count <= ?", options[:max_posts]) if options[ result = result.where("topics.posts_count <= ?", options[:max_posts]) if options[
:max_posts :max_posts
].present? ].present?

51
lib/topics_filter.rb Normal file
View File

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

View File

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

View File

@ -1084,4 +1084,23 @@ RSpec.describe ListController do
expect(parsed["topic_list"]["topics"].first["id"]).to eq(welcome_topic.id) expect(parsed["topic_list"]["topics"].first["id"]).to eq(welcome_topic.id)
end end
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 end

View File

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

View File

@ -3,13 +3,29 @@
module PageObjects module PageObjects
module Components module Components
class TopicList < PageObjects::Components::Base class TopicList < PageObjects::Components::Base
TOPIC_LIST_BODY_CLASS = ".topic-list-body"
def topic_list 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 end
def visit_topic_with_title(title) def visit_topic_with_title(title)
find(".topic-list-body a", text: title).click find(".topic-list-body a", text: title).click
end end
private
def topic_list_item_class(topic)
"#{TOPIC_LIST_BODY_CLASS} .topic-list-item[data-topic-id='#{topic.id}']"
end
end end
end end
end end

View File

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