From 0b16fc8172ccd14e25654302c857ed9ae919eee7 Mon Sep 17 00:00:00 2001 From: Bianca Nenciu Date: Mon, 10 Jul 2023 20:42:09 +0300 Subject: [PATCH] FEATURE: Show tooltip for bootstrap mode (#22257) Improve user tips UX and make them smoother. --- .../app/components/bootstrap-mode-notice.hbs | 28 ++- .../app/components/bootstrap-mode-notice.js | 20 +- .../discourse/app/components/d-tooltip.js | 5 +- .../app/components/suggested-topics.hbs | 2 +- .../discourse/app/components/user-tip.hbs | 2 +- .../discourse/app/components/user-tip.js | 27 ++- .../discourse/app/lib/user-tips.js | 186 ++++++++++++------ .../javascripts/discourse/app/models/user.js | 41 ++-- .../tests/acceptance/user-tips-test.js | 16 +- .../stylesheets/common/base/header.scss | 5 + .../stylesheets/common/base/user-tips.scss | 22 +-- app/models/user.rb | 1 + config/locales/client.en.yml | 7 +- config/locales/server.en.yml | 2 +- .../api/schemas/json/site_response.json | 6 +- 15 files changed, 255 insertions(+), 115 deletions(-) diff --git a/app/assets/javascripts/discourse/app/components/bootstrap-mode-notice.hbs b/app/assets/javascripts/discourse/app/components/bootstrap-mode-notice.hbs index c5fe4ff364f..ff5f71c474d 100644 --- a/app/assets/javascripts/discourse/app/components/bootstrap-mode-notice.hbs +++ b/app/assets/javascripts/discourse/app/components/bootstrap-mode-notice.hbs @@ -1,3 +1,25 @@ - - {{i18n "bootstrap_mode"}} - \ No newline at end of file + + {{#if this.showUserTip}} + + {{else}} + +
+
+ {{i18n "user_tips.admin_guide.title"}} +
+
+ {{i18n "user_tips.admin_guide.content"}} +
+
+
+ {{/if}} +
\ No newline at end of file diff --git a/app/assets/javascripts/discourse/app/components/bootstrap-mode-notice.js b/app/assets/javascripts/discourse/app/components/bootstrap-mode-notice.js index a67d9551fff..7ff6a5d61c6 100644 --- a/app/assets/javascripts/discourse/app/components/bootstrap-mode-notice.js +++ b/app/assets/javascripts/discourse/app/components/bootstrap-mode-notice.js @@ -1,11 +1,25 @@ +import { action } from "@ember/object"; import { inject as service } from "@ember/service"; import Component from "@glimmer/component"; +import { tracked } from "@glimmer/tracking"; +import DiscourseURL from "discourse/lib/url"; export default class BootstrapModeNotice extends Component { + @service currentUser; @service siteSettings; - get href() { - const topicId = this.siteSettings.admin_quick_start_topic_id; - return `/t/-/${topicId}`; + @tracked showUserTip = false; + + @action + setupUserTip() { + this.showUserTip = this.currentUser?.canSeeUserTip("admin_guide"); + } + + @action + routeToAdminGuide() { + this.showUserTip = false; + DiscourseURL.routeTo( + `/t/-/${this.siteSettings.admin_quick_start_topic_id}` + ); } } diff --git a/app/assets/javascripts/discourse/app/components/d-tooltip.js b/app/assets/javascripts/discourse/app/components/d-tooltip.js index c12843eeb43..7083c0a797e 100644 --- a/app/assets/javascripts/discourse/app/components/d-tooltip.js +++ b/app/assets/javascripts/discourse/app/components/d-tooltip.js @@ -1,5 +1,6 @@ import Component from "@ember/component"; import { schedule } from "@ember/runloop"; +import { iconHTML } from "discourse-common/lib/icon-library"; import tippy from "tippy.js"; import Ember from "ember"; @@ -36,8 +37,8 @@ export default class DiscourseTooltip extends Component { interactive, content: element, trigger: this.capabilities.touch ? "click" : "mouseenter", - theme: "d-tooltip", - arrow: false, + theme: this.attrs.theme || "d-tooltip", + arrow: this.attrs.arrow ? iconHTML("tippy-rounded-arrow") : false, placement: this.placement, onTrigger: this.stopPropagation, onUntrigger: this.stopPropagation, diff --git a/app/assets/javascripts/discourse/app/components/suggested-topics.hbs b/app/assets/javascripts/discourse/app/components/suggested-topics.hbs index cd884ce6863..ec407e6b773 100644 --- a/app/assets/javascripts/discourse/app/components/suggested-topics.hbs +++ b/app/assets/javascripts/discourse/app/components/suggested-topics.hbs @@ -4,7 +4,7 @@ role="complementary" aria-labelledby="suggested-topics-title" > - +

{{i18n this.suggestedTitleLabel}} diff --git a/app/assets/javascripts/discourse/app/components/user-tip.hbs b/app/assets/javascripts/discourse/app/components/user-tip.hbs index b55c1a88437..8731b9c4b6d 100644 --- a/app/assets/javascripts/discourse/app/components/user-tip.hbs +++ b/app/assets/javascripts/discourse/app/components/user-tip.hbs @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/app/assets/javascripts/discourse/app/components/user-tip.js b/app/assets/javascripts/discourse/app/components/user-tip.js index 791a95050bb..a6f4e1479a7 100644 --- a/app/assets/javascripts/discourse/app/components/user-tip.js +++ b/app/assets/javascripts/discourse/app/components/user-tip.js @@ -15,20 +15,29 @@ export default class UserTip extends Component { } schedule("afterRender", () => { - const { id, selector, content, placement } = this.args; + const { + id, + selector, + content, + placement, + primaryLabel, + onDismiss, + onDismissAll, + } = this.args; + element = element.parentElement; + this.currentUser.showUserTip({ id, - titleText: I18n.t(`user_tips.${id}.title`), contentText: content || I18n.t(`user_tips.${id}.content`), - - reference: selector - ? element.parentElement.querySelector(selector) || - element.parentElement - : element, + primaryText: primaryLabel ? I18n.t(primaryLabel) : null, + reference: + (selector && element.parentElement.querySelector(selector)) || + element, appendTo: element.parentElement, - - placement: placement || "top", + placement, + onDismiss, + onDismissAll, }); }); } diff --git a/app/assets/javascripts/discourse/app/lib/user-tips.js b/app/assets/javascripts/discourse/app/lib/user-tips.js index 546db13ec6e..f8e42240632 100644 --- a/app/assets/javascripts/discourse/app/lib/user-tips.js +++ b/app/assets/javascripts/discourse/app/lib/user-tips.js @@ -3,53 +3,92 @@ import { iconHTML } from "discourse-common/lib/icon-library"; import I18n from "I18n"; import { escape } from "pretty-text/sanitizer"; import tippy from "tippy.js"; +import isElementInViewport from "discourse/lib/is-element-in-viewport"; -const instances = {}; -const queue = []; +const TIPPY_DELAY = 500; + +const instancesMap = {}; +window.instancesMap = instancesMap; + +function destroyInstance(instance) { + if (instance.showTimeout) { + clearTimeout(instance.showTimeout); + instance.showTimeout = null; + } + + if (instance.destroyTimeout) { + clearTimeout(instance.destroyTimeout); + instance.destroyTimeout = null; + } + + instance.destroy(); +} + +function cancelDestroyInstance(instance) { + if (instance.destroyTimeout) { + clearTimeout(instance.destroyTimeout); + instance.destroyTimeout = null; + } +} + +function showInstance(instance) { + if (isTesting()) { + instance.show(); + } else if (!instance.showTimeout) { + instance.showTimeout = setTimeout(() => { + instance.showTimeout = null; + if (!instance.state.isDestroyed) { + instance.show(); + } + }, TIPPY_DELAY); + } +} + +function hideInstance(instance) { + clearTimeout(instance.showTimeout); + instance.showTimeout = null; + instance.hide(); +} export function showUserTip(options) { - hideUserTip(options.id); + // Find if a similar instance has been scheduled for destroying recently + // and cancel that + let instance = instancesMap[options.id]; + if (instance) { + if (instance.reference === options.reference) { + return cancelDestroyInstance(instance); + } else { + destroyInstance(instance); + delete instancesMap[options.id]; + } + } if (!options.reference) { return; } - if (Object.keys(instances).length > 0) { - return addToQueue(options); - } - - instances[options.id] = tippy(options.reference, { - // Tippy must be displayed as soon as possible and not be hidden unless - // the user clicks on one of the two buttons. - showOnCreate: true, + instancesMap[options.id] = tippy(options.reference, { hideOnClick: false, trigger: "manual", - theme: "user-tips", - zIndex: "", - delay: isTesting() ? 0 : 100, - - // It must be interactive to make buttons work. - interactive: true, + theme: "user-tip", + zIndex: "", // reset z-index to use inherited value from the parent + duration: TIPPY_DELAY, arrow: iconHTML("tippy-rounded-arrow"), placement: options.placement, appendTo: options.appendTo, - // It often happens for the reference element to be rerendered. In this - // case, tippy must be rerendered too. Having an animation means that the - // animation will replay over and over again. - animation: false, - - // The `content` property below is HTML. + interactive: true, // for buttons in content allowHTML: true, - content: ` -
-
${escape(options.titleText)}
-
${escape(options.contentText)}
-
+ content: + options.content || + `
+
${escape(options.titleText)}
+
${escape(options.contentText)}
+
`, - onCreate(instance) { - instance.popper.classList.add("user-tip"); + onCreate(tippyInstance) { + // Used to set correct z-index property on root tippy element + tippyInstance.popper.classList.add("user-tip"); - instance.popper + tippyInstance.popper .querySelector(".btn-dismiss") .addEventListener("click", (event) => { - options.onDismiss(); + options.onDismiss?.(); event.preventDefault(); }); - instance.popper + tippyInstance.popper .querySelector(".btn-dismiss-all") .addEventListener("click", (event) => { - options.onDismissAll(); + options.onDismissAll?.(); event.preventDefault(); }); }, }); + + showNextUserTip(); } -export function hideUserTip(userTipId) { - const instance = instances[userTipId]; - if (instance && !instance.state.isDestroyed) { - instance.destroy(); - } - delete instances[userTipId]; +export function hideUserTip(userTipId, force = false) { + // Tippy instances are not destroyed immediately because sometimes there + // user tip is recreated immediately. This happens when Ember components + // are re-rendered because a parent component has changed - const index = queue.findIndex((userTip) => userTip.id === userTipId); - if (index > -1) { - queue.splice(index, 1); + const instance = instancesMap[userTipId]; + if (!instance) { + return; + } + + if (force) { + destroyInstance(instance); + delete instancesMap[userTipId]; + showNextUserTip(); + } else if (!instance.destroyTimeout) { + instance.destroyTimeout = setTimeout(() => { + destroyInstance(instancesMap[userTipId]); + delete instancesMap[userTipId]; + showNextUserTip(); + }, TIPPY_DELAY); } } export function hideAllUserTips() { - Object.keys(instances).forEach(hideUserTip); -} - -function addToQueue(options) { - for (let i = 0; i < queue.size; ++i) { - if (queue[i].id === options.id) { - queue[i] = options; - return; - } - } - - queue.push(options); + Object.keys(instancesMap).forEach((userTipId) => { + destroyInstance(instancesMap[userTipId]); + delete instancesMap[userTipId]; + }); } export function showNextUserTip() { - const options = queue.shift(); - if (options) { - showUserTip(options); + const instances = Object.values(instancesMap); + + // Return early if a user tip is already visible and it is in viewport + if ( + instances.find( + (instance) => + instance.state.isVisible && isElementInViewport(instance.reference) + ) + ) { + return; } + + // Otherwise, try to find a user tip in the viewport + const idx = instances.findIndex((instance) => + isElementInViewport(instance.reference) + ); + + // If no instance was found, select first user tip + const newInstance = instances[idx === -1 ? 0 : idx]; + + // Show only selected instance and hide all the other ones + instances.forEach((instance) => { + if (instance === newInstance) { + showInstance(instance); + } else { + hideInstance(instance); + } + }); } diff --git a/app/assets/javascripts/discourse/app/models/user.js b/app/assets/javascripts/discourse/app/models/user.js index 435c2faa33e..f14788e7c9b 100644 --- a/app/assets/javascripts/discourse/app/models/user.js +++ b/app/assets/javascripts/discourse/app/models/user.js @@ -1172,33 +1172,42 @@ const User = RestModel.extend({ return [...trackedTags, ...watchedTags, ...watchingFirstPostTags]; }, - showUserTip(options) { + canSeeUserTip(id) { const userTips = Site.currentProp("user_tips"); if (!userTips || this.user_option?.skip_new_user_tips) { - return; + return false; } - if (!userTips[options.id]) { + if (!userTips[id]) { if (!isTesting()) { // eslint-disable-next-line no-console - console.warn("Cannot show user tip with type =", options.id); + console.warn("Cannot show user tip with type =", id); } - return; + return false; } const seenUserTips = this.user_option?.seen_popups || []; - if ( - seenUserTips.includes(-1) || - seenUserTips.includes(userTips[options.id]) - ) { - return; + if (seenUserTips.includes(-1) || seenUserTips.includes(userTips[id])) { + return false; } - showUserTip({ - ...options, - onDismiss: () => this.hideUserTipForever(options.id), - onDismissAll: () => this.hideUserTipForever(), - }); + return true; + }, + + showUserTip(options) { + if (this.canSeeUserTip(options.id)) { + showUserTip({ + ...options, + onDismiss: () => { + options.onDismiss?.(); + this.hideUserTipForever(options.id); + }, + onDismissAll: () => { + options.onDismissAll?.(); + this.hideUserTipForever(); + }, + }); + } }, hideUserTipForever(userTipId) { @@ -1216,7 +1225,7 @@ const User = RestModel.extend({ // Hide user tips and maybe show the next one. if (userTipId) { - hideUserTip(userTipId); + hideUserTip(userTipId, true); showNextUserTip(); } else { hideAllUserTips(); diff --git a/app/assets/javascripts/discourse/tests/acceptance/user-tips-test.js b/app/assets/javascripts/discourse/tests/acceptance/user-tips-test.js index f4f15791815..cd450ff5107 100644 --- a/app/assets/javascripts/discourse/tests/acceptance/user-tips-test.js +++ b/app/assets/javascripts/discourse/tests/acceptance/user-tips-test.js @@ -12,9 +12,11 @@ acceptance("User Tips - first_notification", function (needs) { needs.hooks.afterEach(() => hideAllUserTips()); test("Shows first notification user tip", async function (assert) { + this.siteSettings.enable_user_tips = true; + await visit("/t/internationalization-localization/280"); assert.equal( - query(".user-tip-title").textContent.trim(), + query(".user-tip__title").textContent.trim(), I18n.t("user_tips.first_notification.title") ); }); @@ -32,7 +34,7 @@ acceptance("User Tips - topic_timeline", function (needs) { await visit("/t/internationalization-localization/280"); assert.equal( - query(".user-tip-title").textContent.trim(), + query(".user-tip__title").textContent.trim(), I18n.t("user_tips.topic_timeline.title") ); }); @@ -50,7 +52,7 @@ acceptance("User Tips - post_menu", function (needs) { await visit("/t/internationalization-localization/280"); assert.equal( - query(".user-tip-title").textContent.trim(), + query(".user-tip__title").textContent.trim(), I18n.t("user_tips.post_menu.title") ); }); @@ -63,13 +65,13 @@ acceptance("User Tips - topic_notification_levels", function (needs) { needs.hooks.beforeEach(() => hideAllUserTips()); needs.hooks.afterEach(() => hideAllUserTips()); - test("Shows post menu user tip", async function (assert) { + test("Shows topic notification levels user tip", async function (assert) { this.siteSettings.enable_user_tips = true; await visit("/t/internationalization-localization/280"); assert.equal( - query(".user-tip-title").textContent.trim(), + query(".user-tip__title").textContent.trim(), I18n.t("user_tips.topic_notification_levels.title") ); }); @@ -82,12 +84,12 @@ acceptance("User Tips - suggested_topics", function (needs) { needs.hooks.beforeEach(() => hideAllUserTips()); needs.hooks.afterEach(() => hideAllUserTips()); - test("Shows post menu user tip", async function (assert) { + test("Shows suggested topics user tip", async function (assert) { this.siteSettings.enable_user_tips = true; await visit("/t/internationalization-localization/280"); assert.equal( - query(".user-tip-title").textContent.trim(), + query(".user-tip__title").textContent.trim(), I18n.t("user_tips.suggested_topics.title") ); }); diff --git a/app/assets/stylesheets/common/base/header.scss b/app/assets/stylesheets/common/base/header.scss index f33ecbcbdfd..b50edafd3e2 100644 --- a/app/assets/stylesheets/common/base/header.scss +++ b/app/assets/stylesheets/common/base/header.scss @@ -454,4 +454,9 @@ $mobile-avatar-height: 1.532em; font-size: var(--font-down-1); margin-left: 0.5em; padding: 0.25em 0.5em; + + &:focus { + background-color: var(--primary-medium); + color: var(--secondary); + } } diff --git a/app/assets/stylesheets/common/base/user-tips.scss b/app/assets/stylesheets/common/base/user-tips.scss index 434669d280c..0fe32c02198 100644 --- a/app/assets/stylesheets/common/base/user-tips.scss +++ b/app/assets/stylesheets/common/base/user-tips.scss @@ -1,4 +1,4 @@ -.tippy-box[data-theme="user-tips"] { +.tippy-box[data-theme="user-tip"] { background-color: var(--tertiary); color: var(--secondary); border-color: var(--tertiary); @@ -27,24 +27,24 @@ .user-tip { z-index: z("composer", "content") - 1; -} -.user-tip-container { - font-weight: normal; - min-width: 300px; - padding: 0.5em; - text-align: left; + &__container { + font-weight: normal; + min-width: 300px; + padding: 0.5em; + text-align: left; + } - .user-tip-title { - font-size: $font-up-2; + &__title { + font-size: var(--font-up-2); font-weight: bold; } - .user-tip-content { + &__content { margin-top: 0.25em; } - .user-tip-buttons { + &__buttons { margin-top: 1em; } } diff --git a/app/models/user.rb b/app/models/user.rb index 981f6dd2d65..bb93fbd5ec9 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -356,6 +356,7 @@ class User < ActiveRecord::Base post_menu: 3, topic_notification_levels: 4, suggested_topics: 5, + admin_guide: 6, ) end diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index 1fe79a20f97..3e24ad8bbf0 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -211,7 +211,7 @@ en: message: "We've updated this site, please refresh, or you may experience unexpected behavior." dismiss: "Dismiss" - bootstrap_mode: "Bootstrap mode" + bootstrap_mode: "Getting started" themes: default_description: "Default" @@ -1932,6 +1932,11 @@ en: title: "Keep reading!" content: "Here are some topics we think you might like to read next." + admin_guide: + title: "Welcome to your new site!" + content: "Read the admin guide to continue building your site and community." + primary: "Let's go!" + loading: "Loading..." errors: prev_page: "while trying to load" diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml index 61f878df49c..4eea9b40d15 100644 --- a/config/locales/server.en.yml +++ b/config/locales/server.en.yml @@ -699,7 +699,7 @@ en: > If you need help or have a suggestion, feel free to ask in [#feedback](%{base_path}/c/site-feedback) or [contact the admins](%{base_path}/about). - admin_quick_start_title: "READ ME FIRST: Admin Quick Start Guide" + admin_quick_start_title: "Admin Guide: Getting Started" category: topic_prefix: "About the %{category} category" diff --git a/spec/requests/api/schemas/json/site_response.json b/spec/requests/api/schemas/json/site_response.json index 1d3c4cb0c6d..fc1a3af5cba 100644 --- a/spec/requests/api/schemas/json/site_response.json +++ b/spec/requests/api/schemas/json/site_response.json @@ -241,6 +241,9 @@ }, "suggested_topics": { "type": "integer" + }, + "admin_guide": { + "type": "integer" } }, "required": [ @@ -248,7 +251,8 @@ "topic_timeline", "post_menu", "topic_notification_levels", - "suggested_topics" + "suggested_topics", + "admin_guide" ] }, "groups": {