mirror of
https://github.com/discourse/discourse.git
synced 2025-02-25 07:08:44 +00: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:
parent
60ae69027c
commit
2e68ead45b
@ -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}`;
|
||||
}
|
||||
|
@ -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;
|
||||
|
@ -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) => {
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -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) {
|
||||
|
@ -16,10 +16,6 @@
|
||||
.topic-count {
|
||||
margin-left: 0.25em;
|
||||
}
|
||||
|
||||
.plus-subcategories {
|
||||
font-size: var(--font-down-2);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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}"
|
||||
|
@ -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"
|
||||
|
@ -2176,6 +2176,7 @@ developer:
|
||||
hidden: true
|
||||
lazy_load_categories:
|
||||
default: false
|
||||
client: true
|
||||
hidden: true
|
||||
|
||||
navigation:
|
||||
|
@ -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
|
||||
|
Loading…
x
Reference in New Issue
Block a user