diff --git a/app/assets/javascripts/discourse/app/components/user-preferences/categories.hbs b/app/assets/javascripts/discourse/app/components/user-preferences/categories.hbs
index 74160c9b62c..b5a2bb12dbe 100644
--- a/app/assets/javascripts/discourse/app/components/user-preferences/categories.hbs
+++ b/app/assets/javascripts/discourse/app/components/user-preferences/categories.hbs
@@ -9,8 +9,8 @@
}}
{{/if}}
@@ -26,8 +26,8 @@
}}
{{/if}}
@@ -41,8 +41,8 @@
@@ -56,8 +56,8 @@
>
@@ -75,8 +75,8 @@
{{/if}}
diff --git a/app/assets/javascripts/discourse/app/controllers/group-manage-categories.js b/app/assets/javascripts/discourse/app/controllers/group-manage-categories.js
index 239a2eab233..9c056f91be1 100644
--- a/app/assets/javascripts/discourse/app/controllers/group-manage-categories.js
+++ b/app/assets/javascripts/discourse/app/controllers/group-manage-categories.js
@@ -3,15 +3,13 @@ import discourseComputed from "discourse-common/utils/decorators";
export default Controller.extend({
@discourseComputed(
- "model.watchingCategories.[]",
- "model.watchingFirstPostCategories.[]",
- "model.trackingCategories.[]",
- "model.regularCategories.[]",
- "model.mutedCategories.[]"
+ "model.watching_category_ids.[]",
+ "model.watching_first_post_category_ids.[]",
+ "model.tracking_category_ids.[]",
+ "model.regular_category_ids.[]",
+ "model.muted_category_ids.[]"
)
- selectedCategories(watching, watchingFirst, tracking, regular, muted) {
- return []
- .concat(watching, watchingFirst, tracking, regular, muted)
- .filter((t) => t);
+ selectedCategoryIds(watching, watchingFirst, tracking, regular, muted) {
+ return [].concat(watching, watchingFirst, tracking, regular, muted);
},
});
diff --git a/app/assets/javascripts/discourse/app/controllers/preferences/categories.js b/app/assets/javascripts/discourse/app/controllers/preferences/categories.js
index d22c1509c51..3368c5f327b 100644
--- a/app/assets/javascripts/discourse/app/controllers/preferences/categories.js
+++ b/app/assets/javascripts/discourse/app/controllers/preferences/categories.js
@@ -17,28 +17,27 @@ export default Controller.extend({
},
@discourseComputed(
- "siteSettings.mute_all_categories_by_default",
- "model.watchedCategories",
- "model.watchedFirstPostCategories",
- "model.trackedCategories",
- "model.mutedCategories",
- "model.regularCategories"
+ "model.watched_categoriy_ids",
+ "model.watched_first_post_categoriy_ids",
+ "model.tracked_categoriy_ids",
+ "model.muted_categoriy_ids",
+ "model.regular_category_ids",
+ "siteSettings.mute_all_categories_by_default"
)
- selectedCategories(
- muteAllCategoriesByDefault,
+ selectedCategoryIds(
watched,
watchedFirst,
tracked,
muted,
- regular
+ regular,
+ muteAllCategoriesByDefault
) {
- let categories = [].concat(watched, watchedFirst, tracked);
-
- categories = categories.concat(
+ return [].concat(
+ watched,
+ watchedFirst,
+ tracked,
muteAllCategoriesByDefault ? regular : muted
);
-
- return categories.filter((t) => t);
},
@discourseComputed
diff --git a/app/assets/javascripts/discourse/app/controllers/preferences/tracking.js b/app/assets/javascripts/discourse/app/controllers/preferences/tracking.js
index 0277c02c616..7522182373e 100644
--- a/app/assets/javascripts/discourse/app/controllers/preferences/tracking.js
+++ b/app/assets/javascripts/discourse/app/controllers/preferences/tracking.js
@@ -122,24 +122,22 @@ export default class extends Controller {
}
@computed(
- "model.watchedCategories",
- "model.watchedFirstPostCategories",
- "model.trackedCategories",
- "model.mutedCategories",
- "model.regularCategories",
+ "model.watched_category_ids",
+ "model.watched_first_post_category_ids",
+ "model.tracked_category_ids",
+ "model.muted_category_ids",
+ "model.regular_category_ids",
"siteSettings.mute_all_categories_by_default"
)
- get selectedCategories() {
- return []
- .concat(
- this.model.watchedCategories,
- this.model.watchedFirstPostCategories,
- this.model.trackedCategories,
- this.siteSettings.mute_all_categories_by_default
- ? this.model.regularCategories
- : this.model.mutedCategories
- )
- .filter((t) => t);
+ get selectedCategoryIds() {
+ return [].concat(
+ this.model.watched_category_ids,
+ this.model.watched_first_post_category_ids,
+ this.model.tracked_category_ids,
+ this.siteSettings.mute_all_categories_by_default
+ ? this.model.regular_category_ids
+ : this.model.muted_category_ids
+ );
}
@computed("siteSettings.remove_muted_tags_from_latest")
diff --git a/app/assets/javascripts/discourse/app/models/category.js b/app/assets/javascripts/discourse/app/models/category.js
index a72f9ff4537..1308c539f42 100644
--- a/app/assets/javascripts/discourse/app/models/category.js
+++ b/app/assets/javascripts/discourse/app/models/category.js
@@ -462,6 +462,26 @@ Category.reopenClass({
return categories;
},
+ hasAsyncFoundAll(ids) {
+ const loadedCategoryIds = Site.current().loadedCategoryIds || new Set();
+ return ids.every((id) => loadedCategoryIds.has(id));
+ },
+
+ async asyncFindByIds(ids = []) {
+ const result = await ajax("/categories/find", { data: { ids } });
+
+ const categories = result["categories"].map((category) =>
+ Site.current().updateCategory(category)
+ );
+
+ // Update loadedCategoryIds list
+ const loadedCategoryIds = Site.current().loadedCategoryIds || new Set();
+ ids.forEach((id) => loadedCategoryIds.add(id));
+ Site.current().set("loadedCategoryIds", loadedCategoryIds);
+
+ return categories;
+ },
+
findBySlugAndParent(slug, parentCategory) {
if (this.slugEncoded()) {
slug = encodeURI(slug);
@@ -490,18 +510,14 @@ Category.reopenClass({
async asyncFindBySlugPathWithID(slugPathWithID) {
const result = await ajax("/categories/find", {
- data: {
- category_slug_path_with_id: slugPathWithID,
- },
+ data: { slug_path_with_id: slugPathWithID },
});
- if (result["ancestors"]) {
- result["ancestors"].map((category) =>
- Site.current().updateCategory(category)
- );
- }
+ const categories = result["categories"].map((category) =>
+ Site.current().updateCategory(category)
+ );
- return Site.current().updateCategory(result.category);
+ return categories[categories.length - 1];
},
findBySlugPathWithID(slugPathWithID) {
diff --git a/app/assets/javascripts/discourse/app/templates/group/manage/categories.hbs b/app/assets/javascripts/discourse/app/templates/group/manage/categories.hbs
index 351e7acfa82..7dd6411dcb7 100644
--- a/app/assets/javascripts/discourse/app/templates/group/manage/categories.hbs
+++ b/app/assets/javascripts/discourse/app/templates/group/manage/categories.hbs
@@ -11,8 +11,8 @@
{{i18n "groups.notifications.watching.title"}}
@@ -26,8 +26,8 @@
{{i18n "groups.notifications.tracking.title"}}
@@ -41,8 +41,8 @@
{{i18n "groups.notifications.watching_first_post.title"}}
@@ -58,8 +58,8 @@
{{i18n "groups.notifications.regular.title"}}
@@ -73,8 +73,8 @@
{{i18n "groups.notifications.muted.title"}}
diff --git a/app/assets/javascripts/discourse/app/templates/preferences/categories.hbs b/app/assets/javascripts/discourse/app/templates/preferences/categories.hbs
index c9a5517f73f..15e7e0a8732 100644
--- a/app/assets/javascripts/discourse/app/templates/preferences/categories.hbs
+++ b/app/assets/javascripts/discourse/app/templates/preferences/categories.hbs
@@ -1,7 +1,7 @@
diff --git a/app/assets/javascripts/select-kit/addon/components/category-chooser.js b/app/assets/javascripts/select-kit/addon/components/category-chooser.js
index c2fd30649d1..d6a5e56b0e7 100644
--- a/app/assets/javascripts/select-kit/addon/components/category-chooser.js
+++ b/app/assets/javascripts/select-kit/addon/components/category-chooser.js
@@ -24,6 +24,19 @@ export default ComboBoxComponent.extend({
prioritizedCategoryId: null,
},
+ init() {
+ this._super(...arguments);
+
+ if (
+ this.siteSettings.lazy_load_categories &&
+ !Category.hasAsyncFoundAll([this.value])
+ ) {
+ Category.asyncFindByIds([this.value]).then(() => {
+ this.notifyPropertyChange("value");
+ });
+ }
+ },
+
modifyComponentForRow() {
return "category-row";
},
diff --git a/app/assets/javascripts/select-kit/addon/components/category-selector.js b/app/assets/javascripts/select-kit/addon/components/category-selector.js
index 9f3f5221c4d..1f3722f097f 100644
--- a/app/assets/javascripts/select-kit/addon/components/category-selector.js
+++ b/app/assets/javascripts/select-kit/addon/components/category-selector.js
@@ -1,5 +1,4 @@
-import { computed } from "@ember/object";
-import { mapBy } from "@ember/object/computed";
+import { computed, defineProperty } from "@ember/object";
import Category from "discourse/models/category";
import { makeArray } from "discourse-common/lib/helpers";
import MultiSelectComponent from "select-kit/components/multi-select";
@@ -21,16 +20,47 @@ export default MultiSelectComponent.extend({
init() {
this._super(...arguments);
- if (!this.categories) {
- this.set("categories", []);
+ if (this.categories && !this.categoryIds) {
+ defineProperty(
+ this,
+ "categoryIds",
+ computed("categories.[]", function () {
+ return this.categories.map((c) => c.id);
+ })
+ );
}
- if (!this.blockedCategories) {
- this.set("blockedCategories", []);
+
+ if (this.blockedCategories && !this.blockedCategoryIds) {
+ defineProperty(
+ this,
+ "blockedCategoryIds",
+ computed("blockedCategories.[]", function () {
+ return this.blockedCategories.map((c) => c.id);
+ })
+ );
+ } else if (!this.blockedCategoryIds) {
+ this.set("blockedCategoryIds", []);
+ }
+
+ if (this.siteSettings.lazy_load_categories) {
+ const allCategoryIds = [
+ ...new Set([...this.categoryIds, ...this.blockedCategoryIds]),
+ ];
+
+ if (!Category.hasAsyncFoundAll(allCategoryIds)) {
+ Category.asyncFindByIds(allCategoryIds).then(() => {
+ this.notifyPropertyChange("categoryIds");
+ this.notifyPropertyChange("blockedCategoryIds");
+ });
+ }
}
},
- content: computed("categories.[]", "blockedCategories.[]", function () {
- const blockedCategories = makeArray(this.blockedCategories);
+ content: computed("categoryIds.[]", "blockedCategoryIds.[]", function () {
+ if (this.siteSettings.lazy_load_categories) {
+ return Category.findByIds(this.categoryIds);
+ }
+
return Category.list().filter((category) => {
if (category.isUncategorizedCategory) {
if (this.options?.allowUncategorized !== undefined) {
@@ -41,13 +71,15 @@ export default MultiSelectComponent.extend({
}
return (
- this.categories.includes(category) ||
- !blockedCategories.includes(category)
+ this.categoryIds.includes(category.id) ||
+ !this.blockedCategoryIds.includes(category.id)
);
});
}),
- value: mapBy("categories", "id"),
+ value: computed("categoryIds.[]", function () {
+ return this.categoryIds;
+ }),
modifyComponentForRow() {
return "category-row";
@@ -58,15 +90,10 @@ export default MultiSelectComponent.extend({
return this._super(filter);
}
- const rejectCategoryIds = new Set();
- // Reject selected options
- if (this.categories) {
- this.categories.forEach((c) => rejectCategoryIds.add(c.id));
- }
- // Reject blocked categories
- if (this.blockedCategories) {
- this.blockedCategories.forEach((c) => rejectCategoryIds.add(c.id));
- }
+ const rejectCategoryIds = new Set([
+ ...(this.categoryIds || []),
+ ...(this.blockedCategoryIds || []),
+ ]);
return await Category.asyncSearch(filter, {
includeUncategorized:
diff --git a/app/controllers/categories_controller.rb b/app/controllers/categories_controller.rb
index 855491f93a2..c0f94046b6b 100644
--- a/app/controllers/categories_controller.rb
+++ b/app/controllers/categories_controller.rb
@@ -301,21 +301,24 @@ class CategoriesController < ApplicationController
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)
+ categories = []
- ancestors = Category.secured(guardian).with_ancestors(category.id).where.not(id: category.id)
+ if params[:ids].present?
+ categories = Category.secured(guardian).where(id: params[:ids])
+ elsif params[:slug_path_with_id].present?
+ category = Category.find_by_slug_path_with_id(params[:slug_path_with_id])
+ raise Discourse::NotFound if category.blank?
+ guardian.ensure_can_see!(category)
- render json: {
- category: SiteCategorySerializer.new(category, scope: guardian, root: nil),
- ancestors:
- ActiveModel::ArraySerializer.new(
- ancestors,
- scope: guardian,
- each_serializer: SiteCategorySerializer,
- ),
- }
+ ancestors = Category.secured(guardian).with_ancestors(category.id).where.not(id: category.id)
+ categories = [*ancestors, category]
+ end
+
+ raise Discourse::NotFound if categories.blank?
+
+ Category.preload_user_fields!(guardian, categories)
+
+ render_serialized(categories, SiteCategorySerializer, root: :categories, scope: guardian)
end
def search
@@ -382,7 +385,9 @@ class CategoriesController < ApplicationController
categories = categories.order(:id)
- render json: categories, each_serializer: SiteCategorySerializer
+ Category.preload_user_fields!(guardian, categories)
+
+ render_serialized(categories, SiteCategorySerializer, root: :categories, scope: guardian)
end
private
diff --git a/app/models/category.rb b/app/models/category.rb
index 69d1cf35078..ae393d7f93c 100644
--- a/app/models/category.rb
+++ b/app/models/category.rb
@@ -109,7 +109,6 @@ class Category < ActiveRecord::Base
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
@@ -130,7 +129,6 @@ class Category < ActiveRecord::Base
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
@@ -227,6 +225,34 @@ class Category < ActiveRecord::Base
# Allows us to skip creating the category definition topic in tests.
attr_accessor :skip_category_definition
+ def self.preload_user_fields!(guardian, categories)
+ category_ids = categories.map(&:id)
+
+ # Load notification levels
+ notification_levels = CategoryUser.notification_levels_for(guardian.user)
+ notification_levels.default = CategoryUser.default_notification_level
+
+ # Load permissions
+ allowed_topic_create_ids =
+ if !guardian.is_admin? && !guardian.is_anonymous?
+ Category.topic_create_allowed(guardian).where(id: category_ids).pluck(:id).to_set
+ end
+
+ # Categories with children
+ with_children =
+ Category.where(parent_category_id: category_ids).pluck(:parent_category_id).to_set
+
+ # Update category attributes
+ categories.each do |category|
+ category.notification_level = notification_levels[category[:id]]
+
+ category.permission = CategoryGroup.permission_types[:full] if guardian.is_admin? ||
+ allowed_topic_create_ids&.include?(category[:id])
+
+ category.has_children = with_children.include?(category[:id])
+ end
+ end
+
def self.topic_id_cache
@topic_id_cache ||= DistributedCache.new("category_topic_ids")
end
@@ -859,24 +885,9 @@ 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?
- !!id && Category.has_children?(id)
- end
-
- def self.clear_parent_ids
- @@has_children.clear
- end
-
- def clear_parent_ids
- Category.clear_parent_ids
+ @has_children ||= (id && Category.where(parent_category_id: id).exists?) ? :true : :false
+ @has_children == :true
end
def uncategorized?
diff --git a/app/serializers/basic_category_serializer.rb b/app/serializers/basic_category_serializer.rb
index 2bd0bcf0c38..2ec27126888 100644
--- a/app/serializers/basic_category_serializer.rb
+++ b/app/serializers/basic_category_serializer.rb
@@ -19,7 +19,7 @@ class BasicCategorySerializer < ApplicationSerializer
:notification_level,
:can_edit,
:topic_template,
- :has_children?,
+ :has_children,
:sort_order,
:sort_ascending,
:show_subcategory_list,
diff --git a/spec/models/category_spec.rb b/spec/models/category_spec.rb
index 7944182bd96..4617cd45349 100644
--- a/spec/models/category_spec.rb
+++ b/spec/models/category_spec.rb
@@ -1298,8 +1298,6 @@ 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)
diff --git a/spec/requests/api/schemas/json/category_create_response.json b/spec/requests/api/schemas/json/category_create_response.json
index d7c7e400a0d..e48af843f66 100644
--- a/spec/requests/api/schemas/json/category_create_response.json
+++ b/spec/requests/api/schemas/json/category_create_response.json
@@ -79,7 +79,10 @@
"items": {}
},
"has_children": {
- "type": "boolean"
+ "type": [
+ "boolean",
+ "null"
+ ]
},
"sort_order": {
"type": [
diff --git a/spec/requests/api/schemas/json/category_update_response.json b/spec/requests/api/schemas/json/category_update_response.json
index 04bccf8f889..08a6611e02d 100644
--- a/spec/requests/api/schemas/json/category_update_response.json
+++ b/spec/requests/api/schemas/json/category_update_response.json
@@ -61,7 +61,7 @@
},
"permission": {
"type": [
- "string",
+ "integer",
"null"
]
},
@@ -82,7 +82,10 @@
"items": {}
},
"has_children": {
- "type": "boolean"
+ "type": [
+ "boolean",
+ "null"
+ ]
},
"sort_order": {
"type": [
diff --git a/spec/requests/categories_controller_spec.rb b/spec/requests/categories_controller_spec.rb
index 2c2484b4f1e..aebd307ee54 100644
--- a/spec/requests/categories_controller_spec.rb
+++ b/spec/requests/categories_controller_spec.rb
@@ -1044,24 +1044,64 @@ RSpec.describe CategoriesController 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}",
- }
+ context "with ids" do
+ it "returns the categories" do
+ get "/categories/find.json", params: { ids: [subcategory.id] }
- expect(response.parsed_body["category"]["id"]).to eq(category.id)
- expect(response.parsed_body["ancestors"]).to eq([])
+ expect(response.parsed_body["categories"].map { |c| c["id"] }).to eq([subcategory.id])
+ end
+
+ it "does not return hidden category" do
+ category.update!(read_restricted: true)
+
+ get "/categories/find.json", params: { ids: [123_456_789] }
+
+ expect(response.status).to eq(404)
+ end
end
- it "returns the subcategory and ancestors" do
- get "/categories/find.json",
- params: {
- category_slug_path_with_id: "#{subcategory.slug}/#{subcategory.id}",
- }
+ context "with slug path" do
+ it "returns the category" do
+ get "/categories/find.json",
+ params: {
+ slug_path_with_id: "#{category.slug}/#{category.id}",
+ }
- expect(response.parsed_body["category"]["id"]).to eq(subcategory.id)
- expect(response.parsed_body["ancestors"].map { |c| c["id"] }).to eq([category.id])
+ expect(response.parsed_body["categories"].map { |c| c["id"] }).to eq([category.id])
+ end
+
+ it "returns the subcategory and ancestors" do
+ get "/categories/find.json",
+ params: {
+ slug_path_with_id: "#{subcategory.slug}/#{subcategory.id}",
+ }
+
+ expect(response.parsed_body["categories"].map { |c| c["id"] }).to eq(
+ [category.id, subcategory.id],
+ )
+ end
+
+ it "does not return hidden category" do
+ category.update!(read_restricted: true)
+
+ get "/categories/find.json",
+ params: {
+ slug_path_with_id: "#{category.slug}/#{category.id}",
+ }
+
+ expect(response.status).to eq(403)
+ end
+ end
+
+ it "returns user fields" do
+ sign_in(admin)
+
+ get "/categories/find.json", params: { slug_path_with_id: "#{category.slug}/#{category.id}" }
+
+ category = response.parsed_body["categories"].first
+ expect(category["notification_level"]).to eq(NotificationLevels.all[:regular])
+ expect(category["permission"]).to eq(CategoryGroup.permission_types[:full])
+ expect(category["has_children"]).to eq(true)
end
end
@@ -1197,5 +1237,16 @@ RSpec.describe CategoriesController do
expect(response.parsed_body["categories"].size).to eq(2)
end
end
+
+ it "returns user fields" do
+ sign_in(admin)
+
+ get "/categories/search.json", params: { select_category_ids: [category.id] }
+
+ category = response.parsed_body["categories"].first
+ expect(category["notification_level"]).to eq(NotificationLevels.all[:regular])
+ expect(category["permission"]).to eq(CategoryGroup.permission_types[:full])
+ expect(category["has_children"]).to eq(true)
+ end
end
end