diff --git a/app/assets/javascripts/discourse/app/components/sidebar/user/categories-section.js b/app/assets/javascripts/discourse/app/components/sidebar/user/categories-section.js index 10c66629d6d..6175aa28a19 100644 --- a/app/assets/javascripts/discourse/app/components/sidebar/user/categories-section.js +++ b/app/assets/javascripts/discourse/app/components/sidebar/user/categories-section.js @@ -1,28 +1,53 @@ import { inject as service } from "@ember/service"; import { action } from "@ember/object"; -import Category from "discourse/models/category"; import { cached } from "@glimmer/tracking"; +import Category from "discourse/models/category"; import SidebarCommonCategoriesSection from "discourse/components/sidebar/common/categories-section"; +import discourseDebounce from "discourse-common/lib/debounce"; + +export const REFRESH_COUNTS_APP_EVENT_NAME = + "sidebar:refresh-categories-section-counts"; export default class SidebarUserCategoriesSection extends SidebarCommonCategoriesSection { @service router; @service currentUser; + @service appEvents; constructor() { super(...arguments); this.callbackId = this.topicTrackingState.onStateChange(() => { - this.sectionLinks.forEach((sectionLink) => { - sectionLink.refreshCounts(); - }); + this.#refreshCounts(); }); + + this.appEvents.on(REFRESH_COUNTS_APP_EVENT_NAME, this, this.#refreshCounts); } willDestroy() { super.willDestroy(...arguments); this.topicTrackingState.offStateChange(this.callbackId); + + this.appEvents.off( + REFRESH_COUNTS_APP_EVENT_NAME, + this, + this.#refreshCounts + ); + } + + #refreshCounts() { + // TopicTrackingState changes or plugins can trigger this function so we debounce to ensure we're not refreshing + // unnecessarily. + discourseDebounce( + this, + () => { + this.sectionLinks.forEach((sectionLink) => { + sectionLink.refreshCounts(); + }); + }, + 300 + ); } @cached diff --git a/app/assets/javascripts/discourse/app/lib/plugin-api.js b/app/assets/javascripts/discourse/app/lib/plugin-api.js index fe587a43358..0822df4fbe3 100644 --- a/app/assets/javascripts/discourse/app/lib/plugin-api.js +++ b/app/assets/javascripts/discourse/app/lib/plugin-api.js @@ -104,6 +104,8 @@ import { downloadCalendar } from "discourse/lib/download-calendar"; import { consolePrefix } from "discourse/lib/source-identifier"; import { addSectionLink as addCustomCommunitySectionLink } from "discourse/lib/sidebar/custom-community-section-links"; import { addSidebarSection } from "discourse/lib/sidebar/custom-sections"; +import { registerCustomCountable as registerUserCategorySectionLinkCountable } from "discourse/lib/sidebar/user/categories-section/category-section-link"; +import { REFRESH_COUNTS_APP_EVENT_NAME as REFRESH_USER_SIDEBAR_CATEGORIES_SECTION_COUNTS_APP_EVENT_NAME } from "discourse/components/sidebar/user/categories-section"; import DiscourseURL from "discourse/lib/url"; import { registerNotificationTypeRenderer } from "discourse/lib/notification-types-manager"; import { registerUserMenuTab } from "discourse/lib/user-menu/tab"; @@ -1809,6 +1811,87 @@ class PluginApi { addCustomCommunitySectionLink(arg, secondary); } + /** + * EXPERIMENTAL. Do not use. + * Registers a new countable for section links under Sidebar Categories section on top of the default countables of + * unread topics count and new topics count. + * + * ``` + * api.registerUserCategorySectionLinkCountable({ + * badgeTextFunction: (count) => { + * return I18n.t("custom.open_count", count: count"); + * }, + * route: "discovery.openCategory", + * shouldRegister: ({ category } => { + * return category.custom_fields.enable_open_topics_count; + * }), + * refreshCountFunction: ({ _topicTrackingState, category } => { + * return category.open_topics_count; + * }), + * prioritizeDefaults: ({ currentUser, category } => { + * return category.custom_fields.show_open_topics_count_first; + * }) + * }) + * ``` + * + * @callback badgeTextFunction + * @param {Integer} count - The count as given by the `refreshCountFunction`. + * @returns {String} - Text for the badge displayed in the section link. + * + * @callback shouldRegister + * @param {Object} arg + * @param {Category} arg.category - The category model for the sidebar section link. + * @returns {Boolean} - Whether the countable should be registered for the sidebar section link. + * + * @callback refreshCountFunction + * @param {Object} arg + * @param {Category} arg.category - The category model for the sidebar section link. + * @returns {integer} - The value used to set the property for the count. + * + * @callback prioritizeOverDefaults + * @param {Object} arg + * @param {Category} arg.category - The category model for the sidebar section link. + * @param {User} arg.currentUser - The user model for the current user. + * @returns {boolean} - Whether the countable should be prioritized over the defaults. + * + * @param {Object} arg - An object + * @param {string} arg.badgeTextFunction - Function used to generate the text for the badge displayed in the section link. + * @param {string} arg.route - The Ember route name to generate the href attribute for the link. + * @param {Object=} arg.routeQuery - Object representing the query params that should be appended to the route generated. + * @param {shouldRegister} arg.shouldRegister - Function used to determine if the countable should be registered for the category. + * @param {refreshCountFunction} arg.refreshCountFunction - Function used to calculate the value used to set the property for the count whenever the sidebar section link refreshes. + * @param {prioritizeOverDefaults} args.prioritizeOverDefaults - Function used to determine whether the countable should be prioritized over the default countables of unread/new. + */ + registerUserCategorySectionLinkCountable({ + badgeTextFunction, + route, + routeQuery, + shouldRegister, + refreshCountFunction, + prioritizeOverDefaults, + }) { + registerUserCategorySectionLinkCountable({ + badgeTextFunction, + route, + routeQuery, + shouldRegister, + refreshCountFunction, + prioritizeOverDefaults, + }); + } + + /** + * EXPERIMENTAL. Do not use. + * Triggers a refresh of the counts for all category section links under the categories section for a logged in user. + */ + refreshUserSidebarCategoriesSectionCounts() { + const appEvents = this._lookupContainer("service:app-events"); + + appEvents?.trigger( + REFRESH_USER_SIDEBAR_CATEGORIES_SECTION_COUNTS_APP_EVENT_NAME + ); + } + /** * EXPERIMENTAL. Do not use. * Support for adding a Sidebar section by returning a class which extends from the BaseCustomSidebarSection diff --git a/app/assets/javascripts/discourse/app/lib/sidebar/user/categories-section/category-section-link.js b/app/assets/javascripts/discourse/app/lib/sidebar/user/categories-section/category-section-link.js index e855089d0af..1427b50bdf5 100644 --- a/app/assets/javascripts/discourse/app/lib/sidebar/user/categories-section/category-section-link.js +++ b/app/assets/javascripts/discourse/app/lib/sidebar/user/categories-section/category-section-link.js @@ -1,35 +1,121 @@ import I18n from "I18n"; import { tracked } from "@glimmer/tracking"; +import { get, set } from "@ember/object"; import { bind } from "discourse-common/utils/decorators"; import Category from "discourse/models/category"; import { UNREAD_LIST_DESTINATION } from "discourse/controllers/preferences/sidebar"; +const DEFAULT_COUNTABLES = [ + { + propertyName: "totalUnread", + badgeTextFunction: (count) => { + return I18n.t("sidebar.unread_count", { count }); + }, + route: "discovery.unreadCategory", + refreshCountFunction: ({ topicTrackingState, category }) => { + return topicTrackingState.countUnread({ + categoryId: category.id, + }); + }, + }, + { + propertyName: "totalNew", + badgeTextFunction: (count) => { + return I18n.t("sidebar.new_count", { count }); + }, + route: "discovery.newCategory", + refreshCountFunction: ({ topicTrackingState, category }) => { + return topicTrackingState.countNew({ + categoryId: category.id, + }); + }, + }, +]; + +const customCountables = []; + +export function registerCustomCountable({ + badgeTextFunction, + route, + routeQuery, + shouldRegister, + refreshCountFunction, + prioritizeOverDefaults, +}) { + const length = customCountables.length + 1; + + customCountables.push({ + propertyName: `customCountableProperty${length}`, + badgeTextFunction, + route, + routeQuery, + shouldRegister, + refreshCountFunction, + prioritizeOverDefaults, + }); +} + +export function resetCustomCountables() { + customCountables.length = 0; +} + export default class CategorySectionLink { - @tracked totalUnread = 0; - @tracked totalNew = 0; - @tracked hideCount = - this.currentUser?.sidebarListDestination !== UNREAD_LIST_DESTINATION; + @tracked activeCountable; constructor({ category, topicTrackingState, currentUser }) { this.category = category; this.topicTrackingState = topicTrackingState; this.currentUser = currentUser; + this.countables = this.#countables(); + this.refreshCounts(); } + #countables() { + const countables = [...DEFAULT_COUNTABLES]; + + if (customCountables.length > 0) { + customCountables.forEach((customCountable) => { + if ( + !customCountable.shouldRegister || + customCountable.shouldRegister({ category: this.category }) + ) { + if ( + customCountable?.prioritizeOverDefaults({ + category: this.category, + currentUser: this.currentUser, + }) + ) { + countables.unshift(customCountable); + } else { + countables.push(customCountable); + } + } + }); + } + + return countables; + } + + get hideCount() { + return this.currentUser?.sidebarListDestination !== UNREAD_LIST_DESTINATION; + } + @bind refreshCounts() { - this.totalUnread = this.topicTrackingState.countUnread({ - categoryId: this.category.id, - }); + this.countables = this.#countables(); - if (this.totalUnread === 0) { - this.totalNew = this.topicTrackingState.countNew({ - categoryId: this.category.id, + this.activeCountable = this.countables.find((countable) => { + const count = countable.refreshCountFunction({ + topicTrackingState: this.topicTrackingState, + category: this.category, }); - } + + set(this, countable.propertyName, count); + return count > 0; + }); } get name() { @@ -74,29 +160,38 @@ export default class CategorySectionLink { if (this.hideCount) { return; } - if (this.totalUnread > 0) { - return I18n.t("sidebar.unread_count", { - count: this.totalUnread, - }); - } else if (this.totalNew > 0) { - return I18n.t("sidebar.new_count", { - count: this.totalNew, - }); + + const activeCountable = this.activeCountable; + + if (activeCountable) { + return activeCountable.badgeTextFunction( + get(this, activeCountable.propertyName) + ); } } get route() { if (this.currentUser?.sidebarListDestination === UNREAD_LIST_DESTINATION) { - if (this.totalUnread > 0) { - return "discovery.unreadCategory"; - } - if (this.totalNew > 0) { - return "discovery.newCategory"; + const activeCountable = this.activeCountable; + + if (activeCountable) { + return activeCountable.route; } } + return "discovery.category"; } + get query() { + if (this.currentUser?.sidebarListDestination === UNREAD_LIST_DESTINATION) { + const activeCountable = this.activeCountable; + + if (activeCountable?.routeQuery) { + return activeCountable.routeQuery; + } + } + } + get suffixCSSClass() { return "unread"; } @@ -106,7 +201,7 @@ export default class CategorySectionLink { } get suffixValue() { - if (this.hideCount && (this.totalUnread || this.totalNew)) { + if (this.hideCount && this.activeCountable) { return "circle"; } } diff --git a/app/assets/javascripts/discourse/tests/acceptance/sidebar-plugin-api-test.js b/app/assets/javascripts/discourse/tests/acceptance/sidebar-plugin-api-test.js index 489972f675f..70f063461e5 100644 --- a/app/assets/javascripts/discourse/tests/acceptance/sidebar-plugin-api-test.js +++ b/app/assets/javascripts/discourse/tests/acceptance/sidebar-plugin-api-test.js @@ -1,13 +1,17 @@ import { test } from "qunit"; import I18n from "I18n"; -import { click, visit } from "@ember/test-helpers"; +import { click, settled, visit } from "@ember/test-helpers"; import { acceptance, exists, query, queryAll, + updateCurrentUser, } from "discourse/tests/helpers/qunit-helpers"; import { withPluginApi } from "discourse/lib/plugin-api"; +import Site from "discourse/models/site"; +import { resetCustomCountables } from "discourse/lib/sidebar/user/categories-section/category-section-link"; +import { UNREAD_LIST_DESTINATION } from "discourse/controllers/preferences/sidebar"; import { bind } from "discourse-common/utils/decorators"; acceptance("Sidebar - Plugin API", function (needs) { @@ -629,4 +633,115 @@ acceptance("Sidebar - Plugin API", function (needs) { "does not display the section" ); }); + + test("Registering a custom countable for a section link in the user's sidebar categories section", async function (assert) { + try { + return await withPluginApi("1.6.0", async (api) => { + const categories = Site.current().categories; + const category1 = categories[0]; + const category2 = categories[1]; + + updateCurrentUser({ + sidebar_category_ids: [category1.id, category2.id], + }); + + // User has one unread topic + this.container.lookup("service:topic-tracking-state").loadStates([ + { + topic_id: 2, + highest_post_number: 12, + last_read_post_number: 11, + created_at: "2020-02-09T09:40:02.672Z", + category_id: category1.id, + notification_level: 2, + created_in_new_period: false, + treat_as_new_topic_start_date: "2022-05-09T03:17:34.286Z", + }, + ]); + + api.registerUserCategorySectionLinkCountable({ + badgeTextFunction: (count) => { + return `some custom ${count}`; + }, + route: "discovery.latestCategory", + routeQuery: { status: "open" }, + shouldRegister: ({ category }) => { + if (category.name === category1.name) { + return true; + } else if (category.name === category2.name) { + return false; + } + }, + refreshCountFunction: ({ category }) => { + return category.topic_count; + }, + prioritizeOverDefaults: ({ category }) => { + return category.topic_count > 1000; + }, + }); + + await visit("/"); + + assert.ok( + exists( + `.sidebar-section-link-${category1.name} .sidebar-section-link-suffix.unread` + ), + "the right suffix is displayed when custom countable is active" + ); + + assert.strictEqual( + query(`.sidebar-section-link-${category1.name}`).pathname, + `/c/${category1.name}/${category1.id}`, + "does not use route configured for custom countable when user has elected not to show any counts in sidebar" + ); + + assert.notOk( + exists( + `.sidebar-section-link-${category2.name} .sidebar-section-link-suffix.unread` + ), + "does not display suffix when custom countable is not registered" + ); + + updateCurrentUser({ + sidebar_list_destination: UNREAD_LIST_DESTINATION, + }); + + assert.strictEqual( + query( + `.sidebar-section-link-${category1.name} .sidebar-section-link-content-badge` + ).innerText.trim(), + I18n.t("sidebar.unread_count", { count: 1 }), + "displays the right badge text in section link when unread is present and custom countable is not prioritised over unread" + ); + + category1.set("topic_count", 2000); + + api.refreshUserSidebarCategoriesSectionCounts(); + + await settled(); + + assert.strictEqual( + query( + `.sidebar-section-link-${category1.name} .sidebar-section-link-content-badge` + ).innerText.trim(), + `some custom ${category1.topic_count}`, + "displays the right badge text in section link when unread is present but custom countable is prioritised over unread" + ); + + assert.strictEqual( + query(`.sidebar-section-link-${category1.name}`).pathname, + `/c/${category1.name}/${category1.id}/l/latest`, + "has the right pathname for section link" + ); + + assert.strictEqual( + query(`.sidebar-section-link-${category1.name}`).search, + "?status=open", + "has the right query params for section link" + ); + }); + } finally { + resetCustomCountables(); + } + }); });