FEATURE: Add assigns tab to the experimental user menu (#366)

This commit adds a tab for assignments in the experimental user menu. The assignments tab behaves very similarly to the bookmarks and messages tab in core: it displays the user's unread assign notifications first and then fills the rest of available space in the menu with assignments (same content that the current user menu displays).

More details of the experimental user menu can be found in https://github.com/discourse/discourse/pull/17379.
This commit is contained in:
Osama Sayegh 2022-08-17 11:57:15 +03:00 committed by GitHub
parent 28bc8ab78a
commit e6e222d8bc
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 850 additions and 10 deletions

View File

@ -1,3 +1,4 @@
2.9.0.beta8: 28bc8ab78a09551548c87f511ade3d64e1b04bc3
2.9.0.beta3: 46f200935dc9e5750c3f2740abd993e27a9b3f6c
2.8.2: 7bec9aeaf786defc9a133b8cb9ed24f1c2522400
2.8.0.beta8: f901c5fe97c272c884c1e6cfdad1ad1cbe0b36be

View File

@ -174,6 +174,49 @@ module DiscourseAssign
}
end
def user_menu_assigns
assign_notifications = Notification.unread_type(
current_user,
Notification.types[:assigned],
user_menu_limit
)
if assign_notifications.size < user_menu_limit
opts = {}
ignored_assignment_ids = assign_notifications.filter_map do |notification|
notification.data_hash[:assignment_id]
end
opts[:ignored_assignment_ids] = ignored_assignment_ids if ignored_assignment_ids.present?
assigns_list = TopicQuery.new(
current_user,
per_page: user_menu_limit - assign_notifications.size
).list_messages_assigned(current_user, ignored_assignment_ids: ignored_assignment_ids)
end
if assign_notifications.present?
serialized_notifications = ActiveModel::ArraySerializer.new(
assign_notifications,
each_serializer: NotificationSerializer,
scope: guardian
)
end
if assigns_list
serialized_assigns = serialize_data(
assigns_list,
TopicListSerializer,
scope: guardian,
root: false
)[:topics]
end
render json: {
notifications: serialized_notifications || [],
topics: serialized_assigns || [],
}
end
private
def translate_failure(reason, assign_to)
@ -238,5 +281,9 @@ module DiscourseAssign
def ensure_assign_allowed
raise Discourse::InvalidAccess.new unless current_user.can_assign?
end
def user_menu_limit
UsersController::USER_MENU_LIST_LIMIT
end
end
end

View File

@ -0,0 +1,59 @@
import UserMenuBaseItem from "discourse/lib/user-menu/base-item";
import { postUrl } from "discourse/lib/utilities";
import { htmlSafe } from "@ember/template";
import { emojiUnescape } from "discourse/lib/text";
import I18n from "I18n";
const ICON = "user-plus";
const GROUP_ICON = "group-plus";
export default class UserMenuAssignItem extends UserMenuBaseItem {
constructor({ assign }) {
super(...arguments);
this.assign = assign;
}
get className() {
return "assign";
}
get linkHref() {
return postUrl(
this.assign.slug,
this.assign.id,
(this.assign.last_read_post_number || 0) + 1
);
}
get linkTitle() {
if (this.assign.assigned_to_group) {
return I18n.t("user.assigned_to_group", {
group_name:
this.assign.assigned_to_group.full_name ||
this.assign.assigned_to_group.name,
});
} else {
return I18n.t("user.assigned_to_you");
}
}
get icon() {
if (this.assign.assigned_to_group) {
return GROUP_ICON;
} else {
return ICON;
}
}
get label() {
return null;
}
get description() {
return htmlSafe(emojiUnescape(this.assign.fancy_title));
}
get topicId() {
return this.assign.id;
}
}

View File

@ -0,0 +1,3 @@
import templateOnly from "@ember/component/template-only";
// TODO: colocate this component's template
export default templateOnly();

View File

