DEV: Refactor notification/reviewable items rendering in the new user menu (#17792)

Prior to this commit, we had a default Glimmer component that was responsible for handling generic rendering of notifications in the user menu, and many notification types had a custom Glimmer component that inherited from the default component to customize how they were rendered. That implementation was less than ideal because it meant plugins would have to create Glimmer components to customize notification types added by them and that would make the surface area of the API too big.

This commit changes the implementation so there's only one Glimmer component for rendering notifications, and then notification types that need to be customized can create a regular JavaScript class - `renderDirector` in the code - that provides the Glimmer component with the content it should display. We also introduce an API for plugins to register a renderer for a notification type or override an existing one.

Some of the changes are partially extracted from https://github.com/discourse/discourse/pull/17379.
This commit is contained in:
Osama Sayegh 2022-08-05 07:55:00 +03:00 committed by GitHub
parent d600c36036
commit 0df1c4eab2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
55 changed files with 1085 additions and 806 deletions

View File

@ -1,11 +0,0 @@
import UserMenuNotificationItem from "discourse/components/user-menu/notification-item";
export default class UserMenuGroupMentionedNotificationItem extends UserMenuNotificationItem {
get label() {
return `${this.username} @${this.notification.data.group_name}`;
}
get labelWrapperClasses() {
return "mention-group notify";
}
}

View File

@ -1,23 +0,0 @@
import UserMenuNotificationItem from "discourse/components/user-menu/notification-item";
import I18n from "I18n";
export default class UserMenuGroupMessageSummaryNotificationItem extends UserMenuNotificationItem {
get inboxCount() {
return this.notification.data.inbox_count;
}
get label() {
return I18n.t("notifications.group_message_summary", {
count: this.inboxCount,
group_name: this.notification.data.group_name,
});
}
get wrapLabel() {
return false;
}
get description() {
return null;
}
}

View File

@ -5,7 +5,7 @@
{{else if this.items.length}} {{else if this.items.length}}
<ul> <ul>
{{#each this.items as |item|}} {{#each this.items as |item|}}
{{component item.userMenuComponent item=item}} {{component this.itemComponent item=item}}
{{/each}} {{/each}}
</ul> </ul>
<div class="panel-body-bottom"> <div class="panel-body-bottom">

View File

@ -28,6 +28,12 @@ export default class UserMenuItemsList extends GlimmerComponent {
return "user-menu/items-list-empty-state"; return "user-menu/items-list-empty-state";
} }
get itemComponent() {
throw new Error(
`the itemComponent property must be implemented in ${this.constructor.name}`
);
}
fetchItems() { fetchItems() {
throw new Error( throw new Error(
`the fetchItems method must be implemented in ${this.constructor.name}` `the fetchItems method must be implemented in ${this.constructor.name}`
@ -51,17 +57,6 @@ export default class UserMenuItemsList extends GlimmerComponent {
} }
this.fetchItems() this.fetchItems()
.then((items) => { .then((items) => {
const valid = items.every((item) => {
if (!item.userMenuComponent) {
// eslint-disable-next-line no-console
console.error("userMenuComponent property is blank on", item);
return false;
}
return true;
});
if (!valid) {
throw new Error("userMenuComponent must be present on all items");
}
this._setCachedItems(items); this._setCachedItems(items);
this.items = items; this.items = items;
}) })

View File

@ -1,21 +0,0 @@
import UserMenuNotificationItem from "discourse/components/user-menu/notification-item";
import { userPath } from "discourse/lib/url";
import I18n from "I18n";
export default class UserMenuLikedConsolidatedNotificationItem extends UserMenuNotificationItem {
get linkHref() {
return userPath(
`${
this.notification.username || this.currentUser.username
}/notifications/likes-received?acting_username=${
this.notification.data.username
}`
);
}
get description() {
return I18n.t("notifications.liked_consolidated_description", {
count: this.notification.data.count,
});
}
}

View File

@ -1,8 +0,0 @@
import UserMenuNotificationItem from "discourse/components/user-menu/notification-item";
import I18n from "I18n";
export default class UserMenuMovedPostNotificationItem extends UserMenuNotificationItem {
get label() {
return I18n.t("notifications.user_moved_post", { username: this.username });
}
}

View File

@ -7,17 +7,13 @@
{{d-icon this.icon}} {{d-icon this.icon}}
<div> <div>
{{#if this.label}} {{#if this.label}}
{{#if this.wrapLabel}} <span class={{concat "notification-label " this.labelWrapperClasses}}>
<span class={{concat "notification-label " this.labelWrapperClasses}}> {{this.label}}
{{this.label}} </span>
</span>
{{else}}
<span>{{this.label}}</span>
{{/if}}
{{/if}} {{/if}}
{{#if this.description}} {{#if this.description}}
<span <span
class={{concat "notification-description " this.descriptionElementClasses}} class={{concat "notification-description " this.descriptionWrapperClasses}}
data-topic-id={{this.topicId}} data-topic-id={{this.topicId}}
> >
{{this.description}} {{this.description}}

View File

@ -1,98 +1,73 @@
import GlimmerComponent from "discourse/components/glimmer"; import GlimmerComponent from "discourse/components/glimmer";
import { formatUsername, postUrl } from "discourse/lib/utilities";
import { userPath } from "discourse/lib/url";
import { setTransientHeader } from "discourse/lib/ajax"; import { setTransientHeader } from "discourse/lib/ajax";
import { action } from "@ember/object"; import { action } from "@ember/object";
import { emojiUnescape } from "discourse/lib/text"; import { getRenderDirector } from "discourse/lib/notification-item";
import { htmlSafe } from "@ember/template";
import getURL from "discourse-common/lib/get-url"; import getURL from "discourse-common/lib/get-url";
import cookie from "discourse/lib/cookie"; import cookie from "discourse/lib/cookie";
import I18n from "I18n";
export default class UserMenuNotificationItem extends GlimmerComponent { export default class UserMenuNotificationItem extends GlimmerComponent {
constructor() {
super(...arguments);
this.renderDirector = getRenderDirector(
this.#notificationName,
this.notification,
this.currentUser,
this.siteSettings,
this.site
);
}
get className() { get className() {
const classes = []; const classes = [];
if (this.notification.read) { if (this.notification.read) {
classes.push("read"); classes.push("read");
} }
if (this.notificationName) { if (this.#notificationName) {
classes.push(this.notificationName.replace(/_/g, "-")); classes.push(this.#notificationName.replace(/_/g, "-"));
} }
if (this.notification.is_warning) { if (this.notification.is_warning) {
classes.push("is-warning"); classes.push("is-warning");
} }
const extras = this.renderDirector.classNames;
if (extras?.length) {
classes.push(...extras);
}
return classes.join(" "); return classes.join(" ");
} }
get linkHref() { get linkHref() {
if (this.topicId) { return this.renderDirector.linkHref;
return postUrl(
this.notification.slug,
this.topicId,
this.notification.post_number
);
}
if (this.notification.data.group_id) {
return userPath(
`${this.notification.data.username}/messages/${this.notification.data.group_name}`
);
}
} }
get linkTitle() { get linkTitle() {
if (this.notificationName) { return this.renderDirector.linkTitle;
return I18n.t(`notifications.titles.${this.notificationName}`);
} else {
return "";
}
} }
get icon() { get icon() {
return `notification.${this.notificationName}`; return this.renderDirector.icon;
} }
get label() { get label() {
return this.username; return this.renderDirector.label;
} }
get wrapLabel() { get labelWrapperClasses() {
return true; return this.renderDirector.labelWrapperClasses?.join(" ") || "";
}
get labelWrapperClasses() {}
get username() {
return formatUsername(this.notification.data.display_username);
} }
get description() { get description() {
const description = return this.renderDirector.description;
emojiUnescape(this.notification.fancy_title) ||
this.notification.data.topic_title;
if (this.descriptionHtmlSafe) {
return htmlSafe(description);
} else {
return description;
}
} }
get descriptionElementClasses() {} get descriptionWrapperClasses() {
return this.renderDirector.descriptionWrapperClasses?.join(" ") || "";
get descriptionHtmlSafe() {
return !!this.notification.fancy_title;
} }
// the following props are helper props -- they're never referenced directly in the hbs template
get notification() { get notification() {
return this.args.item; return this.args.item;
} }
get topicId() { get #notificationName() {
return this.notification.topic_id;
}
get notificationName() {
return this.site.notificationLookup[this.notification.notification_type]; return this.site.notificationLookup[this.notification.notification_type];
} }
@ -103,5 +78,6 @@ export default class UserMenuNotificationItem extends GlimmerComponent {
setTransientHeader("Discourse-Clear-Notifications", this.notification.id); setTransientHeader("Discourse-Clear-Notifications", this.notification.id);
cookie("cn", this.notification.id, { path: getURL("/") }); cookie("cn", this.notification.id, { path: getURL("/") });
} }
this.renderDirector.onClick();
} }
} }

View File

@ -43,6 +43,10 @@ export default class UserMenuNotificationsList extends UserMenuItemsList {
} }
} }
get itemComponent() {
return "user-menu/notification-item";
}
fetchItems() { fetchItems() {
const params = { const params = {
limit: 30, limit: 30,

View File

@ -0,0 +1,28 @@
import GlimmerComponent from "discourse/components/glimmer";
import { getRenderDirector } from "discourse/lib/reviewable-item";
export default class UserMenuReviewableItem extends GlimmerComponent {
constructor() {
super(...arguments);
this.reviewable = this.args.item;
this.renderDirector = getRenderDirector(
this.reviewable.type,
this.reviewable,
this.currentUser,
this.siteSettings,
this.site
);
}
get actor() {
return this.renderDirector.actor;
}
get description() {
return this.renderDirector.description;
}
get icon() {
return this.renderDirector.icon;
}
}

View File

@ -17,6 +17,10 @@ export default class UserMenuReviewablesList extends UserMenuItemsList {
return "pending-reviewables"; return "pending-reviewables";
} }
get itemComponent() {
return "user-menu/reviewable-item";
}
fetchItems() { fetchItems() {
return ajax("/review/user-menu-list").then((data) => { return ajax("/review/user-menu-list").then((data) => {
return data.reviewables.map((item) => { return data.reviewables.map((item) => {

View File

@ -1,8 +0,0 @@
import UserMenuNotificationItem from "discourse/components/user-menu/notification-item";
import I18n from "I18n";
export default class UserMenuWatchingFirstPostNotificationItem extends UserMenuNotificationItem {
get label() {
return I18n.t("notifications.watching_first_post_label");
}
}

View File

@ -0,0 +1,51 @@
import NotificationItemBase from "discourse/lib/notification-items/base";
import BookmarkReminder from "discourse/lib/notification-items/bookmark-reminder";
import Custom from "discourse/lib/notification-items/custom";
import GrantedBadge from "discourse/lib/notification-items/granted-badge";
import GroupMentioned from "discourse/lib/notification-items/group-mentioned";
import GroupMessageSummary from "discourse/lib/notification-items/group-message-summary";
import InviteeAccepted from "discourse/lib/notification-items/invitee-accepted";
import LikedConsolidated from "discourse/lib/notification-items/liked-consolidated";
import Liked from "discourse/lib/notification-items/liked";
import MembershipRequestAccepted from "discourse/lib/notification-items/membership-request-accepted";
import MembershipRequestConsolidated from "discourse/lib/notification-items/membership-request-consolidated";
import MovedPost from "discourse/lib/notification-items/moved-post";
import WatchingFirstPost from "discourse/lib/notification-items/watching-first-post";
const CLASS_FOR_TYPE = {
bookmark_reminder: BookmarkReminder,
custom: Custom,
granted_badge: GrantedBadge,
group_mentioned: GroupMentioned,
group_message_summary: GroupMessageSummary,
invitee_accepted: InviteeAccepted,
liked: Liked,
liked_consolidated: LikedConsolidated,
membership_request_accepted: MembershipRequestAccepted,
membership_request_consolidated: MembershipRequestConsolidated,
moved_post: MovedPost,
watching_first_post: WatchingFirstPost,
};
let _customClassForType = {};
export function registerNotificationTypeRenderer(notificationType, func) {
_customClassForType[notificationType] = func(NotificationItemBase);
}
export function resetRenderDirectorForNotifictaionTypes() {
_customClassForType = {};
}
export function getRenderDirector(
type,
notification,
currentUser,
siteSettings,
site
) {
const klass =
_customClassForType[type] || CLASS_FOR_TYPE[type] || NotificationItemBase;
return new klass({ notification, currentUser, siteSettings, site });
}

View File

@ -0,0 +1,109 @@
import { formatUsername, postUrl } from "discourse/lib/utilities";
import { userPath } from "discourse/lib/url";
import { emojiUnescape } from "discourse/lib/text";
import { htmlSafe } from "@ember/template";
import I18n from "I18n";
export default class NotificationItemBase {
constructor({ notification, currentUser, siteSettings, site }) {
this.notification = notification;
this.currentUser = currentUser;
this.siteSettings = siteSettings;
this.site = site;
}
/**
* @returns {string[]} An array of addtional classes that should be added to the <li> element of the notification item.
*/
get classNames() {
return [];
}
/**
* @returns {string} A href/path that the notification item should link to.
*/
get linkHref() {
if (this.topicId) {
return postUrl(
this.notification.slug,
this.topicId,
this.notification.post_number
);
}
if (this.notification.data.group_id) {
return userPath(
`${this.notification.data.username}/messages/${this.notification.data.group_name}`
);
}
}
/**
* @returns {string} A title for the notification item. It shows up when the user hovers over the notification item.
*/
get linkTitle() {
if (this.notificationName) {
return I18n.t(`notifications.titles.${this.notificationName}`);
} else {
// notifications with unknown types, e.g. notifications that come from a
// plugin that's no longer installed
return "";
}
}
/**
* @returns {string} An icon for the notification item.
*/
get icon() {
return `notification.${this.notificationName}`;
}
/**
* @returns {string} The label is the first part of the text content displayed in the notification. For example, in a like notification, the username of the user who liked the post is the label. If a falsey value is returned, the label is omitted.
*/
get label() {
return this.username;
}
/**
* @returns {string} The description is the second part of the text content displayed in the notification. For example, in a like notification, the topic title is the description. If a falsey value is returned, the description is omitted.
*/
get description() {
const description = emojiUnescape(this.notification.fancy_title);
if (description) {
return htmlSafe(description);
} else {
return this.notification.data.topic_title;
}
}
/**
* Function that is called when the notification item is clicked.
*/
onClick() {}
/**
* @returns {string[]} Include additional classes to the label's wrapper <span>.
*/
get labelWrapperClasses() {
return [];
}
/**
* @returns {string[]} Include additional classes to the description's wrapper <span>.
*/
get descriptionWrapperClasses() {
return [];
}
get topicId() {
return this.notification.topic_id;
}
get username() {
return formatUsername(this.notification.data.display_username);
}
get notificationName() {
return this.site.notificationLookup[this.notification.notification_type];
}
}

View File

@ -1,7 +1,7 @@
import UserMenuNotificationItem from "discourse/components/user-menu/notification-item"; import NotificationItemBase from "discourse/lib/notification-items/base";
import I18n from "I18n"; import I18n from "I18n";
export default class UserMenuBookmarkReminderNotificationItem extends UserMenuNotificationItem { export default class extends NotificationItemBase {
get linkTitle() { get linkTitle() {
if (this.notification.data.bookmark_name) { if (this.notification.data.bookmark_name) {
return I18n.t("notifications.titles.bookmark_reminder_with_name", { return I18n.t("notifications.titles.bookmark_reminder_with_name", {

View File

@ -1,7 +1,7 @@
import UserMenuNotificationItem from "discourse/components/user-menu/notification-item"; import NotificationItemBase from "discourse/lib/notification-items/base";
import I18n from "I18n"; import I18n from "I18n";
export default class UserMenuCustomNotificationItem extends UserMenuNotificationItem { export default class extends NotificationItemBase {
get linkTitle() { get linkTitle() {
if (this.notification.data.title) { if (this.notification.data.title) {
return I18n.t(this.notification.data.title); return I18n.t(this.notification.data.title);

View File

@ -1,8 +1,8 @@
import UserMenuNotificationItem from "discourse/components/user-menu/notification-item"; import NotificationItemBase from "discourse/lib/notification-items/base";
import getURL from "discourse-common/lib/get-url"; import getURL from "discourse-common/lib/get-url";
import I18n from "I18n"; import I18n from "I18n";
export default class UserMenuGrantedBadgeNotificationItem extends UserMenuNotificationItem { export default class extends NotificationItemBase {
get linkHref() { get linkHref() {
const badgeId = this.notification.data.badge_id; const badgeId = this.notification.data.badge_id;
if (badgeId) { if (badgeId) {
@ -20,17 +20,13 @@ export default class UserMenuGrantedBadgeNotificationItem extends UserMenuNotifi
} }
} }
get label() { get description() {
return I18n.t("notifications.granted_badge", { return I18n.t("notifications.granted_badge", {
description: this.notification.data.badge_name, description: this.notification.data.badge_name,
}); });
} }
get wrapLabel() { get label() {
return false;
}
get description() {
return null; return null;
} }
} }

View File

@ -0,0 +1,11 @@
import NotificationItemBase from "discourse/lib/notification-items/base";
export default class extends NotificationItemBase {
get label() {
return `${this.username} @${this.notification.data.group_name}`;
}
get labelWrapperClasses() {
return ["mention-group", "notify"];
}
}

View File

@ -0,0 +1,15 @@
import NotificationItemBase from "discourse/lib/notification-items/base";
import I18n from "I18n";
export default class extends NotificationItemBase {
get description() {
return I18n.t("notifications.group_message_summary", {
count: this.notification.data.inbox_count,
group_name: this.notification.data.group_name,
});
}
get label() {
return null;
}
}

View File

@ -1,8 +1,8 @@
import UserMenuNotificationItem from "discourse/components/user-menu/notification-item"; import NotificationItemBase from "discourse/lib/notification-items/base";
import { userPath } from "discourse/lib/url"; import { userPath } from "discourse/lib/url";
import I18n from "I18n"; import I18n from "I18n";
export default class UserMenuInviteeAcceptedNotificationItem extends UserMenuNotificationItem { export default class extends NotificationItemBase {
get linkHref() { get linkHref() {
return userPath(this.notification.data.display_username); return userPath(this.notification.data.display_username);
} }

View File

@ -0,0 +1,18 @@
import NotificationItemBase from "discourse/lib/notification-items/base";
import { userPath } from "discourse/lib/url";
import I18n from "I18n";
export default class extends NotificationItemBase {
get linkHref() {
// TODO(osama): serialize username with notifications
return userPath(
`${this.currentUser.username}/notifications/likes-received?acting_username=${this.notification.data.username}`
);
}
get description() {
return I18n.t("notifications.liked_consolidated_description", {
count: this.notification.data.count,
});
}
}

View File

@ -1,26 +1,18 @@
import UserMenuNotificationItem from "discourse/components/user-menu/notification-item"; import NotificationItemBase from "discourse/lib/notification-items/base";
import { formatUsername } from "discourse/lib/utilities"; import { formatUsername } from "discourse/lib/utilities";
import I18n from "I18n"; import I18n from "I18n";
export default class UserMenuLikedNotificationItem extends UserMenuNotificationItem { export default class extends NotificationItemBase {
get count() {
return this.notification.data.count;
}
get username2() {
return formatUsername(this.notification.data.username2);
}
get label() { get label() {
if (this.count === 2) { if (this.count === 2) {
return I18n.t("notifications.liked_by_2_users", { return I18n.t("notifications.liked_by_2_users", {
username: this.username, username: this.username,
username2: this.username2, username2: this.#username2,
}); });
} else if (this.count > 2) { } else if (this.count > 2) {
return I18n.t("notifications.liked_by_multiple_users", { return I18n.t("notifications.liked_by_multiple_users", {
username: this.username, username: this.username,
username2: this.username2, username2: this.#username2,
count: this.count - 2, count: this.count - 2,
}); });
} else { } else {
@ -30,9 +22,17 @@ export default class UserMenuLikedNotificationItem extends UserMenuNotificationI
get labelWrapperClasses() { get labelWrapperClasses() {
if (this.count === 2) { if (this.count === 2) {
return "double-user"; return ["double-user"];
} else if (this.count > 2) { } else if (this.count > 2) {
return "multi-user"; return ["multi-user"];
} }
} }
get count() {
return this.notification.data.count;
}
get #username2() {
return formatUsername(this.notification.data.username2);
}
} }

View File

@ -1,23 +1,19 @@
import UserMenuNotificationItem from "discourse/components/user-menu/notification-item"; import NotificationItemBase from "discourse/lib/notification-items/base";
import { groupPath } from "discourse/lib/url"; import { groupPath } from "discourse/lib/url";
import I18n from "I18n"; import I18n from "I18n";
export default class UserMenuMembershipRequestAcceptedNotificationItem extends UserMenuNotificationItem { export default class extends NotificationItemBase {
get linkHref() { get linkHref() {
return groupPath(this.notification.data.group_name); return groupPath(this.notification.data.group_name);
} }
get label() { get description() {
return I18n.t("notifications.membership_request_accepted", { return I18n.t("notifications.membership_request_accepted", {
group_name: this.notification.data.group_name, group_name: this.notification.data.group_name,
}); });
} }
get wrapLabel() { get label() {
return false;
}
get description() {
return null; return null;
} }
} }

View File

@ -1,26 +1,22 @@
import UserMenuNotificationItem from "discourse/components/user-menu/notification-item"; import NotificationItemBase from "discourse/lib/notification-items/base";
import { userPath } from "discourse/lib/url"; import { userPath } from "discourse/lib/url";
import I18n from "I18n"; import I18n from "I18n";
export default class UserMenuMembershipRequestConsolidatedNotificationItem extends UserMenuNotificationItem { export default class extends NotificationItemBase {
get linkHref() { get linkHref() {
return userPath( return userPath(
`${this.notification.username || this.currentUser.username}/messages` `${this.notification.username || this.currentUser.username}/messages`
); );
} }
get label() { get description() {
return I18n.t("notifications.membership_request_consolidated", { return I18n.t("notifications.membership_request_consolidated", {
group_name: this.notification.data.group_name, group_name: this.notification.data.group_name,
count: this.notification.data.count, count: this.notification.data.count,
}); });
} }
get wrapLabel() { get label() {
return false;
}
get description() {
return null; return null;
} }
} }

View File

@ -0,0 +1,8 @@
import NotificationItemBase from "discourse/lib/notification-items/base";
import I18n from "I18n";
export default class extends NotificationItemBase {
get label() {
return I18n.t("notifications.user_moved_post", { username: this.username });
}
}

View File

@ -0,0 +1,8 @@
import NotificationItemBase from "discourse/lib/notification-items/base";
import I18n from "I18n";
export default class extends NotificationItemBase {
get label() {
return I18n.t("notifications.watching_first_post_label");
}
}

View File

@ -98,6 +98,7 @@ import { consolePrefix } from "discourse/lib/source-identifier";
import { addSectionLink as addCustomCommunitySectionLink } from "discourse/lib/sidebar/custom-community-section-links"; import { addSectionLink as addCustomCommunitySectionLink } from "discourse/lib/sidebar/custom-community-section-links";
import { addSidebarSection } from "discourse/lib/sidebar/custom-sections"; import { addSidebarSection } from "discourse/lib/sidebar/custom-sections";
import DiscourseURL from "discourse/lib/url"; import DiscourseURL from "discourse/lib/url";
import { registerNotificationTypeRenderer } from "discourse/lib/notification-item";
// If you add any methods to the API ensure you bump up the version number // If you add any methods to the API ensure you bump up the version number
// based on Semantic Versioning 2.0.0. Please update the changelog at // based on Semantic Versioning 2.0.0. Please update the changelog at
@ -1851,6 +1852,36 @@ class PluginApi {
addSidebarSection(func) { addSidebarSection(func) {
addSidebarSection(func); addSidebarSection(func);
} }
/**
* EXPERIMENTAL. Do not use.
* Register a custom renderer for a notification type or override the
* renderer of an existing type. See lib/notification-items/base.js for
* documentation and the default renderer.
*
* ```
* api.registerNotificationTypeRenderer("your_notification_type", (NotificationItemBase) => {
* return class extends NotificationItemBase {
* get label() {
* return "some label";
* }
*
* get description() {
* return "fancy description";
* }
* };
* });
* ```
* @callback renderDirectorRegistererCallback
* @param {NotificationItemBase} The base class from which the returned class should inherit.
* @returns {NotificationItemBase} A class that inherits from NotificationItemBase.
*
* @param {string} notificationType - ID of the notification type (i.e. the key value of your notification type in the `Notification.types` enum on the server side).
* @param {renderDirectorRegistererCallback} func - Callback function that returns a subclass from the class it receives as its argument.
*/
registerNotificationTypeRenderer(notificationType, func) {
registerNotificationTypeRenderer(notificationType, func);
}
} }
// from http://stackoverflow.com/questions/6832596/how-to-compare-software-version-number-using-js-only-number // from http://stackoverflow.com/questions/6832596/how-to-compare-software-version-number-using-js-only-number

View File

@ -0,0 +1,22 @@
import ReviewableItemBase from "discourse/lib/reviewable-items/base";
import FlaggedPost from "discourse/lib/reviewable-items/flagged-post";
import QueuedPost from "discourse/lib/reviewable-items/queued-post";
import ReviewableUser from "discourse/lib/reviewable-items/user";
const CLASS_FOR_TYPE = {
ReviewableFlaggedPost: FlaggedPost,
ReviewableQueuedPost: QueuedPost,
ReviewableUser,
};
export function getRenderDirector(
type,
reviewable,
currentUser,
siteSettings,
site
) {
const klass = CLASS_FOR_TYPE[type] || ReviewableItemBase;
return new klass({ reviewable, currentUser, siteSettings, site });
}

View File

@ -1,10 +1,11 @@
import GlimmerComponent from "discourse/components/glimmer";
import I18n from "I18n"; import I18n from "I18n";
export default class UserMenuReviewableItem extends GlimmerComponent { export default class ReviewableItemBase {
constructor() { constructor({ reviewable, currentUser, siteSettings, site }) {
super(...arguments); this.reviewable = reviewable;
this.reviewable = this.args.item; this.currentUser = currentUser;
this.siteSettings = siteSettings;
this.site = site;
} }
get actor() { get actor() {

View File

@ -1,8 +1,8 @@
import UserMenuDefaultReviewableItem from "discourse/components/user-menu/default-reviewable-item"; import ReviewableItemBase from "discourse/lib/reviewable-items/base";
import I18n from "I18n";
import { htmlSafe } from "@ember/template"; import { htmlSafe } from "@ember/template";
import I18n from "I18n";
export default class UserMenuReviewableFlaggedPostItem extends UserMenuDefaultReviewableItem { export default class extends ReviewableItemBase {
get description() { get description() {
const title = this.reviewable.topic_fancy_title; const title = this.reviewable.topic_fancy_title;
const postNumber = this.reviewable.post_number; const postNumber = this.reviewable.post_number;

View File

@ -1,10 +1,10 @@
import UserMenuDefaultReviewableItem from "discourse/components/user-menu/default-reviewable-item"; import ReviewableItemBase from "discourse/lib/reviewable-items/base";
import I18n from "I18n";
import { htmlSafe } from "@ember/template"; import { htmlSafe } from "@ember/template";
import { escapeExpression } from "discourse/lib/utilities"; import { escapeExpression } from "discourse/lib/utilities";
import { emojiUnescape } from "discourse/lib/text"; import { emojiUnescape } from "discourse/lib/text";
import I18n from "I18n";
export default class UserMenuReviewableQueuedPostItem extends UserMenuDefaultReviewableItem { export default class extends ReviewableItemBase {
get actor() { get actor() {
return I18n.t("user_menu.reviewable.queue"); return I18n.t("user_menu.reviewable.queue");
} }

View File

@ -1,7 +1,7 @@
import UserMenuDefaultReviewableItem from "discourse/components/user-menu/default-reviewable-item"; import ReviewableItemBase from "discourse/lib/reviewable-items/base";
import I18n from "I18n"; import I18n from "I18n";
export default class UserMenuReviewableUserItem extends UserMenuDefaultReviewableItem { export default class extends ReviewableItemBase {
get description() { get description() {
return I18n.t("user_menu.reviewable.suspicious_user", { return I18n.t("user_menu.reviewable.suspicious_user", {
username: this.reviewable.username, username: this.reviewable.username,

View File

@ -1,36 +1,6 @@
import RestModel from "discourse/models/rest"; import RestModel from "discourse/models/rest";
import { tracked } from "@glimmer/tracking"; import { tracked } from "@glimmer/tracking";
const DEFAULT_ITEM = "user-menu/notification-item";
function defaultComponentForType() {
return {
bookmark_reminder: "user-menu/bookmark-reminder-notification-item",
custom: "user-menu/custom-notification-item",
granted_badge: "user-menu/granted-badge-notification-item",
group_mentioned: "user-menu/group-mentioned-notification-item",
group_message_summary: "user-menu/group-message-summary-notification-item",
invitee_accepted: "user-menu/invitee-accepted-notification-item",
liked: "user-menu/liked-notification-item",
liked_consolidated: "user-menu/liked-consolidated-notification-item",
membership_request_accepted:
"user-menu/membership-request-accepted-notification-item",
membership_request_consolidated:
"user-menu/membership-request-consolidated-notification-item",
moved_post: "user-menu/moved-post-notification-item",
watching_first_post: "user-menu/watching-first-post-notification-item",
};
}
let _componentForType = defaultComponentForType();
// TODO(osama): add plugin API
export default class Notification extends RestModel { export default class Notification extends RestModel {
@tracked read; @tracked read;
get userMenuComponent() {
const component =
_componentForType[this.site.notificationLookup[this.notification_type]];
return component || DEFAULT_ITEM;
}
} }

View File

@ -1,18 +1,6 @@
import RestModel from "discourse/models/rest"; import RestModel from "discourse/models/rest";
import { tracked } from "@glimmer/tracking"; import { tracked } from "@glimmer/tracking";
const DEFAULT_COMPONENT = "user-menu/default-reviewable-item";
const DEFAULT_ITEM_COMPONENTS = {
ReviewableFlaggedPost: "user-menu/reviewable-flagged-post-item",
ReviewableQueuedPost: "user-menu/reviewable-queued-post-item",
ReviewableUser: "user-menu/reviewable-user-item",
};
export default class UserMenuReviewable extends RestModel { export default class UserMenuReviewable extends RestModel {
@tracked pending; @tracked pending;
get userMenuComponent() {
return DEFAULT_ITEM_COMPONENTS[this.type] || DEFAULT_COMPONENT;
}
} }

View File

@ -0,0 +1,19 @@
import { getRenderDirector } from "discourse/lib/notification-item";
import sessionFixtures from "discourse/tests/fixtures/session-fixtures";
import User from "discourse/models/user";
import Site from "discourse/models/site";
export function createRenderDirector(
notification,
notificationType,
siteSettings
) {
const director = getRenderDirector(
notificationType,
notification,
User.create(sessionFixtures["/session/current.json"].current_user),
siteSettings,
Site.current()
);
return director;
}

View File

@ -72,6 +72,7 @@ import {
import { clearTagsHtmlCallbacks } from "discourse/lib/render-tags"; import { clearTagsHtmlCallbacks } from "discourse/lib/render-tags";
import { clearToolbarCallbacks } from "discourse/components/d-editor"; import { clearToolbarCallbacks } from "discourse/components/d-editor";
import { resetSidebarSection } from "discourse/lib/sidebar/custom-sections"; import { resetSidebarSection } from "discourse/lib/sidebar/custom-sections";
import { resetRenderDirectorForNotifictaionTypes } from "discourse/lib/notification-item";
export function currentUser() { export function currentUser() {
return User.create(sessionFixtures["/session/current.json"].current_user); return User.create(sessionFixtures["/session/current.json"].current_user);
@ -200,6 +201,7 @@ export function testCleanup(container, app) {
clearTagsHtmlCallbacks(); clearTagsHtmlCallbacks();
clearToolbarCallbacks(); clearToolbarCallbacks();
resetSidebarSection(); resetSidebarSection();
resetRenderDirectorForNotifictaionTypes();
} }
export function discourseModule(name, options) { export function discourseModule(name, options) {

View File

@ -0,0 +1,15 @@
import { getRenderDirector } from "discourse/lib/reviewable-item";
import sessionFixtures from "discourse/tests/fixtures/session-fixtures";
import User from "discourse/models/user";
import Site from "discourse/models/site";
export function createRenderDirector(reviewable, reviewableType, siteSettings) {
const director = getRenderDirector(
reviewableType,
reviewable,
User.create(sessionFixtures["/session/current.json"].current_user),
siteSettings,
Site.current()
);
return director;
}

View File

@ -1,107 +0,0 @@
import { module, test } from "qunit";
import { setupRenderingTest } from "discourse/tests/helpers/component-test";
import { query } from "discourse/tests/helpers/qunit-helpers";
import { deepMerge } from "discourse-common/lib/object";
import { NOTIFICATION_TYPES } from "discourse/tests/fixtures/concerns/notification-types";
import { render } from "@ember/test-helpers";
import Notification from "discourse/models/notification";
import { hbs } from "ember-cli-htmlbars";
import I18n from "I18n";
function getNotification(overrides = {}) {
return Notification.create(
deepMerge(
{
id: 11,
user_id: 1,
notification_type: NOTIFICATION_TYPES.bookmark_reminder,
read: false,
high_priority: true,
created_at: "2022-07-01T06:00:32.173Z",
post_number: 113,
topic_id: 449,
fancy_title: "This is fancy title &lt;a&gt;!",
slug: "this-is-fancy-title",
data: {
title: "this is unsafe bookmark title <a>!",
display_username: "osama",
bookmark_name: null,
bookmarkable_url: "/t/sometopic/3232",
},
},
overrides
)
);
}
module(
"Integration | Component | user-menu | bookmark-reminder-notification-item",
function (hooks) {
setupRenderingTest(hooks);
const template = hbs`<UserMenu::BookmarkReminderNotificationItem @item={{this.notification}}/>`;
test("when the bookmark has a name", async function (assert) {
this.set(
"notification",
getNotification({ data: { bookmark_name: "MY BOOKMARK" } })
);
await render(template);
const link = query("li a");
assert.strictEqual(
link.title,
I18n.t("notifications.titles.bookmark_reminder_with_name", {
name: "MY BOOKMARK",
}),
"the notification has a title that includes the bookmark name"
);
});
test("when the bookmark doesn't have a name", async function (assert) {
this.set(
"notification",
getNotification({ data: { bookmark_name: null } })
);
await render(template);
const link = query("li a");
assert.strictEqual(
link.title,
I18n.t("notifications.titles.bookmark_reminder"),
"the notification has a generic title"
);
});
test("when the bookmark reminder doesn't originate from a topic and has a title", async function (assert) {
this.set(
"notification",
getNotification({
post_number: null,
topic_id: null,
fancy_title: null,
data: {
title: "this is unsafe bookmark title <a>!",
bookmarkable_url: "/chat/channel/33",
},
})
);
await render(template);
const description = query("li .notification-description");
assert.strictEqual(
description.textContent.trim(),
"this is unsafe bookmark title <a>!",
"the title is rendered safely as description"
);
});
test("when the bookmark reminder originates from a topic", async function (assert) {
this.set("notification", getNotification());
await render(template);
const description = query("li .notification-description");
assert.strictEqual(
description.textContent.trim(),
"This is fancy title <a>!",
"fancy_title is safe and rendered correctly"
);
});
}
);

View File

@ -1,63 +0,0 @@
import { module, test } from "qunit";
import { setupRenderingTest } from "discourse/tests/helpers/component-test";
import { exists, query } from "discourse/tests/helpers/qunit-helpers";
import { deepMerge } from "discourse-common/lib/object";
import { NOTIFICATION_TYPES } from "discourse/tests/fixtures/concerns/notification-types";
import { render } from "@ember/test-helpers";
import Notification from "discourse/models/notification";
import { hbs } from "ember-cli-htmlbars";
import I18n from "I18n";
function getNotification(overrides = {}) {
return Notification.create(
deepMerge(
{
id: 11,
user_id: 1,
notification_type: NOTIFICATION_TYPES.granted_badge,
read: false,
high_priority: false,
created_at: "2022-07-01T06:00:32.173Z",
data: {
badge_id: 12,
badge_name: "Tough Guy <a>",
badge_slug: "tough-guy",
username: "ossa",
badge_title: false,
},
},
overrides
)
);
}
module(
"Integration | Component | user-menu | granted-badge-notification-item",
function (hooks) {
setupRenderingTest(hooks);
const template = hbs`<UserMenu::GrantedBadgeNotificationItem @item={{this.notification}}/>`;
test("links to the badge page and filters by the username", async function (assert) {
this.set("notification", getNotification());
await render(template);
const link = query("li a");
assert.ok(link.href.endsWith("/badges/12/tough-guy?username=ossa"));
});
test("displays the right notification content", async function (assert) {
this.set("notification", getNotification());
await render(template);
const div = query("li div");
assert.strictEqual(
div.textContent.trim(),
I18n.t("notifications.granted_badge", {
description: "Tough Guy <a>",
}),
"label is rendered safely"
);
assert.ok(!exists("li .notification-label"));
assert.ok(!exists("li .notification-description"));
});
}
);

View File

@ -1,68 +0,0 @@
import { module, test } from "qunit";
import { setupRenderingTest } from "discourse/tests/helpers/component-test";
import { query } from "discourse/tests/helpers/qunit-helpers";
import { deepMerge } from "discourse-common/lib/object";
import { render } from "@ember/test-helpers";
import { NOTIFICATION_TYPES } from "discourse/tests/fixtures/concerns/notification-types";
import Notification from "discourse/models/notification";
import { hbs } from "ember-cli-htmlbars";
function getNotification(overrides = {}) {
return Notification.create(
deepMerge(
{
id: 11,
user_id: 1,
notification_type: NOTIFICATION_TYPES.group_mentioned,
read: false,
high_priority: false,
created_at: "2022-07-01T06:00:32.173Z",
post_number: 113,
topic_id: 449,
fancy_title: "This is fancy title &lt;a&gt;!",
slug: "this-is-fancy-title",
data: {
topic_title: "this is title before it becomes fancy <a>!",
original_post_id: 112,
original_post_type: 1,
original_username: "kolary",
display_username: "osama",
group_id: 333,
group_name: "hikers",
},
},
overrides
)
);
}
module(
"Integration | Component | user-menu | group-mentioned-notification-item",
function (hooks) {
setupRenderingTest(hooks);
const template = hbs`<UserMenu::GroupMentionedNotificationItem @item={{this.notification}}/>`;
test("notification label displays the user who mentioned and the mentioned group", async function (assert) {
this.set("notification", getNotification());
await render(template);
const label = query("li .notification-label");
assert.strictEqual(label.textContent.trim(), "osama @hikers");
assert.ok(
label.classList.contains("mention-group"),
"label has mention-group class"
);
assert.ok(label.classList.contains("notify"), "label has notify class");
});
test("notification description displays the topic title", async function (assert) {
this.set("notification", getNotification());
await render(template);
const description = query("li .notification-description");
assert.strictEqual(
description.textContent.trim(),
"This is fancy title <a>!"
);
});
}
);

View File

@ -1,55 +0,0 @@
import { module, test } from "qunit";
import { setupRenderingTest } from "discourse/tests/helpers/component-test";
import { exists, query } from "discourse/tests/helpers/qunit-helpers";
import { render } from "@ember/test-helpers";
import { deepMerge } from "discourse-common/lib/object";
import { NOTIFICATION_TYPES } from "discourse/tests/fixtures/concerns/notification-types";
import Notification from "discourse/models/notification";
import { hbs } from "ember-cli-htmlbars";
import I18n from "I18n";
function getNotification(overrides = {}) {
return Notification.create(
deepMerge(
{
id: 11,
user_id: 1,
notification_type: NOTIFICATION_TYPES.group_message_summary,
read: false,
high_priority: false,
created_at: "2022-07-01T06:00:32.173Z",
data: {
group_id: 321,
group_name: "drummers",
inbox_count: 13,
username: "drummers.boss",
},
},
overrides
)
);
}
module(
"Integration | Component | user-menu | group-message-summary-notification-item",
function (hooks) {
setupRenderingTest(hooks);
const template = hbs`<UserMenu::GroupMessageSummaryNotificationItem @item={{this.notification}}/>`;
test("the notification displays the right content", async function (assert) {
this.set("notification", getNotification());
await render(template);
const notification = query("li");
assert.strictEqual(
notification.textContent.trim(),
I18n.t("notifications.group_message_summary", {
count: 13,
group_name: "drummers",
})
);
assert.ok(!exists("li .notification-label"));
assert.ok(!exists("li .notification-description"));
});
}
);

View File

@ -1,71 +0,0 @@
import { module, test } from "qunit";
import { setupRenderingTest } from "discourse/tests/helpers/component-test";
import { query } from "discourse/tests/helpers/qunit-helpers";
import { render } from "@ember/test-helpers";
import { deepMerge } from "discourse-common/lib/object";
import { NOTIFICATION_TYPES } from "discourse/tests/fixtures/concerns/notification-types";
import Notification from "discourse/models/notification";
import { hbs } from "ember-cli-htmlbars";
import I18n from "I18n";
function getNotification(overrides = {}) {
return Notification.create(
deepMerge(
{
id: 11,
user_id: 1,
notification_type: NOTIFICATION_TYPES.liked_consolidated,
read: false,
high_priority: false,
created_at: "2022-07-01T06:00:32.173Z",
data: {
topic_title: "this is some topic and it's irrelevant",
original_post_id: 3294,
original_post_type: 1,
original_username: "liker439",
display_username: "liker439",
username: "liker439",
count: 44,
},
},
overrides
)
);
}
module(
"Integration | Component | user-menu | liked-consolidated-notification-item",
function (hooks) {
setupRenderingTest(hooks);
const template = hbs`<UserMenu::LikedConsolidatedNotificationItem @item={{this.notification}}/>`;
test("the notification links to the likes received notifications page of the user", async function (assert) {
this.set("notification", getNotification());
await render(template);
const link = query("li a");
assert.ok(
link.href.endsWith(
"/u/eviltrout/notifications/likes-received?acting_username=liker439"
)
);
});
test("the notification label displays the user who liked", async function (assert) {
this.set("notification", getNotification());
await render(template);
const label = query("li .notification-label");
assert.strictEqual(label.textContent.trim(), "liker439");
});
test("the notification description displays the number of likes", async function (assert) {
this.set("notification", getNotification());
await render(template);
const description = query("li .notification-description");
assert.strictEqual(
description.textContent.trim(),
I18n.t("notifications.liked_consolidated_description", { count: 44 })
);
});
}
);

View File

@ -1,114 +0,0 @@
import { module, test } from "qunit";
import { setupRenderingTest } from "discourse/tests/helpers/component-test";
import { query } from "discourse/tests/helpers/qunit-helpers";
import { render } from "@ember/test-helpers";
import { deepMerge } from "discourse-common/lib/object";
import { NOTIFICATION_TYPES } from "discourse/tests/fixtures/concerns/notification-types";
import Notification from "discourse/models/notification";
import { hbs } from "ember-cli-htmlbars";
import I18n from "I18n";
function getNotification(overrides = {}) {
return Notification.create(
deepMerge(
{
id: 11,
user_id: 1,
notification_type: NOTIFICATION_TYPES.liked,
read: false,
high_priority: false,
created_at: "2022-07-01T06:00:32.173Z",
post_number: 113,
topic_id: 449,
fancy_title: "This is fancy title &lt;a&gt;!",
slug: "this-is-fancy-title",
data: {
topic_title: "this is title before it becomes fancy <a>!",
username: "osama",
display_username: "osama",
username2: "shrek",
count: 2,
},
},
overrides
)
);
}
module(
"Integration | Component | user-menu | liked-notification-item",
function (hooks) {
setupRenderingTest(hooks);
const template = hbs`<UserMenu::LikedNotificationItem @item={{this.notification}}/>`;
test("when the likes count is 2", async function (assert) {
this.set("notification", getNotification({ data: { count: 2 } }));
await render(template);
const label = query("li .notification-label");
const description = query("li .notification-description");
assert.strictEqual(
label.textContent.trim(),
"osama, shrek",
"the label displays both usernames comma-concatenated"
);
assert.ok(
label.classList.contains("double-user"),
"label has double-user class"
);
assert.strictEqual(
description.textContent.trim(),
"This is fancy title <a>!",
"the description displays the topic title"
);
});
test("when the likes count is more than 2", async function (assert) {
this.set("notification", getNotification({ data: { count: 3 } }));
await render(template);
const label = query("li .notification-label");
const description = query("li .notification-description");
assert.strictEqual(
label.textContent.trim(),
I18n.t("notifications.liked_by_multiple_users", {
username: "osama",
username2: "shrek",
count: 1,
}),
"the label displays the first 2 usernames comma-concatenated with the count of remaining users"
);
assert.ok(
label.classList.contains("multi-user"),
"label has multi-user class"
);
assert.strictEqual(
description.textContent.trim(),
"This is fancy title <a>!",
"the description displays the topic title"
);
});
test("when the likes count is 1", async function (assert) {
this.set(
"notification",
getNotification({ data: { count: 1, username2: null } })
);
await render(template);
const label = query("li .notification-label");
const description = query("li .notification-description");
assert.strictEqual(
label.textContent.trim(),
"osama",
"the label displays the username"
);
assert.strictEqual(
description.textContent.trim(),
"This is fancy title <a>!",
"the description displays the topic title"
);
});
}
);

View File

@ -1,11 +1,12 @@
import { module, test } from "qunit"; import { module, test } from "qunit";
import { setupRenderingTest } from "discourse/tests/helpers/component-test"; import { setupRenderingTest } from "discourse/tests/helpers/component-test";
import { exists, query } from "discourse/tests/helpers/qunit-helpers"; import { exists, query } from "discourse/tests/helpers/qunit-helpers";
import { render, settled } from "@ember/test-helpers"; import { click, render, settled } from "@ember/test-helpers";
import { deepMerge } from "discourse-common/lib/object"; import { deepMerge } from "discourse-common/lib/object";
import { NOTIFICATION_TYPES } from "discourse/tests/fixtures/concerns/notification-types"; import { NOTIFICATION_TYPES } from "discourse/tests/fixtures/concerns/notification-types";
import Notification from "discourse/models/notification"; import Notification from "discourse/models/notification";
import { hbs } from "ember-cli-htmlbars"; import { hbs } from "ember-cli-htmlbars";
import { withPluginApi } from "discourse/lib/plugin-api";
import I18n from "I18n"; import I18n from "I18n";
function getNotification(overrides = {}) { function getNotification(overrides = {}) {
@ -194,5 +195,202 @@ module(
); );
assert.ok(!query("img"), "no <img> exists"); assert.ok(!query("img"), "no <img> exists");
}); });
test("various aspects can be customized according to the notification's render director", async function (assert) {
withPluginApi("0.1", (api) => {
api.registerNotificationTypeRenderer(
"linked",
(NotificationItemBase) => {
return class extends NotificationItemBase {
get classNames() {
return ["additional", "classes"];
}
get linkHref() {
return "/somewhere/awesome";
}
get linkTitle() {
return "hello world this is unsafe '\"<span>";
}
get icon() {
return "wrench";
}
get label() {
return "notification label 666 <span>";
}
get description() {
return "notification description 123 <script>";
}
get labelWrapperClasses() {
return ["label-wrapper-1"];
}
get descriptionWrapperClasses() {
return ["description-class-1"];
}
};
}
);
});
this.set(
"notification",
getNotification({
notification_type: NOTIFICATION_TYPES.linked,
})
);
await render(template);
assert.ok(
exists("li.additional.classes"),
"extra classes are included on the item"
);
const link = query("li a");
assert.ok(
link.href.endsWith("/somewhere/awesome"),
"link href is customized"
);
assert.strictEqual(
link.title,
"hello world this is unsafe '\"<span>",
"link title is customized and rendered safely"
);
assert.ok(exists("svg.d-icon-wrench"), "icon is customized");
const label = query("li .notification-label");
assert.ok(
label.classList.contains("label-wrapper-1"),
"label wrapper has additional classes"
);
assert.strictEqual(
label.textContent.trim(),
"notification label 666 <span>",
"label content is customized"
);
const description = query(".notification-description");
assert.ok(
description.classList.contains("description-class-1"),
"description has additional classes"
);
assert.strictEqual(
description.textContent.trim(),
"notification description 123 <script>",
"description content is customized"
);
});
test("description can be omitted", async function (assert) {
withPluginApi("0.1", (api) => {
api.registerNotificationTypeRenderer(
"linked",
(NotificationItemBase) => {
return class extends NotificationItemBase {
get description() {
return null;
}
get label() {
return "notification label";
}
};
}
);
});
this.set(
"notification",
getNotification({
notification_type: NOTIFICATION_TYPES.linked,
})
);
await render(template);
assert.notOk(
exists(".notification-description"),
"description is not rendered"
);
assert.ok(
query("li").textContent.trim(),
"notification label",
"only label content is displayed"
);
});
test("label can be omitted", async function (assert) {
withPluginApi("0.1", (api) => {
api.registerNotificationTypeRenderer(
"linked",
(NotificationItemBase) => {
return class extends NotificationItemBase {
get label() {
return null;
}
get description() {
return "notification description";
}
};
}
);
});
this.set(
"notification",
getNotification({
notification_type: NOTIFICATION_TYPES.linked,
})
);
await render(template);
assert.ok(
query("li").textContent.trim(),
"notification description",
"only notification description is displayed"
);
assert.notOk(exists(".notification-label"), "label is not rendered");
});
test("custom click handlers", async function (assert) {
let klass;
withPluginApi("0.1", (api) => {
api.registerNotificationTypeRenderer(
"linked",
(NotificationItemBase) => {
klass = class extends NotificationItemBase {
static onClickCalled = false;
get linkHref() {
return "#";
}
onClick() {
klass.onClickCalled = true;
}
};
return klass;
}
);
});
this.set(
"notification",
getNotification({
notification_type: NOTIFICATION_TYPES.linked,
})
);
await render(template);
await click("li a");
assert.ok(klass.onClickCalled);
});
} }
); );

View File

@ -15,7 +15,7 @@ function getReviewable(overrides = {}) {
pending: false, pending: false,
post_number: 3, post_number: 3,
topic_fancy_title: "anything hello world", topic_fancy_title: "anything hello world",
type: "ReviewableFlaggedPost", type: "Reviewable",
}, },
overrides overrides
) )
@ -23,11 +23,11 @@ function getReviewable(overrides = {}) {
} }
module( module(
"Integration | Component | user-menu | default-reviewable-item", "Integration | Component | user-menu | reviewable-item",
function (hooks) { function (hooks) {
setupRenderingTest(hooks); setupRenderingTest(hooks);
const template = hbs`<UserMenu::DefaultReviewableItem @item={{this.item}}/>`; const template = hbs`<UserMenu::ReviewableItem @item={{this.item}}/>`;
test("doesn't push `reviewed` to the classList if the reviewable is pending", async function (assert) { test("doesn't push `reviewed` to the classList if the reviewable is pending", async function (assert) {
this.set("item", getReviewable({ pending: true })); this.set("item", getReviewable({ pending: true }));

View File

@ -1,77 +0,0 @@
import { module, test } from "qunit";
import { setupRenderingTest } from "discourse/tests/helpers/component-test";
import { exists, query } from "discourse/tests/helpers/qunit-helpers";
import UserMenuReviewable from "discourse/models/user-menu-reviewable";
import { render } from "@ember/test-helpers";
import { hbs } from "ember-cli-htmlbars";
import I18n from "I18n";
function getReviewable(overrides = {}) {
return UserMenuReviewable.create(
Object.assign(
{
flagger_username: "sayo2",
id: 17,
pending: false,
topic_fancy_title: "anything hello world",
type: "ReviewableQueuedPost",
},
overrides
)
);
}
module(
"Integration | Component | user-menu | reviewable-queued-post-item",
function (hooks) {
setupRenderingTest(hooks);
const template = hbs`<UserMenu::ReviewableQueuedPostItem @item={{this.item}}/>`;
test("doesn't escape topic_fancy_title because it's safe", async function (assert) {
this.set(
"item",
getReviewable({
topic_fancy_title: "This is safe title &lt;a&gt; :heart:",
})
);
await render(template);
const description = query(".reviewable-description");
assert.strictEqual(
description.textContent.trim(),
I18n.t("user_menu.reviewable.new_post_in_topic", {
title: "This is safe title <a>",
})
);
assert.strictEqual(
description.querySelectorAll("img.emoji").length,
1,
"emojis are rendered"
);
});
test("escapes payload_title because it's not safe", async function (assert) {
this.set(
"item",
getReviewable({
topic_fancy_title: null,
payload_title: "This is unsafe title <a> :heart:",
})
);
await render(template);
const description = query(".reviewable-description");
assert.strictEqual(
description.textContent.trim(),
I18n.t("user_menu.reviewable.new_post_in_topic", {
title: "This is unsafe title <a>",
})
);
assert.strictEqual(
description.querySelectorAll("img.emoji").length,
1,
"emojis are rendered"
);
assert.ok(!exists(".reviewable-description a"));
});
}
);

View File

@ -0,0 +1,85 @@
import { discourseModule } from "discourse/tests/helpers/qunit-helpers";
import { test } from "qunit";
import { NOTIFICATION_TYPES } from "discourse/tests/fixtures/concerns/notification-types";
import { deepMerge } from "discourse-common/lib/object";
import { createRenderDirector } from "discourse/tests/helpers/notification-items-helper";
import { htmlSafe } from "@ember/template";
import Notification from "discourse/models/notification";
import I18n from "I18n";
function getNotification(overrides = {}) {
return Notification.create(
deepMerge(
{
id: 11,
user_id: 1,
notification_type: NOTIFICATION_TYPES.bookmark_reminder,
read: false,
high_priority: true,
created_at: "2022-07-01T06:00:32.173Z",
post_number: 113,
topic_id: 449,
fancy_title: "This is fancy title &lt;a&gt;!",
slug: "this-is-fancy-title",
data: {
title: "this is unsafe bookmark title <a>!",
display_username: "osama",
bookmark_name: null,
bookmarkable_url: "/t/sometopic/3232",
},
},
overrides
)
);
}
discourseModule("Unit | Notification Items | bookmark-reminder", function () {
test("linkTitle", function (assert) {
const notification = getNotification({
data: { bookmark_name: "My awesome bookmark" },
});
const director = createRenderDirector(
notification,
"bookmark_reminder",
this.siteSettings
);
assert.strictEqual(
director.linkTitle,
I18n.t("notifications.titles.bookmark_reminder_with_name", {
name: "My awesome bookmark",
}),
"content includes the bookmark name when the bookmark has a name"
);
delete notification.data.bookmark_name;
assert.strictEqual(
director.linkTitle,
"bookmark reminder",
"derived from the notification name when there's no bookmark name"
);
});
test("description", function (assert) {
const notification = getNotification({
fancy_title: "my fancy title!",
data: { topic_title: null, title: "custom bookmark title" },
});
const director = createRenderDirector(
notification,
"bookmark_reminder",
this.siteSettings
);
assert.deepEqual(
director.description,
htmlSafe("my fancy title!"),
"description is the fancy title by default"
);
delete notification.fancy_title;
assert.strictEqual(
director.description,
"custom bookmark title",
"description falls back to the bookmark title if there's no fancy title"
);
});
});

View File

@ -0,0 +1,59 @@
import { discourseModule } from "discourse/tests/helpers/qunit-helpers";
import { test } from "qunit";
import { NOTIFICATION_TYPES } from "discourse/tests/fixtures/concerns/notification-types";
import { deepMerge } from "discourse-common/lib/object";
import { createRenderDirector } from "discourse/tests/helpers/notification-items-helper";
import Notification from "discourse/models/notification";
import I18n from "I18n";
function getNotification(overrides = {}) {
return Notification.create(
deepMerge(
{
id: 11,
user_id: 1,
notification_type: NOTIFICATION_TYPES.granted_badge,
read: false,
high_priority: false,
created_at: "2022-07-01T06:00:32.173Z",
data: {
badge_id: 44,
badge_slug: "badge-15-slug",
badge_name: "Badge 15",
username: "gg.player",
},
},
overrides
)
);
}
discourseModule("Unit | Notification Items | granted-badge", function () {
test("linkHref", function (assert) {
const notification = getNotification();
const director = createRenderDirector(
notification,
"granted_badge",
this.siteSettings
);
assert.strictEqual(
director.linkHref,
"/badges/44/badge-15-slug?username=gg.player",
"links to the badge page and filters by the username"
);
});
test("description", async function (assert) {
const notification = getNotification();
const director = createRenderDirector(
notification,
"granted_badge",
this.siteSettings
);
assert.strictEqual(
director.description,
I18n.t("notifications.granted_badge", { description: "Badge 15" }),
"contains the right content"
);
});
});

View File

@ -0,0 +1,51 @@
import { discourseModule } from "discourse/tests/helpers/qunit-helpers";
import { test } from "qunit";
import { NOTIFICATION_TYPES } from "discourse/tests/fixtures/concerns/notification-types";
import { deepMerge } from "discourse-common/lib/object";
import { createRenderDirector } from "discourse/tests/helpers/notification-items-helper";
import Notification from "discourse/models/notification";
function getNotification(overrides = {}) {
return Notification.create(
deepMerge(
{
id: 11,
user_id: 1,
notification_type: NOTIFICATION_TYPES.group_mentioned,
read: false,
high_priority: false,
created_at: "2022-07-01T06:00:32.173Z",
post_number: 113,
topic_id: 449,
fancy_title: "This is fancy title &lt;a&gt;!",
slug: "this-is-fancy-title",
data: {
topic_title: "this is title before it becomes fancy <a>!",
original_post_id: 112,
original_post_type: 1,
original_username: "kolary",
display_username: "osama",
group_id: 333,
group_name: "hikers",
},
},
overrides
)
);
}
discourseModule("Unit | Notification Items | group-mentioned", function () {
test("label", function (assert) {
const notification = getNotification();
const director = createRenderDirector(
notification,
"group_mentioned",
this.siteSettings
);
assert.strictEqual(
director.label,
"osama @hikers",
"contains the user who mentioned and the mentioned group"
);
});
});

View File

@ -0,0 +1,51 @@
import { discourseModule } from "discourse/tests/helpers/qunit-helpers";
import { test } from "qunit";
import { NOTIFICATION_TYPES } from "discourse/tests/fixtures/concerns/notification-types";
import { deepMerge } from "discourse-common/lib/object";
import { createRenderDirector } from "discourse/tests/helpers/notification-items-helper";
import Notification from "discourse/models/notification";
import I18n from "I18n";
function getNotification(overrides = {}) {
return Notification.create(
deepMerge(
{
id: 11,
user_id: 1,
notification_type: NOTIFICATION_TYPES.group_message_summary,
read: false,
high_priority: false,
created_at: "2022-07-01T06:00:32.173Z",
data: {
group_id: 321,
group_name: "drummers",
inbox_count: 13,
username: "drummers.boss",
},
},
overrides
)
);
}
discourseModule(
"Unit | Notification Items | group-message-summary",
function () {
test("description", function (assert) {
const notification = getNotification();
const director = createRenderDirector(
notification,
"group_message_summary",
this.siteSettings
);
assert.strictEqual(
director.description,
I18n.t("notifications.group_message_summary", {
group_name: "drummers",
count: 13,
}),
"displays the right content"
);
});
}
);

View File

@ -0,0 +1,62 @@
import { discourseModule } from "discourse/tests/helpers/qunit-helpers";
import { test } from "qunit";
import { NOTIFICATION_TYPES } from "discourse/tests/fixtures/concerns/notification-types";
import { deepMerge } from "discourse-common/lib/object";
import { createRenderDirector } from "discourse/tests/helpers/notification-items-helper";
import Notification from "discourse/models/notification";
import I18n from "I18n";
function getNotification(overrides = {}) {
return Notification.create(
deepMerge(
{
id: 11,
user_id: 1,
notification_type: NOTIFICATION_TYPES.liked_consolidated,
read: false,
high_priority: false,
created_at: "2022-07-01T06:00:32.173Z",
data: {
topic_title: "this is some topic and it's irrelevant",
original_post_id: 3294,
original_post_type: 1,
original_username: "liker439",
display_username: "liker439",
username: "liker439",
count: 44,
},
},
overrides
)
);
}
discourseModule("Unit | Notification Items | liked-consolidated", function () {
test("linkHref", function (assert) {
const notification = getNotification();
const director = createRenderDirector(
notification,
"liked_consolidated",
this.siteSettings
);
assert.strictEqual(
director.linkHref,
"/u/eviltrout/notifications/likes-received?acting_username=liker439",
"links to the likes received page of the user"
);
});
test("description", function (assert) {
const notification = getNotification();
const director = createRenderDirector(
notification,
"liked_consolidated",
this.siteSettings
);
assert.strictEqual(
director.description,
I18n.t("notifications.liked_consolidated_description", { count: 44 }),
"displays the right content"
);
});
});

View File

@ -0,0 +1,73 @@
import { discourseModule } from "discourse/tests/helpers/qunit-helpers";
import { test } from "qunit";
import { NOTIFICATION_TYPES } from "discourse/tests/fixtures/concerns/notification-types";
import { deepMerge } from "discourse-common/lib/object";
import { createRenderDirector } from "discourse/tests/helpers/notification-items-helper";
import Notification from "discourse/models/notification";
import I18n from "I18n";
function getNotification(overrides = {}) {
return Notification.create(
deepMerge(
{
id: 11,
user_id: 1,
notification_type: NOTIFICATION_TYPES.liked,
read: false,
high_priority: false,
created_at: "2022-07-01T06:00:32.173Z",
post_number: 113,
topic_id: 449,
fancy_title: "This is fancy title &lt;a&gt;!",
slug: "this-is-fancy-title",
data: {
topic_title: "this is title before it becomes fancy <a>!",
username: "osama",
display_username: "osama",
username2: "shrek",
count: 2,
},
},
overrides
)
);
}
discourseModule("Unit | Notification Items | liked", function () {
test("label", function (assert) {
const notification = getNotification();
const director = createRenderDirector(
notification,
"liked",
this.siteSettings
);
notification.data.count = 2;
assert.strictEqual(
director.label,
I18n.t("notifications.liked_by_2_users", {
username: "osama",
username2: "shrek",
}),
"concatenates both usernames with comma when count is 2"
);
notification.data.count = 3;
assert.strictEqual(
director.label,
I18n.t("notifications.liked_by_multiple_users", {
username: "osama",
username2: "shrek",
count: 1,
}),
"concatenates 2 usernames with comma and displays the remaining count when count larger than 2"
);
notification.data.count = 1;
delete notification.data.username2;
assert.strictEqual(
director.label,
"osama",
"displays the liker's username when the count is 1"
);
});
});

View File

@ -0,0 +1,56 @@
import { discourseModule } from "discourse/tests/helpers/qunit-helpers";
import { test } from "qunit";
import { createRenderDirector } from "discourse/tests/helpers/reviewable-items-helper";
import { htmlSafe } from "@ember/template";
import { emojiUnescape } from "discourse/lib/text";
import UserMenuReviewable from "discourse/models/user-menu-reviewable";
import I18n from "I18n";
function getReviewable(overrides = {}) {
return UserMenuReviewable.create(
Object.assign(
{
flagger_username: "sayo2",
id: 17,
pending: false,
topic_fancy_title: "anything hello world",
type: "ReviewableQueuedPost",
},
overrides
)
);
}
discourseModule("Unit | Reviewable Items | queued-post", function () {
test("description", function (assert) {
const reviewable = getReviewable({
topic_fancy_title: "This is safe title &lt;a&gt; :heart:",
});
const director = createRenderDirector(
reviewable,
"ReviewableQueuedPost",
this.siteSettings
);
assert.deepEqual(
director.description,
htmlSafe(
I18n.t("user_menu.reviewable.new_post_in_topic", {
title: `This is safe title &lt;a&gt; ${emojiUnescape(":heart:")}`,
})
),
"contains the fancy title without escaping because it's already safe"
);
delete reviewable.topic_fancy_title;
reviewable.payload_title = "This is unsafe title <a> :heart:";
assert.deepEqual(
director.description,
htmlSafe(
I18n.t("user_menu.reviewable.new_post_in_topic", {
title: `This is unsafe title &lt;a&gt; ${emojiUnescape(":heart:")}`,
})
),
"contains the payload title escaped and correctly unescapes emojis"
);
});
});

View File

@ -151,6 +151,21 @@
.multi-user { .multi-user {
white-space: unset; white-space: unset;
} }
.notification-label {
color: var(--primary);
}
}
}
// remove when the widgets-based implementation of the user menu is removed
.user-menu:not(.revamped) {
.quick-access-panel {
li {
span:first-child {
color: var(--primary);
}
}
} }
} }
@ -312,10 +327,6 @@
display: none; display: none;
} }
span:first-child {
color: var(--primary);
}
span.double-user, span.double-user,
// e.g., "username, username2" // e.g., "username, username2"
span.multi-user span.multi-user