From 67bb0d8a5543f7c7c45275059f5d45b1c12a3b31 Mon Sep 17 00:00:00 2001 From: Osama Sayegh Date: Fri, 19 Aug 2022 13:02:11 +0300 Subject: [PATCH] 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. --- .../app/components/user-menu/menu.hbs | 33 +-- .../app/components/user-menu/menu.js | 27 +- .../user-menu/profile-tab-content.hbs | 100 ++++++++ .../user-menu/profile-tab-content.js | 63 +++++ .../app/components/user-menu/tab-button.hbs | 15 ++ .../app/components/user-menu/tab-button.js | 3 + .../tests/acceptance/do-not-disturb-test.js | 110 ++++++++ .../tests/acceptance/user-menu-test.js | 240 ++++++++++++++++++ .../components/site-header-test.js | 10 +- .../components/user-menu/menu-test.js | 8 +- .../stylesheets/common/base/menu-panel.scss | 57 ++++- 11 files changed, 631 insertions(+), 35 deletions(-) create mode 100644 app/assets/javascripts/discourse/app/components/user-menu/profile-tab-content.hbs create mode 100644 app/assets/javascripts/discourse/app/components/user-menu/profile-tab-content.js create mode 100644 app/assets/javascripts/discourse/app/components/user-menu/tab-button.hbs create mode 100644 app/assets/javascripts/discourse/app/components/user-menu/tab-button.js diff --git a/app/assets/javascripts/discourse/app/components/user-menu/menu.hbs b/app/assets/javascripts/discourse/app/components/user-menu/menu.hbs index da8b762cdd7..aa5d2624a38 100644 --- a/app/assets/javascripts/discourse/app/components/user-menu/menu.hbs +++ b/app/assets/javascripts/discourse/app/components/user-menu/menu.hbs @@ -11,37 +11,24 @@ diff --git a/app/assets/javascripts/discourse/app/components/user-menu/menu.js b/app/assets/javascripts/discourse/app/components/user-menu/menu.js index 3686da0a77f..b442692ecb5 100644 --- a/app/assets/javascripts/discourse/app/components/user-menu/menu.js +++ b/app/assets/javascripts/discourse/app/components/user-menu/menu.js @@ -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 { @service currentUser; @service siteSettings; @@ -185,8 +201,17 @@ export default class UserMenu extends Component { } 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; - return this._coreBottomTabs.map((tab, index) => { + return tabs.map((tab, index) => { tab.position = index + topTabsLength; return tab; }); diff --git a/app/assets/javascripts/discourse/app/components/user-menu/profile-tab-content.hbs b/app/assets/javascripts/discourse/app/components/user-menu/profile-tab-content.hbs new file mode 100644 index 00000000000..fbe403e4b86 --- /dev/null +++ b/app/assets/javascripts/discourse/app/components/user-menu/profile-tab-content.hbs @@ -0,0 +1,100 @@ + diff --git a/app/assets/javascripts/discourse/app/components/user-menu/profile-tab-content.js b/app/assets/javascripts/discourse/app/components/user-menu/profile-tab-content.js new file mode 100644 index 00000000000..0ec63964397 --- /dev/null +++ b/app/assets/javascripts/discourse/app/components/user-menu/profile-tab-content.js @@ -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"); + } + } +} diff --git a/app/assets/javascripts/discourse/app/components/user-menu/tab-button.hbs b/app/assets/javascripts/discourse/app/components/user-menu/tab-button.hbs new file mode 100644 index 00000000000..81d91194400 --- /dev/null +++ b/app/assets/javascripts/discourse/app/components/user-menu/tab-button.hbs @@ -0,0 +1,15 @@ + diff --git a/app/assets/javascripts/discourse/app/components/user-menu/tab-button.js b/app/assets/javascripts/discourse/app/components/user-menu/tab-button.js new file mode 100644 index 00000000000..742bb3c18ef --- /dev/null +++ b/app/assets/javascripts/discourse/app/components/user-menu/tab-button.js @@ -0,0 +1,3 @@ +import templateOnly from "@ember/component/template-only"; + +export default templateOnly(); diff --git a/app/assets/javascripts/discourse/tests/acceptance/do-not-disturb-test.js b/app/assets/javascripts/discourse/tests/acceptance/do-not-disturb-test.js index eb1f7e3bfb3..b9c6ae37121 100644 --- a/app/assets/javascripts/discourse/tests/acceptance/do-not-disturb-test.js +++ b/app/assets/javascripts/discourse/tests/acceptance/do-not-disturb-test.js @@ -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" + ); + }); +}); diff --git a/app/assets/javascripts/discourse/tests/acceptance/user-menu-test.js b/app/assets/javascripts/discourse/tests/acceptance/user-menu-test.js index e0d125177fe..902d3d661d1 100644 --- a/app/assets/javascripts/discourse/tests/acceptance/user-menu-test.js +++ b/app/assets/javascripts/discourse/tests/acceptance/user-menu-test.js @@ -6,6 +6,7 @@ import { publishToMessageBus, query, queryAll, + updateCurrentUser, } from "discourse/tests/helpers/qunit-helpers"; import { test } from "qunit"; import { cloneJSON } from "discourse-common/lib/object"; @@ -19,7 +20,14 @@ acceptance("User menu", function (needs) { needs.user({ redesigned_user_menu_enabled: true, unread_high_priority_notifications: 73, + trust_level: 3, }); + + needs.settings({ + allow_anonymous_posting: true, + anonymous_posting_min_trust_level: 3, + }); + let requestHeaders = {}; needs.pretender((server, helper) => { @@ -178,6 +186,238 @@ acceptance("User menu", function (needs) { "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) { diff --git a/app/assets/javascripts/discourse/tests/integration/components/site-header-test.js b/app/assets/javascripts/discourse/tests/integration/components/site-header-test.js index 05823eff3a2..620227d0613 100644 --- a/app/assets/javascripts/discourse/tests/integration/components/site-header-test.js +++ b/app/assets/javascripts/discourse/tests/integration/components/site-header-test.js @@ -164,8 +164,9 @@ module("Integration | Component | site-header", function (hooks) { await triggerKeyEvent(document, "keydown", "ArrowDown"); focusedTab = document.activeElement; - assert.ok( - focusedTab.href.endsWith("/u/eviltrout/preferences"), + assert.strictEqual( + focusedTab.id, + "user-menu-button-profile", "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"); focusedTab = document.activeElement; - assert.ok( - focusedTab.href.endsWith("/u/eviltrout/preferences"), + assert.strictEqual( + focusedTab.id, + "user-menu-button-profile", "the up arrow key moves the focus in the opposite direction" ); }); diff --git a/app/assets/javascripts/discourse/tests/integration/components/user-menu/menu-test.js b/app/assets/javascripts/discourse/tests/integration/components/user-menu/menu-test.js index a42004fbbce..130d43f2954 100644 --- a/app/assets/javascripts/discourse/tests/integration/components/user-menu/menu-test.js +++ b/app/assets/javascripts/discourse/tests/integration/components/user-menu/menu-test.js @@ -70,10 +70,10 @@ module("Integration | Component | user-menu", function (hooks) { 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.dataset.tabNumber, "6"); - assert.strictEqual(preferencesTab.getAttribute("tabindex"), "-1"); + const profileTab = tabs[0]; + assert.strictEqual(profileTab.id, "user-menu-button-profile"); + assert.strictEqual(profileTab.dataset.tabNumber, "6"); + assert.strictEqual(profileTab.getAttribute("tabindex"), "-1"); }); test("likes tab is hidden if current user's like notifications frequency is 'never'", async function (assert) { diff --git a/app/assets/stylesheets/common/base/menu-panel.scss b/app/assets/stylesheets/common/base/menu-panel.scss index 73ab7444280..68b086ff4a7 100644 --- a/app/assets/stylesheets/common/base/menu-panel.scss +++ b/app/assets/stylesheets/common/base/menu-panel.scss @@ -94,6 +94,28 @@ right: 0; width: 320px; 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 { flex: 0; @@ -102,9 +124,8 @@ .menu-tabs-container { display: flex; flex-direction: column; - justify-content: space-between; border-left: 1px solid var(--primary-low); - padding: 0.75em 0; + padding: 0.75em 0 0; } .tabs-list { @@ -137,6 +158,10 @@ } } + .bottom-tabs { + border-top: 1px solid var(--primary-low); + } + .panel-body-contents { display: flex; 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 @@ -399,7 +449,8 @@ } } - a { + a, + .profile-tab-btn { display: flex; margin: 0.25em; padding: 0em 0.25em;