FEATURE: Add setting & preference for search sort default order (#24428)

This commit adds a new `search_default_sort_order` site setting,
set to "relevance" by default, that controls the default sort order
for the full page /search route.

If the user changes the order in the dropdown on that page, we remember
their preference automatically, and it takes precedence over the site
setting as a default from then on. This way people who prefer e.g.
Latest Post as their default can make it so.
This commit is contained in:
Martin Brennan 2023-11-20 10:43:58 +10:00 committed by GitHub
parent 186e415e38
commit 146da75fd7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 138 additions and 4 deletions

View File

@ -57,6 +57,8 @@ export default Controller.extend({
composer: service(),
modal: service(),
appEvents: service(),
siteSettings: service(),
searchPreferencesManager: service(),
bulkSelectEnabled: null,
loading: false,
@ -86,6 +88,12 @@ export default Controller.extend({
init() {
this._super(...arguments);
this.set(
"sortOrder",
this.searchPreferencesManager.sortOrder ||
this.siteSettings.search_default_sort_order
);
const searchTypes = [
{ name: I18n.t("search.type.default"), id: SEARCH_TYPE_DEFAULT },
{
@ -486,6 +494,12 @@ export default Controller.extend({
});
},
@action
setSortOrder(value) {
this.set("sortOrder", value);
this.searchPreferencesManager.sortOrder = value;
},
actions: {
selectAll() {
this.selected.addObjects(this.get("searchResultPosts").mapBy("topic"));

View File

@ -0,0 +1,16 @@
import Service from "@ember/service";
import KeyValueStore from "discourse/lib/key-value-store";
export default class SearchPreferencesManager extends Service {
STORE_NAMESPACE = "discourse_search_preferences_manager_";
store = new KeyValueStore(this.STORE_NAMESPACE);
get sortOrder() {
return this.store.getObject("sortOrder");
}
set sortOrder(value) {
this.store.setObject({ key: "sortOrder", value });
}
}

View File

@ -145,7 +145,7 @@
<ComboBox
@value={{this.sortOrder}}
@content={{this.sortOrders}}
@onChange={{action (mut this.sortOrder)}}
@onChange={{this.setSortOrder}}
@id="search-sort-by"
@options={{hash castInteger=true}}
/>

View File

@ -332,6 +332,43 @@ acceptance("Search - Anonymous", function (needs) {
});
});
acceptance("Search - Default sort order", function (needs) {
needs.user();
needs.settings({
search_default_sort_order: 1, // "latest"
});
needs.hooks.beforeEach(function () {
this.searchPreferencesManager = this.container.lookup(
"service:search-preferences-manager"
);
this.searchPreferencesManager.sortOrder = null;
});
needs.hooks.afterEach(function () {
this.searchPreferencesManager.sortOrder = null;
});
test("Default sort order is used if there is no preference in user key value store", async function (assert) {
await visit("/search?q=discourse");
const searchSortByDropdown = selectKit("#search-sort-by");
await searchSortByDropdown.expand();
assert.strictEqual(searchSortByDropdown.header().value(), "1");
});
test("User preference from SearchPreferencesManager key value store is used if present", async function (assert) {
this.searchPreferencesManager = this.container.lookup(
"service:search-preferences-manager"
);
this.searchPreferencesManager.sortOrder = 2; // "likes"
await visit("/search?q=discourse");
const searchSortByDropdown = selectKit("#search-sort-by");
await searchSortByDropdown.expand();
assert.strictEqual(searchSortByDropdown.header().value(), "2");
});
});
acceptance("Search - Authenticated", function (needs) {
needs.user();
needs.settings({

View File

@ -358,7 +358,11 @@ export function applyDefaultHandlers(pretender) {
pretender.post("/clicks/track", success);
pretender.get("/search", (request) => {
if (request.queryParams.q === "discourse") {
if (
request.queryParams.q === "discourse" ||
request.queryParams.q === "discourse order:latest" ||
request.queryParams.q === "discourse order:likes"
) {
return response(fixturesByUrl["/search.json"]);
} else if (request.queryParams.q === "discourse visited") {
const obj = JSON.parse(JSON.stringify(fixturesByUrl["/search.json"]));

View File

@ -0,0 +1,29 @@
# frozen_string_literal: true
class SearchSortOrderSiteSetting < EnumSiteSetting
def self.valid_value?(val)
val.to_i.to_s == val.to_s && values.any? { |v| v[:value] == val.to_i }
end
def self.values
@values ||= [
{ name: "search.relevance", value: 0, id: :relevance },
{ name: "search.latest_post", value: 1, id: :latest },
{ name: "search.most_liked", value: 2, id: :likes },
{ name: "search.most_viewed", value: 3, id: :views },
{ name: "search.latest_topic", value: 4, id: :latest_topic },
]
end
def self.value_from_id(id)
values.find { |v| v[:id] == id }[:value]
end
def self.id_from_value(value)
values.find { |v| v[:value] == value }[:id]
end
def self.translate_names?
true
end
end

View File

@ -1551,6 +1551,7 @@ en:
search_query_log_max_size: "Maximum amount of search queries to keep"
search_query_log_max_retention_days: "Maximum amount of time to keep search queries, in days."
search_ignore_accents: "Ignore accents when searching for text."
search_default_sort_order: "Default sort order for full-page search"
category_search_priority_low_weight: "Weight applied to ranking for low category search priority."
category_search_priority_high_weight: "Weight applied to ranking for high category search priority."
default_composer_category: "The category used to pre-populate the category dropdown when creating a new topic."

View File

@ -2389,6 +2389,11 @@ search:
search_page_size:
default: 50
hidden: true
search_default_sort_order:
default: 0 # "relevance"
client: true
type: enum
enum: "SearchSortOrderSiteSetting"
uncategorized:
version_checks:

View File

@ -236,6 +236,11 @@ class Search
@in_title = false
term = process_advanced_search!(term)
if !@order &&
SiteSetting.search_default_sort_order !=
SearchSortOrderSiteSetting.value_from_id(:relevance)
@order = SearchSortOrderSiteSetting.id_from_value(SiteSetting.search_default_sort_order)
end
if term.present?
@term = Search.prepare_data(term, Topic === @search_context ? :topic : nil)

View File

@ -2003,7 +2003,30 @@ RSpec.describe Search do
expect(Search.execute("with:images").posts.map(&:id)).to contain_exactly(post_uploaded.id)
end
it "can find by latest" do
it "defaults to search_default_sort_order when no order is provided" do
topic1 = Fabricate(:topic, title: "I do not like that Sam I am", created_at: 1.minute.ago)
post1 = Fabricate(:post, topic: topic1, created_at: 10.minutes.ago)
post2 =
Fabricate(
:post,
raw: "that Sam I am, that Sam I am",
created_at: 5.minutes.ago,
topic: Fabricate(:topic, created_at: 1.hour.ago),
)
SiteSetting.search_default_sort_order = SearchSortOrderSiteSetting.value_from_id(:latest)
expect(Search.execute("sam").posts.map(&:id)).to eq([post2.id, post1.id])
expect(Search.execute("sam ORDER:LATEST").posts.map(&:id)).to eq([post2.id, post1.id])
SiteSetting.search_default_sort_order =
SearchSortOrderSiteSetting.value_from_id(:latest_topic)
expect(Search.execute("sam").posts.map(&:id)).to eq([post1.id, post2.id])
expect(Search.execute("sam ORDER:LATEST_TOPIC").posts.map(&:id)).to eq([post1.id, post2.id])
end
it "can order by latest" do
topic1 = Fabricate(:topic, title: "I do not like that Sam I am")
post1 = Fabricate(:post, topic: topic1, created_at: 10.minutes.ago)
post2 = Fabricate(:post, raw: "that Sam I am, that Sam I am", created_at: 5.minutes.ago)
@ -2014,7 +2037,7 @@ RSpec.describe Search do
expect(Search.execute("l sam").posts.map(&:id)).to eq([post2.id, post1.id])
end
it "can find by oldest" do
it "can order by oldest" do
topic1 = Fabricate(:topic, title: "I do not like that Sam I am")
post1 = Fabricate(:post, topic: topic1, raw: "sam is a sam sam sam") # score higher