FEATURE: Use async search for category dropdowns (#23774)

This commit introduces a new endpoint to search categories and uses it
instead of the categories map that is preloaded using SiteSerializer.

This feature is enabled only when the hidden site setting
lazy_load_categories is enabled and should be used only on sites with
many categories.
This commit is contained in:
Bianca Nenciu 2023-10-17 19:46:54 +03:00 committed by GitHub
parent 60ae69027c
commit 2e68ead45b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 257 additions and 64 deletions

View File

@ -185,11 +185,5 @@ export function defaultCategoryLinkRenderer(category, opts) {
if (opts.topicCount && categoryStyle === "box") {
afterBadgeWrapper += buildTopicCount(opts.topicCount);
}
if (opts.plusSubcategories && opts.lastSubcategory) {
afterBadgeWrapper += `<span class="plus-subcategories">${I18n.t(
"category_row.plus_subcategories",
{ count: opts.plusSubcategories }
)}</span>`;
}
return `<${tagName} class="badge-wrapper ${extraClasses}" ${href}>${html}</${tagName}>${afterBadgeWrapper}`;
}

View File

@ -652,6 +652,27 @@ Category.reopenClass({
return data.sortBy("read_restricted");
},
async asyncSearch(term, opts) {
opts ||= {};
const result = await ajax("/categories/search", {
data: {
term,
parent_category_id: opts.parentCategoryId,
include_uncategorized: opts.includeUncategorized,
select_category_ids: opts.selectCategoryIds,
reject_category_ids: opts.rejectCategoryIds,
include_subcategories: opts.includeSubcategories,
prioritized_category_id: opts.prioritizedCategoryId,
limit: opts.limit,
},
});
return result["categories"].map((category) =>
Site.current().updateCategory(category)
);
},
});
export default Category;

View File