@ -0,0 +1,78 @@
import UserMenuNotificationsList from "discourse/components/user-menu/notifications-list";
import { ajax } from "discourse/lib/ajax";
import UserMenuNotificationItem from "discourse/lib/user-menu/notification-item";
import UserMenuAssignItem from "discourse/plugins/discourse-assign/discourse-assign/lib/user-menu/assign-item";
import Notification from "discourse/models/notification";
import I18n from "I18n";
import showModal from "discourse/lib/show-modal";
export default class UserMenuAssignNotificationsList extends UserMenuNotificationsList {
get dismissTypes() {
return ["assigned"];
}
get showDismiss() {
return this._unreadAssignedNotificationsCount > 0;
}
get dismissTitle() {
return I18n.t("user.dismiss_assigned_tooltip");
}
get showAllHref() {
return `${this.currentUser.path}/activity/assigned`;
}
get showAllTitle() {
return I18n.t("user_menu.view_all_assigned");
}
get itemsCacheKey() {
return "user-menu-assigns-tab";
}
get emptyStateComponent() {
return "user-menu/assigns-list-empty-state";
}
fetchItems() {
return ajax("/assign/user-menu-assigns.json").then((data) => {
const content = [];
data.notifications.forEach((rawNotification) => {
const notification = Notification.create(rawNotification);
content.push(
new UserMenuNotificationItem({
notification,
currentUser: this.currentUser,
siteSettings: this.siteSettings,
site: this.site,
})
);
});
content.push(
...data.topics.map((assign) => new UserMenuAssignItem({ assign }))
);
return content;
});
}
dismissWarningModal() {
const modalController = showModal("dismiss-notification-confirmation");
modalController.set(
"confirmationMessage",
I18n.t("notifications.dismiss_confirmation.body.assigns", {
count: this._unreadAssignedNotificationsCount,
})
);
return modalController;
}
get _unreadAssignedNotificationsCount() {
const key = `grouped_unread_high_priority_notifications.${this.site.notification_types.assigned}`;
// we're retrieving the value with get() so that Ember tracks the property
// and re-renders the UI when it changes.
// we can stop using `get()` when the User model is refactored into native
// class with @tracked properties.
return this.currentUser.get(key) || 0;
}
}

View File

@ -0,0 +1,40 @@
import { withPluginApi } from "discourse/lib/plugin-api";
export default {
name: "assign-user-menu",
initialize(container) {
withPluginApi("1.2.0", (api) => {
if (api.registerUserMenuTab) {
const siteSettings = container.lookup("service:site-settings");
if (!siteSettings.assign_enabled) {
return;
}
const currentUser = api.getCurrentUser();
if (!currentUser?.can_assign) {
return;
}
api.registerUserMenuTab((UserMenuTab) => {
return class extends UserMenuTab {
get id() {
return "assign-list";
}
get panelComponent() {
return "user-menu/assigns-list";
}
get icon() {
return "user-plus";
}
get count() {
return this.getUnreadCountForType("assigned");
}
};
});
}
});
},
};

View File

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

View File

@ -93,6 +93,9 @@ en:
Your assigned topics and messages will be listed here. You will also receive a periodic reminder notification of your assignments, which you can adjust in your <a href='%{preferencesUrl}'>user preferences</a>.
<br><br>
To assign a topic or message to yourself or to someone else, look for the %{icon} assign button at the bottom.
dismiss_assigned_tooltip: "Mark all unread assign notifications as read"
assigned_to_group: "assigned to %{group_name}"
assigned_to_you: "assigned to you"
admin:
web_hooks:
assign_event:
@ -138,3 +141,10 @@ en:
assigned: "%{username} assigned you"
titles:
assigned: "Assigned"
dismiss_confirmation:
body:
assigns:
one: "Are you sure? You have %{count} unread assign notification."
other: "Are you sure? You have %{count} unread assign notifications."
user_menu:
view_all_assigned: "view all assigned"

View File

@ -7,4 +7,5 @@ DiscourseAssign::Engine.routes.draw do
get "/suggestions" => "assign#suggestions"
get "/assigned" => "assign#assigned"
get "/members/:group_name" => "assign#group_members"
get "/user-menu-assigns" => "assign#user_menu_assigns"
end

View File

