DEV: Convert user-tips functions into a service (#23032)

The original motivation for this change was to avoid mutating imported modules (by stubbing imported functions in tests)

Other than that it's a good practice to place code like this in services, especially (although not the case here) if it requires access to other services or controller.
This commit is contained in:
Jarek Radosz 2023-08-10 16:41:46 +02:00 committed by GitHub
parent 822ecdc91a
commit e91fa0ef80
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 251 additions and 256 deletions

View File

@ -2,7 +2,6 @@
class="btn-default bootstrap-mode"
@label="bootstrap_mode"
@action={{this.routeToAdminGuide}}
{{did-insert this.setupUserTip}}
>
{{#if this.showUserTip}}
<UserTip @id="admin_guide" @content={{this.userTipContent}} />

View File

@ -10,12 +10,7 @@ export default class BootstrapModeNotice extends Component {
@service currentUser;
@service siteSettings;
@tracked showUserTip = false;
@action
setupUserTip() {
this.showUserTip = this.currentUser?.canSeeUserTip("admin_guide");
}
@tracked showUserTip = this.currentUser?.canSeeUserTip("admin_guide");
@action
routeToAdminGuide() {

View File

@ -1,12 +1,15 @@
import { action } from "@ember/object";
import { schedule } from "@ember/runloop";
import { inject as service } from "@ember/service";
import Component from "@glimmer/component";
import { hideUserTip } from "discourse/lib/user-tips";
import { action } from "@ember/object";
import { inject as service } from "@ember/service";
import I18n from "I18n";
export default class UserTip extends Component {
@service currentUser;
@service userTips;
willDestroy() {
this.userTips.hideTip(this.args.id);
}
@action
showUserTip(element) {
@ -14,36 +17,29 @@ export default class UserTip extends Component {
return;
}
schedule("afterRender", () => {
const {
id,
selector,
content,
placement,
buttonLabel,
buttonIcon,
onDismiss,
} = this.args;
element = element.parentElement;
const {
id,
selector,
content,
placement,
buttonLabel,
buttonIcon,
onDismiss,
} = this.args;
element = element.parentElement;
this.currentUser.showUserTip({
id,
titleText: I18n.t(`user_tips.${id}.title`),
contentHtml: content,
contentText: I18n.t(`user_tips.${id}.content`),
buttonLabel,
buttonIcon,
reference:
(selector && element.parentElement.querySelector(selector)) ||
element,
appendTo: element.parentElement,
placement,
onDismiss,
});
this.currentUser.showUserTip({
id,
titleText: I18n.t(`user_tips.${id}.title`),
contentHtml: content,
contentText: I18n.t(`user_tips.${id}.content`),
buttonLabel,
buttonIcon,
reference:
(selector && element.parentElement.querySelector(selector)) || element,
appendTo: element.parentElement,
placement,
onDismiss,
});
}
willDestroy() {
hideUserTip(this.args.id);
}
}

View File

@ -1,176 +0,0 @@
import { isTesting } from "discourse-common/config/environment";
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 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) {
// 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;
}
let buttonText = escape(I18n.t(options.buttonLabel || "user_tips.button"));
if (options.buttonIcon) {
buttonText = `${iconHTML(options.buttonIcon)} ${buttonText}`;
}
instancesMap[options.id] = tippy(options.reference, {
hideOnClick: false,
trigger: "manual",
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,
interactive: true, // for buttons in content
allowHTML: true,
content:
options.content ||
`<div class='user-tip__container'>
<div class='user-tip__title'>${escape(options.titleText)}</div>
<div class='user-tip__content'>${
options.contentHtml || escape(options.contentText)
}</div>
<div class='user-tip__buttons'>
<button class="btn btn-primary">${buttonText}</button>
</div>
</div>`,
onCreate(tippyInstance) {
// Used to set correct z-index property on root tippy element
tippyInstance.popper.classList.add("user-tip");
tippyInstance.popper
.querySelector(".btn")
.addEventListener("click", (event) => {
options.onDismiss?.();
event.preventDefault();
});
},
});
showNextUserTip();
}
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 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(instancesMap).forEach((userTipId) => {
destroyInstance(instancesMap[userTipId]);
delete instancesMap[userTipId];
});
}
export function showNextUserTip() {
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);
}
});
}

