diff --git a/app/assets/javascripts/discourse/app/controllers/full-page-search.js b/app/assets/javascripts/discourse/app/controllers/full-page-search.js index d281b33e9ad..5ea48679760 100644 --- a/app/assets/javascripts/discourse/app/controllers/full-page-search.js +++ b/app/assets/javascripts/discourse/app/controllers/full-page-search.js @@ -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")); diff --git a/app/assets/javascripts/discourse/app/services/search-preferences-manager.js b/app/assets/javascripts/discourse/app/services/search-preferences-manager.js new file mode 100644 index 00000000000..2e3335c6f7a --- /dev/null +++ b/app/assets/javascripts/discourse/app/services/search-preferences-manager.js @@ -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 }); + } +} diff --git a/app/assets/javascripts/discourse/app/templates/full-page-search.hbs b/app/assets/javascripts/discourse/app/templates/full-page-search.hbs index fdbafdaf6e4..69220e2ed2b 100644 --- a/app/assets/javascripts/discourse/app/templates/full-page-search.hbs +++ b/app/assets/javascripts/discourse/app/templates/full-page-search.hbs @@ -145,7 +145,7 @@ diff --git a/app/assets/javascripts/discourse/tests/acceptance/search-test.js b/app/assets/javascripts/discourse/tests/acceptance/search-test.js index 68afbd79665..a82237039be 100644 --- a/app/assets/javascripts/discourse/tests/acceptance/search-test.js +++ b/app/assets/javascripts/discourse/tests/acceptance/search-test.js @@ -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({ diff --git a/app/assets/javascripts/discourse/tests/helpers/create-pretender.js b/app/assets/javascripts/discourse/tests/helpers/create-pretender.js index 75fc09e7bdc..e5e5953f4f5 100644 --- a/app/assets/javascripts/discourse/tests/helpers/create-pretender.js +++ b/app/assets/javascripts/discourse/tests/helpers/create-pretender.js @@ -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"])); diff --git a/app/models/search_sort_order_site_setting.rb b/app/models/search_sort_order_site_setting.rb new file mode 100644 index 00000000000..b51ac59e5e6 --- /dev/null +++ b/app/models/search_sort_order_site_setting.rb @@ -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 diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml index 2a7a0e5eb26..deb1e17f260 100644 --- a/config/locales/server.en.yml +++ b/config/locales/server.en.yml @@ -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." diff --git a/config/site_settings.yml b/config/site_settings.yml index fd40357e5e8..dc311d065c8 100644 --- a/config/site_settings.yml +++ b/config/site_settings.yml @@ -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: diff --git a/lib/search.rb b/lib/search.rb index 3183f756afe..d1669d6d227 100644 --- a/lib/search.rb +++ b/lib/search.rb @@ -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) diff --git a/spec/lib/search_spec.rb b/spec/lib/search_spec.rb index 6f5e6290bc7..4ebc44caebc 100644 --- a/spec/lib/search_spec.rb +++ b/spec/lib/search_spec.rb @@ -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