DEV: Add profile tab to the experimental user menu (#17982)

This commit adds the profile tab to the experimental user menu. We're adding it to the user menu because it contains links/buttons that are not available anywhere else. We may remove the tab again if we find better places for those links/buttons, but for now it'll stay.

For more context on the experimental user menu, see https://github.com/discourse/discourse/pull/17379.
This commit is contained in:
Osama Sayegh 2022-08-19 13:02:11 +03:00 committed by GitHub
parent 66376a6569
commit 67bb0d8a55
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 631 additions and 35 deletions

View File

@ -11,37 +11,24 @@
<div class="menu-tabs-container" role="tablist" aria-orientation="vertical" aria-label={{i18n "user_menu.sr_menu_tabs"}}> <div class="menu-tabs-container" role="tablist" aria-orientation="vertical" aria-label={{i18n "user_menu.sr_menu_tabs"}}>
<div class="top-tabs tabs-list"> <div class="top-tabs tabs-list">
{{#each this.topTabs as |tab|}} {{#each this.topTabs as |tab|}}
<button <UserMenu::TabButton
class={{concat "btn btn-flat btn-icon no-text" (if (eq tab.id this.currentTabId) " active")}} @tab={{tab}}
type="button" @currentTabId={{this.currentTabId}}
role="tab" @changeTabFunction={{fn this.changeTab 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}} {{#if tab.count}}
<span class="badge-notification">{{tab.count}}</span> <span class="badge-notification">{{tab.count}}</span>
{{/if}} {{/if}}
</button> </UserMenu::TabButton>
{{/each}} {{/each}}
</div> </div>
<div class="bottom-tabs tabs-list"> <div class="bottom-tabs tabs-list">
{{#each this.bottomTabs as |tab|}} {{#each this.bottomTabs as |tab|}}
<a <UserMenu::TabButton
class="btn btn-flat btn-icon no-text" @tab={{tab}}
role="tab" @currentTabId={{this.currentTabId}}
tabindex="-1" @changeTabFunction={{fn this.changeTab tab}}
href={{tab.href}} />
data-tab-number={{tab.position}}
{{!-- template-lint-disable require-context-role --}}
>
{{d-icon tab.icon}}
</a>
{{/each}} {{/each}}
</div> </div>
</div> </div>

View File

@ -136,6 +136,22 @@ const CORE_TOP_TABS = [
}, },
]; ];
const CORE_BOTTOM_TABS = [
class extends UserMenuTab {
get id() {
return "profile";
}
get icon() {
return "user";
}
get panelComponent() {
return "user-menu/profile-tab-content";
}
},
];
export default class UserMenu extends Component { export default class UserMenu extends Component {
@service currentUser; @service currentUser;
@service siteSettings; @service siteSettings;
@ -185,8 +201,17 @@ export default class UserMenu extends Component {
} }
get _bottomTabs() { get _bottomTabs() {
const tabs = [];
CORE_BOTTOM_TABS.forEach((tabClass) => {
const tab = new tabClass(this.currentUser, this.siteSettings, this.site);
if (tab.shouldDisplay) {
tabs.push(tab);
}
});
const topTabsLength = this.topTabs.length; const topTabsLength = this.topTabs.length;
return this._coreBottomTabs.map((tab, index) => { return tabs.map((tab, index) => {
tab.position = index + topTabsLength; tab.position = index + topTabsLength;
return tab; return tab;
}); });

View File

@ -0,0 +1,100 @@
<ul>
<li class="summary">
<LinkTo @route="user.summary" @model={{this.currentUser}}>
{{d-icon "user"}}
<span class="item-label">
{{i18n "user.summary.title"}}
</span>
</LinkTo>
</li>
<li class="activity">
<LinkTo @route="userActivity" @model={{this.currentUser}}>
{{d-icon "stream"}}
<span class="item-label">
{{i18n "user.activity_stream"}}
</span>
</LinkTo>
</li>
{{#if this.currentUser.can_invite_to_forum}}
<li class="invites">
<LinkTo @route="userInvited" @model={{this.currentUser}}>
{{d-icon "user-plus"}}
<span class="item-label">
{{i18n "user.invited.title"}}
</span>
</LinkTo>
</li>
{{/if}}
<li class="drafts">
<LinkTo @route="userActivity.drafts" @model={{this.currentUser}}>
{{d-icon "pencil-alt"}}
<span class="item-label">
{{#if this.currentUser.draft_count}}
{{i18n "drafts.label_with_count" count=this.currentUser.draft_count}}
{{else}}
{{i18n "drafts.label"}}
{{/if}}
</span>
</LinkTo>
</li>
<li class="preferences">
<LinkTo @route="preferences" @model={{this.currentUser}}>
{{d-icon "cog"}}
<span class="item-label">
{{i18n "user.preferences"}}
</span>
</LinkTo>
</li>
<li class="do-not-disturb">
<DButton @class="btn-flat profile-tab-btn" @action={{this.doNotDisturbClick}}>
{{d-icon (if this.isInDoNotDisturb "toggle-on" "toggle-off")}}
<span class="item-label">
{{#if this.isInDoNotDisturb}}
<span>{{i18n "do_not_disturb.label"}}</span>
<span
title={{this.doNotDisturbDateTitle}}
data-time={{this.doNotDisturbDateTime}}
data-format="tiny"
class="relative-date"
>
{{this.doNotDisturbDateContent}}
</span>
{{else}}
{{i18n "do_not_disturb.label"}}
{{/if}}
</span>
</DButton>
</li>
{{#if this.showToggleAnonymousButton}}
<li class={{if this.currentUser.is_anonymous "disable-anonymous" "enable-anonymous"}}>
<DButton @class="btn-flat profile-tab-btn" @action={{route-action "toggleAnonymous"}}>
{{#if this.currentUser.is_anonymous}}
{{d-icon "ban"}}
<span class="item-label">
{{i18n "switch_from_anon"}}
</span>
{{else}}
{{d-icon "user-secret"}}
<span class="item-label">
{{i18n "switch_to_anon"}}
</span>
{{/if}}
</DButton>
</li>
{{/if}}
<li class="logout">
<DButton @class="btn-flat profile-tab-btn" @action={{route-action "logout"}}>
{{d-icon "sign-out-alt"}}
<span class="item-label">
{{i18n "user.log_out"}}
</span>
</DButton>
</li>
</ul>

View File

@ -0,0 +1,63 @@
import Component from "@glimmer/component";
import { inject as service } from "@ember/service";
import { action } from "@ember/object";
import showModal from "discourse/lib/show-modal";
import { longDate, relativeAge } from "discourse/lib/formatter";
export default class UserMenuProfileTabContent extends Component {
@service currentUser;
@service siteSettings;
saving = false;
get showToggleAnonymousButton() {
return (
(this.siteSettings.allow_anonymous_posting &&
this.currentUser.trust_level >=
this.siteSettings.anonymous_posting_min_trust_level) ||
this.currentUser.is_anonymous
);
}
get isInDoNotDisturb() {
return !!this.#doNotDisturbUntilDate;
}
get doNotDisturbDateTitle() {
return longDate(this.#doNotDisturbUntilDate);
}
get doNotDisturbDateContent() {
return relativeAge(this.#doNotDisturbUntilDate);
}
get doNotDisturbDateTime() {
return this.#doNotDisturbUntilDate.getTime();
}
get #doNotDisturbUntilDate() {
if (!this.currentUser.get("do_not_disturb_until")) {
return;
}
const date = new Date(this.currentUser.get("do_not_disturb_until"));
if (date < new Date()) {
return;
}
return date;
}
@action
doNotDisturbClick() {
if (this.saving) {
return;
}
this.saving = true;
if (this.currentUser.do_not_disturb_until) {
return this.currentUser.leaveDoNotDisturb().finally(() => {
this.saving = false;
});
} else {
this.saving = false;
showModal("do-not-disturb");
}
}
}

View File

@ -0,0 +1,15 @@
<button
class={{concat "btn btn-flat btn-icon no-text" (if (eq @tab.id @currentTabId) " active")}}
type="button"
role="tab"
id={{concat "user-menu-button-" @tab.id}}
tabindex={{if (eq @tab.id @currentTabId) "0" "-1"}}
aria-selected={{if (eq @tab.id @currentTabId) "true" "false"}}
aria-controls={{concat "quick-access-" @tab.id}}
data-tab-number={{@tab.position}}
{{on "click" @changeTabFunction}}
{{!-- template-lint-disable require-context-role --}}
>
{{d-icon @tab.icon}}
{{yield}}
</button>

View File

@ -0,0 +1,3 @@
import templateOnly from "@ember/component/template-only";
export default templateOnly();

View File

@ -100,3 +100,113 @@ acceptance("Do not disturb", function (needs) {
); );
}); });
}); });
acceptance("Do not disturb - new user menu", function (needs) {
needs.user({ redesigned_user_menu_enabled: true });
needs.pretender((server, helper) => {
server.post("/do-not-disturb.json", () => {
const now = new Date();
now.setHours(now.getHours() + 1);
return helper.response({ ends_at: now });
});
server.delete("/do-not-disturb.json", () =>
helper.response({ success: true })
);
});
test("when turned off, it is turned on from modal", async function (assert) {
updateCurrentUser({ do_not_disturb_until: null });
await visit("/");
await click(".header-dropdown-toggle.current-user");
await click("#user-menu-button-profile");
await click("#quick-access-profile .do-not-disturb .btn");
assert.ok(exists(".do-not-disturb-modal"), "modal to choose time appears");
let tiles = queryAll(".do-not-disturb-tile");
assert.ok(tiles.length === 4, "There are 4 duration choices");
await click(tiles[0]);
assert.ok(query(".do-not-disturb-modal.hidden"), "modal is hidden");
assert.ok(
exists(".header-dropdown-toggle .do-not-disturb-background .d-icon-moon"),
"moon icon is present in header"
);
});
test("Can be invoked via keyboard", async function (assert) {
updateCurrentUser({ do_not_disturb_until: null });
await visit("/");
await click(".header-dropdown-toggle.current-user");
await click("#user-menu-button-profile");
await click("#quick-access-profile .do-not-disturb .btn");
assert.ok(exists(".do-not-disturb-modal"), "DND modal is displayed");
assert.strictEqual(
count(".do-not-disturb-tile"),
4,
"There are 4 duration choices"
);
await triggerKeyEvent(
".do-not-disturb-tile:nth-child(1)",
"keydown",
"Enter"
);
assert.ok(
query(".do-not-disturb-modal.hidden"),
"DND modal is hidden after making a choice"
);
assert.ok(
exists(".header-dropdown-toggle .do-not-disturb-background .d-icon-moon"),
"moon icon is shown in header avatar"
);
});
test("when turned on, it can be turned off", async function (assert) {
const now = new Date();
now.setHours(now.getHours() + 1);
updateCurrentUser({ do_not_disturb_until: now });
await visit("/");
assert.ok(
exists(".do-not-disturb-background"),
"The active moon icon is shown"
);
await click(".header-dropdown-toggle.current-user");
await click("#user-menu-button-profile");
assert.strictEqual(
query(".do-not-disturb .relative-date").textContent.trim(),
"1h",
"the Do Not Disturb button shows how much time is left for DND mode"
);
assert.ok(
exists(".do-not-disturb .d-icon-toggle-on"),
"the Do Not Disturb button has the toggle-on icon"
);
await click("#quick-access-profile .do-not-disturb .btn");
assert.notOk(
exists(".do-not-disturb-background"),
"The active moon icons are removed"
);
assert.notOk(
exists(".do-not-disturb .relative-date"),
"the text showing how much time is left for DND mode is gone"
);
assert.ok(
exists(".do-not-disturb .d-icon-toggle-off"),
"the Do Not Disturb button has the toggle-off icon"
);
});
});

View File

@ -6,6 +6,7 @@ import {
publishToMessageBus, publishToMessageBus,
query, query,
queryAll, queryAll,
updateCurrentUser,
} from "discourse/tests/helpers/qunit-helpers"; } from "discourse/tests/helpers/qunit-helpers";
import { test } from "qunit"; import { test } from "qunit";
import { cloneJSON } from "discourse-common/lib/object"; import { cloneJSON } from "discourse-common/lib/object";
@ -19,7 +20,14 @@ acceptance("User menu", function (needs) {
needs.user({ needs.user({
redesigned_user_menu_enabled: true, redesigned_user_menu_enabled: true,
unread_high_priority_notifications: 73, unread_high_priority_notifications: 73,
trust_level: 3,
}); });
needs.settings({
allow_anonymous_posting: true,
anonymous_posting_min_trust_level: 3,
});
let requestHeaders = {}; let requestHeaders = {};
needs.pretender((server, helper) => { needs.pretender((server, helper) => {
@ -178,6 +186,238 @@ acceptance("User menu", function (needs) {
"the tab's content is now displayed in the panel" "the tab's content is now displayed in the panel"
); );
}); });
test("the profile tab", async function (assert) {
updateCurrentUser({ draft_count: 13 });
await visit("/");
await click(".d-header-icons .current-user");
await click("#user-menu-button-profile");
const summaryLink = query("#quick-access-profile ul li.summary a");
assert.ok(
summaryLink.href.endsWith("/u/eviltrout/summary"),
"has a link to the summary page of the user"
);
assert.strictEqual(
summaryLink.textContent.trim(),
I18n.t("user.summary.title"),
"summary link has the right label"
);
assert.ok(
summaryLink.querySelector(".d-icon-user"),
"summary link has the right icon"
);
const activityLink = query("#quick-access-profile ul li.activity a");
assert.ok(
activityLink.href.endsWith("/u/eviltrout/activity"),
"has a link to the activity page of the user"
);
assert.strictEqual(
activityLink.textContent.trim(),
I18n.t("user.activity_stream"),
"activity link has the right label"
);
assert.ok(
activityLink.querySelector(".d-icon-stream"),
"activity link has the right icon"
);
const invitesLink = query("#quick-access-profile ul li.invites a");
assert.ok(
invitesLink.href.endsWith("/u/eviltrout/invited"),
"has a link to the invites page of the user"
);
assert.strictEqual(
invitesLink.textContent.trim(),
I18n.t("user.invited.title"),
"invites link has the right label"
);
assert.ok(
invitesLink.querySelector(".d-icon-user-plus"),
"invites link has the right icon"
);
await click("header.d-header"); // close the menu
updateCurrentUser({ can_invite_to_forum: false });
await click(".d-header-icons .current-user");
await click("#user-menu-button-profile");
assert.notOk(
exists("#quick-access-profile ul li.invites"),
"invites link not shown when the user can't invite"
);
const dratsLink = query("#quick-access-profile ul li.drafts a");
assert.ok(
dratsLink.href.endsWith("/u/eviltrout/activity/drafts"),
"has a link to the drafts page of the user"
);
assert.strictEqual(
dratsLink.textContent.trim(),
I18n.t("drafts.label_with_count", { count: 13 }),
"drafts link has the right label with count of the user's drafts"
);
assert.ok(
dratsLink.querySelector(".d-icon-pencil-alt"),
"drafts link has the right icon"
);
const preferencesLink = query("#quick-access-profile ul li.preferences a");
assert.ok(
preferencesLink.href.endsWith("/u/eviltrout/preferences"),
"has a link to the preferences page of the user"
);
assert.strictEqual(
preferencesLink.textContent.trim(),
I18n.t("user.preferences"),
"preferences link has the right label"
);
assert.ok(
preferencesLink.querySelector(".d-icon-cog"),
"preferences link has the right icon"
);
let doNotDisturbButton = query(
"#quick-access-profile ul li.do-not-disturb .btn"
);
assert.strictEqual(
doNotDisturbButton.textContent
.replaceAll(/\s+/g, " ")
.replaceAll(/\u200B/g, "")
.trim(),
I18n.t("do_not_disturb.label"),
"Do Not Disturb button has the right label"
);
assert.ok(
doNotDisturbButton.querySelector(".d-icon-toggle-off"),
"Do Not Disturb button has the right icon"
);
await click("header.d-header"); // close the menu
const date = new Date();
date.setHours(date.getHours() + 2);
updateCurrentUser({ do_not_disturb_until: date.toISOString() });
await click(".d-header-icons .current-user");
await click("#user-menu-button-profile");
doNotDisturbButton = query(
"#quick-access-profile ul li.do-not-disturb .btn"
);
assert.strictEqual(
doNotDisturbButton.textContent
.replaceAll(/\s+/g, " ")
.replaceAll(/\u200B/g, "")
.trim(),
`${I18n.t("do_not_disturb.label")} 2h`,
"Do Not Disturb button has the right label when Do Not Disturb is enabled"
);
assert.ok(
doNotDisturbButton.querySelector(".d-icon-toggle-on"),
"Do Not Disturb button has the right icon when Do Not Disturb is enabled"
);
let toggleAnonButton = query(
"#quick-access-profile ul li.enable-anonymous .btn"
);
assert.strictEqual(
toggleAnonButton.textContent
.replaceAll(/\s+/g, " ")
.replaceAll(/\u200B/g, "")
.trim(),
I18n.t("switch_to_anon"),
"toggle anonymous button has the right label when the user isn't anonymous"
);
assert.ok(
toggleAnonButton.querySelector(".d-icon-user-secret"),
"toggle anonymous button has the right icon when the user isn't anonymous"
);
await click("header.d-header"); // close the menu
updateCurrentUser({ is_anonymous: true });
await click(".d-header-icons .current-user");
await click("#user-menu-button-profile");
toggleAnonButton = query(
"#quick-access-profile ul li.disable-anonymous .btn"
);
assert.strictEqual(
toggleAnonButton.textContent
.replaceAll(/\s+/g, " ")
.replaceAll(/\u200B/g, "")
.trim(),
I18n.t("switch_from_anon"),
"toggle anonymous button has the right label when the user is anonymous"
);
assert.ok(
toggleAnonButton.querySelector(".d-icon-ban"),
"toggle anonymous button has the right icon when the user is anonymous"
);
await click("header.d-header"); // close the menu
updateCurrentUser({ is_anonymous: false, trust_level: 2 });
await click(".d-header-icons .current-user");
await click("#user-menu-button-profile");
assert.notOk(
exists("#quick-access-profile ul li.enable-anonymous"),
"toggle anon button isn't shown when the user can't use it"
);
assert.notOk(
exists("#quick-access-profile ul li.disable-anonymous"),
"toggle anon button isn't shown when the user can't use it"
);
await click("header.d-header"); // close the menu
updateCurrentUser({ is_anonymous: true, trust_level: 2 });
this.siteSettings.allow_anonymous_posting = false;
this.siteSettings.anonymous_posting_min_trust_level = 3;
await click(".d-header-icons .current-user");
await click("#user-menu-button-profile");
assert.ok(
exists("#quick-access-profile ul li.disable-anonymous"),
"toggle anon button is always shown if the user is anonymous"
);
await click("header.d-header"); // close the menu
updateCurrentUser({ is_anonymous: false, trust_level: 4 });
this.siteSettings.allow_anonymous_posting = false;
this.siteSettings.anonymous_posting_min_trust_level = 3;
await click(".d-header-icons .current-user");
await click("#user-menu-button-profile");
assert.notOk(
exists("#quick-access-profile ul li.enable-anonymous"),
"toggle anon button is not shown if the allow_anonymous_posting setting is false"
);
await click("header.d-header"); // close the menu
updateCurrentUser({ is_anonymous: false, trust_level: 2 });
this.siteSettings.allow_anonymous_posting = true;
this.siteSettings.anonymous_posting_min_trust_level = 3;
await click(".d-header-icons .current-user");
await click("#user-menu-button-profile");
assert.notOk(
exists("#quick-access-profile ul li.enable-anonymous"),
"toggle anon button is not shown if the user doesn't have a high enough trust level"
);
const logoutButton = query("#quick-access-profile ul li.logout .btn");
assert.strictEqual(
logoutButton.textContent
.replaceAll(/\s+/g, " ")
.replaceAll(/\u200B/g, "")
.trim(),
I18n.t("user.log_out"),
"logout button has the right label"
);
assert.ok(
logoutButton.querySelector(".d-icon-sign-out-alt"),
"logout button has the right icon"
);
});
}); });
acceptance("User menu - Dismiss button", function (needs) { acceptance("User menu - Dismiss button", function (needs) {

View File

@ -164,8 +164,9 @@ module("Integration | Component | site-header", function (hooks) {
await triggerKeyEvent(document, "keydown", "ArrowDown"); await triggerKeyEvent(document, "keydown", "ArrowDown");
focusedTab = document.activeElement; focusedTab = document.activeElement;
assert.ok( assert.strictEqual(
focusedTab.href.endsWith("/u/eviltrout/preferences"), focusedTab.id,
"user-menu-button-profile",
"the down arrow key can move the focus to the bottom tabs" "the down arrow key can move the focus to the bottom tabs"
); );
@ -179,8 +180,9 @@ module("Integration | Component | site-header", function (hooks) {
await triggerKeyEvent(document, "keydown", "ArrowUp"); await triggerKeyEvent(document, "keydown", "ArrowUp");
focusedTab = document.activeElement; focusedTab = document.activeElement;
assert.ok( assert.strictEqual(
focusedTab.href.endsWith("/u/eviltrout/preferences"), focusedTab.id,
"user-menu-button-profile",
"the up arrow key moves the focus in the opposite direction" "the up arrow key moves the focus in the opposite direction"
); );
}); });

View File

@ -70,10 +70,10 @@ module("Integration | Component | user-menu", function (hooks) {
await render(template); await render(template);
const tabs = queryAll(".bottom-tabs.tabs-list .btn"); const tabs = queryAll(".bottom-tabs.tabs-list .btn");
assert.strictEqual(tabs.length, 1); assert.strictEqual(tabs.length, 1);
const preferencesTab = tabs[0]; const profileTab = tabs[0];
assert.ok(preferencesTab.href.endsWith("/u/eviltrout/preferences")); assert.strictEqual(profileTab.id, "user-menu-button-profile");
assert.strictEqual(preferencesTab.dataset.tabNumber, "6"); assert.strictEqual(profileTab.dataset.tabNumber, "6");
assert.strictEqual(preferencesTab.getAttribute("tabindex"), "-1"); assert.strictEqual(profileTab.getAttribute("tabindex"), "-1");
}); });
test("likes tab is hidden if current user's like notifications frequency is 'never'", async function (assert) { test("likes tab is hidden if current user's like notifications frequency is 'never'", async function (assert) {

View File

@ -94,6 +94,28 @@
right: 0; right: 0;
width: 320px; width: 320px;
padding: 0; padding: 0;
#quick-access-profile {
ul {
flex-wrap: nowrap;
height: 100%;
align-items: center;
overflow-y: auto; // really short viewports
}
li {
flex: 1 1 auto;
max-height: 3em; // prevent buttons from getting too tall
> * {
// button, a, and everything else
height: 100%;
align-items: center;
margin: 0;
padding: 0 0.5em;
}
.d-icon {
padding-top: 0;
}
}
}
.panel-body-bottom { .panel-body-bottom {
flex: 0; flex: 0;
@ -102,9 +124,8 @@
.menu-tabs-container { .menu-tabs-container {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
justify-content: space-between;
border-left: 1px solid var(--primary-low); border-left: 1px solid var(--primary-low);
padding: 0.75em 0; padding: 0.75em 0 0;
} }
.tabs-list { .tabs-list {
@ -137,6 +158,10 @@
} }
} }
.bottom-tabs {
border-top: 1px solid var(--primary-low);
}
.panel-body-contents { .panel-body-contents {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
@ -179,6 +204,31 @@
} }
} }
} }
#quick-access-profile {
display: inline;
.profile-tab-btn {
justify-content: unset;
line-height: $line-height-large;
width: 100%;
.d-icon {
padding: 0;
}
}
.do-not-disturb {
.relative-date {
font-size: $font-down-3;
color: var(--primary-medium);
}
.d-icon-toggle-on {
color: var(--tertiary);
}
}
}
} }
// remove when the widgets-based implementation of the user menu is removed // remove when the widgets-based implementation of the user menu is removed
@ -399,7 +449,8 @@
} }
} }
a { a,
.profile-tab-btn {
display: flex; display: flex;
margin: 0.25em; margin: 0.25em;
padding: 0em 0.25em; padding: 0em 0.25em;