@ -76,6 +76,13 @@ export default ComboBoxComponent.extend({
},
search(filter) {
if (this.siteSettings.lazy_load_categories) {
return Category.asyncSearch(this._normalize(filter), {
scopedCategoryId: this.selectKit.options?.scopedCategoryId,
prioritizedCategoryId: this.selectKit.options?.prioritizedCategoryId,
});
}
if (filter) {
filter = this._normalize(filter);
return this.content.filter((item) => {

View File

@ -132,13 +132,28 @@ export default ComboBoxComponent.extend({
}
),
search(filter) {
if (filter) {
let opts = {
parentCategoryId: this.options.parentCategory?.id,
};
let results = Category.search(filter, opts);
results = this._filterUncategorized(results).sort((a, b) => {
async search(filter) {
const opts = {
parentCategoryId: this.options.parentCategory?.id,
includeUncategorized: this.siteSettings.allow_uncategorized_topics,
};
if (this.siteSettings.lazy_load_categories) {
const results = await Category.asyncSearch(filter, { ...opts, limit: 5 });
return results.sort((a, b) => {
if (a.parent_category_id && !b.parent_category_id) {
return 1;
} else if (!a.parent_category_id && b.parent_category_id) {
return -1;
} else {
return 0;
}
});
}
if (filter) {
let results = Category.search(filter, opts);
return this._filterUncategorized(results).sort((a, b) => {
if (a.parent_category_id && !b.parent_category_id) {
return 1;
} else if (!a.parent_category_id && b.parent_category_id) {
@ -147,7 +162,6 @@ export default ComboBoxComponent.extend({
return 0;
}
});
return results;
} else {
return this._filterUncategorized(this.content);
}

View File

@ -1,10 +1,7 @@
import EmberObject, { computed } from "@ember/object";
import { computed } from "@ember/object";
import { mapBy } from "@ember/object/computed";
import { htmlSafe } from "@ember/template";
import { categoryBadgeHTML } from "discourse/helpers/category-link";
import Category from "discourse/models/category";
import { makeArray } from "discourse-common/lib/helpers";
import I18n from "I18n";
import MultiSelectComponent from "select-kit/components/multi-select";
export default MultiSelectComponent.extend({
@ -56,42 +53,21 @@ export default MultiSelectComponent.extend({
return "category-row";
},
search(filter) {
const result = this._super(filter);
if (result.length === 1) {
const subcategoryIds = new Set([result[0].id]);
for (let i = 0; i < this.siteSettings.max_category_nesting; ++i) {
subcategoryIds.forEach((categoryId) => {
this.content.forEach((category) => {
if (category.parent_category_id === categoryId) {
subcategoryIds.add(category.id);
}
});
});
}
if (subcategoryIds.size > 1) {
result.push(
EmberObject.create({
multiCategory: [...subcategoryIds],
category: result[0],
title: I18n.t("category_row.plus_subcategories_title", {
name: result[0].name,
count: subcategoryIds.size - 1,
}),
label: htmlSafe(
categoryBadgeHTML(result[0], {
link: false,
recursive: true,
plusSubcategories: subcategoryIds.size - 1,
})
),
})
);
}
}
return result;
async search(filter) {
return this.siteSettings.lazy_load_categories
? await Category.asyncSearch(filter, {
includeUncategorized:
this.attrs.options?.allowUncategorized !== undefined
? this.attrs.options.allowUncategorized
: this.selectKit.options.allowUncategorized,
selectCategoryIds: this.categories
? this.categories.map((x) => x.id)
: null,
rejectCategoryIds: this.blockedCategories
? this.blockedCategories.map((x) => x.id)
: null,
})
: this._super(filter);
},
select(value, item) {

View File

@ -16,10 +16,6 @@
.topic-count {
margin-left: 0.25em;
}
.plus-subcategories {
font-size: var(--font-down-2);
}
}
}
}

View File

@ -11,6 +11,7 @@ class CategoriesController < ApplicationController
redirect
find_by_slug
visible_groups
search
]
before_action :fetch_category, only: %i[show update destroy visible_groups]
@ -19,6 +20,7 @@ class CategoriesController < ApplicationController
SYMMETRICAL_CATEGORIES_TO_TOPICS_FACTOR = 1.5
MIN_CATEGORIES_TOPICS = 5
MAX_CATEGORIES_LIMIT = 25
def redirect
return if handle_permalink("/category/#{params[:path]}")
@ -297,6 +299,69 @@ class CategoriesController < ApplicationController
render json: success_json.merge(groups: groups || [])
end
def search
term = params[:term].to_s.strip
parent_category_id = params[:parent_category_id].to_i if params[:parent_category_id].present?
include_uncategorized =
(
if params[:include_uncategorized].present?
ActiveModel::Type::Boolean.new.cast(params[:include_uncategorized])
else
true
end
)
select_category_ids = params[:select_category_ids].presence
reject_category_ids = params[:reject_category_ids].presence
include_subcategories =
if params[:include_subcategories].present?
ActiveModel::Type::Boolean.new.cast(params[:include_subcategories])
else
true
end
prioritized_category_id = params[:prioritized_category_id].to_i if params[
:prioritized_category_id
].present?
limit = params[:limit].to_i.clamp(1, MAX_CATEGORIES_LIMIT) if params[:limit].present?
categories = Category.secured(guardian)
categories =
categories
.includes(:category_search_data)
.references(:category_search_data)
.where(
"category_search_data.search_data @@ #{Search.ts_query(term: term)}",
) if term.present?
categories =
categories.where(
"id = :id OR parent_category_id = :id",
id: parent_category_id,
) if parent_category_id.present?
categories =
categories.where.not(id: SiteSetting.uncategorized_category_id) if !include_uncategorized
categories = categories.where(id: select_category_ids) if select_category_ids
categories = categories.where.not(id: reject_category_ids) if reject_category_ids
categories = categories.where(parent_category_id: nil) if !include_subcategories
categories = categories.limit(limit || MAX_CATEGORIES_LIMIT)
categories = categories.order(<<~SQL) if prioritized_category_id.present?
CASE
WHEN id = #{prioritized_category_id} OR parent_category_id = #{prioritized_category_id} THEN 0
ELSE 1
END
SQL
categories = categories.order(:read_restricted)
render json: categories, each_serializer: SiteCategorySerializer
end
private
def self.topics_per_page

View File

@ -2312,12 +2312,6 @@ en:
topic_count:
one: "%{count} topic in this category"
other: "%{count} topics in this category"
plus_subcategories_title:
one: "%{name} and one subcategory"
other: "%{name} and %{count} subcategories"
plus_subcategories:
one: "+ %{count} subcategory"
other: "+ %{count} subcategories"
select_kit:
delete_item: "Delete %{name}"

View File

@ -1155,6 +1155,7 @@ Discourse::Application.routes.draw do
resources :categories, except: %i[show new edit]
post "categories/reorder" => "categories#reorder"
get "categories/search" => "categories#search"
scope path: "category/:category_id" do
post "/move" => "categories#move"

View File

@ -2176,6 +2176,7 @@ developer:
hidden: true
lazy_load_categories:
default: false
client: true
hidden: true
navigation:

View File

@ -1039,4 +1039,128 @@ RSpec.describe CategoriesController do
expect(response.parsed_body["groups"]).to eq([])
end
end
describe "#search" do
fab!(:category) { Fabricate(:category, name: "Foo") }
fab!(:subcategory) { Fabricate(:category, name: "Foobar", parent_category: category) }
fab!(:category2) { Fabricate(:category, name: "Notfoo") }
before do
SearchIndexer.enable
[category, category2, subcategory].each { |c| SearchIndexer.index(c, force: true) }
end
context "with term" do
it "returns categories" do
get "/categories/search.json", params: { term: "Foo" }
expect(response.parsed_body["categories"].size).to eq(2)
expect(response.parsed_body["categories"].map { |c| c["name"] }).to contain_exactly(
"Foo",
"Foobar",
)
end
end
context "with parent_category_id" do
it "returns categories" do
get "/categories/search.json", params: { parent_category_id: category.id }
expect(response.parsed_body["categories"].size).to eq(2)
expect(response.parsed_body["categories"].map { |c| c["name"] }).to contain_exactly(
"Foo",
"Foobar",
)
end
end
context "with include_uncategorized" do
it "returns Uncategorized" do
get "/categories/search.json", params: { include_uncategorized: true }
expect(response.parsed_body["categories"].size).to eq(4)
expect(response.parsed_body["categories"].map { |c| c["name"] }).to contain_exactly(
"Uncategorized",
"Foo",
"Foobar",
"Notfoo",
)
end
it "does not return Uncategorized" do
get "/categories/search.json", params: { include_uncategorized: false }
expect(response.parsed_body["categories"].size).to eq(3)
expect(response.parsed_body["categories"].map { |c| c["name"] }).to contain_exactly(
"Foo",
"Foobar",
"Notfoo",
)
end
end
context "with select_category_ids" do
it "returns categories" do
get "/categories/search.json", params: { select_category_ids: [category.id] }
expect(response.parsed_body["categories"].size).to eq(1)
expect(response.parsed_body["categories"].map { |c| c["name"] }).to contain_exactly("Foo")
end
end
context "with reject_category_ids" do
it "returns categories" do
get "/categories/search.json", params: { reject_category_ids: [category2.id] }
expect(response.parsed_body["categories"].size).to eq(3)
expect(response.parsed_body["categories"].map { |c| c["name"] }).to contain_exactly(
"Uncategorized",
"Foo",
"Foobar",
)
end
end
context "with include_subcategories" do
it "returns categories" do
get "/categories/search.json", params: { include_subcategories: false }
expect(response.parsed_body["categories"].size).to eq(3)
expect(response.parsed_body["categories"].map { |c| c["name"] }).to contain_exactly(
"Uncategorized",
"Foo",
"Notfoo",
)
end
it "returns categories and subcategories" do
get "/categories/search.json", params: { include_subcategories: true }
expect(response.parsed_body["categories"].size).to eq(4)
expect(response.parsed_body["categories"].map { |c| c["name"] }).to contain_exactly(
"Uncategorized",
"Foo",
"Foobar",
"Notfoo",
)
end
end
context "with prioritized_category_id" do
it "returns categories" do
get "/categories/search.json", params: { prioritized_category_id: category2.id }
expect(response.parsed_body["categories"].size).to eq(4)
expect(response.parsed_body["categories"][0]["name"]).to eq("Notfoo")
end
end
context "with limit" do
it "returns categories" do
get "/categories/search.json", params: { limit: 2 }
expect(response.parsed_body["categories"].size).to eq(2)
end
end
end
end