@ -380,23 +380,30 @@ after_initialize do
next results
end
add_to_class(:topic_query, :list_messages_assigned) do |user|
add_to_class(:topic_query, :list_messages_assigned) do |user, ignored_assignment_ids: nil|
list = default_results(include_pms: true)
where_clause = +"("
where_clause << "(assigned_to_id = :user_id AND assigned_to_type = 'User' AND active)"
if @options[:filter] != :direct
where_clause << "OR (assigned_to_id IN (group_users.group_id) AND assigned_to_type = 'Group' AND active)"
end
where_clause << ")"
if ignored_assignment_ids.present?
where_clause << "AND assignments.id NOT IN (:ignored_assignment_ids)"
end
topic_ids_sql = +<<~SQL
SELECT topic_id FROM assignments
LEFT JOIN group_users ON group_users.user_id = :user_id
WHERE
(assigned_to_id = :user_id AND assigned_to_type = 'User' AND active)
WHERE #{where_clause}
SQL
topic_ids_sql << <<~SQL if @options[:filter] != :direct
OR (assigned_to_id IN (group_users.group_id) AND assigned_to_type = 'Group' AND active)
SQL
sql = "topics.id IN (#{topic_ids_sql})"
list = list.where(sql, user_id: user.id).includes(:allowed_users)
where_args = { user_id: user.id }
if ignored_assignment_ids.present?
where_args[:ignored_assignment_ids] = ignored_assignment_ids
end
list = list.where("topics.id IN (#{topic_ids_sql})", **where_args).includes(:allowed_users)
create_list(:assigned, { unordered: true }, list)
end

View File

@ -392,4 +392,133 @@ RSpec.describe DiscourseAssign::AssignController do
expect(response.status).to eq(200)
end
end
describe "#user_menu_assigns" do
fab!(:unread_assigned_topic) { Fabricate(:post).topic }
fab!(:read_assigned_topic) { Fabricate(:post).topic }
fab!(:unread_assigned_post) { Fabricate(:post, topic: Fabricate(:post).topic) }
fab!(:read_assigned_post) { Fabricate(:post, topic: Fabricate(:post).topic) }
fab!(:read_assigned_post_in_same_topic) { Fabricate(:post, topic: Fabricate(:post).topic) }
fab!(:unread_assigned_post_in_same_topic) { Fabricate(:post, topic: read_assigned_post_in_same_topic.topic) }
fab!(:another_user_unread_assigned_topic) { Fabricate(:post).topic }
fab!(:another_user_read_assigned_topic) { Fabricate(:post).topic }
before do
Jobs.run_immediately!
[
unread_assigned_topic,
read_assigned_topic,
unread_assigned_post,
read_assigned_post,
unread_assigned_post_in_same_topic,
read_assigned_post_in_same_topic,
].each do |target|
Assigner.new(target, normal_admin).assign(user)
end
Notification
.where(
notification_type: Notification.types[:assigned],
read: false,
user_id: user.id,
topic_id: [
read_assigned_topic.id,
read_assigned_post.topic.id,
read_assigned_post_in_same_topic.topic.id
]
)
.where.not(
topic_id: read_assigned_post_in_same_topic.topic.id,
post_number: unread_assigned_post_in_same_topic.post_number
)
.update_all(read: true)
Assigner.new(another_user_read_assigned_topic, normal_admin).assign(user2)
Assigner.new(another_user_unread_assigned_topic, normal_admin).assign(user2)
Notification
.where(
notification_type: Notification.types[:assigned],
read: false,
user_id: user2.id,
topic_id: another_user_read_assigned_topic,
)
.update_all(read: true)
end
context "when logged out" do
it "responds with 403" do
get "/assign/user-menu-assigns.json"
expect(response.status).to eq(403)
end
end
context "when logged in" do
before do
sign_in(user)
end
it "responds with 403 if the current user can't assign" do
user.update!(admin: false)
user.group_users.where(group_id: default_allowed_group.id).destroy_all
get "/assign/user-menu-assigns.json"
expect(response.status).to eq(403)
end
it "responds with 403 if the assign_enabled setting is disabled" do
SiteSetting.assign_enabled = false
get "/assign/user-menu-assigns.json"
expect(response.status).to eq(403)
end
it "sends an array of unread assigned notifications" do
get "/assign/user-menu-assigns.json"
expect(response.status).to eq(200)
notifications = response.parsed_body["notifications"]
expect(notifications.map { |n| [n["topic_id"], n["post_number"]] }).to eq([
[unread_assigned_topic.id, 1],
[unread_assigned_post.topic.id, unread_assigned_post.post_number],
[unread_assigned_post_in_same_topic.topic.id, unread_assigned_post_in_same_topic.post_number]
])
end
it "responds with an array of assigned topics that are not associated with any of the unread assigned notifications" do
get "/assign/user-menu-assigns.json"
expect(response.status).to eq(200)
topics = response.parsed_body["topics"]
expect(topics.map { |t| t["id"] }).to eq([
read_assigned_post_in_same_topic.topic.id,
read_assigned_post.topic.id,
read_assigned_topic.id,
])
end
it "fills up the remaining of the UsersController::USER_MENU_LIST_LIMIT limit with assigned topics" do
stub_const(UsersController, "USER_MENU_LIST_LIMIT", 3) do
get "/assign/user-menu-assigns.json"
end
expect(response.status).to eq(200)
notifications = response.parsed_body["notifications"]
expect(notifications.size).to eq(3)
topics = response.parsed_body["topics"]
expect(topics.size).to eq(0)
stub_const(UsersController, "USER_MENU_LIST_LIMIT", 4) do
get "/assign/user-menu-assigns.json"
end
expect(response.status).to eq(200)
notifications = response.parsed_body["notifications"]
expect(notifications.size).to eq(3)
topics = response.parsed_body["topics"]
expect(topics.size).to eq(1)
end
end
end
end

