mirror of
https://github.com/discourse/discourse.git
synced 2024-11-23 02:19:27 +08:00
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:
parent
a9dfda2d66
commit
d51baa3bb3
|
@ -0,0 +1,3 @@
|
|||
{{#if this.loadingSlider.stillLoading}}
|
||||
<div class="loading-slider-fallback-spinner">{{loading-spinner}}</div>
|
||||
{{/if}}
|
|
@ -0,0 +1,6 @@
|
|||
import Component from "@glimmer/component";
|
||||
import { inject as service } from "@ember/service";
|
||||
|
||||
export default class LoadingSliderFallbackSpinner extends Component {
|
||||
@service loadingSlider;
|
||||
}
|
|
@ -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}}
|
|
@ -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`);
|
||||
}
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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));
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -98,4 +98,9 @@ export default DiscourseRoute.extend(OpenComposer, {
|
|||
includeSubcategories: !controller.noSubcategories,
|
||||
});
|
||||
},
|
||||
|
||||
@action
|
||||
triggerRefresh() {
|
||||
this.refresh();
|
||||
},
|
||||
});
|
||||
|
|
129
app/assets/javascripts/discourse/app/services/loading-slider.js
Normal file
129
app/assets/javascripts/discourse/app/services/loading-slider.js
Normal 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");
|
||||
}
|
||||
}
|
|
@ -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">
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -17,3 +17,4 @@
|
|||
@import "common/d-editor";
|
||||
@import "common/software-update-prompt";
|
||||
@import "common/topic-timeline";
|
||||
@import "common/loading-slider";
|
||||
|
|
74
app/assets/stylesheets/common/loading-slider.scss
Normal file
74
app/assets/stylesheets/common/loading-slider.scss
Normal 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;
|
||||
}
|
||||
}
|
|
@ -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."
|
||||
|
|
|
@ -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:
|
||||
|
|
Loading…
Reference in New Issue
Block a user