View File

@ -36,11 +36,6 @@ import Evented from "@ember/object/evented";
import { cancel } from "@ember/runloop";
import discourseLater from "discourse-common/lib/later";
import { isTesting } from "discourse-common/config/environment";
import {
hideUserTip,
showNextUserTip,
showUserTip,
} from "discourse/lib/user-tips";
import { dependentKeyCompat } from "@ember/object/compat";
export const SECOND_FACTOR_METHODS = {
@ -1180,7 +1175,7 @@ const User = RestModel.extend({
if (!userTips[id]) {
if (!isTesting()) {
// eslint-disable-next-line no-console
console.warn("Cannot show user tip with type =", id);
console.warn("Cannot show user tip with id", id);
}
return false;
}
@ -1195,7 +1190,7 @@ const User = RestModel.extend({
showUserTip(options) {
if (this.canSeeUserTip(options.id)) {
showUserTip({
this.userTips.showTip({
...options,
onDismiss: () => {
options.onDismiss?.();
@ -1214,13 +1209,13 @@ const User = RestModel.extend({
// Empty userTipId means all user tips.
if (!userTips[userTipId]) {
// eslint-disable-next-line no-console
console.warn("Cannot hide user tip with type =", userTipId);
console.warn("Cannot hide user tip with id", userTipId);
return;
}
// Hide user tips and maybe show the next one.
hideUserTip(userTipId, true);
showNextUserTip();
this.userTips.hideTip(userTipId, true);
this.userTips.showNextTip();
// Update list of seen user tips.
let seenUserTips = this.user_option?.seen_popups || [];
@ -1353,6 +1348,10 @@ User.reopenClass(Singleton, {
create(args) {
args = args || {};
this.deleteStatusTrackingFields(args);
const owner = getOwner(this);
args.userTips = owner.lookup("service:user-tips");
return this._super(args);
},

View File

@ -0,0 +1,196 @@
import Service from "@ember/service";
import { isTesting } from "discourse-common/config/environment";
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";
import discourseLater from "discourse-common/lib/later";
import { cancel } from "@ember/runloop";
const TIPPY_DELAY = 500;
export default class UserTips extends Service {
#instances = new Map();
/**
* @param {Object} options
* @param {Integer} options.id
* @param {Element} options.reference
* @param {string} [options.buttonLabel]
* @param {string} [options.buttonIcon]
* @param {string} [options.placement]
* @param {Element} [options.appendTo]
* @param {string} [options.content]
* @param {string} [options.contentText]
* @param {string} [options.titleText]
* @param {function} [options.onDismiss]
*/
showTip(options) {
// Find if a similar instance has been scheduled for destroying recently
// and cancel that
const instance = this.#instances.get(options.id);
if (instance) {
if (instance.reference === options.reference) {
return this.#cancelDestroyInstance(instance);
} else {
this.#destroyInstance(instance);
this.#instances.delete(options.id);
}
}
if (!options.reference) {
return;
}
let buttonText = escape(I18n.t(options.buttonLabel || "user_tips.button"));
if (options.buttonIcon) {
buttonText = `${iconHTML(options.buttonIcon)} ${buttonText}`;
}
this.#instances.set(
options.id,
tippy(options.reference, {
hideOnClick: false,
trigger: "manual",
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,
interactive: true, // for buttons in content
allowHTML: true,
content:
options.content ||
`<div class='user-tip__container'>
<div class='user-tip__title'>${escape(options.titleText)}</div>
<div class='user-tip__content'>${
options.contentHtml || escape(options.contentText)
}</div>
<div class='user-tip__buttons'>
<button class="btn btn-primary">${buttonText}</button>
</div>
</div>`,
onCreate(tippyInstance) {
// Used to set correct z-index property on root tippy element
tippyInstance.popper.classList.add("user-tip");
tippyInstance.popper
.querySelector(".btn")
.addEventListener("click", (event) => {
options.onDismiss?.();
event.preventDefault();
});
},
})
);
this.showNextTip();
}
hideTip(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 instance = this.#instances.get(userTipId);
if (!instance) {
return;
}
if (force) {
this.#destroyInstance(instance);
this.#instances.delete(userTipId);
this.showNextTip();
} else if (!instance.destroyTimer) {
instance.destroyTimer = discourseLater(() => {
this.#destroyInstance(this.#instances.get(userTipId));
this.#instances.delete(userTipId);
this.showNextTip();
}, TIPPY_DELAY);
}
}
hideAll() {
for (const [id, tip] of this.#instances.entries()) {
this.#destroyInstance(tip);
this.#instances.delete(id);
}
}
showNextTip() {
// Return early if a user tip is already visible and it is in viewport
for (const tip of this.#instances.values()) {
if (tip.state.isVisible && isElementInViewport(tip.reference)) {
return;
}
}
// Otherwise, try to find a user tip in the viewport
let visibleTip;
for (const tip of this.#instances.values()) {
if (isElementInViewport(tip.reference)) {
visibleTip = tip;
break;
}
}
// If no instance was found, select first user tip
const newTip = visibleTip || this.#instances.values().next();
// Show only selected instance and hide all the other ones
for (const tip of this.#instances.values()) {
if (tip === newTip) {
this.#showInstance(tip);
} else {
this.#hideInstance(tip);
}
}
}
#destroyInstance(instance) {
if (instance.showTimer) {
cancel(instance.showTimer);
instance.showTimer = null;
}
if (instance.destroyTimer) {
cancel(instance.destroyTimer);
instance.destroyTimer = null;
}
instance.destroy();
}
#cancelDestroyInstance(instance) {
if (instance.destroyTimer) {
cancel(instance.destroyTimer);
instance.destroyTimer = null;
}
}
#showInstance(instance) {
if (isTesting()) {
instance.show();
} else if (!instance.showTimer) {
instance.showTimer = discourseLater(() => {
instance.showTimer = null;
if (!instance.state.isDestroyed) {
instance.show();
}
}, TIPPY_DELAY);
}
}
#hideInstance(instance) {
cancel(instance.showTimer);
instance.showTimer = null;
instance.hide();
}
}

View File

@ -12,7 +12,6 @@ import { wantsNewWindow } from "discourse/lib/intercept-click";
import { logSearchLinkClick } from "discourse/lib/search";
import RenderGlimmer from "discourse/widgets/render-glimmer";
import { hbs } from "ember-cli-htmlbars";
import { hideUserTip } from "discourse/lib/user-tips";
import { SEARCH_BUTTON_ID } from "discourse/components/search-menu";
let _extraHeaderIcons = [];
@ -47,6 +46,8 @@ export const dropdown = {
};
createWidget("header-notifications", {
services: ["user-tips"],
settings: {
avatarSize: "medium",
},
@ -169,11 +170,11 @@ createWidget("header-notifications", {
},
destroy() {
hideUserTip("first_notification");
this.userTips.hideTip("first_notification");
},
willRerenderWidget() {
hideUserTip("first_notification");
this.userTips.hideTip("first_notification");
},
});

View File

@ -21,7 +21,6 @@ import { relativeAgeMediumSpan } from "discourse/lib/formatter";
import { transformBasicPost } from "discourse/lib/transform-post";
import autoGroupFlairForUser from "discourse/lib/avatar-flair";
import { nativeShare } from "discourse/lib/pwa-utils";
import { hideUserTip } from "discourse/lib/user-tips";
import ShareTopicModal from "discourse/components/modal/share-topic";
import { getOwner } from "@ember/application";
@ -871,7 +870,7 @@ export function addPostClassesCallback(callback) {
export default createWidget("post", {
buildKey: (attrs) => `post-${attrs.id}`,
services: ["dialog"],
services: ["dialog", "user-tips"],
shadowTree: true,
buildAttributes(attrs) {
@ -1004,10 +1003,10 @@ export default createWidget("post", {
},
destroy() {
hideUserTip("post_menu");
this.userTips.hideTip("post_menu");
},
willRerenderWidget() {
hideUserTip("post_menu");
this.userTips.hideTip("post_menu");
},
});

View File

@ -26,6 +26,7 @@ import { h } from "virtual-dom";
import { isProduction } from "discourse-common/config/environment";
import { consolePrefix } from "discourse/lib/source-identifier";
import { getOwner, setOwner } from "@ember/application";
import { camelize } from "@ember/string";
const _registry = {};
@ -161,7 +162,7 @@ export default class Widget {
// We can inject services into widgets by passing a `services` parameter on creation
(this.services || []).forEach((s) => {
this[s] = register.lookup(`service:${s}`);
this[camelize(s)] = register.lookup(`service:${s}`);
});
this.init(this.attrs);

View File

@ -1,5 +1,4 @@
import { visit } from "@ember/test-helpers";
import { hideAllUserTips } from "discourse/lib/user-tips";
import { acceptance, query } from "discourse/tests/helpers/qunit-helpers";
import I18n from "I18n";
import { test } from "qunit";
@ -8,9 +7,6 @@ acceptance("User Tips - first_notification", function (needs) {
needs.user({ new_personal_messages_notifications_count: 1 });
needs.site({ user_tips: { first_notification: 1 } });
needs.hooks.beforeEach(() => hideAllUserTips());
needs.hooks.afterEach(() => hideAllUserTips());
test("Shows first notification user tip", async function (assert) {
this.siteSettings.enable_user_tips = true;
@ -26,9 +22,6 @@ acceptance("User Tips - topic_timeline", function (needs) {
needs.user();
needs.site({ user_tips: { topic_timeline: 2 } });
needs.hooks.beforeEach(() => hideAllUserTips());
needs.hooks.afterEach(() => hideAllUserTips());
test("Shows topic timeline user tip", async function (assert) {
this.siteSettings.enable_user_tips = true;
@ -44,9 +37,6 @@ acceptance("User Tips - post_menu", function (needs) {
needs.user();
needs.site({ user_tips: { post_menu: 3 } });
needs.hooks.beforeEach(() => hideAllUserTips());
needs.hooks.afterEach(() => hideAllUserTips());
test("Shows post menu user tip", async function (assert) {
this.siteSettings.enable_user_tips = true;
@ -62,9 +52,6 @@ acceptance("User Tips - topic_notification_levels", function (needs) {
needs.user();
needs.site({ user_tips: { topic_notification_levels: 4 } });
needs.hooks.beforeEach(() => hideAllUserTips());
needs.hooks.afterEach(() => hideAllUserTips());
test("Shows topic notification levels user tip", async function (assert) {
this.siteSettings.enable_user_tips = true;
@ -81,9 +68,6 @@ acceptance("User Tips - suggested_topics", function (needs) {
needs.user();
needs.site({ user_tips: { suggested_topics: 5 } });
needs.hooks.beforeEach(() => hideAllUserTips());
needs.hooks.afterEach(() => hideAllUserTips());
test("Shows suggested topics user tip", async function (assert) {
this.siteSettings.enable_user_tips = true;

View File

@ -6,7 +6,6 @@ import { settled } from "@ember/test-helpers";
import User from "discourse/models/user";
import pretender, { response } from "discourse/tests/helpers/create-pretender";
import { getOwner } from "discourse-common/lib/get-owner";
import * as userTips from "discourse/lib/user-tips";
module("Unit | Model | user", function (hooks) {
setupTest(hooks);
@ -224,15 +223,17 @@ module("Unit | Model | user", function (hooks) {
test("hideUserTipForever() can hide the user tip", async function (assert) {
const site = getOwner(this).lookup("service:site");
site.set("user_tips", { first_notification: 1 });
const store = getOwner(this).lookup("service:store");
const userTips = getOwner(this).lookup("service:user-tips");
site.set("user_tips", { first_notification: 1 });
const user = store.createRecord("user", { username: "eviltrout" });
const hideSpy = sinon.spy(userTips, "hideUserTip");
const showNextSpy = sinon.spy(userTips, "showNextUserTip");
const hideSpy = sinon.spy(userTips, "hideTip");
const showNextSpy = sinon.spy(userTips, "showNextTip");
await user.hideUserTipForever("first_notification");
assert.ok(hideSpy.calledWith("first_notification"));
assert.ok(showNextSpy.calledWith());
assert.true(hideSpy.calledWith("first_notification"));
assert.true(showNextSpy.calledWith());
});
});