ha-frontend-cdce8p/src/layouts/hass-router-page.ts
2021-05-07 13:16:14 -07:00

322 lines
8.5 KiB
TypeScript

import { property, PropertyValues, ReactiveElement } from "lit-element";
import memoizeOne from "memoize-one";
import { navigate } from "../common/navigate";
import { Route } from "../types";
import "./hass-error-screen";
import "./hass-loading-screen";
const extractPage = (path: string, defaultPage: string) => {
if (path === "") {
return defaultPage;
}
const subpathStart = path.indexOf("/", 1);
return subpathStart === -1
? path.substr(1)
: path.substr(1, subpathStart - 1);
};
export interface RouteOptions {
// HTML tag of the route page.
tag: string;
// Function to load the page.
load?: () => Promise<unknown>;
cache?: boolean;
}
export interface RouterOptions {
// The default route to show if path does not define a page.
defaultPage?: string;
// If all routes should be preloaded
preloadAll?: boolean;
// If a route has been shown, should we keep the element in memory
cacheAll?: boolean;
// Should we show a loading spinner while we load the element for the route
showLoading?: boolean;
// Promise that resolves when the initial data is loaded which is needed to show any route.
initialLoad?: () => Promise<unknown>;
// Hook that is called before rendering a new route. Allowing redirects.
// If string returned, that page will be rendered instead.
beforeRender?: (page: string) => string | undefined;
routes: {
// If it's a string, it is another route whose options should be adopted.
[route: string]: RouteOptions | string;
};
}
// Time to wait for code to load before we show loading screen.
const LOADING_SCREEN_THRESHOLD = 400; // ms
export class HassRouterPage extends ReactiveElement {
@property() public route?: Route;
protected routerOptions!: RouterOptions;
protected _currentPage = "";
private _currentLoadProm?: Promise<void>;
private _cache = {};
private _initialLoadDone = false;
private _computeTail = memoizeOne((route: Route) => {
const dividerPos = route.path.indexOf("/", 1);
return dividerPos === -1
? {
prefix: route.prefix + route.path,
path: "",
}
: {
prefix: route.prefix + route.path.substr(0, dividerPos),
path: route.path.substr(dividerPos),
};
});
protected createRenderRoot() {
return this;
}
protected update(changedProps: PropertyValues) {
super.update(changedProps);
const routerOptions = this.routerOptions || { routes: {} };
if (routerOptions && routerOptions.initialLoad && !this._initialLoadDone) {
return;
}
if (!changedProps.has("route")) {
// Do not update if we have a currentLoadProm, because that means
// that there is still an old panel shown and we're moving to a new one.
if (this.lastChild && !this._currentLoadProm) {
this.updatePageEl(this.lastChild, changedProps);
}
return;
}
const route = this.route;
const defaultPage = routerOptions.defaultPage;
if (route && route.path === "" && defaultPage !== undefined) {
navigate(this, `${route.prefix}/${defaultPage}`, true);
}
let newPage = route
? extractPage(route.path, defaultPage || "")
: "not_found";
let routeOptions = routerOptions.routes[newPage];
// Handle redirects
while (typeof routeOptions === "string") {
newPage = routeOptions;
routeOptions = routerOptions.routes[newPage];
}
if (routerOptions.beforeRender) {
const result = routerOptions.beforeRender(newPage);
if (result !== undefined) {
newPage = result;
routeOptions = routerOptions.routes[newPage];
// Handle redirects
while (typeof routeOptions === "string") {
newPage = routeOptions;
routeOptions = routerOptions.routes[newPage];
}
// Update the url if we know where we're mounted.
if (route) {
navigate(this, `${route.prefix}/${result}`, true);
}
}
}
if (this._currentPage === newPage) {
if (this.lastChild) {
this.updatePageEl(this.lastChild, changedProps);
}
return;
}
if (!routeOptions) {
this._currentPage = "";
if (this.lastChild) {
this.removeChild(this.lastChild);
}
return;
}
this._currentPage = newPage;
const loadProm = routeOptions.load
? routeOptions.load()
: Promise.resolve();
let showLoadingScreenTimeout: undefined | number;
// Check when loading the page source failed.
loadProm.catch((err) => {
// eslint-disable-next-line
console.error("Error loading page", newPage, err);
// Verify that we're still trying to show the same page.
if (this._currentPage !== newPage) {
return;
}
// Removes either loading screen or the panel
if (this.lastChild) {
this.removeChild(this.lastChild!);
}
if (showLoadingScreenTimeout) {
clearTimeout(showLoadingScreenTimeout);
}
// Show error screen
this.appendChild(
this.createErrorScreen(`Error while loading page ${newPage}.`)
);
});
// If we don't show loading screen, just show the panel.
// It will be automatically upgraded when loading done.
if (!routerOptions.showLoading) {
this._createPanel(routerOptions, newPage, routeOptions);
return;
}
// We are only going to show the loading screen after some time.
// That way we won't have a double fast flash on fast connections.
let created = false;
showLoadingScreenTimeout = window.setTimeout(() => {
if (created || this._currentPage !== newPage) {
return;
}
// Show a loading screen.
if (this.lastChild) {
this.removeChild(this.lastChild);
}
this.appendChild(this.createLoadingScreen());
}, LOADING_SCREEN_THRESHOLD);
this._currentLoadProm = loadProm.then(
() => {
this._currentLoadProm = undefined;
// Check if we're still trying to show the same page.
if (this._currentPage !== newPage) {
return;
}
created = true;
this._createPanel(
routerOptions,
newPage,
// @ts-ignore TS forgot this is not a string.
routeOptions
);
},
() => {
this._currentLoadProm = undefined;
}
);
}
protected firstUpdated(changedProps: PropertyValues) {
super.firstUpdated(changedProps);
const options = this.routerOptions;
if (!options) {
return;
}
if (options.preloadAll) {
Object.values(options.routes).forEach(
(route) => typeof route === "object" && route.load && route.load()
);
}
if (options.initialLoad) {
setTimeout(() => {
if (!this._initialLoadDone) {
this.appendChild(this.createLoadingScreen());
}
}, LOADING_SCREEN_THRESHOLD);
options.initialLoad().then(() => {
this._initialLoadDone = true;
this.requestUpdate("route");
});
}
}
protected createLoadingScreen() {
return document.createElement("hass-loading-screen");
}
protected createErrorScreen(error: string) {
const errorEl = document.createElement("hass-error-screen");
errorEl.error = error;
return errorEl;
}
/**
* Rebuild the current panel.
*
* Promise will resolve when rebuilding is done and DOM updated.
*/
protected async rebuild(): Promise<void> {
const oldRoute = this.route;
if (oldRoute === undefined) {
return;
}
this.route = undefined;
await this.updateComplete;
// Make sure that the parent didn't override this in the meanwhile.
if (this.route === undefined) {
this.route = oldRoute;
}
}
/**
* Promise that resolves when the page has rendered.
*/
protected get pageRendered(): Promise<void> {
return this.updateComplete.then(() => this._currentLoadProm);
}
protected createElement(tag: string) {
return document.createElement(tag);
}
protected updatePageEl(_pageEl, _changedProps?: PropertyValues) {
// default we do nothing
}
protected get routeTail(): Route {
return this._computeTail(this.route!);
}
private _createPanel(
routerOptions: RouterOptions,
page: string,
routeOptions: RouteOptions
) {
if (this.lastChild) {
this.removeChild(this.lastChild);
}
const panelEl = this._cache[page] || this.createElement(routeOptions.tag);
this.updatePageEl(panelEl);
this.appendChild(panelEl);
if (routerOptions.cacheAll || routeOptions.cache) {
this._cache[page] = panelEl;
}
}
}