FIX: Make search work with sub-sub-categories (#13901)

FIX: Make search work with sub-sub-categories (#13901)

Searching in a category looked only one level down, ignoring the site setting max_category_nesting. The user interface did not support the third level of categories and did not display them in the “Categorized” input of the advanced search options.

diff --git a/app/assets/javascripts/discourse/app/components/search-advanced-options.js b/app/assets/javascripts/discourse/app/components/search-advanced-options.js
index 86d11e1..5b72e09 100644
--- a/app/assets/javascripts/discourse/app/components/search-advanced-options.js
+++ b/app/assets/javascripts/discourse/app/components/search-advanced-options.js
@@ -263,34 +263,33 @@ export default Component.extend({
       const subcategories = match[0]
         .replace(REGEXP_CATEGORY_PREFIX, "")
         .split(":");
+
+      let userInput;
       if (subcategories.length > 1) {
-        const userInput = Category.findBySlug(
-          subcategories[1],
-          subcategories[0]
+        userInput = Category.list().find(
+          (category) =>
+            category.get("parentCategory.slug") === subcategories[0] &&
+            category.slug === subcategories[1]
         );
-        if (
-          (!existingInput && userInput) ||
-          (existingInput && userInput && existingInput.id !== userInput.id)
-        ) {
-          this.set("searchedTerms.category", userInput);
-        }
-      } else if (isNaN(subcategories)) {
-        const userInput = Category.findSingleBySlug(subcategories[0]);
-        if (
-          (!existingInput && userInput) ||
-          (existingInput && userInput && existingInput.id !== userInput.id)
-        ) {
-          this.set("searchedTerms.category", userInput);
-        }
       } else {
-        const userInput = Category.findById(subcategories[0]);
-        if (
-          (!existingInput && userInput) ||
-          (existingInput && userInput && existingInput.id !== userInput.id)
-        ) {
-          this.set("searchedTerms.category", userInput);
+        userInput = Category.list().find(
+          (category) =>
+            !category.parentCategory && category.slug === subcategories[0]
+        );
+
+        if (!userInput) {
+          userInput = Category.list().find(
+            (category) => category.slug === subcategories[0]
+          );
         }
       }
+
+      if (
+        (!existingInput && userInput) ||
+        (existingInput && userInput && existingInput.id !== userInput.id)
+      ) {
+        this.set("searchedTerms.category", userInput);
+      }
     } else {
       this.set("searchedTerms.category", null);
     }
diff --git a/app/assets/javascripts/discourse/app/widgets/category-link.js b/app/assets/javascripts/discourse/app/widgets/category-link.js
index 1963962..9795fde 100644
--- a/app/assets/javascripts/discourse/app/widgets/category-link.js
+++ b/app/assets/javascripts/discourse/app/widgets/category-link.js
@@ -4,7 +4,7 @@ import { categoryBadgeHTML } from "discourse/helpers/category-link";
 // Right now it's RawHTML. Eventually it should emit nodes
 export default class CategoryLink extends RawHtml {
   constructor(attrs) {
-    attrs.html = categoryBadgeHTML(attrs.category, attrs);
+    attrs.html = `<span>${categoryBadgeHTML(attrs.category, attrs)}</span>`;
     super(attrs);
   }
 }
diff --git a/app/assets/javascripts/discourse/app/widgets/search-menu-results.js b/app/assets/javascripts/discourse/app/widgets/search-menu-results.js
index 3245773..6da7139 100644
--- a/app/assets/javascripts/discourse/app/widgets/search-menu-results.js
+++ b/app/assets/javascripts/discourse/app/widgets/search-menu-results.js
@@ -374,9 +374,11 @@ createWidget("search-menu-assistant", {
     switch (suggestionKeyword) {
       case "#":
         attrs.results.forEach((category) => {
-          const slug = prefix
-            ? `${prefix} #${category.slug} `
-            : `#${category.slug} `;
+          const fullSlug = category.parentCategory
+            ? `#${category.parentCategory.slug}:${category.slug}`
+            : `#${category.slug}`;
+
+          const slug = prefix ? `${prefix} ${fullSlug} ` : `${fullSlug} `;
 
           content.push(
             this.attach("search-menu-assistant-item", {
@@ -433,6 +435,7 @@ createWidget("search-menu-assistant-item", {
           this.attach("category-link", {
             category: attrs.category,
             allowUncategorized: true,
+            recursive: true,
           }),
         ]
       );
diff --git a/lib/search.rb b/lib/search.rb
index 0bf753d..a2e024c 100644
--- a/lib/search.rb
+++ b/lib/search.rb
@@ -511,12 +511,7 @@ class Search
     category_ids = Category.where('slug ilike ? OR name ilike ? OR id = ?',
                                   match, match, match.to_i).pluck(:id)
     if category_ids.present?
-
-      unless exact
-        category_ids +=
-          Category.where('parent_category_id = ?', category_ids.first).pluck(:id)
-      end
-
+      category_ids += Category.subcategory_ids(category_ids.first) unless exact
       @category_filter_matched ||= true
       posts.where("topics.category_id IN (?)", category_ids)
     else
@@ -525,44 +520,31 @@ class Search
   end
 
   advanced_filter(/^\#([\p{L}\p{M}0-9\-:=]+)$/i) do |posts, match|
-
-    exact = true
-
     category_slug, subcategory_slug = match.to_s.split(":")
     next unless category_slug
 
-    if subcategory_slug
-
-      category_id, _ = DB.query_single(<<~SQL, category_slug.downcase, subcategory_slug.downcase)
-        SELECT sub.id
-        FROM categories sub
-        JOIN categories c ON sub.parent_category_id = c.id
-        WHERE LOWER(c.slug)  = ? AND LOWER(sub.slug) = ?
-        ORDER BY c.id
-        LIMIT 1
-      SQL
-
+    exact = true
+    if category_slug[0] == "="
+      category_slug = category_slug[1..-1]
     else
-      # main category
-      if category_slug[0] == "="
-        category_slug = category_slug[1..-1]
-      else
-        exact = false
-      end
+      exact = false
+    end
 
-      category_id = Category.where("lower(slug) = ?", category_slug.downcase)
+    category_id = if subcategory_slug
+      Category
+        .where('lower(slug) = ?', subcategory_slug.downcase)
+        .where(parent_category_id: Category.where('lower(slug) = ?', category_slug.downcase).select(:id))
+        .pluck_first(:id)
+    else
+      Category
+        .where('lower(slug) = ?', category_slug.downcase)
         .order('case when parent_category_id is null then 0 else 1 end')
-        .pluck(:id)
-        .first
+        .pluck_first(:id)
     end
 
     if category_id
       category_ids = [category_id]
-
-      unless exact
-        category_ids +=
-          Category.where('parent_category_id = ?', category_id).pluck(:id)
-      end
+      category_ids += Category.subcategory_ids(category_id) if !exact
 
       @category_filter_matched ||= true
       posts.where("topics.category_id IN (?)", category_ids)
diff --git a/spec/lib/search_spec.rb b/spec/lib/search_spec.rb
index 6ed8332..63f28b7 100644
--- a/spec/lib/search_spec.rb
+++ b/spec/lib/search_spec.rb
@@ -166,4 +166,32 @@ describe Search do
       ])
     end
   end
+
+  context "categories" do
+    it "finds topics in sub-sub-categories" do
+      SiteSetting.max_category_nesting = 3
+
+      category = Fabricate(:category_with_definition)
+      subcategory = Fabricate(:category_with_definition, parent_category_id: category.id)
+      subsubcategory = Fabricate(:category_with_definition, parent_category_id: subcategory.id)
+
+      topic = Fabricate(:topic, category: subsubcategory)
+      post = Fabricate(:post, topic: topic)
+
+      SearchIndexer.enable
+      SearchIndexer.index(post, force: true)
+
+      expect(Search.execute("test ##{category.slug}").posts).to contain_exactly(post)
+      expect(Search.execute("test ##{category.slug}:#{subcategory.slug}").posts).to contain_exactly(post)
+      expect(Search.execute("test ##{subcategory.slug}").posts).to contain_exactly(post)
+      expect(Search.execute("test ##{subcategory.slug}:#{subsubcategory.slug}").posts).to contain_exactly(post)
+      expect(Search.execute("test ##{subsubcategory.slug}").posts).to contain_exactly(post)
+
+      expect(Search.execute("test #=#{category.slug}").posts).to be_empty

[... diff too long, it was truncated ...]

GitHub sha: fbf7627c8e87f4c4792319295cf54dc1c123cdb6

This commit appears in #13901 which was approved by ZogStriP. It was merged by nbianca.