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.
This commit is contained in:
Bianca Nenciu
2024-05-16 10:45:13 +03:00
committed by GitHub
parent d964709644
commit 77b032c2b5
17 changed files with 252 additions and 65 deletions

View File

@ -24,11 +24,13 @@
@tagId={{this.tag.id}} @tagId={{this.tag.id}}
@editingCategory={{this.editingCategory}} @editingCategory={{this.editingCategory}}
@editingCategoryTab={{this.editingCategoryTab}} @editingCategoryTab={{this.editingCategoryTab}}
@filterType={{this.filterType}}
@options={{hash @options={{hash
parentCategory=breadcrumb.parentCategory parentCategory=breadcrumb.parentCategory
subCategory=breadcrumb.isSubcategory subCategory=breadcrumb.isSubcategory
noSubcategories=breadcrumb.noSubcategories noSubcategories=breadcrumb.noSubcategories
autoFilterable=true autoFilterable=true
disableIfHasNoChildren=(eq this.filterType "categories")
}} }}
/> />
</li> </li>

View File

@ -4,6 +4,7 @@
@noSubcategories={{this.noSubcategories}} @noSubcategories={{this.noSubcategories}}
@tag={{this.tag}} @tag={{this.tag}}
@additionalTags={{this.additionalTags}} @additionalTags={{this.additionalTags}}
@filterType={{this.filterType}}
/> />
{{#unless this.additionalTags}} {{#unless this.additionalTags}}

View File

@ -4,6 +4,7 @@ import { number } from "discourse/lib/formatter";
import PreloadStore from "discourse/lib/preload-store"; import PreloadStore from "discourse/lib/preload-store";
import Site from "discourse/models/site"; import Site from "discourse/models/site";
import Topic from "discourse/models/topic"; import Topic from "discourse/models/topic";
import deprecated from "discourse-common/lib/deprecated";
import { bind } from "discourse-common/utils/decorators"; import { bind } from "discourse-common/utils/decorators";
import I18n from "discourse-i18n"; import I18n from "discourse-i18n";
@ -29,8 +30,8 @@ export default class CategoryList extends ArrayProxy {
result.category_list.categories.forEach((c) => { result.category_list.categories.forEach((c) => {
c = this._buildCategoryResult(c, statPeriod); c = this._buildCategoryResult(c, statPeriod);
if ( if (
!c.parent_category_id || (parentCategory && c.parent_category_id === parentCategory.id) ||
c.parent_category_id === parentCategory?.id (!parentCategory && !c.parent_category_id)
) { ) {
categories.pushObject(c); categories.pushObject(c);
} }
@ -79,28 +80,30 @@ export default class CategoryList extends ArrayProxy {
} }
static listForParent(store, category) { static listForParent(store, category) {
return ajax( deprecated(
`/categories.json?parent_category_id=${category.get("id")}` "The listForParent method of CategoryList is deprecated. Use list instead",
).then((result) => { id: "discourse.category-list.listForParent" }
CategoryList.create({
store,
categories: this.categoriesFrom(store, result, category),
parentCategory: category,
})
); );
return CategoryList.list(store, category);
} }
static list(store) { static list(store, parentCategory = null) {
return PreloadStore.getAndRemove("categories_list", () => return PreloadStore.getAndRemove("categories_list", () => {
ajax("/categories.json") const data = {};
).then((result) => if (parentCategory) {
CategoryList.create({ data.parent_category_id = parentCategory?.id;
}
return ajax("/categories.json", { data });
}).then((result) => {
return CategoryList.create({
store, store,
categories: this.categoriesFrom(store, result), categories: this.categoriesFrom(store, result, parentCategory),
parentCategory,
can_create_category: result.category_list.can_create_category, can_create_category: result.category_list.can_create_category,
can_create_topic: result.category_list.can_create_topic, can_create_topic: result.category_list.can_create_topic,
}) });
); });
} }
init() { init() {
@ -119,6 +122,9 @@ export default class CategoryList extends ArrayProxy {
this.set("isLoading", true); this.set("isLoading", true);
const data = { page: this.page + 1 }; const data = { page: this.page + 1 };
if (this.parentCategory) {
data.parent_category_id = this.parentCategory.id;
}
const result = await ajax("/categories.json", { data }); const result = await ajax("/categories.json", { data });
this.set("page", data.page); this.set("page", data.page);
@ -127,7 +133,10 @@ export default class CategoryList extends ArrayProxy {
} }
this.set("isLoading", false); this.set("isLoading", false);
const newCategoryList = CategoryList.categoriesFrom(this.store, result); CategoryList.categoriesFrom(
newCategoryList.forEach((c) => this.categories.pushObject(c)); this.store,
result,
this.parentCategory
).forEach((c) => this.categories.pushObject(c));
} }
} }

View File

@ -62,6 +62,9 @@ export default function () {
// default filter for a category // default filter for a category
this.route("categoryNone", { path: "/c/*category_slug_path_with_id/none" }); this.route("categoryNone", { path: "/c/*category_slug_path_with_id/none" });
this.route("categoryAll", { path: "/c/*category_slug_path_with_id/all" }); 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("category", { path: "/c/*category_slug_path_with_id" });
this.route("custom"); this.route("custom");

View File

@ -84,7 +84,7 @@ class AbstractCategoryRoute extends DiscourseRoute {
async _createSubcategoryList(category) { async _createSubcategoryList(category) {
if (category.isParent && category.show_subcategory_list) { if (category.isParent && category.show_subcategory_list) {
return CategoryList.listForParent(this.store, category); return CategoryList.list(this.store, category);
} }
} }

View File

@ -4,6 +4,7 @@ import { hash } from "rsvp";
import { ajax } from "discourse/lib/ajax"; import { ajax } from "discourse/lib/ajax";
import PreloadStore from "discourse/lib/preload-store"; import PreloadStore from "discourse/lib/preload-store";
import { defaultHomepage } from "discourse/lib/utilities"; import { defaultHomepage } from "discourse/lib/utilities";
import Category from "discourse/models/category";
import CategoryList from "discourse/models/category-list"; import CategoryList from "discourse/models/category-list";
import TopicList from "discourse/models/topic-list"; import TopicList from "discourse/models/topic-list";
import DiscourseRoute from "discourse/routes/discourse"; import DiscourseRoute from "discourse/routes/discourse";
@ -17,28 +18,40 @@ export default class DiscoveryCategoriesRoute extends DiscourseRoute {
templateName = "discovery/categories"; templateName = "discovery/categories";
controllerName = "discovery/categories"; controllerName = "discovery/categories";
findCategories() { async findCategories(parentCategory) {
let style = let model;
const style =
this.site.desktopView && this.siteSettings.desktop_category_page_style; this.site.desktopView && this.siteSettings.desktop_category_page_style;
if ( if (
style === "categories_and_latest_topics" || style === "categories_and_latest_topics" ||
style === "categories_and_latest_topics_created_date" style === "categories_and_latest_topics_created_date"
) { ) {
return this._findCategoriesAndTopics("latest"); model = await this._findCategoriesAndTopics("latest", parentCategory);
} else if (style === "categories_and_top_topics") { } else if (style === "categories_and_top_topics") {
return this._findCategoriesAndTopics("top"); model = await this._findCategoriesAndTopics("top", parentCategory);
} else { } else {
// The server may have serialized this. Based on the logic above, we don't need it // 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. // so remove it to avoid it being used later by another TopicList route.
PreloadStore.remove("topic_list"); PreloadStore.remove("topic_list");
model = await CategoryList.list(this.store, parentCategory);
} }
return CategoryList.list(this.store); return model;
} }
model() { async model(params) {
return this.findCategories().then((model) => { 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; const tracking = this.topicTrackingState;
if (tracking) { if (tracking) {
tracking.sync(model, "categories"); tracking.sync(model, "categories");
@ -79,7 +92,7 @@ export default class DiscoveryCategoriesRoute extends DiscourseRoute {
}; };
} }
async _findCategoriesAndTopics(filter) { async _findCategoriesAndTopics(filter, parentCategory = null) {
return hash({ return hash({
categoriesList: PreloadStore.getAndRemove("categories_list"), categoriesList: PreloadStore.getAndRemove("categories_list"),
topicsList: PreloadStore.getAndRemove("topic_list"), topicsList: PreloadStore.getAndRemove("topic_list"),
@ -92,7 +105,11 @@ export default class DiscoveryCategoriesRoute extends DiscourseRoute {
return { ...result.categoriesList, ...result.topicsList }; return { ...result.categoriesList, ...result.topicsList };
} else { } else {
// Otherwise, return the ajax result // 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) => { .then((result) => {
@ -102,7 +119,12 @@ export default class DiscoveryCategoriesRoute extends DiscourseRoute {
return CategoryList.create({ return CategoryList.create({
store: this.store, store: this.store,
categories: CategoryList.categoriesFrom(this.store, result), categories: CategoryList.categoriesFrom(
this.store,
result,
parentCategory
),
parentCategory,
topics: TopicList.topicsFrom(this.store, result), topics: TopicList.topicsFrom(this.store, result),
can_create_category: result.category_list.can_create_category, can_create_category: result.category_list.can_create_category,
can_create_topic: result.category_list.can_create_topic, can_create_topic: result.category_list.can_create_topic,

View File

@ -0,0 +1,3 @@
import DiscoveryCategoriesRoute from "discourse/routes/discovery-categories";
export default class DiscoverySubcategoriesRoute extends DiscoveryCategoriesRoute {}

View File

@ -1,6 +1,7 @@
<Discovery::Layout @model={{this.model}}> <Discovery::Layout @model={{this.model}}>
<:navigation> <:navigation>
<Discovery::Navigation <Discovery::Navigation
@category={{this.model.parentCategory}}
@showCategoryAdmin={{this.model.can_create_category}} @showCategoryAdmin={{this.model.can_create_category}}
@canCreateTopic={{this.model.can_create_topic}} @canCreateTopic={{this.model.can_create_topic}}
@createTopic={{this.createTopic}} @createTopic={{this.createTopic}}

View File

@ -0,0 +1,32 @@
import { currentRouteName, currentURL, visit } from "@ember/test-helpers";
import { test } from "qunit";
import { acceptance } from "discourse/tests/helpers/qunit-helpers";
import selectKit from "discourse/tests/helpers/select-kit-helper";
acceptance("Subcategories", function (needs) {
needs.site({
lazy_load_categories: true,
});
test("navigation can be used to navigate subcategories pages", async function (assert) {
await visit("/categories");
let categoryDrop = selectKit(
".category-breadcrumb li:nth-of-type(1) .category-drop"
);
await categoryDrop.expand();
await categoryDrop.selectRowByValue("2"); // "feature" category
assert.strictEqual(currentRouteName(), "discovery.subcategories");
assert.strictEqual(currentURL(), "/c/feature/2/subcategories");
categoryDrop = selectKit(
".category-breadcrumb li:nth-of-type(2) .category-drop"
);
await categoryDrop.expand();
await categoryDrop.selectRowByValue("26"); // "spec" category
assert.strictEqual(currentRouteName(), "discovery.subcategories");
assert.strictEqual(currentURL(), "/c/feature/spec/26/subcategories");
});
});

View File

@ -492,9 +492,43 @@ export function applyDefaultHandlers(pretender) {
return response([{ id: 1234, cooked: "wat" }]); return response([{ id: 1234, cooked: "wat" }]);
}); });
pretender.get("/categories_and_latest", () => pretender.get("/categories.json", (request) => {
response(fixturesByUrl["/categories_and_latest.json"]) 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", () => pretender.get("/c/bug/find_by_slug.json", () =>
response(fixturesByUrl["/c/1/show.json"]) response(fixturesByUrl["/c/1/show.json"])
@ -529,6 +563,26 @@ export function applyDefaultHandlers(pretender) {
response(fixturesByUrl["/c/11/show.json"]) 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", () => pretender.get("/c/testing/find_by_slug.json", () =>
response(fixturesByUrl["/c/11/show.json"]) response(fixturesByUrl["/c/11/show.json"])
); );

View File

@ -30,14 +30,24 @@ export default class CategoryDropMoreCollection extends Component {
<template> <template>
{{#if this.moreCount}} {{#if this.moreCount}}
<div class="category-drop-footer"> <div class="category-drop-footer">
<span>{{i18n <span>
"categories.plus_more_count" {{i18n "categories.plus_more_count" (hash count=this.moreCount)}}
(hash count=this.moreCount) </span>
}}</span>
<LinkTo @route="discovery.categories"> {{#if @selectKit.options.parentCategory}}
{{i18n "categories.view_all"}} <LinkTo
{{icon "external-link-alt"}} @route="discovery.subcategories"
</LinkTo> @model={{@selectKit.options.parentCategory.id}}
>
{{i18n "categories.view_all"}}
{{icon "external-link-alt"}}
</LinkTo>
{{else}}
<LinkTo @route="discovery.categories">
{{i18n "categories.view_all"}}
{{icon "external-link-alt"}}
</LinkTo>
{{/if}}
</div> </div>
{{/if}} {{/if}}
</template> </template>

View File

@ -46,6 +46,7 @@ export default ComboBoxComponent.extend({
headerComponent: "category-drop/category-drop-header", headerComponent: "category-drop/category-drop-header",
parentCategory: false, parentCategory: false,
allowUncategorized: "allowUncategorized", allowUncategorized: "allowUncategorized",
disableIfHasNoChildren: false,
}, },
init() { init() {
@ -93,6 +94,7 @@ export default ComboBoxComponent.extend({
if ( if (
this.selectKit.options.subCategory && this.selectKit.options.subCategory &&
this.filterType !== "categories" &&
(this.value || !this.selectKit.options.noSubcategories) (this.value || !this.selectKit.options.noSubcategories)
) { ) {
shortcuts.push({ shortcuts.push({
@ -157,6 +159,7 @@ export default ComboBoxComponent.extend({
if (this.editingCategory) { if (this.editingCategory) {
return this.noCategoriesLabel; return this.noCategoriesLabel;
} }
if (this.selectKit.options.subCategory) { if (this.selectKit.options.subCategory) {
return I18n.t("categories.all_subcategories", { return I18n.t("categories.all_subcategories", {
categoryName: this.parentCategoryName, categoryName: this.parentCategoryName,
@ -225,17 +228,35 @@ export default ComboBoxComponent.extend({
? this.selectKit.options.parentCategory ? this.selectKit.options.parentCategory
: Category.findById(parseInt(categoryId, 10)); : Category.findById(parseInt(categoryId, 10));
const route = this.editingCategory let route;
? getEditCategoryUrl( if (this.editingCategoryTab) {
category, // rendered on category page
categoryId !== NO_CATEGORIES_ID, route = getEditCategoryUrl(
this.editingCategoryTab category,
) categoryId !== NO_CATEGORIES_ID,
: getCategoryAndTagUrl( this.editingCategoryTab
category, );
categoryId !== NO_CATEGORIES_ID, } else if (
this.tagId 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); DiscourseURL.routeToUrl(route);
}, },

View File

@ -90,6 +90,13 @@ export default class CategoryRow extends Component {
return this.category.description_text; return this.category.description_text;
} }
get isDisabled() {
return (
this.args.selectKit.options.disableIfHasNoChildren &&
this.args.item.has_children === false
);
}
@cached @cached
get category() { get category() {
if (isEmpty(this.rowValue)) { if (isEmpty(this.rowValue)) {
@ -187,7 +194,9 @@ export default class CategoryRow extends Component {
handleClick(event) { handleClick(event) {
event.preventDefault(); event.preventDefault();
event.stopPropagation(); 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; return false;
} }
@ -226,10 +235,12 @@ export default class CategoryRow extends Component {
} else if (event.key === "Enter") { } else if (event.key === "Enter") {
event.stopImmediatePropagation(); event.stopImmediatePropagation();
this.args.selectKit.select( if (!this.isDisabled) {
this.args.selectKit.highlighted.id, this.args.selectKit.select(
this.args.selectKit.highlighted this.args.selectKit.highlighted.id,
); this.args.selectKit.highlighted
);
}
event.preventDefault(); event.preventDefault();
} else if (event.key === "Escape") { } else if (event.key === "Escape") {
this.args.selectKit.close(event); this.args.selectKit.close(event);
@ -272,6 +283,7 @@ export default class CategoryRow extends Component {
(if this.isSelected "is-selected") (if this.isSelected "is-selected")
(if this.isHighlighted "is-highlighted") (if this.isHighlighted "is-highlighted")
(if this.isNone "is-none") (if this.isNone "is-none")
(if this.isDisabled "is-disabled")
}} }}
role="menuitemradio" role="menuitemradio"
data-index={{@index}} data-index={{@index}}

View File

@ -29,6 +29,14 @@
font-weight: 700; font-weight: 700;
} }
&.is-disabled {
cursor: not-allowed;
.badge-category__name {
color: var(--primary-low-mid);
}
}
.category-desc { .category-desc {
font-weight: normal; font-weight: normal;
color: var(--primary-medium); color: var(--primary-medium);
@ -60,6 +68,10 @@
span { span {
color: var(--primary-high); color: var(--primary-high);
margin: 0 10px; margin: 0 10px;
&.active {
display: none;
}
} }
} }
} }

View File

@ -34,8 +34,12 @@ class CategoriesController < ApplicationController
@description = SiteSetting.site_description @description = SiteSetting.site_description
parent_category = parent_category =
Category.find_by_slug(params[:parent_category_id]) || if params[:parent_category_id].present?
Category.find_by(id: params[:parent_category_id].to_i) 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 = include_subcategories =
SiteSetting.desktop_category_page_style == "subcategories_with_featured_topics" || SiteSetting.desktop_category_page_style == "subcategories_with_featured_topics" ||
@ -43,7 +47,7 @@ class CategoriesController < ApplicationController
category_options = { category_options = {
is_homepage: current_homepage == "categories", 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_topics: include_topics(parent_category),
include_subcategories: include_subcategories, include_subcategories: include_subcategories,
tag: params[:tag], tag: params[:tag],

View File

@ -180,9 +180,6 @@ class CategoryList
include_subcategories = @options[:include_subcategories] == true 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? if @guardian.can_lazy_load_categories?
subcategory_ids = {} subcategory_ids = {}
Category Category

View File

@ -1166,10 +1166,11 @@ Discourse::Application.routes.draw do
get "/c", to: redirect(relative_url_root + "categories") 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" post "categories/reorder" => "categories#reorder"
get "categories/find" => "categories#find" get "categories/find" => "categories#find"
post "categories/search" => "categories#search" post "categories/search" => "categories#search"
get "categories/:parent_category_id" => "categories#index"
scope path: "category/:category_id" do scope path: "category/:category_id" do
post "/move" => "categories#move" post "/move" => "categories#move"
@ -1211,6 +1212,9 @@ Discourse::Application.routes.draw do
:constraints => { :constraints => {
format: "html", format: "html",
} }
get "/subcategories" => "categories#index"
get "/" => "list#category_default", :as => "category_default" get "/" => "list#category_default", :as => "category_default"
end end