DEV: Introduce a basic version of the new notifications menu behind a feature flag (#17492)

This commit is contained in:
Osama Sayegh 2022-07-19 05:35:02 +03:00 committed by GitHub
parent 9028df0fda
commit fac04f3e73
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
26 changed files with 1070 additions and 27 deletions

View File

@ -8,7 +8,6 @@ import Docking from "discourse/mixins/docking";
import MountWidget from "discourse/components/mount-widget";
import ItsATrap from "@discourse/itsatrap";
import RerenderOnDoNotDisturbChange from "discourse/mixins/rerender-on-do-not-disturb-change";
import { headerOffset } from "discourse/lib/offset-calculator";
import { observes } from "discourse-common/utils/decorators";
import { topicTitleDecorators } from "discourse/components/topic-title";
@ -232,6 +231,9 @@ const SiteHeaderComponent = MountWidget.extend(
this.appEvents.on("header:show-topic", this, "setTopic");
this.appEvents.on("header:hide-topic", this, "setTopic");
if (this.currentUser?.redesigned_user_menu_enabled) {
this.appEvents.on("user-menu:rendered", this, "_animateMenu");
}
this.dispatch("notifications:changed", "user-notifications");
this.dispatch("header:keyboard-trigger", "header");
@ -280,7 +282,40 @@ const SiteHeaderComponent = MountWidget.extend(
const header = document.querySelector("header.d-header");
this._itsatrap = new ItsATrap(header);
this._itsatrap.bind(["right", "left"], (e) => {
const dirs = this.currentUser?.redesigned_user_menu_enabled
? ["up", "down"]
: ["right", "left"];
this._itsatrap.bind(dirs, (e) => this._handleArrowKeysNav(e));
},
_handleArrowKeysNav(event) {
if (this.currentUser?.redesigned_user_menu_enabled) {
const activeTab = document.querySelector(
".menu-tabs-container .btn.active"
);
if (activeTab) {
let activeTabNumber = Number(
document.activeElement.dataset.tabNumber ||
activeTab.dataset.tabNumber
);
const maxTabNumber =
document.querySelectorAll(".menu-tabs-container .btn").length - 1;
const isNext = event.key === "ArrowDown";
let nextTab = isNext ? activeTabNumber + 1 : activeTabNumber - 1;
if (isNext && nextTab > maxTabNumber) {
nextTab = 0;
}
if (!isNext && nextTab < 0) {
nextTab = maxTabNumber;
}
event.preventDefault();
document
.querySelector(
`.menu-tabs-container .btn[data-tab-number='${nextTab}']`
)
.focus();
}
} else {
const activeTab = document.querySelector(".glyphs .menu-link.active");
if (activeTab) {
@ -290,11 +325,11 @@ const SiteHeaderComponent = MountWidget.extend(
}
this.appEvents.trigger("user-menu:navigation", {
key: e.key,
key: event.key,
tabNumber: Number(focusedTab.dataset.tabNumber),
});
}
});
}
},
_cleanDom() {
@ -312,6 +347,9 @@ const SiteHeaderComponent = MountWidget.extend(
this.appEvents.off("header:show-topic", this, "setTopic");
this.appEvents.off("header:hide-topic", this, "setTopic");
this.appEvents.off("dom:clean", this, "_cleanDom");
if (this.currentUser?.redesigned_user_menu_enabled) {
this.appEvents.off("user-menu:rendered", this, "_animateMenu");
}
if (this.currentUser) {
this.currentUser.off("status-changed", this, "queueRerender");
@ -339,7 +377,10 @@ const SiteHeaderComponent = MountWidget.extend(
cb(this._topic, headerTitle, "header-title")
);
}
this._animateMenu();
},
_animateMenu() {
const menuPanels = document.querySelectorAll(".menu-panel");
if (menuPanels.length === 0) {
if (this.site.mobileView) {
@ -383,7 +424,7 @@ const SiteHeaderComponent = MountWidget.extend(
// We use a mutationObserver to check for style changes, so it's important
// we don't set it if it doesn't change. Same goes for the panelBody!
if (viewMode === "drop-down") {
if (!this.site.mobileView) {
const buttonPanel = document.querySelectorAll("header ul.icons");
if (buttonPanel.length === 0) {
return;
@ -395,23 +436,18 @@ const SiteHeaderComponent = MountWidget.extend(
panel.style.setProperty("top", "100%");
panel.style.setProperty("height", "auto");
}
document.body.classList.add("drop-down-mode");
} else {
if (this.site.mobileView) {
headerCloak.style.display = "block";
}
headerCloak.style.display = "block";
const menuTop = this.site.mobileView ? headerTop() : headerOffset();
const menuTop = headerTop();
const winHeightOffset = 16;
const winHeightOffset = this.currentUser?.redesigned_user_menu_enabled
? 0
: 16;
let initialWinHeight = window.innerHeight;
const winHeight = initialWinHeight - winHeightOffset;
let height;
if (this.site.mobileView) {
height = winHeight - menuTop;
}
let height = winHeight - menuTop;
const isIPadApp = document.body.classList.contains("footer-nav-ipad"),
heightProp = isIPadApp ? "max-height" : "height",
@ -434,10 +470,13 @@ const SiteHeaderComponent = MountWidget.extend(
headerCloak.style.top = `${menuTop}px`;
}
}
document.body.classList.remove("drop-down-mode");
}
panel.style.setProperty("width", `${width}px`);
// TODO: remove the if condition when redesigned_user_menu_enabled is
// removed
if (!panel.classList.contains("revamped")) {
panel.style.setProperty("width", `${width}px`);
}
if (this._animate) {
this._animateOpening(panel);
}
@ -449,6 +488,7 @@ const SiteHeaderComponent = MountWidget.extend(
export default SiteHeaderComponent.extend({
classNames: ["d-header-wrap"],
classNameBindings: ["site.mobileView::drop-down-mode"],
init() {
this._super(...arguments);

View File

@ -0,0 +1,12 @@
import Component from "@ember/component";
import layout from "discourse/templates/components/user-menu-wrapper";
export default Component.extend({
layout,
tagName: "",
didInsertElement() {
this._super(...arguments);
this.appEvents.trigger("user-menu:rendered");
},
});

View File

@ -0,0 +1,5 @@
<div class="empty-state">
<span class="empty-state-title">
{{i18n "user_menu.generic_no_items"}}
</span>
</div>

View File

@ -0,0 +1,31 @@
{{#if this.loading}}
<div class="spinner-container">
<div class="spinner"></div>
</div>
{{else if this.items.length}}
<ul>
{{#each this.items as |item|}}
<item.userMenuComponent @item={{item}}/>
{{/each}}
</ul>
<div class="panel-body-bottom">
{{#if this.showAll}}
<a class="btn btn-default btn-icon no-text show-all" href={{this.showAllHref}} title={{this.showAllTitle}}>
{{d-icon "chevron-down" aria-label=this.showAllTitle}}
</a>
{{/if}}
{{#if this.showDismiss}}
<button
type="button"
class="btn btn-default notifications-dismiss btn-icon-text"
title={{this.dismissTitle}}
{{on "click" this.dismissButtonClick}}
>
{{d-icon "check"}}
{{i18n "user.dismiss"}}
</button>
{{/if}}
</div>
{{else}}
<this.emptyStateComponent/>
{{/if}}

View File

@ -0,0 +1,99 @@
import GlimmerComponent from "discourse/components/glimmer";
import { tracked } from "@glimmer/tracking";
import { action } from "@ember/object";
import Session from "discourse/models/session";
export default class UserMenuItemsList extends GlimmerComponent {
@tracked loading = false;
@tracked items = [];
constructor() {
super(...arguments);
this._load();
}
get itemsCacheKey() {}
get showAll() {
return false;
}
get showAllHref() {
throw new Error(
`the showAllHref getter must be implemented in ${this.constructor.name}`
);
}
get showAllTitle() {}
get showDismiss() {
return false;
}
get dismissTitle() {}
get emptyStateComponent() {
return "user-menu/items-list-empty-state";
}
fetchItems() {
throw new Error(
`the fetchItems method must be implemented in ${this.constructor.name}`
);
}
refreshList() {
this._load();
}
dismissWarningModal() {
return null;
}
_load() {
const cached = this._getCachedItems();
if (cached?.length) {
this.items = cached;
} else {
this.loading = true;
}
this.fetchItems()
.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.items = items;
})
.finally(() => (this.loading = false));
}
_getCachedItems() {
const key = this.itemsCacheKey;
if (key) {
return Session.currentProp(`user-menu-items:${key}`);
}
}
_setCachedItems(newItems) {
const key = this.itemsCacheKey;
if (key) {
Session.currentProp(`user-menu-items:${key}`, newItems);
}
}
@action
dismissButtonClick() {
throw new Error(
`dismissButtonClick must be implemented in ${this.constructor.name}.`
);
}
}

View File

@ -0,0 +1,50 @@
<div class="user-menu revamped menu-panel drop-down" data-max-width="320">
<div class="panel-body">
<div class="panel-body-contents">
<div
id={{concat "quick-access-" this.currentTabId}}
class="quick-access-panel"
tabindex="-1"
aria-labelledby={{concat "user-menu-button-" this.currentTabId}}>
<this.currentPanelComponent/>
</div>
<div class="menu-tabs-container" role="tablist" aria-orientation="vertical" aria-label={{i18n "user_menu.sr_menu_tabs"}}>
<div class="top-tabs tabs-list">
{{#each this.topTabs as |tab|}}
<button
class={{concat "btn btn-flat btn-icon no-text" (if (eq tab.id this.currentTabId) " active")}}
type="button"
role="tab"
id={{concat "user-menu-button-" tab.id}}
tabindex={{if (eq tab.id this.currentTabId) "0" "-1"}}
aria-selected={{if (eq tab.id this.currentTabId) "true" "false"}}
aria-controls={{concat "quick-access-" tab.id}}
data-tab-number={{tab.position}}
{{on "click" (fn this.changeTab tab)}}
{{!-- template-lint-disable require-context-role --}}
>
{{d-icon tab.icon}}
{{#if tab.count}}
<span class="badge-notification">{{tab.count}}</span>
{{/if}}
</button>
{{/each}}
</div>
<div class="bottom-tabs tabs-list">
{{#each this.bottomTabs as |tab|}}
<a
class="btn btn-flat btn-icon no-text"
role="tab"
tabindex="-1"
href={{tab.href}}
data-tab-number={{tab.position}}
{{!-- template-lint-disable require-context-role --}}
>
{{d-icon tab.icon}}
</a>
{{/each}}
</div>
</div>
</div>
</div>
</div>

View File

@ -0,0 +1,55 @@
import GlimmerComponent from "discourse/components/glimmer";
import { tracked } from "@glimmer/tracking";
import { action } from "@ember/object";
const DEFAULT_TAB_ID = "all-notifications";
const DEFAULT_PANEL_COMPONENT = "user-menu/notifications-list";
export default class UserMenu extends GlimmerComponent {
@tracked currentTabId = DEFAULT_TAB_ID;
@tracked currentPanelComponent = DEFAULT_PANEL_COMPONENT;
get topTabs() {
const tabs = this._coreTopTabs;
return tabs.map((tab, index) => {
tab.position = index;
return tab;
});
}
get bottomTabs() {
const topTabsLength = this.topTabs.length;
return this._coreBottomTabs.map((tab, index) => {
tab.position = index + topTabsLength;
return tab;
});
}
get _coreTopTabs() {
return [
{
id: DEFAULT_TAB_ID,
icon: "bell",
panelComponent: DEFAULT_PANEL_COMPONENT,
},
];
}
get _coreBottomTabs() {
return [
{
id: "preferences",
icon: "user-cog",
href: `${this.currentUser.path}/preferences`,
},
];
}
@action
changeTab(tab) {
if (this.currentTabId !== tab.id) {
this.currentTabId = tab.id;
this.currentPanelComponent = tab.panelComponent;
}
}
}

View File

@ -0,0 +1,28 @@
<li class={{this.className}}>
<a
href={{this.linkHref}}
title={{this.linkTitle}}
{{on "click" this.onClick}}
>
{{d-icon this.icon}}
<div>
{{#if this.label}}
{{#if this.wrapLabel}}
<span class={{concat "notification-label " this.labelWrapperClasses}}>
{{this.label}}
</span>
{{else}}
<span>{{this.label}}</span>
{{/if}}
{{/if}}
{{#if this.description}}
<span
class={{concat "notification-description " this.descriptionElementClasses}}
data-topic-id={{this.topicId}}
>
{{this.description}}
</span>
{{/if}}
</div>
</a>
</li>

View File

@ -0,0 +1,107 @@
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 { action } from "@ember/object";
import { emojiUnescape } from "discourse/lib/text";
import { htmlSafe } from "@ember/template";
import getURL from "discourse-common/lib/get-url";
import cookie from "discourse/lib/cookie";
import I18n from "I18n";
export default class UserMenuNotificationItem extends GlimmerComponent {
get className() {
const classes = [];
if (this.notification.read) {
classes.push("read");
}
if (this.notificationName) {
classes.push(this.notificationName.replace(/_/g, "-"));
}
if (this.notification.is_warning) {
classes.push("is-warning");
}
return classes.join(" ");
}
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}`
);
}
}
get linkTitle() {
if (this.notificationName) {
return I18n.t(`notifications.titles.${this.notificationName}`);
} else {
return "";
}
}
get icon() {
return `notification.${this.notificationName}`;
}
get label() {
return this.username;
}
get wrapLabel() {
return true;
}
get labelWrapperClasses() {}
get username() {
return formatUsername(this.notification.data.display_username);
}
get description() {
const description =
emojiUnescape(this.notification.fancy_title) ||
this.notification.data.topic_title;
if (this.descriptionHtmlSafe) {
return htmlSafe(description);
} else {
return description;
}
}
get descriptionElementClasses() {}
get descriptionHtmlSafe() {
return !!this.notification.fancy_title;
}
// the following props are helper props -- they're never referenced directly in the hbs template
get notification() {
return this.args.item;
}
get topicId() {
return this.notification.topic_id;
}
get notificationName() {
return this.site.notificationLookup[this.notification.notification_type];
}
@action
onClick() {
if (!this.notification.read) {
this.notification.set("read", true);
setTransientHeader("Discourse-Clear-Notifications", this.notification.id);
cookie("cn", this.notification.id, { path: getURL("/") });
}
}
}

View File

@ -0,0 +1,14 @@
<div class="empty-state">
<span class="empty-state-title">
{{i18n "user.no_notifications_title"}}
</span>
<div class="empty-state-body">
<p>
{{html-safe (i18n
"user.no_notifications_body"
icon=(d-icon "bell")
preferencesUrl=(get-url "/my/preferences/notifications")
)}}
</p>
</div>
</div>

View File

@ -0,0 +1,3 @@
import GlimmerComponent from "discourse/components/glimmer";
export default class UserMenuNotificationsListEmptyState extends GlimmerComponent {}

View File

@ -0,0 +1,74 @@
import UserMenuItemsList from "discourse/components/user-menu/items-list";
import I18n from "I18n";
import { action } from "@ember/object";
export default class UserMenuNotificationsList extends UserMenuItemsList {
get filterByTypes() {
return null;
}
get showAll() {
return true;
}
get showAllHref() {
return `${this.currentUser.path}/notifications`;
}
get showAllTitle() {
return I18n.t("user_menu.view_all_notifications");
}
get showDismiss() {
return this.items.some((item) => !item.read);
}
get dismissTitle() {
return I18n.t("user.dismiss_notifications_tooltip");
}
get itemsCacheKey() {
let key = "recent-notifications";
const types = this.filterByTypes?.toString();
if (types) {
key += `-type-${types}`;
}
return key;
}
get emptyStateComponent() {
if (this.constructor === UserMenuNotificationsList) {
return "user-menu/notifications-list-empty-state";
} else {
return super.emptyStateComponent;
}
}
fetchItems() {
const params = {
limit: 30,
recent: true,
bump_last_seen_reviewable: true,
silent: this.currentUser.enforcedSecondFactor,
};
const types = this.filterByTypes?.toString();
if (types) {
params.filter_by_types = types;
}
return this.store
.findStale("notification", params)
.refresh()
.then((c) => c.content);
}
dismissWarningModal() {
// TODO: add warning modal when there are unread high pri notifications
return null;
}
@action
dismissButtonClick() {
// TODO
}
}

View File

@ -1,4 +1,4 @@
import EmberObject, { set } from "@ember/object";
import { set } from "@ember/object";
// Subscribes to user events on the message bus
import {
alertChannel,
@ -12,6 +12,7 @@ import {
unsubscribe as unsubscribePushNotifications,
} from "discourse/lib/push-notifications";
import { isTesting } from "discourse-common/config/environment";
import Notification from "discourse/models/notification";
export default {
name: "subscribe-user-notifications",
@ -88,7 +89,7 @@ export default {
oldNotifications.insertAt(
insertPosition,
EmberObject.create(lastNotification)
Notification.create(lastNotification)
);
}

View File

@ -0,0 +1,15 @@
import RestModel from "discourse/models/rest";
import { tracked } from "@glimmer/tracking";
const DEFAULT_ITEM = "user-menu/notification-item";
const _componentForType = {};
export default class Notification extends RestModel {
@tracked read;
get userMenuComponent() {
const component =
_componentForType[this.site.notificationLookup[this.notification_type]];
return component || DEFAULT_ITEM;
}
}

View File

@ -0,0 +1 @@
<UserMenu::Menu/>

View File

@ -2,16 +2,26 @@ import { getOwner } from "@ember/application";
import { scheduleOnce } from "@ember/runloop";
export default class ComponentConnector {
constructor(widget, componentName, opts, trackedProperties) {
constructor(
widget,
componentName,
opts,
trackedProperties,
{ applyStyle = true } = {}
) {
this.widget = widget;
this.opts = opts;
this.componentName = componentName;
this.trackedProperties = trackedProperties || [];
this.applyStyle = applyStyle;
this._component = null;
}
init() {
const elem = document.createElement("div");
elem.style.display = "inline-flex";
if (this.applyStyle) {
elem.style.display = "inline-flex";
}
elem.className = "widget-component-connector";
this.elem = elem;
scheduleOnce("afterRender", this, this.connectComponent);
@ -19,6 +29,10 @@ export default class ComponentConnector {
return this.elem;
}
destroy() {
this._component?.destroy();
}
update(prev) {
// mutated external properties might not correctly update the underlying component
// in this case we can define trackedProperties, if different from previous
@ -54,6 +68,7 @@ export default class ComponentConnector {
mounted._connected.push(component);
component.renderer.appendTo(component, elem);
this._component = component;
}
}

View File

@ -11,6 +11,7 @@ import { schedule } from "@ember/runloop";
import { scrollTop } from "discourse/mixins/scroll-top";
import { wantsNewWindow } from "discourse/lib/intercept-click";
import { logSearchLinkClick } from "discourse/lib/search";
import ComponentConnector from "discourse/widgets/component-connector";
const _extraHeaderIcons = [];
@ -330,6 +331,24 @@ export function attachAdditionalPanel(name, toggle, transformAttrs) {
additionalPanels.push({ name, toggle, transformAttrs });
}
createWidget("revamped-user-menu-wrapper", {
buildAttributes() {
return { "data-click-outside": true };
},
html() {
return [
new ComponentConnector(this, "user-menu-wrapper", {}, [], {
applyStyle: false,
}),
];
},
clickOutside() {
this.sendWidgetAction("toggleUserMenu");
},
});
export default createWidget("header", {
tagName: "header.d-header.clearfix",
buildKey: () => `header`,
@ -383,7 +402,11 @@ export default createWidget("header", {
} else if (state.hamburgerVisible) {
panels.push(this.attach("hamburger-menu"));
} else if (state.userVisible) {
panels.push(this.attach("user-menu"));
if (this.currentUser.redesigned_user_menu_enabled) {
panels.push(this.attach("revamped-user-menu-wrapper", {}));
} else {
panels.push(this.attach("user-menu"));
}
}
additionalPanels.map((panel) => {

View File

@ -1,7 +1,7 @@
import { module, test } from "qunit";
import { setupRenderingTest } from "discourse/tests/helpers/component-test";
import { click, render } from "@ember/test-helpers";
import { count, exists } from "discourse/tests/helpers/qunit-helpers";
import { count, exists, query } from "discourse/tests/helpers/qunit-helpers";
import pretender from "discourse/tests/helpers/create-pretender";
import { hbs } from "ember-cli-htmlbars";
@ -49,4 +49,38 @@ module("Integration | Component | site-header", function (hooks) {
// Click anywhere
await click("header.d-header");
});
test("user avatar is highlighted when the user receives the first notification", async function (assert) {
this.currentUser.set("all_unread_notifications", 1);
this.currentUser.set("redesigned_user_menu_enabled", true);
this.currentUser.set("read_first_notification", false);
await render(hbs`<SiteHeader />`);
assert.ok(exists(".ring-first-notification"));
});
test("user avatar is not highlighted when the user receives notifications beyond the first one", async function (assert) {
this.currentUser.set("redesigned_user_menu_enabled", true);
this.currentUser.set("all_unread_notifications", 1);
this.currentUser.set("read_first_notification", true);
await render(hbs`<SiteHeader />`);
assert.ok(!exists(".ring-first-notification"));
});
test("hamburger menu icon shows pending reviewables count", async function (assert) {
this.currentUser.set("reviewable_count", 1);
await render(hbs`<SiteHeader />`);
let pendingReviewablesBadge = query(
".hamburger-dropdown .badge-notification"
);
assert.strictEqual(pendingReviewablesBadge.textContent, "1");
});
test("clicking outside the revamped menu closes it", async function (assert) {
this.currentUser.set("redesigned_user_menu_enabled", true);
await render(hbs`<SiteHeader />`);
await click(".header-dropdown-toggle.current-user");
assert.ok(exists(".user-menu.revamped"));
await click("header.d-header");
assert.ok(!exists(".user-menu.revamped"));
});
});

View File

@ -0,0 +1,55 @@
import { module, test } from "qunit";
import { setupRenderingTest } from "discourse/tests/helpers/component-test";
import { query, queryAll } from "discourse/tests/helpers/qunit-helpers";
import { render } from "@ember/test-helpers";
import { hbs } from "ember-cli-htmlbars";
module("Integration | Component | user-menu", function (hooks) {
setupRenderingTest(hooks);
const template = hbs`<UserMenu::Menu/>`;
test("notifications panel has a11y attributes", async function (assert) {
await render(template);
const panel = query("#quick-access-all-notifications");
assert.strictEqual(panel.getAttribute("tabindex"), "-1");
assert.strictEqual(
panel.getAttribute("aria-labelledby"),
"user-menu-button-all-notifications"
);
});
test("active tab has a11y attributes that indicate it's active", async function (assert) {
await render(template);
const activeTab = query(".top-tabs.tabs-list .btn.active");
assert.strictEqual(activeTab.getAttribute("tabindex"), "0");
assert.strictEqual(activeTab.getAttribute("aria-selected"), "true");
});
test("the menu has a group of tabs at the top", async function (assert) {
await render(template);
const tabs = queryAll(".top-tabs.tabs-list .btn");
assert.strictEqual(tabs.length, 1);
["all-notifications"].forEach((tab, index) => {
assert.strictEqual(tabs[index].id, `user-menu-button-${tab}`);
assert.strictEqual(
tabs[index].getAttribute("data-tab-number"),
index.toString()
);
assert.strictEqual(
tabs[index].getAttribute("aria-controls"),
`quick-access-${tab}`
);
});
});
test("the menu has a group of tabs at the bottom", async function (assert) {
await render(template);
const tabs = queryAll(".bottom-tabs.tabs-list .btn");
assert.strictEqual(tabs.length, 1);
const preferencesTab = tabs[0];
assert.ok(preferencesTab.href.endsWith("/u/eviltrout/preferences"));
assert.strictEqual(preferencesTab.getAttribute("data-tab-number"), "1");
assert.strictEqual(preferencesTab.getAttribute("tabindex"), "-1");
});
});

View File

@ -0,0 +1,198 @@
import { module, test } from "qunit";
import { setupRenderingTest } from "discourse/tests/helpers/component-test";
import { exists, query } from "discourse/tests/helpers/qunit-helpers";
import { render, settled } 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.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>!",
display_username: "osama",
original_post_id: 1,
original_post_type: 1,
original_username: "velesin",
},
},
overrides
)
);
}
module(
"Integration | Component | user-menu | notification-item",
function (hooks) {
setupRenderingTest(hooks);
const template = hbs`<UserMenu::NotificationItem @item={{this.notification}}/>`;
test("pushes `read` to the classList if the notification is read", async function (assert) {
this.set("notification", getNotification());
this.notification.read = false;
await render(template);
assert.ok(!exists("li.read"));
assert.ok(exists("li"));
this.notification.read = true;
await settled();
assert.ok(
exists("li.read"),
"the item re-renders when the read property is updated"
);
});
test("pushes the notification type name to the classList", async function (assert) {
this.set("notification", getNotification());
await render(template);
let item = query("li");
assert.strictEqual(item.className, "mentioned");
this.set(
"notification",
getNotification({
notification_type: NOTIFICATION_TYPES.private_message,
})
);
await settled();
assert.ok(
exists("li.private-message"),
"replaces underscores in type name with dashes"
);
});
test("pushes is-warning to the classList if the notification originates from a warning PM", async function (assert) {
this.set("notification", getNotification({ is_warning: true }));
await render(template);
assert.ok(exists("li.is-warning"));
});
test("doesn't push is-warning to the classList if the notification doesn't originate from a warning PM", async function (assert) {
this.set("notification", getNotification());
await render(template);
assert.ok(!exists("li.is-warning"));
assert.ok(exists("li"));
});
test("the item's href links to the topic that the notification originates from", async function (assert) {
this.set("notification", getNotification());
await render(template);
const link = query("li a");
assert.ok(link.href.endsWith("/t/this-is-fancy-title/449/113"));
});
test("the item's href links to the group messages if the notification is for a group messages", async function (assert) {
this.set(
"notification",
getNotification({
topic_id: null,
post_number: null,
slug: null,
data: {
group_id: 33,
group_name: "grouperss",
username: "ossaama",
},
})
);
await render(template);
const link = query("li a");
assert.ok(link.href.endsWith("/u/ossaama/messages/grouperss"));
});
test("the item's link has a title for accessibility", async function (assert) {
this.set("notification", getNotification());
await render(template);
const link = query("li a");
assert.strictEqual(link.title, I18n.t("notifications.titles.mentioned"));
});
test("has elements for label and description", async function (assert) {
this.set("notification", getNotification());
await render(template);
const label = query("li a .notification-label");
const description = query("li a .notification-description");
assert.strictEqual(
label.textContent.trim(),
"osama",
"the label's content is the username by default"
);
assert.strictEqual(
description.textContent.trim(),
"This is fancy title <a>!",
"the description defaults to the fancy_title"
);
});
test("the description falls back to topic_title from data if fancy_title is absent", async function (assert) {
this.set(
"notification",
getNotification({
fancy_title: null,
})
);
await render(template);
const description = query("li a .notification-description");
assert.strictEqual(
description.textContent.trim(),
"this is title before it becomes fancy <a>!",
"topic_title from data is rendered safely"
);
});
test("fancy_title is emoji-unescaped", async function (assert) {
this.set(
"notification",
getNotification({
fancy_title: "title with emoji :phone:",
})
);
await render(template);
assert.ok(
exists("li a .notification-description img.emoji"),
"emojis are unescaped when fancy_title is used for description"
);
});
test("topic_title from data is not emoji-unescaped", async function (assert) {
this.set(
"notification",
getNotification({
fancy_title: null,
data: {
topic_title: "unsafe title with unescaped emoji :phone:",
},
})
);
await render(template);
const description = query("li a .notification-description");
assert.strictEqual(
description.textContent.trim(),
"unsafe title with unescaped emoji :phone:",
"emojis aren't unescaped when topic title is not safe"
);
assert.ok(!query("img"), "no <img> exists");
});
}
);

View File

@ -0,0 +1,103 @@
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 { cloneJSON } from "discourse-common/lib/object";
import NotificationFixtures from "discourse/tests/fixtures/notification-fixtures";
import { hbs } from "ember-cli-htmlbars";
import pretender from "discourse/tests/helpers/create-pretender";
import I18n from "I18n";
function getNotificationsData() {
return cloneJSON(NotificationFixtures["/notifications"].notifications);
}
module(
"Integration | Component | user-menu | notifications-list",
function (hooks) {
setupRenderingTest(hooks);
let notificationsData = getNotificationsData();
let queryParams = null;
hooks.beforeEach(() => {
pretender.get("/notifications", (request) => {
queryParams = request.queryParams;
return [
200,
{ "Content-Type": "application/json" },
{ notifications: notificationsData },
];
});
pretender.put("/notifications/mark-read", () => {
return [200, { "Content-Type": "application/json" }, { success: true }];
});
});
hooks.afterEach(() => {
notificationsData = getNotificationsData();
queryParams = null;
});
const template = hbs`<UserMenu::NotificationsList/>`;
test("empty state when there are no notifications", async function (assert) {
notificationsData.clear();
await render(template);
assert.ok(exists(".empty-state .empty-state-title"));
assert.ok(exists(".empty-state .empty-state-body"));
});
test("doesn't set filter_by_types in the params of the request that fetches the notifications", async function (assert) {
await render(template);
assert.strictEqual(
queryParams.filter_by_types,
undefined,
"filter_by_types param is absent"
);
});
test("displays a show all button that takes to the notifications page of the current user", async function (assert) {
await render(template);
const showAllBtn = query(".panel-body-bottom .btn.show-all");
assert.ok(
showAllBtn.href.endsWith("/u/eviltrout/notifications"),
"it takes you to the notifications page"
);
assert.strictEqual(
showAllBtn.getAttribute("title"),
I18n.t("user_menu.view_all_notifications"),
"title attribute is present"
);
});
test("has a dismiss button if some notifications are not read", async function (assert) {
notificationsData.forEach((notification) => {
notification.read = true;
});
notificationsData[0].read = false;
await render(template);
const dismissButton = query(
".panel-body-bottom .btn.notifications-dismiss"
);
assert.strictEqual(
dismissButton.textContent.trim(),
I18n.t("user.dismiss"),
"dismiss button has a label"
);
assert.strictEqual(
dismissButton.getAttribute("title"),
I18n.t("user.dismiss_notifications_tooltip"),
"dismiss button has title attribute"
);
});
test("doesn't have a dismiss button if all notifications are read", async function (assert) {
notificationsData.forEach((notification) => {
notification.read = true;
});
await render(template);
assert.ok(!exists(".panel-body-bottom .btn.notifications-dismiss"));
});
}
);

View File

@ -90,6 +90,70 @@
}
}
.user-menu.revamped {
right: 0;
width: 320px;
padding: 0;
.panel-body-bottom {
flex: 0;
}
.menu-tabs-container {
display: flex;
flex-direction: column;
justify-content: space-between;
border-left: 1px solid var(--primary-low);
}
.tabs-list {
display: flex;
flex-direction: column;
.btn {
display: flex;
padding: 0.857em;
position: relative;
.d-icon {
color: var(--primary-medium);
}
.badge-notification {
background-color: var(--tertiary-med-or-tertiary);
position: absolute;
right: 6px;
top: 6px;
font-size: var(--font-down-3);
}
&.active {
background-color: var(--tertiary-low);
}
&:hover {
background-color: var(--highlight-medium);
}
}
}
.panel-body-contents {
display: flex;
flex-direction: row;
}
.quick-access-panel {
width: 320px;
padding: 0.75em;
justify-content: space-between;
box-sizing: border-box;
.double-user,
.multi-user {
white-space: unset;
}
}
}
.hamburger-panel {
a.widget-link {
width: 100%;
@ -351,7 +415,8 @@
color: var(--danger);
}
}
.read {
.read,
.reviewed {
background-color: var(--secondary);
}
.none {

View File

@ -1,3 +1,9 @@
.menu-panel .panel-body {
max-height: calc(100vh - 100px);
.menu-panel {
&.user-menu.revamped {
width: unset;
}
.panel-body {
max-height: calc(100vh - 100px);
}
}

View File

@ -62,4 +62,8 @@
.panel-body-contents {
// 2em padding very useful for iOS Safari's overlayed bottom nav
padding-bottom: calc(env(safe-area-inset-bottom) + 2em);
.user-menu.revamped & {
height: 100%;
}
}

View File

@ -2546,6 +2546,10 @@ en:
not_logged_in_user: "user page with summary of current activity and preferences"
current_user: "go to your user page"
view_all: "view all %{tab}"
user_menu:
generic_no_items: "There are no items in this list."
sr_menu_tabs: "Menu tabs"
view_all_notifications: "view all notifications"
topics:
new_messages_marker: "last visit"

View File

@ -205,6 +205,7 @@ module SvgSprite
"unlock-alt",
"upload",
"user",
"user-cog",
"user-edit",
"user-friends",
"user-plus",