From 77b032c2b5a449596d9a0dc9c1e86bb0e8002f29 Mon Sep 17 00:00:00 2001 From: Bianca Nenciu Date: Thu, 16 May 2024 10:45:13 +0300 Subject: [PATCH] FEATURE: Filter with CategoryDrop on category page (#26689) Using the CategoryDrop on the categories page redirected the user to the "latest topics" page with topics only from that category. With these changes, selecting a category will take the user to a "subcategories page" where only the subcategories of the selected property will be displayed. --- .../discourse/app/components/bread-crumbs.hbs | 2 + .../discourse/app/components/d-navigation.hbs | 1 + .../discourse/app/models/category-list.js | 49 ++++++++------- .../discourse/app/routes/app-route-map.js | 3 + .../app/routes/build-category-route.js | 2 +- .../app/routes/discovery-categories.js | 42 +++++++++---- .../app/routes/discovery-subcategories.js | 3 + .../app/templates/discovery/categories.hbs | 1 + .../tests/acceptance/subcategories-test.js | 32 ++++++++++ .../tests/helpers/create-pretender.js | 60 ++++++++++++++++++- .../category-drop-more-collection.gjs | 26 +++++--- .../addon/components/category-drop.js | 43 +++++++++---- .../addon/components/category-row.gjs | 22 +++++-- .../common/select-kit/category-drop.scss | 12 ++++ app/controllers/categories_controller.rb | 10 +++- app/models/category_list.rb | 3 - config/routes.rb | 6 +- 17 files changed, 252 insertions(+), 65 deletions(-) create mode 100644 app/assets/javascripts/discourse/app/routes/discovery-subcategories.js create mode 100644 app/assets/javascripts/discourse/tests/acceptance/subcategories-test.js diff --git a/app/assets/javascripts/discourse/app/components/bread-crumbs.hbs b/app/assets/javascripts/discourse/app/components/bread-crumbs.hbs index 0562ed9eb97..93e1980f5b0 100644 --- a/app/assets/javascripts/discourse/app/components/bread-crumbs.hbs +++ b/app/assets/javascripts/discourse/app/components/bread-crumbs.hbs @@ -24,11 +24,13 @@ @tagId={{this.tag.id}} @editingCategory={{this.editingCategory}} @editingCategoryTab={{this.editingCategoryTab}} + @filterType={{this.filterType}} @options={{hash parentCategory=breadcrumb.parentCategory subCategory=breadcrumb.isSubcategory noSubcategories=breadcrumb.noSubcategories autoFilterable=true + disableIfHasNoChildren=(eq this.filterType "categories") }} /> diff --git a/app/assets/javascripts/discourse/app/components/d-navigation.hbs b/app/assets/javascripts/discourse/app/components/d-navigation.hbs index 50d1f7afe93..b666d512fe9 100644 --- a/app/assets/javascripts/discourse/app/components/d-navigation.hbs +++ b/app/assets/javascripts/discourse/app/components/d-navigation.hbs @@ -4,6 +4,7 @@ @noSubcategories={{this.noSubcategories}} @tag={{this.tag}} @additionalTags={{this.additionalTags}} + @filterType={{this.filterType}} /> {{#unless this.additionalTags}} diff --git a/app/assets/javascripts/discourse/app/models/category-list.js b/app/assets/javascripts/discourse/app/models/category-list.js index d5464d40c74..4ad96d56b5c 100644 --- a/app/assets/javascripts/discourse/app/models/category-list.js +++ b/app/assets/javascripts/discourse/app/models/category-list.js @@ -4,6 +4,7 @@ import { number } from "discourse/lib/formatter"; import PreloadStore from "discourse/lib/preload-store"; import Site from "discourse/models/site"; import Topic from "discourse/models/topic"; +import deprecated from "discourse-common/lib/deprecated"; import { bind } from "discourse-common/utils/decorators"; import I18n from "discourse-i18n"; @@ -29,8 +30,8 @@ export default class CategoryList extends ArrayProxy { result.category_list.categories.forEach((c) => { c = this._buildCategoryResult(c, statPeriod); if ( - !c.parent_category_id || - c.parent_category_id === parentCategory?.id + (parentCategory && c.parent_category_id === parentCategory.id) || + (!parentCategory && !c.parent_category_id) ) { categories.pushObject(c); } @@ -79,28 +80,30 @@ export default class CategoryList extends ArrayProxy { } static listForParent(store, category) { - return ajax( - `/categories.json?parent_category_id=${category.get("id")}` - ).then((result) => - CategoryList.create({ - store, - categories: this.categoriesFrom(store, result, category), - parentCategory: category, - }) + deprecated( + "The listForParent method of CategoryList is deprecated. Use list instead", + { id: "discourse.category-list.listForParent" } ); + + return CategoryList.list(store, category); } - static list(store) { - return PreloadStore.getAndRemove("categories_list", () => - ajax("/categories.json") - ).then((result) => - CategoryList.create({ + static list(store, parentCategory = null) { + return PreloadStore.getAndRemove("categories_list", () => { + const data = {}; + if (parentCategory) { + data.parent_category_id = parentCategory?.id; + } + return ajax("/categories.json", { data }); + }).then((result) => { + return CategoryList.create({ store, - categories: this.categoriesFrom(store, result), + categories: this.categoriesFrom(store, result, parentCategory), + parentCategory, can_create_category: result.category_list.can_create_category, can_create_topic: result.category_list.can_create_topic, - }) - ); + }); + }); } init() { @@ -119,6 +122,9 @@ export default class CategoryList extends ArrayProxy { this.set("isLoading", true); const data = { page: this.page + 1 }; + if (this.parentCategory) { + data.parent_category_id = this.parentCategory.id; + } const result = await ajax("/categories.json", { data }); this.set("page", data.page); @@ -127,7 +133,10 @@ export default class CategoryList extends ArrayProxy { } this.set("isLoading", false); - const newCategoryList = CategoryList.categoriesFrom(this.store, result); - newCategoryList.forEach((c) => this.categories.pushObject(c)); + CategoryList.categoriesFrom( + this.store, + result, + this.parentCategory + ).forEach((c) => this.categories.pushObject(c)); } } diff --git a/app/assets/javascripts/discourse/app/routes/app-route-map.js b/app/assets/javascripts/discourse/app/routes/app-route-map.js index 226aed8d478..7e741df35c0 100644 --- a/app/assets/javascripts/discourse/app/routes/app-route-map.js +++ b/app/assets/javascripts/discourse/app/routes/app-route-map.js @@ -62,6 +62,9 @@ export default function () { // default filter for a category this.route("categoryNone", { path: "/c/*category_slug_path_with_id/none" }); this.route("categoryAll", { path: "/c/*category_slug_path_with_id/all" }); + this.route("subcategories", { + path: "/c/*category_slug_path_with_id/subcategories", + }); this.route("category", { path: "/c/*category_slug_path_with_id" }); this.route("custom"); diff --git a/app/assets/javascripts/discourse/app/routes/build-category-route.js b/app/assets/javascripts/discourse/app/routes/build-category-route.js index 93965ea158d..678f7027534 100644 --- a/app/assets/javascripts/discourse/app/routes/build-category-route.js +++ b/app/assets/javascripts/discourse/app/routes/build-category-route.js @@ -84,7 +84,7 @@ class AbstractCategoryRoute extends DiscourseRoute { async _createSubcategoryList(category) { if (category.isParent && category.show_subcategory_list) { - return CategoryList.listForParent(this.store, category); + return CategoryList.list(this.store, category); } } diff --git a/app/assets/javascripts/discourse/app/routes/discovery-categories.js b/app/assets/javascripts/discourse/app/routes/discovery-categories.js index 21f3a113cc8..503cf0a2e22 100644 --- a/app/assets/javascripts/discourse/app/routes/discovery-categories.js +++ b/app/assets/javascripts/discourse/app/routes/discovery-categories.js @@ -4,6 +4,7 @@ import { hash } from "rsvp"; import { ajax } from "discourse/lib/ajax"; import PreloadStore from "discourse/lib/preload-store"; import { defaultHomepage } from "discourse/lib/utilities"; +import Category from "discourse/models/category"; import CategoryList from "discourse/models/category-list"; import TopicList from "discourse/models/topic-list"; import DiscourseRoute from "discourse/routes/discourse"; @@ -17,28 +18,40 @@ export default class DiscoveryCategoriesRoute extends DiscourseRoute { templateName = "discovery/categories"; controllerName = "discovery/categories"; - findCategories() { - let style = + async findCategories(parentCategory) { + let model; + + const style = this.site.desktopView && this.siteSettings.desktop_category_page_style; if ( style === "categories_and_latest_topics" || style === "categories_and_latest_topics_created_date" ) { - return this._findCategoriesAndTopics("latest"); + model = await this._findCategoriesAndTopics("latest", parentCategory); } else if (style === "categories_and_top_topics") { - return this._findCategoriesAndTopics("top"); + model = await this._findCategoriesAndTopics("top", parentCategory); } else { // The server may have serialized this. Based on the logic above, we don't need it // so remove it to avoid it being used later by another TopicList route. PreloadStore.remove("topic_list"); + model = await CategoryList.list(this.store, parentCategory); } - return CategoryList.list(this.store); + return model; } - model() { - return this.findCategories().then((model) => { + async model(params) { + let parentCategory; + if (params.category_slug_path_with_id) { + parentCategory = this.site.lazy_load_categories + ? await Category.asyncFindBySlugPathWithID( + params.category_slug_path_with_id + ) + : Category.findBySlugPathWithID(params.category_slug_path_with_id); + } + + return this.findCategories(parentCategory).then((model) => { const tracking = this.topicTrackingState; if (tracking) { tracking.sync(model, "categories"); @@ -79,7 +92,7 @@ export default class DiscoveryCategoriesRoute extends DiscourseRoute { }; } - async _findCategoriesAndTopics(filter) { + async _findCategoriesAndTopics(filter, parentCategory = null) { return hash({ categoriesList: PreloadStore.getAndRemove("categories_list"), topicsList: PreloadStore.getAndRemove("topic_list"), @@ -92,7 +105,11 @@ export default class DiscoveryCategoriesRoute extends DiscourseRoute { return { ...result.categoriesList, ...result.topicsList }; } else { // Otherwise, return the ajax result - return ajax(`/categories_and_${filter}`); + const data = {}; + if (parentCategory) { + data.parent_category_id = parentCategory.id; + } + return ajax(`/categories_and_${filter}`, { data }); } }) .then((result) => { @@ -102,7 +119,12 @@ export default class DiscoveryCategoriesRoute extends DiscourseRoute { return CategoryList.create({ store: this.store, - categories: CategoryList.categoriesFrom(this.store, result), + categories: CategoryList.categoriesFrom( + this.store, + result, + parentCategory + ), + parentCategory, topics: TopicList.topicsFrom(this.store, result), can_create_category: result.category_list.can_create_category, can_create_topic: result.category_list.can_create_topic, diff --git a/app/assets/javascripts/discourse/app/routes/discovery-subcategories.js b/app/assets/javascripts/discourse/app/routes/discovery-subcategories.js new file mode 100644 index 00000000000..88135936b17 --- /dev/null +++ b/app/assets/javascripts/discourse/app/routes/discovery-subcategories.js @@ -0,0 +1,3 @@ +import DiscoveryCategoriesRoute from "discourse/routes/discovery-categories"; + +export default class DiscoverySubcategoriesRoute extends DiscoveryCategoriesRoute {} diff --git a/app/assets/javascripts/discourse/app/templates/discovery/categories.hbs b/app/assets/javascripts/discourse/app/templates/discovery/categories.hbs index 2cdf30c800d..99bc98b2eb2 100644 --- a/app/assets/javascripts/discourse/app/templates/discovery/categories.hbs +++ b/app/assets/javascripts/discourse/app/templates/discovery/categories.hbs @@ -1,6 +1,7 @@ <:navigation> - response(fixturesByUrl["/categories_and_latest.json"]) - ); + pretender.get("/categories.json", (request) => { + const data = cloneJSON(fixturesByUrl["/categories.json"]); + + // replace categories list if parent_category_id filter is present + if (request.queryParams.parent_category_id) { + const parentCategoryId = parseInt( + request.queryParams.parent_category_id, + 10 + ); + data.category_list.categories = fixturesByUrl[ + "site.json" + ].site.categories.filter( + (c) => c.parent_category_id === parentCategoryId + ); + } + + return response(data); + }); + + pretender.get("/categories_and_latest", (request) => { + const data = cloneJSON(fixturesByUrl["/categories_and_latest.json"]); + + // replace categories list if parent_category_id filter is present + if (request.queryParams.parent_category_id) { + const parentCategoryId = parseInt( + request.queryParams.parent_category_id, + 10 + ); + data.category_list.categories = fixturesByUrl[ + "site.json" + ].site.categories.filter( + (c) => c.parent_category_id === parentCategoryId + ); + } + + return response(data); + }); pretender.get("/c/bug/find_by_slug.json", () => response(fixturesByUrl["/c/1/show.json"]) @@ -529,6 +563,26 @@ export function applyDefaultHandlers(pretender) { response(fixturesByUrl["/c/11/show.json"]) ); + pretender.get("/categories/find", () => { + return response({ + categories: fixturesByUrl["site.json"].site.categories, + }); + }); + + pretender.post("/categories/search", (request) => { + const data = parsePostData(request.requestBody); + if (data.include_ancestors) { + return response({ + categories: fixturesByUrl["site.json"].site.categories, + ancestors: fixturesByUrl["site.json"].site.categories, + }); + } else { + return response({ + categories: fixturesByUrl["site.json"].site.categories, + }); + } + }); + pretender.get("/c/testing/find_by_slug.json", () => response(fixturesByUrl["/c/11/show.json"]) ); diff --git a/app/assets/javascripts/select-kit/addon/components/category-drop-more-collection.gjs b/app/assets/javascripts/select-kit/addon/components/category-drop-more-collection.gjs index ccabf247a35..8783e28385b 100644 --- a/app/assets/javascripts/select-kit/addon/components/category-drop-more-collection.gjs +++ b/app/assets/javascripts/select-kit/addon/components/category-drop-more-collection.gjs @@ -30,14 +30,24 @@ export default class CategoryDropMoreCollection extends Component { 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 282892e9e52..88fb3e13888 100644 --- a/app/assets/javascripts/select-kit/addon/components/category-drop.js +++ b/app/assets/javascripts/select-kit/addon/components/category-drop.js @@ -46,6 +46,7 @@ export default ComboBoxComponent.extend({ headerComponent: "category-drop/category-drop-header", parentCategory: false, allowUncategorized: "allowUncategorized", + disableIfHasNoChildren: false, }, init() { @@ -93,6 +94,7 @@ export default ComboBoxComponent.extend({ if ( this.selectKit.options.subCategory && + this.filterType !== "categories" && (this.value || !this.selectKit.options.noSubcategories) ) { shortcuts.push({ @@ -157,6 +159,7 @@ export default ComboBoxComponent.extend({ if (this.editingCategory) { return this.noCategoriesLabel; } + if (this.selectKit.options.subCategory) { return I18n.t("categories.all_subcategories", { categoryName: this.parentCategoryName, @@ -225,17 +228,35 @@ export default ComboBoxComponent.extend({ ? this.selectKit.options.parentCategory : Category.findById(parseInt(categoryId, 10)); - const route = this.editingCategory - ? getEditCategoryUrl( - category, - categoryId !== NO_CATEGORIES_ID, - this.editingCategoryTab - ) - : getCategoryAndTagUrl( - category, - categoryId !== NO_CATEGORIES_ID, - this.tagId - ); + let route; + if (this.editingCategoryTab) { + // rendered on category page + route = getEditCategoryUrl( + category, + categoryId !== NO_CATEGORIES_ID, + this.editingCategoryTab + ); + } else if ( + this.site.lazy_load_categories && + this.filterType === "categories" + ) { + // rendered on categories page + if (categoryId === "all-categories" || categoryId === "no-categories") { + route = this.selectKit.options.parentCategory + ? `${this.selectKit.options.parentCategory.url}/subcategories` + : "/categories"; + } else if (categoryId) { + route = `${Category.findById(categoryId).url}/subcategories`; + } else { + route = "/categories"; + } + } else { + route = getCategoryAndTagUrl( + category, + categoryId !== NO_CATEGORIES_ID, + this.tagId + ); + } DiscourseURL.routeToUrl(route); }, diff --git a/app/assets/javascripts/select-kit/addon/components/category-row.gjs b/app/assets/javascripts/select-kit/addon/components/category-row.gjs index 1b183f0f30e..73d749e2caf 100644 --- a/app/assets/javascripts/select-kit/addon/components/category-row.gjs +++ b/app/assets/javascripts/select-kit/addon/components/category-row.gjs @@ -90,6 +90,13 @@ export default class CategoryRow extends Component { return this.category.description_text; } + get isDisabled() { + return ( + this.args.selectKit.options.disableIfHasNoChildren && + this.args.item.has_children === false + ); + } + @cached get category() { if (isEmpty(this.rowValue)) { @@ -187,7 +194,9 @@ export default class CategoryRow extends Component { handleClick(event) { event.preventDefault(); event.stopPropagation(); - this.args.selectKit.select(this.rowValue, this.args.item); + if (!this.isDisabled) { + this.args.selectKit.select(this.rowValue, this.args.item); + } return false; } @@ -226,10 +235,12 @@ export default class CategoryRow extends Component { } else if (event.key === "Enter") { event.stopImmediatePropagation(); - this.args.selectKit.select( - this.args.selectKit.highlighted.id, - this.args.selectKit.highlighted - ); + if (!this.isDisabled) { + this.args.selectKit.select( + this.args.selectKit.highlighted.id, + this.args.selectKit.highlighted + ); + } event.preventDefault(); } else if (event.key === "Escape") { this.args.selectKit.close(event); @@ -272,6 +283,7 @@ export default class CategoryRow extends Component { (if this.isSelected "is-selected") (if this.isHighlighted "is-highlighted") (if this.isNone "is-none") + (if this.isDisabled "is-disabled") }} role="menuitemradio" data-index={{@index}} diff --git a/app/assets/stylesheets/common/select-kit/category-drop.scss b/app/assets/stylesheets/common/select-kit/category-drop.scss index 4bc6735308e..ce002cc8817 100644 --- a/app/assets/stylesheets/common/select-kit/category-drop.scss +++ b/app/assets/stylesheets/common/select-kit/category-drop.scss @@ -29,6 +29,14 @@ font-weight: 700; } + &.is-disabled { + cursor: not-allowed; + + .badge-category__name { + color: var(--primary-low-mid); + } + } + .category-desc { font-weight: normal; color: var(--primary-medium); @@ -60,6 +68,10 @@ span { color: var(--primary-high); margin: 0 10px; + + &.active { + display: none; + } } } } diff --git a/app/controllers/categories_controller.rb b/app/controllers/categories_controller.rb index af1ae82f7a2..03cacb920da 100644 --- a/app/controllers/categories_controller.rb +++ b/app/controllers/categories_controller.rb @@ -34,8 +34,12 @@ class CategoriesController < ApplicationController @description = SiteSetting.site_description parent_category = - Category.find_by_slug(params[:parent_category_id]) || - Category.find_by(id: params[:parent_category_id].to_i) + if params[:parent_category_id].present? + Category.find_by_slug(params[:parent_category_id]) || + Category.find_by(id: params[:parent_category_id].to_i) + elsif params[:category_slug_path_with_id].present? + Category.find_by_slug_path_with_id(params[:category_slug_path_with_id]) + end include_subcategories = SiteSetting.desktop_category_page_style == "subcategories_with_featured_topics" || @@ -43,7 +47,7 @@ class CategoriesController < ApplicationController category_options = { is_homepage: current_homepage == "categories", - parent_category_id: params[:parent_category_id], + parent_category_id: parent_category&.id, include_topics: include_topics(parent_category), include_subcategories: include_subcategories, tag: params[:tag], diff --git a/app/models/category_list.rb b/app/models/category_list.rb index 1d08a9a6d9e..39cafac9172 100644 --- a/app/models/category_list.rb +++ b/app/models/category_list.rb @@ -180,9 +180,6 @@ class CategoryList include_subcategories = @options[:include_subcategories] == true - notification_levels = CategoryUser.notification_levels_for(@guardian.user) - default_notification_level = CategoryUser.default_notification_level - if @guardian.can_lazy_load_categories? subcategory_ids = {} Category diff --git a/config/routes.rb b/config/routes.rb index fb45a69cc58..c4339404949 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1166,10 +1166,11 @@ Discourse::Application.routes.draw do get "/c", to: redirect(relative_url_root + "categories") - resources :categories, except: %i[show new edit] + resources :categories, only: %i[index create update destroy] post "categories/reorder" => "categories#reorder" get "categories/find" => "categories#find" post "categories/search" => "categories#search" + get "categories/:parent_category_id" => "categories#index" scope path: "category/:category_id" do post "/move" => "categories#move" @@ -1211,6 +1212,9 @@ Discourse::Application.routes.draw do :constraints => { format: "html", } + + get "/subcategories" => "categories#index" + get "/" => "list#category_default", :as => "category_default" end