FEATURE: Show tooltip for bootstrap mode (#22257)

Improve user tips UX and make them smoother.
This commit is contained in:
Bianca Nenciu 2023-07-10 20:42:09 +03:00 committed by GitHub
parent 8c74bb6573
commit 0b16fc8172
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 255 additions and 115 deletions

View File

@ -1,3 +1,25 @@
<a class="btn btn-default bootstrap-mode" href={{this.href}}> <DButton
{{i18n "bootstrap_mode"}} class="btn-default bootstrap-mode"
</a> @label="bootstrap_mode"
@action={{this.routeToAdminGuide}}
{{did-insert this.setupUserTip}}
>
{{#if this.showUserTip}}
<UserTip
@id="admin_guide"
@primaryLabel="user_tips.admin_guide.primary"
@onDismiss={{this.routeToAdminGuide}}
/>
{{else}}
<DTooltip @theme="user-tip" @arrow={{true}}>
<div class="user-tip__container">
<div class="user-tip__title">
{{i18n "user_tips.admin_guide.title"}}
</div>
<div class="user-tip__content">
{{i18n "user_tips.admin_guide.content"}}
</div>
</div>
</DTooltip>
{{/if}}
</DButton>

View File

@ -1,11 +1,25 @@
import { action } from "@ember/object";
import { inject as service } from "@ember/service"; import { inject as service } from "@ember/service";
import Component from "@glimmer/component"; import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import DiscourseURL from "discourse/lib/url";
export default class BootstrapModeNotice extends Component { export default class BootstrapModeNotice extends Component {
@service currentUser;
@service siteSettings; @service siteSettings;
get href() { @tracked showUserTip = false;
const topicId = this.siteSettings.admin_quick_start_topic_id;
return `/t/-/${topicId}`; @action
setupUserTip() {
this.showUserTip = this.currentUser?.canSeeUserTip("admin_guide");
}
@action
routeToAdminGuide() {
this.showUserTip = false;
DiscourseURL.routeTo(
`/t/-/${this.siteSettings.admin_quick_start_topic_id}`
);
} }
} }

View File

@ -1,5 +1,6 @@
import Component from "@ember/component"; import Component from "@ember/component";
import { schedule } from "@ember/runloop"; import { schedule } from "@ember/runloop";
import { iconHTML } from "discourse-common/lib/icon-library";
import tippy from "tippy.js"; import tippy from "tippy.js";
import Ember from "ember"; import Ember from "ember";
@ -36,8 +37,8 @@ export default class DiscourseTooltip extends Component {
interactive, interactive,
content: element, content: element,
trigger: this.capabilities.touch ? "click" : "mouseenter", trigger: this.capabilities.touch ? "click" : "mouseenter",
theme: "d-tooltip", theme: this.attrs.theme || "d-tooltip",
arrow: false, arrow: this.attrs.arrow ? iconHTML("tippy-rounded-arrow") : false,
placement: this.placement, placement: this.placement,
onTrigger: this.stopPropagation, onTrigger: this.stopPropagation,
onUntrigger: this.stopPropagation, onUntrigger: this.stopPropagation,

View File

@ -4,7 +4,7 @@
role="complementary" role="complementary"
aria-labelledby="suggested-topics-title" aria-labelledby="suggested-topics-title"
> >
<UserTip @id="suggested_topics" /> <UserTip @id="suggested_topics" @selector=".user-tip-reference" />
<h3 id="suggested-topics-title" class="suggested-topics-title"> <h3 id="suggested-topics-title" class="suggested-topics-title">
{{i18n this.suggestedTitleLabel}} {{i18n this.suggestedTitleLabel}}

View File

@ -1 +1 @@
<span {{did-insert this.showUserTip}}></span> <span class="user-tip-reference" {{did-insert this.showUserTip}}></span>

View File

@ -15,20 +15,29 @@ export default class UserTip extends Component {
} }
schedule("afterRender", () => { 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({ this.currentUser.showUserTip({
id, id,
titleText: I18n.t(`user_tips.${id}.title`), titleText: I18n.t(`user_tips.${id}.title`),
contentText: content || I18n.t(`user_tips.${id}.content`), contentText: content || I18n.t(`user_tips.${id}.content`),
primaryText: primaryLabel ? I18n.t(primaryLabel) : null,
reference: selector reference:
? element.parentElement.querySelector(selector) || (selector && element.parentElement.querySelector(selector)) ||
element.parentElement element,
: element,
appendTo: element.parentElement, appendTo: element.parentElement,
placement,
placement: placement || "top", onDismiss,
onDismissAll,
}); });
}); });
} }

