FIX: Show "no category" in category-chooser (#25917)

CategoryChooser component usually displays just categories, but
sometimes it can show two none values: a "no category" or Uncategorized.
This commit makes sure that these are rendered correctly.

The problem was that the "none" item was automatically inserted in the
list of options, but that should not always happen. Toggling option
`autoInsertNoneItem` requires setting `none` too.
This commit is contained in:
Bianca Nenciu 2024-02-29 13:48:20 +02:00 committed by GitHub
parent 0bb492c6b6
commit e74a9efee1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 97 additions and 18 deletions

View File

@ -19,13 +19,13 @@
<label>{{i18n "category.parent"}}</label> <label>{{i18n "category.parent"}}</label>
<CategoryChooser <CategoryChooser
@value={{this.category.parent_category_id}} @value={{this.category.parent_category_id}}
@categories={{this.parentCategories}}
@allowSubCategories={{true}} @allowSubCategories={{true}}
@allowRestrictedCategories={{true}} @allowRestrictedCategories={{true}}
@onChange={{action (mut this.category.parent_category_id)}} @onChange={{action (mut this.category.parent_category_id)}}
@options={{hash @options={{hash
allowUncategorized=false allowUncategorized=false
excludeCategoryId=this.category.id excludeCategoryId=this.category.id
autoInsertNoneItem=true
none=true none=true
}} }}
/> />

View File

@ -176,6 +176,25 @@ export default class Category extends RestModel {
return category; 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) { static async asyncFindBySlugPathWithID(slugPathWithID) {
const result = await ajax("/categories/find", { const result = await ajax("/categories/find", {
data: { slug_path_with_id: slugPathWithID }, data: { slug_path_with_id: slugPathWithID },

View File

@ -7,11 +7,9 @@ export default DiscourseRoute.extend({
router: service(), router: service(),
model(params) { model(params) {
return Category.reloadCategoryWithPermissions( return this.site.lazy_load_categories
params, ? Category.asyncFindBySlugPath(params.slug, { includePermissions: true })
this.store, : Category.reloadCategoryWithPermissions(params, this.store, this.site);
this.site
);
}, },
afterModel(model) { afterModel(model) {

View File

@ -145,6 +145,36 @@ acceptance("Category Edit", function (needs) {
assert.deepEqual(removePayload.allowed_tag_groups, []); 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) { test("Index Route", async function (assert) {
await visit("/c/bug/edit"); await visit("/c/bug/edit");
assert.strictEqual( assert.strictEqual(

View File

@ -12,12 +12,13 @@ import ComboBoxComponent from "select-kit/components/combo-box";
export default ComboBoxComponent.extend({ export default ComboBoxComponent.extend({
pluginApiIdentifiers: ["category-chooser"], pluginApiIdentifiers: ["category-chooser"],
classNames: ["category-chooser"], classNames: ["category-chooser"],
allowUncategorizedTopics: setting("allow_uncategorized_topics"), allowUncategorized: setting("allow_uncategorized_topics"),
fixedCategoryPositionsOnCreate: setting("fixed_category_positions_on_create"), fixedCategoryPositionsOnCreate: setting("fixed_category_positions_on_create"),
selectKitOptions: { selectKitOptions: {
filterable: true, filterable: true,
allowUncategorized: false, allowUncategorized: "allowUncategorized",
autoInsertNoneItem: false,
allowSubCategories: true, allowSubCategories: true,
permissionType: PermissionType.FULL, permissionType: PermissionType.FULL,
excludeCategoryId: null, excludeCategoryId: null,
@ -30,6 +31,7 @@ export default ComboBoxComponent.extend({
if ( if (
this.site.lazy_load_categories && this.site.lazy_load_categories &&
this.value &&
!Category.hasAsyncFoundAll([this.value]) !Category.hasAsyncFoundAll([this.value])
) { ) {
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
@ -54,10 +56,7 @@ export default ComboBoxComponent.extend({
I18n.t(isString ? this.selectKit.options.none : "category.none") I18n.t(isString ? this.selectKit.options.none : "category.none")
) )
); );
} else if ( } else if (this.selectKit.options.allowUncategorized) {
this.allowUncategorizedTopics ||
this.selectKit.options.allowUncategorized
) {
return Category.findUncategorized(); return Category.findUncategorized();
} else { } else {
const defaultCategoryId = parseInt( const defaultCategoryId = parseInt(
@ -94,8 +93,10 @@ export default ComboBoxComponent.extend({
search(filter) { search(filter) {
if (this.site.lazy_load_categories) { if (this.site.lazy_load_categories) {
return Category.asyncSearch(this._normalize(filter), { return Category.asyncSearch(this._normalize(filter), {
scopedCategoryId: this.selectKit.options?.scopedCategoryId, includeUncategorized: this.selectKit.options.allowUncategorized,
prioritizedCategoryId: this.selectKit.options?.prioritizedCategoryId, rejectCategoryIds: [this.selectKit.options.excludeCategoryId],
scopedCategoryId: this.selectKit.options.scopedCategoryId,
prioritizedCategoryId: this.selectKit.options.prioritizedCategoryId,
}); });
} }

View File

@ -24,6 +24,7 @@ export default ComboBoxComponent.extend({
navigateToEdit: false, navigateToEdit: false,
editingCategory: false, editingCategory: false,
editingCategoryTab: null, editingCategoryTab: null,
allowUncategorized: setting("allow_uncategorized_topics"),
selectKitOptions: { selectKitOptions: {
filterable: true, filterable: true,
@ -40,7 +41,7 @@ export default ComboBoxComponent.extend({
displayCategoryDescription: "displayCategoryDescription", displayCategoryDescription: "displayCategoryDescription",
headerComponent: "category-drop/category-drop-header", headerComponent: "category-drop/category-drop-header",
parentCategory: false, parentCategory: false,
allowUncategorized: setting("allow_uncategorized_topics"), allowUncategorized: "allowUncategorized",
}, },
modifyComponentForRow() { modifyComponentForRow() {

View File

@ -303,9 +303,17 @@ class CategoriesController < ApplicationController
def find def find
categories = [] categories = []
serializer = params[:include_permissions] ? CategorySerializer : SiteCategorySerializer
if params[:ids].present? if params[:ids].present?
categories = Category.secured(guardian).where(id: params[:ids]) 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? elsif params[:slug_path_with_id].present?
category = Category.find_by_slug_path_with_id(params[:slug_path_with_id]) category = Category.find_by_slug_path_with_id(params[:slug_path_with_id])
raise Discourse::NotFound if category.blank? raise Discourse::NotFound if category.blank?
@ -319,7 +327,7 @@ class CategoriesController < ApplicationController
Category.preload_user_fields!(guardian, categories) Category.preload_user_fields!(guardian, categories)
render_serialized(categories, SiteCategorySerializer, root: :categories, scope: guardian) render_serialized(categories, serializer, root: :categories, scope: guardian)
end end
def search def search
@ -333,8 +341,12 @@ class CategoriesController < ApplicationController
true true
end end
) )
select_category_ids = params[:select_category_ids].presence if params[:select_category_ids].is_a?(Array)
reject_category_ids = params[:reject_category_ids].presence 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 = include_subcategories =
if params[:include_subcategories].present? if params[:include_subcategories].present?
ActiveModel::Type::Boolean.new.cast(params[:include_subcategories]) ActiveModel::Type::Boolean.new.cast(params[:include_subcategories])

View File

@ -1217,6 +1217,12 @@ RSpec.describe CategoriesController do
expect(response.parsed_body["categories"].size).to eq(1) expect(response.parsed_body["categories"].size).to eq(1)
expect(response.parsed_body["categories"].map { |c| c["name"] }).to contain_exactly("Foo") expect(response.parsed_body["categories"].map { |c| c["name"] }).to contain_exactly("Foo")
end 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 end
context "with reject_category_ids" do context "with reject_category_ids" do
@ -1230,6 +1236,18 @@ RSpec.describe CategoriesController do
"Foobar", "Foobar",
) )
end 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 end
context "with include_subcategories" do context "with include_subcategories" do