FIX: Make category-drop work with lazy_load_categories (#24187)
The category drop was rerendered after every category async change because it updated the categories list. This is not necessary and categories can be referenced indirectly by ID instead.
This commit is contained in:
parent
21d614215b
commit
e85a81f33c
|
@ -10,20 +10,7 @@ export default Component.extend({
|
|||
editingCategory: false,
|
||||
editingCategoryTab: null,
|
||||
|
||||
@discourseComputed("categories")
|
||||
filteredCategories(categories) {
|
||||
return categories.filter(
|
||||
(category) =>
|
||||
this.siteSettings.allow_uncategorized_topics ||
|
||||
category.id !== this.site.uncategorized_category_id
|
||||
);
|
||||
},
|
||||
|
||||
@discourseComputed(
|
||||
"category.ancestors",
|
||||
"filteredCategories",
|
||||
"noSubcategories"
|
||||
)
|
||||
@discourseComputed("category.ancestors", "categories", "noSubcategories")
|
||||
categoryBreadcrumbs(categoryAncestors, filteredCategories, noSubcategories) {
|
||||
categoryAncestors = categoryAncestors || [];
|
||||
const parentCategories = [undefined, ...categoryAncestors];
|
||||
|
@ -44,7 +31,7 @@ export default Component.extend({
|
|||
options,
|
||||
isSubcategory: !!parentCategory,
|
||||
noSubcategories: !category && noSubcategories,
|
||||
hasOptions: options.length !== 0,
|
||||
hasOptions: !parentCategory || parentCategory.has_children,
|
||||
};
|
||||
});
|
||||
},
|
||||
|
|
|
@ -22,16 +22,24 @@ export default Component.extend({
|
|||
|
||||
// Should be a `readOnly` instead but some themes/plugins still pass
|
||||
// the `categories` property into this component
|
||||
@discourseComputed("site.categoriesList")
|
||||
categories(categoriesList) {
|
||||
@discourseComputed()
|
||||
categories() {
|
||||
let categories = this.site.categoriesList;
|
||||
|
||||
if (!this.siteSettings.allow_uncategorized_topics) {
|
||||
categories = categories.filter(
|
||||
(category) => category.id !== this.site.uncategorized_category_id
|
||||
);
|
||||
}
|
||||
|
||||
if (this.currentUser?.indirectly_muted_category_ids) {
|
||||
return categoriesList.filter(
|
||||
categories = categories.filter(
|
||||
(category) =>
|
||||
!this.currentUser.indirectly_muted_category_ids.includes(category.id)
|
||||
);
|
||||
} else {
|
||||
return categoriesList;
|
||||
}
|
||||
|
||||
return categories;
|
||||
},
|
||||
|
||||
@discourseComputed("category")
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import { get } from "@ember/object";
|
||||
import { htmlSafe } from "@ember/template";
|
||||
import categoryVariables from "discourse/helpers/category-variables";
|
||||
import { isRTL } from "discourse/lib/text-direction";
|
||||
import { escapeExpression } from "discourse/lib/utilities";
|
||||
import Category from "discourse/models/category";
|
||||
|
@ -180,5 +181,9 @@ export function defaultCategoryLinkRenderer(category, opts) {
|
|||
})}
|
||||
</span>`;
|
||||
}
|
||||
return `<${tagName} class="badge-category__wrapper ${extraClasses}" ${href}>${html}</${tagName}>${afterBadgeWrapper}`;
|
||||
|
||||
const style = categoryVariables(category);
|
||||
const extraAttrs = style.string ? `style="${style}"` : "";
|
||||
|
||||
return `<${tagName} class="badge-category__wrapper ${extraClasses}" ${extraAttrs} ${href}>${html}</${tagName}>${afterBadgeWrapper}`;
|
||||
}
|
||||
|
|
|
@ -488,6 +488,22 @@ Category.reopenClass({
|
|||
return category;
|
||||
},
|
||||
|
||||
async asyncFindBySlugPathWithID(slugPathWithID) {
|
||||
const result = await ajax("/categories/find", {
|
||||
data: {
|
||||
category_slug_path_with_id: slugPathWithID,
|
||||
},
|
||||
});
|
||||
|
||||
if (result["ancestors"]) {
|
||||
result["ancestors"].map((category) =>
|
||||
Site.current().updateCategory(category)
|
||||
);
|
||||
}
|
||||
|
||||
return Site.current().updateCategory(result.category);
|
||||
},
|
||||
|
||||
findBySlugPathWithID(slugPathWithID) {
|
||||
let parts = slugPathWithID.split("/").filter(Boolean);
|
||||
// slugs found by star/glob pathing in ember do not automatically url decode - ensure that these are decoded
|
||||
|
|
|
@ -124,6 +124,10 @@ const Site = RestModel.extend({
|
|||
newCategory = this.store.createRecord("category", newCategory);
|
||||
categories.pushObject(newCategory);
|
||||
this.categoriesById[categoryId] = newCategory;
|
||||
newCategory.set(
|
||||
"parentCategory",
|
||||
this.categoriesById[newCategory.parent_category_id]
|
||||
);
|
||||
return newCategory;
|
||||
}
|
||||
},
|
||||
|
|
|
@ -18,6 +18,7 @@ import I18n from "discourse-i18n";
|
|||
class AbstractCategoryRoute extends DiscourseRoute {
|
||||
@service composer;
|
||||
@service router;
|
||||
@service siteSettings;
|
||||
@service store;
|
||||
@service topicTrackingState;
|
||||
@service("search") searchService;
|
||||
|
@ -29,9 +30,11 @@ class AbstractCategoryRoute extends DiscourseRoute {
|
|||
controllerName = "discovery/list";
|
||||
|
||||
async model(params, transition) {
|
||||
const category = Category.findBySlugPathWithID(
|
||||
params.category_slug_path_with_id
|
||||
);
|
||||
const category = this.siteSettings.lazy_load_categories
|
||||
? await Category.asyncFindBySlugPathWithID(
|
||||
params.category_slug_path_with_id
|
||||
)
|
||||
: Category.findBySlugPathWithID(params.category_slug_path_with_id);
|
||||
|
||||
if (!category) {
|
||||
this.router.replaceWith("/404");
|
||||
|
|
|
@ -138,6 +138,7 @@ export default {
|
|||
show_subcategory_list: true,
|
||||
default_view: "latest",
|
||||
subcategory_list_style: "boxes_with_featured_topics",
|
||||
has_children: true,
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
|
@ -158,6 +159,7 @@ export default {
|
|||
background_url: null,
|
||||
show_subcategory_list: false,
|
||||
default_view: "latest",
|
||||
has_children: true,
|
||||
},
|
||||
{
|
||||
id: 24,
|
||||
|
@ -309,6 +311,7 @@ export default {
|
|||
notification_level: null,
|
||||
show_subcategory_list: false,
|
||||
default_view: "latest",
|
||||
has_children: true,
|
||||
},
|
||||
{
|
||||
id: 11,
|
||||
|
@ -463,6 +466,7 @@ export default {
|
|||
default_view: "latest",
|
||||
subcategory_list_style: "boxes",
|
||||
default_list_filter: "all",
|
||||
has_children: true,
|
||||
},
|
||||
{
|
||||
id: 240,
|
||||
|
@ -516,6 +520,7 @@ export default {
|
|||
parent_category_id: null,
|
||||
notification_level: null,
|
||||
background_url: null,
|
||||
has_children: true,
|
||||
},
|
||||
{
|
||||
id: 1002,
|
||||
|
@ -533,6 +538,7 @@ export default {
|
|||
parent_category_id: 1001,
|
||||
notification_level: null,
|
||||
background_url: null,
|
||||
has_children: true,
|
||||
},
|
||||
{
|
||||
id: 1003,
|
||||
|
|
|
@ -54,8 +54,7 @@ export default ComboBoxComponent.extend({
|
|||
return this.options.subCategory || false;
|
||||
}),
|
||||
|
||||
categoriesWithShortcuts: computed(
|
||||
"categories.[]",
|
||||
shortcuts: computed(
|
||||
"value",
|
||||
"selectKit.options.{subCategory,noSubcategories}",
|
||||
function () {
|
||||
|
@ -82,11 +81,15 @@ export default ComboBoxComponent.extend({
|
|||
});
|
||||
}
|
||||
|
||||
const results = this._filterUncategorized(this.categories || []);
|
||||
return shortcuts.concat(results);
|
||||
return shortcuts;
|
||||
}
|
||||
),
|
||||
|
||||
categoriesWithShortcuts: computed("categories.[]", "shortcuts", function () {
|
||||
const results = this._filterUncategorized(this.categories || []);
|
||||
return this.shortcuts.concat(results);
|
||||
}),
|
||||
|
||||
modifyNoSelection() {
|
||||
if (this.selectKit.options.noSubcategories) {
|
||||
return this.defaultItem(NO_CATEGORIES_ID, this.noCategoriesLabel);
|
||||
|
@ -138,15 +141,17 @@ export default ComboBoxComponent.extend({
|
|||
|
||||
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;
|
||||
}
|
||||
});
|
||||
return this.shortcuts.concat(
|
||||
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) {
|
||||
|
|
|
@ -11,6 +11,7 @@ class CategoriesController < ApplicationController
|
|||
redirect
|
||||
find_by_slug
|
||||
visible_groups
|
||||
find
|
||||
search
|
||||
]
|
||||
|
||||
|
@ -299,6 +300,24 @@ class CategoriesController < ApplicationController
|
|||
render json: success_json.merge(groups: groups || [])
|
||||
end
|
||||
|
||||
def find
|
||||
category = Category.find_by_slug_path_with_id(params[:category_slug_path_with_id])
|
||||
raise Discourse::NotFound if category.blank?
|
||||
guardian.ensure_can_see!(category)
|
||||
|
||||
ancestors = Category.secured(guardian).with_ancestors(category.id).where.not(id: category.id)
|
||||
|
||||
render json: {
|
||||
category: SiteCategorySerializer.new(category, scope: guardian, root: nil),
|
||||
ancestors:
|
||||
ActiveModel::ArraySerializer.new(
|
||||
ancestors,
|
||||
scope: guardian,
|
||||
each_serializer: SiteCategorySerializer,
|
||||
),
|
||||
}
|
||||
end
|
||||
|
||||
def search
|
||||
term = params[:term].to_s.strip
|
||||
parent_category_id = params[:parent_category_id].to_i if params[:parent_category_id].present?
|
||||
|
@ -334,10 +353,7 @@ class CategoriesController < ApplicationController
|
|||
) if term.present?
|
||||
|
||||
categories =
|
||||
categories.where(
|
||||
"id = :id OR parent_category_id = :id",
|
||||
id: parent_category_id,
|
||||
) if parent_category_id.present?
|
||||
categories.where(parent_category_id: parent_category_id) if parent_category_id.present?
|
||||
|
||||
categories =
|
||||
categories.where.not(id: SiteSetting.uncategorized_category_id) if !include_uncategorized
|
||||
|
@ -358,7 +374,7 @@ class CategoriesController < ApplicationController
|
|||
END
|
||||
SQL
|
||||
|
||||
categories.order(:id)
|
||||
categories = categories.order(:id)
|
||||
|
||||
render json: categories, each_serializer: SiteCategorySerializer
|
||||
end
|
||||
|
|
|
@ -107,12 +107,13 @@ class Category < ActiveRecord::Base
|
|||
before_save :downcase_name
|
||||
before_save :ensure_category_setting
|
||||
|
||||
after_save :publish_discourse_stylesheet
|
||||
after_save :publish_category
|
||||
after_save :reset_topic_ids_cache
|
||||
after_save :clear_subcategory_ids
|
||||
after_save :clear_parent_ids
|
||||
after_save :clear_url_cache
|
||||
after_save :update_reviewables
|
||||
after_save :publish_discourse_stylesheet
|
||||
after_save :publish_category
|
||||
|
||||
after_save do
|
||||
if saved_change_to_uploaded_logo_id? || saved_change_to_uploaded_logo_dark_id? ||
|
||||
|
@ -128,6 +129,8 @@ class Category < ActiveRecord::Base
|
|||
end
|
||||
|
||||
after_destroy :reset_topic_ids_cache
|
||||
after_destroy :clear_subcategory_ids
|
||||
after_destroy :clear_parent_ids
|
||||
after_destroy :publish_category_deletion
|
||||
after_destroy :remove_site_settings
|
||||
|
||||
|
@ -197,6 +200,19 @@ class Category < ActiveRecord::Base
|
|||
scope :post_create_allowed,
|
||||
->(guardian) { scoped_to_permissions(guardian, POST_CREATION_PERMISSIONS) }
|
||||
|
||||
scope :with_ancestors, ->(id) { where(<<~SQL, id) }
|
||||
id IN (
|
||||
WITH RECURSIVE ancestors(category_id) AS (
|
||||
SELECT ?
|
||||
UNION
|
||||
SELECT parent_category_id
|
||||
FROM categories, ancestors
|
||||
WHERE id = ancestors.category_id
|
||||
)
|
||||
SELECT category_id FROM ancestors
|
||||
)
|
||||
SQL
|
||||
|
||||
delegate :post_template, to: "self.class"
|
||||
|
||||
# permission is just used by serialization
|
||||
|
@ -843,9 +859,24 @@ class Category < ActiveRecord::Base
|
|||
self.where("string_to_array(email_in, '|') @> ARRAY[?]", Email.downcase(email)).first
|
||||
end
|
||||
|
||||
@@has_children = DistributedCache.new("has_children")
|
||||
|
||||
def self.has_children?(category_id)
|
||||
@@has_children.defer_get_set(category_id.to_s) do
|
||||
Category.where(parent_category_id: category_id).exists?
|
||||
end
|
||||
end
|
||||
|
||||
def has_children?
|
||||
@has_children ||= (id && Category.where(parent_category_id: id).exists?) ? :true : :false
|
||||
@has_children == :true
|
||||
!!id && Category.has_children?(id)
|
||||
end
|
||||
|
||||
def self.clear_parent_ids
|
||||
@@has_children.clear
|
||||
end
|
||||
|
||||
def clear_parent_ids
|
||||
Category.clear_parent_ids
|
||||
end
|
||||
|
||||
def uncategorized?
|
||||
|
|
|
@ -5,7 +5,7 @@ class Site
|
|||
include ActiveModel::Serialization
|
||||
|
||||
# Number of categories preloaded when lazy_load_categories is enabled
|
||||
LAZY_LOAD_CATEGORIES_LIMIT = 50
|
||||
LAZY_LOAD_CATEGORIES_LIMIT = 10
|
||||
|
||||
cattr_accessor :preloaded_category_custom_fields
|
||||
|
||||
|
@ -120,10 +120,6 @@ class Site
|
|||
)
|
||||
categories << category
|
||||
end
|
||||
|
||||
if SiteSetting.lazy_load_categories && categories.size >= Site::LAZY_LOAD_CATEGORIES_LIMIT
|
||||
break
|
||||
end
|
||||
end
|
||||
|
||||
with_children = Set.new
|
||||
|
@ -161,7 +157,11 @@ class Site
|
|||
|
||||
self.class.categories_callbacks.each { |callback| callback.call(categories, @guardian) }
|
||||
|
||||
categories
|
||||
if SiteSetting.lazy_load_categories
|
||||
categories[0...Site::LAZY_LOAD_CATEGORIES_LIMIT]
|
||||
else
|
||||
categories
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
@ -19,7 +19,7 @@ class BasicCategorySerializer < ApplicationSerializer
|
|||
:notification_level,
|
||||
:can_edit,
|
||||
:topic_template,
|
||||
:has_children,
|
||||
:has_children?,
|
||||
:sort_order,
|
||||
:sort_ascending,
|
||||
:show_subcategory_list,
|
||||
|
|
|
@ -1160,6 +1160,7 @@ Discourse::Application.routes.draw do
|
|||
|
||||
resources :categories, except: %i[show new edit]
|
||||
post "categories/reorder" => "categories#reorder"
|
||||
get "categories/find" => "categories#find"
|
||||
get "categories/search" => "categories#search"
|
||||
|
||||
scope path: "category/:category_id" do
|
||||
|
|
|
@ -1298,6 +1298,8 @@ RSpec.describe Category do
|
|||
let(:guardian) { Guardian.new(admin) }
|
||||
fab!(:category)
|
||||
|
||||
before { Category.clear_parent_ids }
|
||||
|
||||
describe "when category is uncategorized" do
|
||||
it "should return the reason" do
|
||||
category = Category.find(SiteSetting.uncategorized_category_id)
|
||||
|
|
|
@ -79,10 +79,7 @@
|
|||
"items": {}
|
||||
},
|
||||
"has_children": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
"type": "boolean"
|
||||
},
|
||||
"sort_order": {
|
||||
"type": [
|
||||
|
|
|
@ -82,10 +82,7 @@
|
|||
"items": {}
|
||||
},
|
||||
"has_children": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
"type": "boolean"
|
||||
},
|
||||
"sort_order": {
|
||||
"type": [
|
||||
|
|
|
@ -1040,6 +1040,31 @@ RSpec.describe CategoriesController do
|
|||
end
|
||||
end
|
||||
|
||||
describe "#find" do
|
||||
fab!(:category) { Fabricate(:category, name: "Foo") }
|
||||
fab!(:subcategory) { Fabricate(:category, name: "Foobar", parent_category: category) }
|
||||
|
||||
it "returns the category" do
|
||||
get "/categories/find.json",
|
||||
params: {
|
||||
category_slug_path_with_id: "#{category.slug}/#{category.id}",
|
||||
}
|
||||
|
||||
expect(response.parsed_body["category"]["id"]).to eq(category.id)
|
||||
expect(response.parsed_body["ancestors"]).to eq([])
|
||||
end
|
||||
|
||||
it "returns the subcategory and ancestors" do
|
||||
get "/categories/find.json",
|
||||
params: {
|
||||
category_slug_path_with_id: "#{subcategory.slug}/#{subcategory.id}",
|
||||
}
|
||||
|
||||
expect(response.parsed_body["category"]["id"]).to eq(subcategory.id)
|
||||
expect(response.parsed_body["ancestors"].map { |c| c["id"] }).to eq([category.id])
|
||||
end
|
||||
end
|
||||
|
||||
describe "#search" do
|
||||
fab!(:category) { Fabricate(:category, name: "Foo") }
|
||||
fab!(:subcategory) { Fabricate(:category, name: "Foobar", parent_category: category) }
|
||||
|
@ -1066,9 +1091,8 @@ RSpec.describe CategoriesController 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"].size).to eq(1)
|
||||
expect(response.parsed_body["categories"].map { |c| c["name"] }).to contain_exactly(
|
||||
"Foo",
|
||||
"Foobar",
|
||||
)
|
||||
end
|
||||
|
|
Loading…
Reference in New Issue