View File

@ -0,0 +1,451 @@
import {
acceptance,
exists,
query,
queryAll,
updateCurrentUser,
} from "discourse/tests/helpers/qunit-helpers";
import { click, visit } from "@ember/test-helpers";
import { test } from "qunit";
import I18n from "I18n";
const USER_MENU_ASSIGN_RESPONSE = {
notifications: [
{
id: 1716,
user_id: 1,
notification_type: 34,
read: false,
high_priority: true,
created_at: "2022-08-11T21:32:32.404Z",
post_number: 1,
topic_id: 227,
fancy_title: "Test poll topic please bear with me",
slug: "test-poll-topic-please-bear-with-me",
data: {
message: "discourse_assign.assign_notification",
display_username: "tony",
topic_title: "Test poll topic please bear with me",
assignment_id: 2,
},
},
],
topics: [
{
id: 209,
title: "Howdy this a test topic!",
fancy_title: "Howdy this <b>my test topic</b> with emoji :heart:!",
slug: "howdy-this-a-test-topic",
posts_count: 1,
reply_count: 0,
highest_post_number: 1,
image_url: null,
created_at: "2022-03-10T20:09:25.772Z",
last_posted_at: "2022-03-10T20:09:25.959Z",
bumped: true,
bumped_at: "2022-03-10T20:09:25.959Z",
archetype: "regular",
unseen: false,
last_read_post_number: 2,
unread: 0,
new_posts: 0,
unread_posts: 0,
pinned: false,
unpinned: null,
visible: true,
closed: false,
archived: false,
notification_level: 3,
bookmarked: false,
liked: false,
thumbnails: null,
tags: [],
tags_descriptions: {},
views: 11,
like_count: 7,
has_summary: false,
last_poster_username: "osama",
category_id: 1,
pinned_globally: false,
featured_link: null,
assigned_to_user: {
id: 1,
username: "osama",
name: "Osama.OG",
avatar_template: "/letter_avatar_proxy/v4/letter/o/f05b48/{size}.png",
assign_icon: "user-plus",
assign_path: "/u/osama/activity/assigned",
},
posters: [
{
extras: "latest single",
description: "Original Poster, Most Recent Poster",
user_id: 1,
primary_group_id: 45,
flair_group_id: 45,
},
],
},
{
id: 173,
title: "Owners elegance entrance startled spirits losing",
fancy_title:
"Owners <i>elegance entrance :car: startled</i> spirits losing",
slug: "owners-elegance-entrance-startled-spirits-losing",
posts_count: 7,
reply_count: 0,
highest_post_number: 7,
image_url: null,
created_at: "2021-07-11T04:50:17.029Z",
last_posted_at: "2021-12-24T17:21:03.418Z",
bumped: true,
bumped_at: "2021-12-24T17:21:03.418Z",
archetype: "regular",
unseen: false,
last_read_post_number: 3,
unread: 0,
new_posts: 0,
unread_posts: 0,
pinned: false,
unpinned: null,
visible: true,
closed: false,
archived: false,
notification_level: 1,
bookmarked: false,
liked: false,
thumbnails: null,
tags: ["music", "job-application"],
tags_descriptions: {},
views: 23,
like_count: 24,
has_summary: false,
last_poster_username: "ambrose.bradtke",
category_id: 1,
pinned_globally: false,
featured_link: null,
assigned_to_group: {
id: 45,
automatic: false,
name: "Team",
user_count: 4,
mentionable_level: 99,
messageable_level: 99,
visibility_level: 0,
primary_group: true,
title: "",
grant_trust_level: null,
incoming_email: null,
has_messages: true,
flair_url: null,
flair_bg_color: "",
flair_color: "",
bio_raw: "",
bio_cooked: null,
bio_excerpt: null,
public_admission: true,
public_exit: true,
allow_membership_requests: false,
full_name: "",
default_notification_level: 3,
membership_request_template: "",
members_visibility_level: 0,
can_see_members: true,
can_admin_group: true,
publish_read_state: true,
assign_icon: "group-plus",
assign_path: "/g/Team/assigned/everyone",
},
posters: [
{
extras: null,
description: "Original Poster",
user_id: 26,
primary_group_id: null,
flair_group_id: null,
},
{
extras: null,
description: "Frequent Poster",
user_id: 16,
primary_group_id: null,
flair_group_id: null,
},
{
extras: null,
description: "Frequent Poster",
user_id: 22,
primary_group_id: null,
flair_group_id: null,
},
{
extras: null,
description: "Frequent Poster",
user_id: 12,
primary_group_id: null,
flair_group_id: null,
},
{
extras: "latest",
description: "Most Recent Poster",
user_id: 13,
primary_group_id: null,
flair_group_id: null,
},
],
},
],
};
acceptance(
"Discourse Assign | experimental user menu | user cannot assign",
function (needs) {
needs.user({ redesigned_user_menu_enabled: true, can_assign: false });
needs.settings({
assign_enabled: true,
});
test("the assigns tab is not shown", async function (assert) {
await visit("/");
await click(".d-header-icons .current-user");
assert.notOk(exists("#user-menu-button-assign-list"));
});
}
);
acceptance(
"Discourse Assign | experimental user menu | assign_enabled setting is disabled",
function (needs) {
needs.user({ redesigned_user_menu_enabled: true, can_assign: true });
needs.settings({
assign_enabled: false,
});
test("the assigns tab is not shown", async function (assert) {
await visit("/");
await click(".d-header-icons .current-user");
assert.notOk(exists("#user-menu-button-assign-list"));
});
}
);
acceptance("Discourse Assign | experimental user menu", function (needs) {
needs.user({
redesigned_user_menu_enabled: true,
can_assign: true,
grouped_unread_high_priority_notifications: {
34: 173, // assigned notification type
},
});
needs.settings({
assign_enabled: true,
});
let forceEmptyState = false;
let markRead = false;
let requestBody;
needs.pretender((server, helper) => {
server.get("/assign/user-menu-assigns.json", () => {
if (forceEmptyState) {
return helper.response({ notifications: [], topics: [] });
} else {
return helper.response(USER_MENU_ASSIGN_RESPONSE);
}
});
server.put("/notifications/mark-read", (request) => {
requestBody = request.requestBody;
markRead = true;
return helper.response({ success: true });
});
});
needs.hooks.afterEach(() => {
forceEmptyState = false;
markRead = false;
requestBody = null;
});
test("assigns tab", async function (assert) {
await visit("/");
await click(".d-header-icons .current-user");
assert.ok(exists("#user-menu-button-assign-list"), "assigns tab exists");
assert.ok(
exists("#user-menu-button-assign-list .d-icon-user-plus"),
"assigns tab has the user-plus icon"
);
assert.strictEqual(
query(
"#user-menu-button-assign-list .badge-notification"
).textContent.trim(),
"173",
"assigns tab has a count badge"
);
updateCurrentUser({
grouped_unread_high_priority_notifications: {},
});
assert.notOk(
exists("#user-menu-button-assign-list .badge-notification"),
"badge count disappears when it goes to zero"
);
assert.ok(
exists("#user-menu-button-assign-list"),
"assigns tab still exists"
);
});
test("displays unread assign notifications on top and fills the remaining space with read assigns", async function (assert) {
await visit("/");
await click(".d-header-icons .current-user");
await click("#user-menu-button-assign-list");
const notifications = queryAll("#quick-access-assign-list .notification");
assert.strictEqual(
notifications.length,
1,
"there is one unread notification"
);
assert.ok(
notifications[0].classList.contains("unread"),
"the notification is unread"
);
assert.ok(
notifications[0].classList.contains("assigned"),
"the notification is of type assigned"
);
const assigns = queryAll("#quick-access-assign-list .assign");
assert.strictEqual(assigns.length, 2, "there are 2 assigns");
const userAssign = assigns[0];
const groupAssign = assigns[1];
assert.ok(
userAssign.querySelector(".d-icon-user-plus"),
"user assign has the right icon"
);
assert.ok(
groupAssign.querySelector(".d-icon-group-plus"),
"group assign has the right icon"
);
assert.ok(
userAssign
.querySelector("a")
.href.endsWith("/t/howdy-this-a-test-topic/209/3"),
"user assign links to the first unread post (last read post + 1)"
);
assert.ok(
groupAssign
.querySelector("a")
.href.endsWith(
"/t/owners-elegance-entrance-startled-spirits-losing/173/4"
),
"group assign links to the first unread post (last read post + 1)"
);
assert.strictEqual(
userAssign.textContent.trim(),
"Howdy this my test topic with emoji !",
"user assign contains the topic title"
);
assert.ok(
userAssign.querySelector(".item-description img.emoji"),
"emojis are rendered in user assign"
);
assert.ok(
userAssign.querySelector(".item-description b").textContent.trim(),
"my test topic",
"user assign topic title is trusted"
);
assert.strictEqual(
groupAssign.textContent.trim().replaceAll(/\s+/g, " "),
"Owners elegance entrance startled spirits losing",
"group assign contains the topic title"
);
assert.ok(
groupAssign.querySelector(".item-description i img.emoji"),
"emojis are rendered in group assign"
);
assert.strictEqual(
groupAssign
.querySelector(".item-description i")
.textContent.trim()
.replaceAll(/\s+/g, " "),
"elegance entrance startled",
"group assign topic title is trusted"
);
assert.strictEqual(
userAssign.querySelector("a").title,
I18n.t("user.assigned_to_you"),
"user assign has the right title"
);
assert.strictEqual(
groupAssign.querySelector("a").title,
I18n.t("user.assigned_to_group", { group_name: "Team" }),
"group assign has the right title"
);
});
test("dismiss button", async function (assert) {
await visit("/");
await click(".d-header-icons .current-user");
await click("#user-menu-button-assign-list");
assert.ok(
exists("#user-menu-button-assign-list .badge-notification"),
"badge count is visible before dismissing"
);
await click(".notifications-dismiss");
assert.notOk(markRead, "mark-read request isn't sent");
assert.strictEqual(
query(".dismiss-notification-confirmation.modal-body").textContent.trim(),
I18n.t("notifications.dismiss_confirmation.body.assigns", { count: 173 }),
"dismiss confirmation modal is shown"
);
await click(".modal-footer .btn-primary");
assert.ok(markRead, "mark-read request is sent");
assert.notOk(exists(".notifications-dismiss"), "dismiss button is gone");
assert.notOk(
exists("#user-menu-button-assign-list .badge-notification"),
"badge count is gone after dismissing"
);
assert.strictEqual(
requestBody,
"dismiss_types=assigned",
"mark-read request is sent with the right params"
);
});
test("empty state", async function (assert) {
forceEmptyState = true;
await visit("/");
await click(".d-header-icons .current-user");
await click("#user-menu-button-assign-list");
assert.strictEqual(
query(".empty-state-title").textContent.trim(),
I18n.t("user.no_assignments_title"),
"empty state title is rendered"
);
const emptyStateBody = query(".empty-state-body");
assert.ok(emptyStateBody, "empty state body exists");
assert.ok(
emptyStateBody.querySelector(".d-icon-user-plus"),
"empty state body has user-plus icon"
);
assert.ok(
emptyStateBody
.querySelector("a")
.href.endsWith("/my/preferences/notifications"),
"empty state body has user-plus icon"
);
});
});