diff --git a/app/assets/javascripts/discourse/app/components/edit-category-general.hbs b/app/assets/javascripts/discourse/app/components/edit-category-general.hbs index ab7d6ed51f2..eeb9ec837f9 100644 --- a/app/assets/javascripts/discourse/app/components/edit-category-general.hbs +++ b/app/assets/javascripts/discourse/app/components/edit-category-general.hbs @@ -19,13 +19,13 @@ diff --git a/app/assets/javascripts/discourse/app/models/category.js b/app/assets/javascripts/discourse/app/models/category.js index 64fcca5948a..b3b802dd0ee 100644 --- a/app/assets/javascripts/discourse/app/models/category.js +++ b/app/assets/javascripts/discourse/app/models/category.js @@ -176,6 +176,25 @@ export default class Category extends RestModel { return category; } + static async asyncFindBySlugPath(slugPath, opts = {}) { + const data = { slug_path: slugPath }; + if (opts.includePermissions) { + data.include_permissions = true; + } + + const result = await ajax("/categories/find", { data }); + + const categories = result["categories"].map((category) => { + category = Site.current().updateCategory(category); + if (opts.includePermissions) { + category.setupGroupsAndPermissions(); + } + return category; + }); + + return categories[categories.length - 1]; + } + static async asyncFindBySlugPathWithID(slugPathWithID) { const result = await ajax("/categories/find", { data: { slug_path_with_id: slugPathWithID }, diff --git a/app/assets/javascripts/discourse/app/routes/edit-category.js b/app/assets/javascripts/discourse/app/routes/edit-category.js index 9d9d818b6ef..8ada365d503 100644 --- a/app/assets/javascripts/discourse/app/routes/edit-category.js +++ b/app/assets/javascripts/discourse/app/routes/edit-category.js @@ -7,11 +7,9 @@ export default DiscourseRoute.extend({ router: service(), model(params) { - return Category.reloadCategoryWithPermissions( - params, - this.store, - this.site - ); + return this.site.lazy_load_categories + ? Category.asyncFindBySlugPath(params.slug, { includePermissions: true }) + : Category.reloadCategoryWithPermissions(params, this.store, this.site); }, afterModel(model) { diff --git a/app/assets/javascripts/discourse/tests/acceptance/category-edit-test.js b/app/assets/javascripts/discourse/tests/acceptance/category-edit-test.js index dfc8df543d7..14a5eda0a7b 100644 --- a/app/assets/javascripts/discourse/tests/acceptance/category-edit-test.js +++ b/app/assets/javascripts/discourse/tests/acceptance/category-edit-test.js @@ -145,6 +145,36 @@ acceptance("Category Edit", function (needs) { assert.deepEqual(removePayload.allowed_tag_groups, []); }); + test("Editing parent category (disabled Uncategorized)", async function (assert) { + this.siteSettings.allow_uncategorized_topics = false; + + await visit("/c/bug/edit"); + const categoryChooser = selectKit(".category-chooser"); + await categoryChooser.expand(); + await categoryChooser.selectRowByValue(6); + + await categoryChooser.expand(); + + const names = [...categoryChooser.rows()].map((row) => row.dataset.name); + assert.ok(names.includes("(no category)")); + assert.notOk(names.includes("Uncategorized")); + }); + + test("Editing parent category (enabled Uncategorized)", async function (assert) { + this.siteSettings.allow_uncategorized_topics = true; + + await visit("/c/bug/edit"); + const categoryChooser = selectKit(".category-chooser"); + await categoryChooser.expand(); + await categoryChooser.selectRowByValue(6); + + await categoryChooser.expand(); + + const names = [...categoryChooser.rows()].map((row) => row.dataset.name); + assert.ok(names.includes("(no category)")); + assert.notOk(names.includes("Uncategorized")); + }); + test("Index Route", async function (assert) { await visit("/c/bug/edit"); assert.strictEqual( 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 7b3fdd8a918..6eaeb5dd2b2 100644 --- a/app/assets/javascripts/select-kit/addon/components/category-chooser.js +++ b/app/assets/javascripts/select-kit/addon/components/category-chooser.js @@ -12,12 +12,13 @@ import ComboBoxComponent from "select-kit/components/combo-box"; export default ComboBoxComponent.extend({ pluginApiIdentifiers: ["category-chooser"], classNames: ["category-chooser"], - allowUncategorizedTopics: setting("allow_uncategorized_topics"), + allowUncategorized: setting("allow_uncategorized_topics"), fixedCategoryPositionsOnCreate: setting("fixed_category_positions_on_create"), selectKitOptions: { filterable: true, - allowUncategorized: false, + allowUncategorized: "allowUncategorized", + autoInsertNoneItem: false, allowSubCategories: true, permissionType: PermissionType.FULL, excludeCategoryId: null, @@ -30,6 +31,7 @@ export default ComboBoxComponent.extend({ if ( this.site.lazy_load_categories && + this.value && !Category.hasAsyncFoundAll([this.value]) ) { // eslint-disable-next-line no-console @@ -54,10 +56,7 @@ export default ComboBoxComponent.extend({ I18n.t(isString ? this.selectKit.options.none : "category.none") ) ); - } else if ( - this.allowUncategorizedTopics || - this.selectKit.options.allowUncategorized - ) { + } else if (this.selectKit.options.allowUncategorized) { return Category.findUncategorized(); } else { const defaultCategoryId = parseInt( @@ -94,8 +93,10 @@ export default ComboBoxComponent.extend({ search(filter) { if (this.site.lazy_load_categories) { return Category.asyncSearch(this._normalize(filter), { - scopedCategoryId: this.selectKit.options?.scopedCategoryId, - prioritizedCategoryId: this.selectKit.options?.prioritizedCategoryId, + includeUncategorized: this.selectKit.options.allowUncategorized, + rejectCategoryIds: [this.selectKit.options.excludeCategoryId], + scopedCategoryId: this.selectKit.options.scopedCategoryId, + prioritizedCategoryId: this.selectKit.options.prioritizedCategoryId, }); } diff --git a/app/assets/javascripts/select-kit/addon/components/category-drop.js b/app/assets/javascripts/select-kit/addon/components/category-drop.js index a68aa893f86..684fa8380d8 100644 --- a/app/assets/javascripts/select-kit/addon/components/category-drop.js +++ b/app/assets/javascripts/select-kit/addon/components/category-drop.js @@ -24,6 +24,7 @@ export default ComboBoxComponent.extend({ navigateToEdit: false, editingCategory: false, editingCategoryTab: null, + allowUncategorized: setting("allow_uncategorized_topics"), selectKitOptions: { filterable: true, @@ -40,7 +41,7 @@ export default ComboBoxComponent.extend({ displayCategoryDescription: "displayCategoryDescription", headerComponent: "category-drop/category-drop-header", parentCategory: false, - allowUncategorized: setting("allow_uncategorized_topics"), + allowUncategorized: "allowUncategorized", }, modifyComponentForRow() { diff --git a/app/controllers/categories_controller.rb b/app/controllers/categories_controller.rb index f6e24e905f9..ec63117b18a 100644 --- a/app/controllers/categories_controller.rb +++ b/app/controllers/categories_controller.rb @@ -303,9 +303,17 @@ class CategoriesController < ApplicationController def find categories = [] + serializer = params[:include_permissions] ? CategorySerializer : SiteCategorySerializer if params[:ids].present? categories = Category.secured(guardian).where(id: params[:ids]) + elsif params[:slug_path].present? + category = Category.find_by_slug_path(params[:slug_path].split("/")) + raise Discourse::NotFound if category.blank? + guardian.ensure_can_see!(category) + + ancestors = Category.secured(guardian).with_ancestors(category.id).where.not(id: category.id) + categories = [*ancestors, category] 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? @@ -319,7 +327,7 @@ class CategoriesController < ApplicationController Category.preload_user_fields!(guardian, categories) - render_serialized(categories, SiteCategorySerializer, root: :categories, scope: guardian) + render_serialized(categories, serializer, root: :categories, scope: guardian) end def search @@ -333,8 +341,12 @@ class CategoriesController < ApplicationController true end ) - select_category_ids = params[:select_category_ids].presence - reject_category_ids = params[:reject_category_ids].presence + if params[:select_category_ids].is_a?(Array) + select_category_ids = params[:select_category_ids].map(&:presence) + end + if params[:reject_category_ids].is_a?(Array) + reject_category_ids = params[:reject_category_ids].map(&:presence) + end include_subcategories = if params[:include_subcategories].present? ActiveModel::Type::Boolean.new.cast(params[:include_subcategories]) diff --git a/spec/requests/categories_controller_spec.rb b/spec/requests/categories_controller_spec.rb index aa12a3b4b30..1b182ec9a4a 100644 --- a/spec/requests/categories_controller_spec.rb +++ b/spec/requests/categories_controller_spec.rb @@ -1217,6 +1217,12 @@ RSpec.describe CategoriesController do expect(response.parsed_body["categories"].size).to eq(1) expect(response.parsed_body["categories"].map { |c| c["name"] }).to contain_exactly("Foo") end + + it "works with empty categories list" do + get "/categories/search.json", params: { select_category_ids: [""] } + + expect(response.parsed_body["categories"].size).to eq(0) + end end context "with reject_category_ids" do @@ -1230,6 +1236,18 @@ RSpec.describe CategoriesController do "Foobar", ) end + + it "works with empty categories list" do + get "/categories/search.json", params: { reject_category_ids: [""] } + + 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 include_subcategories" do