FEATURE: Introduce 'loading slider' for page navigations (#22042)

This brings the functionality from https://github.com/discourse/discourse-loading-slider into Discourse core. Default behaviour remains the same - the new slider mode can be enabled using the new 'page_loading_indicator' site setting.
This commit is contained in:
David Taylor 2023-07-05 14:59:24 +01:00 committed by GitHub
parent a9dfda2d66
commit d51baa3bb3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 451 additions and 38 deletions

View File

@ -0,0 +1,3 @@
{{#if this.loadingSlider.stillLoading}}
<div class="loading-slider-fallback-spinner">{{loading-spinner}}</div>
{{/if}}

View File

@ -0,0 +1,6 @@
import Component from "@glimmer/component";
import { inject as service } from "@ember/service";
export default class LoadingSliderFallbackSpinner extends Component {
@service loadingSlider;
}

View File

@ -0,0 +1,17 @@
{{#if this.loadingSlider.enabled}}
<div
class={{concat-class
"loading-indicator-container"
this.state
(if this.capabilities.isAppWebview "discourse-hub-webview")
}}
{{on "transitionend" this.onContainerTransitionEnd}}
style={{this.containerStyle}}
>
<div
class="loading-indicator"
{{on "transitionend" this.onBarTransitionEnd}}
>
</div>
</div>
{{/if}}

View File

@ -0,0 +1,67 @@
import Component from "@glimmer/component";
import { inject as service } from "@ember/service";
import { cancel, next } from "@ember/runloop";
import { tracked } from "@glimmer/tracking";
import { action } from "@ember/object";
import { bind } from "discourse-common/utils/decorators";
import { htmlSafe } from "@ember/template";
export default class extends Component {
@service loadingSlider;
@service capabilities;
@tracked state = "ready";
constructor() {
super(...arguments);
this.loadingSlider.on("stateChanged", this.stateChanged);
}
@bind
stateChanged(loading) {
if (this._deferredStateChange) {
cancel(this._deferredStateChange);
this._deferredStateChange = null;
}
if (loading && this.ready) {
this.state = "loading";
} else if (loading) {
this.state = "ready";
this._deferredStateChange = next(() => (this.state = "loading"));
} else {
this.state = "done";
}
}
destroy() {
this.loadingSlider.off("stateChange", this, "stateChange");
super.destroy();
}
@action
onContainerTransitionEnd(event) {
if (
event.target === event.currentTarget &&
event.propertyName === "opacity"
) {
this.state = "ready";
}
}
@action
onBarTransitionEnd(event) {
if (
event.target === event.currentTarget &&
event.propertyName === "transform" &&
this.state === "loading"
) {
this.state = "still-loading";
}
}
get containerStyle() {
const duration = this.loadingSlider.averageLoadingDuration.toFixed(2);
return htmlSafe(`--loading-duration: ${duration}s`);
}
}

View File

@ -309,6 +309,7 @@ export default MountWidget.extend({
}
}
this.queueRerender();
this._scrollTriggered();
},
@bind
@ -370,6 +371,10 @@ export default MountWidget.extend({
this.appEvents.off("post-stream:posted", this, "_posted");
},
didUpdateAttrs() {
this._refresh();
},
_handleWidgetButtonHoverState(event) {
if (event.target.classList.contains("widget-button")) {
document

View File

@ -34,7 +34,11 @@ export function showEntrance(e) {
}
export function navigateToTopic(topic, href) {
this.appEvents.trigger("header:update-topic", topic);
if (this.siteSettings.page_loading_indicator !== "slider") {
// With the slider, it feels nicer for the header to update once the rest of the topic content loads,
// so skip setting it early.
this.appEvents.trigger("header:update-topic", topic);
}
DiscourseURL.routeTo(href || topic.get("url"));
return false;
}

View File

@ -60,6 +60,13 @@ export default Controller.extend({
return `${url}?${urlSearchParams.toString()}`;
},
get showLoadingSpinner() {
return (
this.get("loading") &&
this.siteSettings.page_loading_indicator === "spinner"
);
},
actions: {
changePeriod(p) {
DiscourseURL.routeTo(this.showMoreUrl(p));

View File

@ -4,7 +4,6 @@ import DismissTopics from "discourse/mixins/dismiss-topics";
import DiscoveryController from "discourse/controllers/discovery";
import I18n from "I18n";
import Topic from "discourse/models/topic";
import TopicList from "discourse/models/topic-list";
import { inject as controller } from "@ember/controller";
import deprecated from "discourse-common/lib/deprecated";
import discourseComputed from "discourse-common/utils/decorators";
@ -106,41 +105,11 @@ const controllerOpts = {
);
return routeAction("changeSort", this.router._router, ...arguments)();
},
},
refresh(options = { skipResettingParams: [] }) {
const filter = this.get("model.filter");
this.send("resetParams", options.skipResettingParams);
// Don't refresh if we're still loading
if (this.discovery.loading) {
return;
}
// If we `send('loading')` here, due to returning true it bubbles up to the
// router and ember throws an error due to missing `handlerInfos`.
// Lesson learned: Don't call `loading` yourself.
this.discovery.loadingBegan();
this.topicTrackingState.resetTracking();
this.store.findFiltered("topicList", { filter }).then((list) => {
TopicList.hideUniformCategory(list, this.category);
// If query params are present in the current route, we need still need to sync topic
// tracking with the topicList without any query params. Then we set the topic
// list to the list filtered with query params in the afterRefresh.
const params = this.router.currentRoute.queryParams;
if (Object.keys(params).length) {
this.store
.findFiltered("topicList", { filter, params })
.then((listWithParams) => {
this.afterRefresh(filter, list, listWithParams);
});
} else {
this.afterRefresh(filter, list);
}
});
},
@action
refresh() {
this.send("triggerRefresh");
},
afterRefresh(filter, list, listModel = list) {

View File

@ -14,6 +14,7 @@ import { inject as service } from "@ember/service";
import { setting } from "discourse/lib/computed";
import showModal from "discourse/lib/show-modal";
import KeyboardShortcutsHelp from "discourse/components/modal/keyboard-shortcuts-help";
import { action } from "@ember/object";
function unlessReadOnly(method, message) {
return function () {
@ -42,6 +43,20 @@ const ApplicationRoute = DiscourseRoute.extend(OpenComposer, {
dialog: service(),
composer: service(),
modal: service(),
loadingSlider: service(),
@action
loading(transition) {
if (this.loadingSlider.enabled) {
this.loadingSlider.transitionStarted();
transition.promise.finally(() => {
this.loadingSlider.transitionEnded();
});
return false;
} else {
return true; // Use native ember loading implementation
}
},
actions: {
toggleAnonymous() {

View File

@ -98,4 +98,9 @@ export default DiscourseRoute.extend(OpenComposer, {
includeSubcategories: !controller.noSubcategories,
});
},
@action
triggerRefresh() {
this.refresh();
},
});

View File

@ -0,0 +1,129 @@
import Service, { inject as service } from "@ember/service";
import Evented from "@ember/object/evented";
import { cancel, later, schedule } from "@ember/runloop";
import { tracked } from "@glimmer/tracking";
import { bind } from "discourse-common/utils/decorators";
import { disableImplicitInjections } from "discourse/lib/implicit-injections";
const STORE_LOADING_TIMES = 5;
const DEFAULT_LOADING_TIME = 0.3;
const MIN_LOADING_TIME = 0.1;
const STILL_LOADING_DURATION = 2;
class RollingAverage {
@tracked average;
#values = [];
#i = 0;
#size;
constructor(size, initialAverage) {
this.#size = size;
this.average = initialAverage;
}
record(value) {
this.#values[this.#i] = value;
this.#i = (this.#i + 1) % this.#size;
this.average =
this.#values.reduce((p, c) => p + c, 0) / this.#values.length;
}
}
class ScheduleManager {
#scheduled = [];
cancelAll() {
this.#scheduled.forEach((s) => cancel(s));
this.#scheduled = [];
}
schedule() {
this.#scheduled.push(schedule(...arguments));
}
later() {
this.#scheduled.push(later(...arguments));
}
}
class Timer {
#startedAt;
start() {
this.#startedAt = Date.now();
}
stop() {
return (Date.now() - this.#startedAt) / 1000;
}
}
@disableImplicitInjections
export default class LoadingSlider extends Service.extend(Evented) {
@service siteSettings;
@tracked loading = false;
@tracked stillLoading = false;
rollingAverage = new RollingAverage(
STORE_LOADING_TIMES,
DEFAULT_LOADING_TIME
);
scheduleManager = new ScheduleManager();
timer = new Timer();
get enabled() {
return this.siteSettings.page_loading_indicator === "slider";
}
get averageLoadingDuration() {
return this.rollingAverage.average;
}
transitionStarted() {
this.timer.start();
this.loading = true;
this.trigger("stateChanged", true);
this.scheduleManager.cancelAll();
this.scheduleManager.later(
this.setStillLoading,
STILL_LOADING_DURATION * 1000
);
}
@bind
transitionEnded() {
let duration = this.timer.stop();
if (duration < MIN_LOADING_TIME) {
duration = MIN_LOADING_TIME;
}
this.rollingAverage.record(duration);
this.loading = false;
this.stillLoading = false;
this.trigger("stateChanged", false);
this.scheduleManager.cancelAll();
this.scheduleManager.schedule("afterRender", this.removeClasses);
}
@bind
setStillLoading() {
this.stillLoading = true;
this.scheduleManager.schedule("afterRender", this.addStillLoadingClass);
}
@bind
addStillLoadingClass() {
document.body.classList.add("still-loading");
}
@bind
removeClasses() {
document.body.classList.remove("loading", "still-loading");
}
}

View File

@ -1,6 +1,7 @@
<DiscourseRoot>
<a href="#main-container" id="skip-link">{{i18n "skip_to_main_content"}}</a>
<DDocument />
<PageLoadingSlider />
<PluginOutlet
@name="above-site-header"
@connectorTagName="div"
@ -44,6 +45,8 @@
{{/if}}
</div>
<LoadingSliderFallbackSpinner />
<PluginOutlet @name="before-main-outlet" />
<div id="main-outlet">

View File

@ -22,13 +22,13 @@
</div>
</div>
<ConditionalLoadingSpinner @condition={{this.loading}} />
<ConditionalLoadingSpinner @condition={{this.showLoadingSpinner}} />
<span>
<PluginOutlet @name="discovery-above" @connectorTagName="div" />
</span>
<div class="container list-container {{if this.loading 'hidden'}}">
<div class="container list-container {{if this.showLoadingSpinner 'hidden'}}">
<div class="row">
<div class="full-width">
<div id="header-list-area">

View File

@ -0,0 +1,99 @@
import {
currentRouteName,
getSettledState,
settled,
visit,
waitUntil,
} from "@ember/test-helpers";
import { acceptance, query } from "discourse/tests/helpers/qunit-helpers";
import { test } from "qunit";
import pretender from "discourse/tests/helpers/create-pretender";
import AboutFixtures from "discourse/tests/fixtures/about";
// Like settled(), but ignores timers, transitions and network requests
function isMostlySettled() {
let { hasRunLoop, hasPendingWaiters, isRenderPending } = getSettledState();
if (hasRunLoop || hasPendingWaiters || isRenderPending) {
return false;
} else {
return true;
}
}
function mostlySettled() {
return waitUntil(isMostlySettled);
}
acceptance("Page Loading Indicator", function (needs) {
let pendingRequest;
let resolvePendingRequest;
needs.pretender((server, helper) => {
pendingRequest = new Promise(
(resolve) => (resolvePendingRequest = resolve)
);
pretender.get(
"/about.json",
(request) => {
resolvePendingRequest(request);
return helper.response(AboutFixtures["about.json"]);
},
true // Require manual resolution
);
});
test("it works in 'spinner' mode", async function (assert) {
this.siteSettings.page_loading_indicator = "spinner";
await visit("/");
visit("/about");
const aboutRequest = await pendingRequest;
await mostlySettled();
assert.strictEqual(currentRouteName(), "about_loading");
assert.dom("#main-outlet > div.spinner").exists();
assert.dom(".loading-indicator-container").doesNotExist();
pretender.resolve(aboutRequest);
await settled();
assert.strictEqual(currentRouteName(), "about");
assert.dom("#main-outlet > div.spinner").doesNotExist();
assert.dom("#main-outlet section.about").exists();
});
test("it works in 'slider' mode", async function (assert) {
this.siteSettings.page_loading_indicator = "slider";
await visit("/");
assert.dom(".loading-indicator-container").exists();
assert.dom(".loading-indicator-container").hasClass("ready");
visit("/about");
const aboutRequest = await pendingRequest;
await mostlySettled();
assert.strictEqual(currentRouteName(), "discovery.latest");
assert.dom("#main-outlet > div.spinner").doesNotExist();
await waitUntil(() =>
query(".loading-indicator-container").classList.contains("loading")
);
pretender.resolve(aboutRequest);
await waitUntil(() =>
query(".loading-indicator-container").classList.contains("done")
);
await settled();
assert.strictEqual(currentRouteName(), "about");
assert.dom("#main-outlet section.about").exists();
});
});

View File

@ -17,3 +17,4 @@
@import "common/d-editor";
@import "common/software-update-prompt";
@import "common/topic-timeline";
@import "common/loading-slider";

View File

@ -0,0 +1,74 @@
.loading-indicator-container {
--loading-width: 0.8;
--still-loading-width: 0.9;
--still-loading-duration: 10s;
--done-duration: 0.4s;
--fade-out-duration: 0.4s;
position: fixed;
top: 0;
left: 0;
z-index: z("header") + 1;
height: 3px;
width: 100%;
opacity: 0;
transition: opacity var(--fade-out-duration) ease var(--done-duration);
background-color: var(--primary-low);
.loading-indicator {
height: 100%;
width: 100%;
transform: scaleX(0);
transform-origin: left;
background-color: var(--tertiary);
}
&.loading,
&.still-loading {
opacity: 1;
transition: opacity 0s;
}
&.loading .loading-indicator {
transition: transform var(--loading-duration) ease-in;
transform: scaleX(var(--loading-width));
}
&.still-loading .loading-indicator {
transition: transform var(--still-loading-duration) linear;
transform: scaleX(var(--still-loading-width));
}
&.done .loading-indicator {
transition: transform var(--done-duration) ease-out;
transform: scaleX(1);
}
&.discourse-hub-webview {
// DiscourseHub obscures the top 1px to work around an iOS bug
top: 1px;
}
body.footer-nav-ipad & {
top: var(--footer-nav-height);
}
}
.loading-slider-fallback-spinner {
padding-top: 1.8em;
display: none;
}
body.still-loading {
.loading-slider-fallback-spinner {
display: block;
}
#main-outlet {
display: none;
}
}

View File

@ -2435,6 +2435,8 @@ en:
experimental_topics_filter: "EXPERIMENTAL: Enables the experimental topics filter route at /filter"
experimental_search_menu_groups: "EXPERIMENTAL: Enables the new search menu that has been upgraded to use glimmer"
page_loading_indicator: "Configure the loading indicator which appears during page navigations within Discourse. 'Spinner' is a full page indicator. 'Slider' shows a narrow bar at the top of the screen."
errors:
invalid_css_color: "Invalid color. Enter a color name or hex value."
invalid_email: "Invalid email address."

View File

@ -393,6 +393,13 @@ basic:
client: true
default: true
refresh: true
page_loading_indicator:
client: true
type: enum
default: "spinner"
choices:
- spinner
- slider
login:
invite_only: