mirror of
https://github.com/discourse/discourse.git
synced 2025-06-14 16:56:35 +08:00
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:
@ -185,11 +185,5 @@ export function defaultCategoryLinkRenderer(category, opts) {
|
|||||||
if (opts.topicCount && categoryStyle === "box") {
|
if (opts.topicCount && categoryStyle === "box") {
|
||||||
afterBadgeWrapper += buildTopicCount(opts.topicCount);
|
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}`;
|
return `<${tagName} class="badge-wrapper ${extraClasses}" ${href}>${html}</${tagName}>${afterBadgeWrapper}`;
|
||||||
}
|
}
|
||||||
|
@ -652,6 +652,27 @@ Category.reopenClass({
|
|||||||
|
|
||||||
return data.sortBy("read_restricted");
|
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;
|
export default Category;
|
||||||
|
@ -76,6 +76,13 @@ export default ComboBoxComponent.extend({
|
|||||||
},
|
},
|
||||||
|
|
||||||
search(filter) {
|
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) {
|
if (filter) {
|
||||||
filter = this._normalize(filter);
|
filter = this._normalize(filter);
|
||||||
return this.content.filter((item) => {
|
return this.content.filter((item) => {
|
||||||
|
@ -132,13 +132,28 @@ export default ComboBoxComponent.extend({
|
|||||||
}
|
}
|
||||||
),
|
),
|
||||||
|
|
||||||
search(filter) {
|
async search(filter) {
|
||||||
if (filter) {
|
const opts = {
|
||||||
let opts = {
|
parentCategoryId: this.options.parentCategory?.id,
|
||||||
parentCategoryId: this.options.parentCategory?.id,
|
includeUncategorized: this.siteSettings.allow_uncategorized_topics,
|
||||||
};
|
};
|
||||||
let results = Category.search(filter, opts);
|
|
||||||
results = this._filterUncategorized(results).sort((a, b) => {
|
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) {
|
if (a.parent_category_id && !b.parent_category_id) {
|
||||||
return 1;
|
return 1;
|
||||||
} else if (!a.parent_category_id && b.parent_category_id) {
|
} else if (!a.parent_category_id && b.parent_category_id) {
|
||||||
@ -147,7 +162,6 @@ export default ComboBoxComponent.extend({
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return results;
|
|
||||||
} else {
|
} else {
|
||||||
return this._filterUncategorized(this.content);
|
return this._filterUncategorized(this.content);
|
||||||
}
|
}
|
||||||
|
@ -1,10 +1,7 @@
|
|||||||
import EmberObject, { computed } from "@ember/object";
|
import { computed } from "@ember/object";
|
||||||
import { mapBy } from "@ember/object/computed";
|
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 Category from "discourse/models/category";
|
||||||
import { makeArray } from "discourse-common/lib/helpers";
|
import { makeArray } from "discourse-common/lib/helpers";
|
||||||
import I18n from "I18n";
|
|
||||||
import MultiSelectComponent from "select-kit/components/multi-select";
|
import MultiSelectComponent from "select-kit/components/multi-select";
|
||||||
|
|
||||||
export default MultiSelectComponent.extend({
|
export default MultiSelectComponent.extend({
|
||||||
@ -56,42 +53,21 @@ export default MultiSelectComponent.extend({
|
|||||||
return "category-row";
|
return "category-row";
|
||||||
},
|
},
|
||||||
|
|
||||||
search(filter) {
|
async search(filter) {
|
||||||
const result = this._super(filter);
|
return this.siteSettings.lazy_load_categories
|
||||||
if (result.length === 1) {
|
? await Category.asyncSearch(filter, {
|
||||||
const subcategoryIds = new Set([result[0].id]);
|
includeUncategorized:
|
||||||
for (let i = 0; i < this.siteSettings.max_category_nesting; ++i) {
|
this.attrs.options?.allowUncategorized !== undefined
|
||||||
subcategoryIds.forEach((categoryId) => {
|
? this.attrs.options.allowUncategorized
|
||||||
this.content.forEach((category) => {
|
: this.selectKit.options.allowUncategorized,
|
||||||
if (category.parent_category_id === categoryId) {
|
selectCategoryIds: this.categories
|
||||||
subcategoryIds.add(category.id);
|
? this.categories.map((x) => x.id)
|
||||||
}
|
: null,
|
||||||
});
|
rejectCategoryIds: this.blockedCategories
|
||||||
});
|
? this.blockedCategories.map((x) => x.id)
|
||||||
}
|
: null,
|
||||||
|
})
|
||||||
if (subcategoryIds.size > 1) {
|
: this._super(filter);
|
||||||
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;
|
|
||||||
},
|
},
|
||||||
|
|
||||||
select(value, item) {
|
select(value, item) {
|
||||||
|
@ -16,10 +16,6 @@
|
|||||||
.topic-count {
|
.topic-count {
|
||||||
margin-left: 0.25em;
|
margin-left: 0.25em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.plus-subcategories {
|
|
||||||
font-size: var(--font-down-2);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -11,6 +11,7 @@ class CategoriesController < ApplicationController
|
|||||||
redirect
|
redirect
|
||||||
find_by_slug
|
find_by_slug
|
||||||
visible_groups
|
visible_groups
|
||||||
|
search
|
||||||
]
|
]
|
||||||
|
|
||||||
before_action :fetch_category, only: %i[show update destroy visible_groups]
|
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
|
SYMMETRICAL_CATEGORIES_TO_TOPICS_FACTOR = 1.5
|
||||||
MIN_CATEGORIES_TOPICS = 5
|
MIN_CATEGORIES_TOPICS = 5
|
||||||
|
MAX_CATEGORIES_LIMIT = 25
|
||||||
|
|
||||||
def redirect
|
def redirect
|
||||||
return if handle_permalink("/category/#{params[:path]}")
|
return if handle_permalink("/category/#{params[:path]}")
|
||||||
@ -297,6 +299,69 @@ class CategoriesController < ApplicationController
|
|||||||
render json: success_json.merge(groups: groups || [])
|
render json: success_json.merge(groups: groups || [])
|
||||||
end
|
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
|
private
|
||||||
|
|
||||||
def self.topics_per_page
|
def self.topics_per_page
|
||||||
|
@ -2312,12 +2312,6 @@ en:
|
|||||||
topic_count:
|
topic_count:
|
||||||
one: "%{count} topic in this category"
|
one: "%{count} topic in this category"
|
||||||
other: "%{count} topics 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:
|
select_kit:
|
||||||
delete_item: "Delete %{name}"
|
delete_item: "Delete %{name}"
|
||||||
|
@ -1155,6 +1155,7 @@ Discourse::Application.routes.draw do
|
|||||||
|
|
||||||
resources :categories, except: %i[show new edit]
|
resources :categories, except: %i[show new edit]
|
||||||
post "categories/reorder" => "categories#reorder"
|
post "categories/reorder" => "categories#reorder"
|
||||||
|
get "categories/search" => "categories#search"
|
||||||
|
|
||||||
scope path: "category/:category_id" do
|
scope path: "category/:category_id" do
|
||||||
post "/move" => "categories#move"
|
post "/move" => "categories#move"
|
||||||
|
@ -2176,6 +2176,7 @@ developer:
|
|||||||
hidden: true
|
hidden: true
|
||||||
lazy_load_categories:
|
lazy_load_categories:
|
||||||
default: false
|
default: false
|
||||||
|
client: true
|
||||||
hidden: true
|
hidden: true
|
||||||
|
|
||||||
navigation:
|
navigation:
|
||||||
|
@ -1039,4 +1039,128 @@ RSpec.describe CategoriesController do
|
|||||||
expect(response.parsed_body["groups"]).to eq([])
|
expect(response.parsed_body["groups"]).to eq([])
|
||||||
end
|
end
|
||||||
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
|
end
|
||||||
|
Reference in New Issue
Block a user