diff --git a/app/assets/javascripts/discourse/app/models/category-list.js b/app/assets/javascripts/discourse/app/models/category-list.js index b7a44e042c5..cd31670b34b 100644 --- a/app/assets/javascripts/discourse/app/models/category-list.js +++ b/app/assets/javascripts/discourse/app/models/category-list.js @@ -32,67 +32,73 @@ CategoryList.reopenClass({ } }); - result.category_list.categories.forEach((c) => { - if (c.parent_category_id) { - c.parentCategory = list.findBy("id", c.parent_category_id); - } + result.category_list.categories.forEach((c) => + categories.pushObject(this._buildCategoryResult(c, list, statPeriod)) + ); - if (c.subcategory_ids) { - c.subcategories = c.subcategory_ids.map((scid) => - list.findBy("id", parseInt(scid, 10)) - ); - } - - if (c.topics) { - c.topics = c.topics.map((t) => Topic.create(t)); - } - - switch (statPeriod) { - case "week": - case "month": - const stat = c[`topics_${statPeriod}`]; - if (stat > 0) { - const unit = I18n.t(`categories.topic_stat_unit.${statPeriod}`); - - c.stat = I18n.t("categories.topic_stat", { - count: stat, // only used to correctly pluralize the string - number: `${number(stat)}`, - unit: `${unit}`, - }); - - c.statTitle = I18n.t( - `categories.topic_stat_sentence_${statPeriod}`, - { - count: stat, - } - ); - - c.pickAll = false; - break; - } - default: - c.stat = `${number(c.topics_all_time)}`; - c.statTitle = I18n.t("categories.topic_sentence", { - count: c.topics_all_time, - }); - c.pickAll = true; - break; - } - - if (Site.currentProp("mobileView")) { - c.statTotal = I18n.t("categories.topic_stat_all_time", { - count: c.topics_all_time, - number: `${number(c.topics_all_time)}`, - }); - } - - const record = Site.current().updateCategory(c); - record.setupGroupsAndPermissions(); - categories.pushObject(record); - }); return categories; }, + _buildCategoryResult(c, list, statPeriod) { + if (c.parent_category_id) { + c.parentCategory = list.findBy("id", c.parent_category_id); + } + + if (c.subcategory_list) { + c.subcategories = c.subcategory_list.map((subCategory) => + this._buildCategoryResult(subCategory, list, statPeriod) + ); + } else if (c.subcategory_ids) { + c.subcategories = c.subcategory_ids.map((scid) => + list.findBy("id", parseInt(scid, 10)) + ); + } + + if (c.topics) { + c.topics = c.topics.map((t) => Topic.create(t)); + } + + switch (statPeriod) { + case "week": + case "month": + const stat = c[`topics_${statPeriod}`]; + if (stat > 0) { + const unit = I18n.t(`categories.topic_stat_unit.${statPeriod}`); + + c.stat = I18n.t("categories.topic_stat", { + count: stat, // only used to correctly pluralize the string + number: `${number(stat)}`, + unit: `${unit}`, + }); + + c.statTitle = I18n.t(`categories.topic_stat_sentence_${statPeriod}`, { + count: stat, + }); + + c.pickAll = false; + break; + } + default: + c.stat = `${number(c.topics_all_time)}`; + c.statTitle = I18n.t("categories.topic_sentence", { + count: c.topics_all_time, + }); + c.pickAll = true; + break; + } + + if (Site.currentProp("mobileView")) { + c.statTotal = I18n.t("categories.topic_stat_all_time", { + count: c.topics_all_time, + number: `${number(c.topics_all_time)}`, + }); + } + + const record = Site.current().updateCategory(c); + record.setupGroupsAndPermissions(); + return record; + }, + listForParent(store, category) { return ajax( `/categories.json?parent_category_id=${category.get("id")}` diff --git a/app/assets/javascripts/discourse/app/templates/components/subcategories-with-featured-topics.hbs b/app/assets/javascripts/discourse/app/templates/components/subcategories-with-featured-topics.hbs new file mode 100644 index 00000000000..974b0ce7fe9 --- /dev/null +++ b/app/assets/javascripts/discourse/app/templates/components/subcategories-with-featured-topics.hbs @@ -0,0 +1,22 @@ +{{#each categories as |category|}} + + + + + + + + + + {{#each category.subcategories as |subCategory|}} + {{parent-category-row category=subCategory showTopics=true}} + {{else}} + {{!-- No subcategories... so just show the parent to avoid confusion --}} + {{parent-category-row category=category showTopics=true}} + {{/each}} + +
+ {{category-title-link category=category}} + {{html-safe category.stat}} + {{i18n "categories.topics"}}{{i18n "categories.latest"}}
+{{/each}} diff --git a/app/assets/javascripts/discourse/tests/acceptance/categories-test.js b/app/assets/javascripts/discourse/tests/acceptance/categories-test.js new file mode 100644 index 00000000000..c1b4ed3bd38 --- /dev/null +++ b/app/assets/javascripts/discourse/tests/acceptance/categories-test.js @@ -0,0 +1,76 @@ +import { acceptance, exists } from "discourse/tests/helpers/qunit-helpers"; +import { visit } from "@ember/test-helpers"; +import { test } from "qunit"; + +acceptance("Categories - 'categories_only'", function (needs) { + needs.settings({ + desktop_category_page_style: "categories_only", + }); + test("basic functionality", async function (assert) { + await visit("/categories"); + assert.ok( + exists("table.category-list tr[data-category-id=1]"), + "shows the topic list" + ); + }); +}); + +acceptance("Categories - 'categories_and_latest_topics'", function (needs) { + needs.settings({ + desktop_category_page_style: "categories_and_latest_topics", + }); + test("basic functionality", async function (assert) { + await visit("/categories"); + assert.ok( + exists("table.category-list tr[data-category-id=1]"), + "shows a category" + ); + assert.ok( + exists("div.latest-topic-list div[data-topic-id=8]"), + "shows the topic list" + ); + }); +}); + +acceptance("Categories - 'categories_with_featured_topics'", function (needs) { + needs.settings({ + desktop_category_page_style: "categories_with_featured_topics", + }); + test("basic functionality", async function (assert) { + await visit("/categories"); + assert.ok( + exists("table.category-list.with-topics tr[data-category-id=1]"), + "shows a category" + ); + assert.ok( + exists("table.category-list.with-topics div[data-topic-id=11994]"), + "shows a featured topic" + ); + }); +}); + +acceptance( + "Categories - 'subcategories_with_featured_topics'", + function (needs) { + needs.settings({ + desktop_category_page_style: "subcategories_with_featured_topics", + }); + test("basic functionality", async function (assert) { + await visit("/categories"); + assert.ok( + exists("table.subcategory-list.with-topics thead h3 .category-name"), + "shows heading for top-level category" + ); + assert.ok( + exists( + "table.subcategory-list.with-topics tr[data-category-id=26] h3 .category-name" + ), + "shows table row for subcategories" + ); + assert.ok( + exists("table.category-list.with-topics div[data-topic-id=11994]"), + "shows a featured topic" + ); + }); + } +); diff --git a/app/assets/stylesheets/common/base/category-list.scss b/app/assets/stylesheets/common/base/category-list.scss index d45b6b3e8d9..37eba8e9a7a 100644 --- a/app/assets/stylesheets/common/base/category-list.scss +++ b/app/assets/stylesheets/common/base/category-list.scss @@ -23,6 +23,24 @@ } } +.navigation-categories .category-list.subcategory-list { + margin-bottom: 1em; +} + +.subcategory-list { + th.category { + h3 { + display: inline; + } + .category-text-title { + display: inline-flex; + } + .stat { + margin-left: 0.5em; + } + } +} + .category-boxes, .category-boxes-with-topics { display: flex; diff --git a/app/controllers/categories_controller.rb b/app/controllers/categories_controller.rb index eddf3489478..746bc2d3aec 100644 --- a/app/controllers/categories_controller.rb +++ b/app/controllers/categories_controller.rb @@ -23,11 +23,14 @@ class CategoriesController < ApplicationController parent_category = Category.find_by_slug(params[:parent_category_id]) || Category.find_by(id: params[:parent_category_id].to_i) + include_subcategories = SiteSetting.desktop_category_page_style == "subcategories_with_featured_topics" || + params[:include_subcategories] == "true" + category_options = { is_homepage: current_homepage == "categories", parent_category_id: params[:parent_category_id], include_topics: include_topics(parent_category), - include_subcategories: params[:include_subcategories] == "true" + include_subcategories: include_subcategories } @category_list = CategoryList.new(guardian, category_options) @@ -377,6 +380,7 @@ class CategoriesController < ApplicationController params[:include_topics] || (parent_category && parent_category.subcategory_list_includes_topics?) || style == "categories_with_featured_topics" || + style == "subcategories_with_featured_topics" || style == "categories_boxes_with_topics" || style == "categories_with_top_topics" end diff --git a/app/models/category_list.rb b/app/models/category_list.rb index d6a1b67ed42..90ca8e2a7f3 100644 --- a/app/models/category_list.rb +++ b/app/models/category_list.rb @@ -113,13 +113,6 @@ class CategoryList notification_levels = CategoryUser.notification_levels_for(@guardian.user) default_notification_level = CategoryUser.default_notification_level - allowed_topic_create = Set.new(Category.topic_create_allowed(@guardian).pluck(:id)) - @categories.each do |category| - category.notification_level = notification_levels[category.id] || default_notification_level - category.permission = CategoryGroup.permission_types[:full] if allowed_topic_create.include?(category.id) - category.has_children = category.subcategories.present? - end - if @options[:parent_category_id].blank? subcategory_ids = {} subcategory_list = {} @@ -144,8 +137,15 @@ class CategoryList @categories.delete_if { |c| to_delete.include?(c) } end + allowed_topic_create = Set.new(Category.topic_create_allowed(@guardian).pluck(:id)) + categories_with_descendants.each do |category| + category.notification_level = notification_levels[category.id] || default_notification_level + category.permission = CategoryGroup.permission_types[:full] if allowed_topic_create.include?(category.id) + category.has_children = category.subcategories.present? + end + if @topics_by_category_id - @categories.each do |c| + categories_with_descendants.each do |c| topics_in_cat = @topics_by_category_id[c.id] if topics_in_cat.present? c.displayable_topics = [] @@ -178,7 +178,7 @@ class CategoryList # Put unpinned topics at the end of the list def sort_unpinned if @guardian.current_user && @all_topics.present? - @categories.each do |c| + categories_with_descendants.each do |c| next if c.displayable_topics.blank? || c.displayable_topics.size <= c.num_featured_topics unpinned = [] c.displayable_topics.each do |t| @@ -198,10 +198,22 @@ class CategoryList end def trim_results - @categories.each do |c| + categories_with_descendants.each do |c| next if c.displayable_topics.blank? c.displayable_topics = c.displayable_topics[0, c.num_featured_topics] end end + def categories_with_descendants(categories = @categories) + return @categories_with_children if @categories_with_children && (categories == @categories) + return nil if categories.nil? + + result = categories.flat_map do |c| + [c, *categories_with_descendants(c.subcategory_list)] + end + + @categories_with_children = result if categories == @categories + + result + end end diff --git a/app/models/category_page_style.rb b/app/models/category_page_style.rb index d1598380909..4d0eaf75170 100644 --- a/app/models/category_page_style.rb +++ b/app/models/category_page_style.rb @@ -16,6 +16,7 @@ class CategoryPageStyle < EnumSiteSetting { name: 'category_page_style.categories_and_top_topics', value: 'categories_and_top_topics' }, { name: 'category_page_style.categories_boxes', value: 'categories_boxes' }, { name: 'category_page_style.categories_boxes_with_topics', value: 'categories_boxes_with_topics' }, + { name: 'category_page_style.subcategories_with_featured_topics', value: 'subcategories_with_featured_topics' }, ] end diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index e6683a9fc16..d45d731e3a2 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -2016,6 +2016,7 @@ en: categories_and_top_topics: "Categories and Top Topics" categories_boxes: "Boxes with Subcategories" categories_boxes_with_topics: "Boxes with Featured Topics" + subcategories_with_featured_topics: "Subcategories with Featured Topics" shortcut_modifier_key: shift: "Shift" diff --git a/spec/requests/categories_controller_spec.rb b/spec/requests/categories_controller_spec.rb index 1a908dd19a5..7a539a4af01 100644 --- a/spec/requests/categories_controller_spec.rb +++ b/spec/requests/categories_controller_spec.rb @@ -115,6 +115,50 @@ describe CategoriesController do expect(subcategories_for_category).to eq(nil) end + it 'includes topics for categories, subcategories and subsubcategories when requested' do + SiteSetting.max_category_nesting = 3 + subcategory = Fabricate(:category, user: admin, parent_category: category) + subsubcategory = Fabricate(:category, user: admin, parent_category: subcategory) + + topic1 = Fabricate(:topic, category: category) + topic2 = Fabricate(:topic, category: subcategory) + topic3 = Fabricate(:topic, category: subsubcategory) + CategoryFeaturedTopic.feature_topics + + get "/categories.json?include_subcategories=true&include_topics=true" + expect(response.status).to eq(200) + + category_list = response.parsed_body["category_list"] + + category_response = category_list["categories"].find { |c| c["id"] == category.id } + expect(category_response["topics"].map { |c| c['id'] }).to contain_exactly(topic1.id) + + subcategory_response = category_response["subcategory_list"][0] + expect(subcategory_response["topics"].map { |c| c['id'] }).to contain_exactly(topic2.id) + + subsubcategory_response = subcategory_response["subcategory_list"][0] + expect(subsubcategory_response["topics"].map { |c| c['id'] }).to contain_exactly(topic3.id) + end + + it 'includes subcategories and topics by default when view is subcategories_with_featured_topics' do + SiteSetting.max_category_nesting = 3 + subcategory = Fabricate(:category, user: admin, parent_category: category) + + topic1 = Fabricate(:topic, category: category) + CategoryFeaturedTopic.feature_topics + + SiteSetting.desktop_category_page_style = "subcategories_with_featured_topics" + get "/categories.json" + expect(response.status).to eq(200) + + category_list = response.parsed_body["category_list"] + + category_response = category_list["categories"].find { |c| c["id"] == category.id } + expect(category_response["topics"].map { |c| c['id'] }).to contain_exactly(topic1.id) + + expect(category_response["subcategory_list"][0]["id"]).to eq(subcategory.id) + end + it 'does not show uncategorized unless allow_uncategorized_topics' do SiteSetting.desktop_category_page_style = "categories_boxes_with_topics"