View File

@ -3,53 +3,92 @@ import { iconHTML } from "discourse-common/lib/icon-library";
import I18n from "I18n"; import I18n from "I18n";
import { escape } from "pretty-text/sanitizer"; import { escape } from "pretty-text/sanitizer";
import tippy from "tippy.js"; import tippy from "tippy.js";
import isElementInViewport from "discourse/lib/is-element-in-viewport";
const instances = {}; const TIPPY_DELAY = 500;
const queue = [];
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) { 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) { if (!options.reference) {
return; return;
} }
if (Object.keys(instances).length > 0) { instancesMap[options.id] = tippy(options.reference, {
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,
hideOnClick: false, hideOnClick: false,
trigger: "manual", trigger: "manual",
theme: "user-tips", theme: "user-tip",
zIndex: "", zIndex: "", // reset z-index to use inherited value from the parent
delay: isTesting() ? 0 : 100, duration: TIPPY_DELAY,
// It must be interactive to make buttons work.
interactive: true,
arrow: iconHTML("tippy-rounded-arrow"), arrow: iconHTML("tippy-rounded-arrow"),
placement: options.placement, placement: options.placement,
appendTo: options.appendTo, appendTo: options.appendTo,
// It often happens for the reference element to be rerendered. In this interactive: true, // for buttons in content
// 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.
allowHTML: true, allowHTML: true,
content: ` content:
<div class='user-tip-container'> options.content ||
<div class='user-tip-title'>${escape(options.titleText)}</div> `<div class='user-tip__container'>
<div class='user-tip-content'>${escape(options.contentText)}</div> <div class='user-tip__title'>${escape(options.titleText)}</div>
<div class='user-tip-buttons'> <div class='user-tip__content'>${escape(options.contentText)}</div>
<div class='user-tip__buttons'>
<button class="btn btn-primary btn-dismiss">${escape( <button class="btn btn-primary btn-dismiss">${escape(
options.primaryBtnText || I18n.t("user_tips.primary") options.primaryText || I18n.t("user_tips.primary")
)}</button> )}</button>
<button class="btn btn-flat btn-text btn-dismiss-all">${escape( <button class="btn btn-flat btn-text btn-dismiss-all">${escape(
options.secondaryBtnText || I18n.t("user_tips.secondary") options.secondaryBtnText || I18n.t("user_tips.secondary")
@ -57,57 +96,86 @@ export function showUserTip(options) {
</div> </div>
</div>`, </div>`,
onCreate(instance) { onCreate(tippyInstance) {
instance.popper.classList.add("user-tip"); // Used to set correct z-index property on root tippy element
tippyInstance.popper.classList.add("user-tip");
instance.popper tippyInstance.popper
.querySelector(".btn-dismiss") .querySelector(".btn-dismiss")
.addEventListener("click", (event) => { .addEventListener("click", (event) => {
options.onDismiss(); options.onDismiss?.();
event.preventDefault(); event.preventDefault();
}); });
instance.popper tippyInstance.popper
.querySelector(".btn-dismiss-all") .querySelector(".btn-dismiss-all")
.addEventListener("click", (event) => { .addEventListener("click", (event) => {
options.onDismissAll(); options.onDismissAll?.();
event.preventDefault(); event.preventDefault();
}); });
}, },
}); });
showNextUserTip();
} }
export function hideUserTip(userTipId) { export function hideUserTip(userTipId, force = false) {
const instance = instances[userTipId]; // Tippy instances are not destroyed immediately because sometimes there
if (instance && !instance.state.isDestroyed) { // user tip is recreated immediately. This happens when Ember components
instance.destroy(); // are re-rendered because a parent component has changed
}
delete instances[userTipId];
const index = queue.findIndex((userTip) => userTip.id === userTipId); const instance = instancesMap[userTipId];
if (index > -1) { if (!instance) {
queue.splice(index, 1); 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() { export function hideAllUserTips() {
Object.keys(instances).forEach(hideUserTip); Object.keys(instancesMap).forEach((userTipId) => {
} destroyInstance(instancesMap[userTipId]);
delete instancesMap[userTipId];
function addToQueue(options) { });
for (let i = 0; i < queue.size; ++i) {
if (queue[i].id === options.id) {
queue[i] = options;
return;
}
}
queue.push(options);
} }
export function showNextUserTip() { export function showNextUserTip() {
const options = queue.shift(); const instances = Object.values(instancesMap);
if (options) {
showUserTip(options); // 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);
}
});
} }

View File

@ -1172,33 +1172,42 @@ const User = RestModel.extend({
return [...trackedTags, ...watchedTags, ...watchingFirstPostTags]; return [...trackedTags, ...watchedTags, ...watchingFirstPostTags];
}, },
showUserTip(options) { canSeeUserTip(id) {
const userTips = Site.currentProp("user_tips"); const userTips = Site.currentProp("user_tips");
if (!userTips || this.user_option?.skip_new_user_tips) { if (!userTips || this.user_option?.skip_new_user_tips) {
return; return false;
} }
if (!userTips[options.id]) { if (!userTips[id]) {
if (!isTesting()) { if (!isTesting()) {
// eslint-disable-next-line no-console // 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 || []; const seenUserTips = this.user_option?.seen_popups || [];
if ( if (seenUserTips.includes(-1) || seenUserTips.includes(userTips[id])) {
seenUserTips.includes(-1) || return false;
seenUserTips.includes(userTips[options.id])
) {
return;
} }
showUserTip({ return true;
...options, },
onDismiss: () => this.hideUserTipForever(options.id),
onDismissAll: () => this.hideUserTipForever(), showUserTip(options) {
}); if (this.canSeeUserTip(options.id)) {
showUserTip({
...options,
onDismiss: () => {
options.onDismiss?.();
this.hideUserTipForever(options.id);
},
onDismissAll: () => {
options.onDismissAll?.();
this.hideUserTipForever();
},
});
}
}, },
hideUserTipForever(userTipId) { hideUserTipForever(userTipId) {
@ -1216,7 +1225,7 @@ const User = RestModel.extend({
// Hide user tips and maybe show the next one. // Hide user tips and maybe show the next one.
if (userTipId) { if (userTipId) {
hideUserTip(userTipId); hideUserTip(userTipId, true);
showNextUserTip(); showNextUserTip();
} else { } else {
hideAllUserTips(); hideAllUserTips();

View File

@ -12,9 +12,11 @@ acceptance("User Tips - first_notification", function (needs) {
needs.hooks.afterEach(() => hideAllUserTips()); needs.hooks.afterEach(() => hideAllUserTips());
test("Shows first notification user tip", async function (assert) { test("Shows first notification user tip", async function (assert) {
this.siteSettings.enable_user_tips = true;
await visit("/t/internationalization-localization/280"); await visit("/t/internationalization-localization/280");
assert.equal( assert.equal(
query(".user-tip-title").textContent.trim(), query(".user-tip__title").textContent.trim(),
I18n.t("user_tips.first_notification.title") I18n.t("user_tips.first_notification.title")
); );
}); });
@ -32,7 +34,7 @@ acceptance("User Tips - topic_timeline", function (needs) {
await visit("/t/internationalization-localization/280"); await visit("/t/internationalization-localization/280");
assert.equal( assert.equal(
query(".user-tip-title").textContent.trim(), query(".user-tip__title").textContent.trim(),
I18n.t("user_tips.topic_timeline.title") I18n.t("user_tips.topic_timeline.title")
); );
}); });
@ -50,7 +52,7 @@ acceptance("User Tips - post_menu", function (needs) {
await visit("/t/internationalization-localization/280"); await visit("/t/internationalization-localization/280");
assert.equal( assert.equal(
query(".user-tip-title").textContent.trim(), query(".user-tip__title").textContent.trim(),
I18n.t("user_tips.post_menu.title") 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.beforeEach(() => hideAllUserTips());
needs.hooks.afterEach(() => 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; this.siteSettings.enable_user_tips = true;
await visit("/t/internationalization-localization/280"); await visit("/t/internationalization-localization/280");
assert.equal( assert.equal(
query(".user-tip-title").textContent.trim(), query(".user-tip__title").textContent.trim(),
I18n.t("user_tips.topic_notification_levels.title") 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.beforeEach(() => hideAllUserTips());
needs.hooks.afterEach(() => 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; this.siteSettings.enable_user_tips = true;
await visit("/t/internationalization-localization/280"); await visit("/t/internationalization-localization/280");
assert.equal( assert.equal(
query(".user-tip-title").textContent.trim(), query(".user-tip__title").textContent.trim(),
I18n.t("user_tips.suggested_topics.title") I18n.t("user_tips.suggested_topics.title")
); );
}); });

View File

@ -454,4 +454,9 @@ $mobile-avatar-height: 1.532em;
font-size: var(--font-down-1); font-size: var(--font-down-1);
margin-left: 0.5em; margin-left: 0.5em;
padding: 0.25em 0.5em; padding: 0.25em 0.5em;
&:focus {
background-color: var(--primary-medium);
color: var(--secondary);
}
} }

View File

@ -1,4 +1,4 @@
.tippy-box[data-theme="user-tips"] { .tippy-box[data-theme="user-tip"] {
background-color: var(--tertiary); background-color: var(--tertiary);
color: var(--secondary); color: var(--secondary);
border-color: var(--tertiary); border-color: var(--tertiary);
@ -27,24 +27,24 @@
.user-tip { .user-tip {
z-index: z("composer", "content") - 1; z-index: z("composer", "content") - 1;
}
.user-tip-container { &__container {
font-weight: normal; font-weight: normal;
min-width: 300px; min-width: 300px;
padding: 0.5em; padding: 0.5em;
text-align: left; text-align: left;
}
.user-tip-title { &__title {
font-size: $font-up-2; font-size: var(--font-up-2);
font-weight: bold; font-weight: bold;
} }
.user-tip-content { &__content {
margin-top: 0.25em; margin-top: 0.25em;
} }
.user-tip-buttons { &__buttons {
margin-top: 1em; margin-top: 1em;
} }
} }

View File

@ -356,6 +356,7 @@ class User < ActiveRecord::Base
post_menu: 3, post_menu: 3,
topic_notification_levels: 4, topic_notification_levels: 4,
suggested_topics: 5, suggested_topics: 5,
admin_guide: 6,
) )
end end

View File

@ -211,7 +211,7 @@ en:
message: "We've updated this site, <span>please refresh</span>, or you may experience unexpected behavior." message: "We've updated this site, <span>please refresh</span>, or you may experience unexpected behavior."
dismiss: "Dismiss" dismiss: "Dismiss"
bootstrap_mode: "Bootstrap mode" bootstrap_mode: "Getting started"
themes: themes:
default_description: "Default" default_description: "Default"
@ -1932,6 +1932,11 @@ en:
title: "Keep reading!" title: "Keep reading!"
content: "Here are some topics we think you might like to read next." 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..." loading: "Loading..."
errors: errors:
prev_page: "while trying to load" prev_page: "while trying to load"

View File

@ -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). > 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: category:
topic_prefix: "About the %{category} category" topic_prefix: "About the %{category} category"

View File

@ -241,6 +241,9 @@
}, },
"suggested_topics": { "suggested_topics": {
"type": "integer" "type": "integer"
},
"admin_guide": {
"type": "integer"
} }
}, },
"required": [ "required": [
@ -248,7 +251,8 @@
"topic_timeline", "topic_timeline",
"post_menu", "post_menu",
"topic_notification_levels", "topic_notification_levels",
"suggested_topics" "suggested_topics",
"admin_guide"
] ]
}, },
"groups": { "groups": {