DEV: Experiment plugin api to add custom count to category section link

This commit introduces the experimental `registerUserCategorySectionLinkCountable`
and `refreshUserSidebarCategoriesSectionCounts` plugin APIs that allows
a plugin to register custom countables to category section links on top
of the defaults of unread and new.
This commit is contained in:
Alan Guo Xiang Tan 2022-12-19 10:31:00 +08:00
parent c46cd1bd04
commit f71e3c07dd
4 changed files with 348 additions and 30 deletions

View File

@ -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

View File

@ -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

View File

@ -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";
}
}

View File

@ -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();
}
});
});