UNPKG

stem-core

Version:

Frontend and core-library framework

324 lines (271 loc) 9.15 kB
import {UI} from "./UIBase"; import {Switcher} from "./Switcher"; import {Dispatcher} from "../base/Dispatcher"; import {PageTitleManager} from "../base/PageTitleManager"; import {unwrapArray, isString} from "../base/Utils"; export class Router extends Switcher { // TODO: This works bad with query params. Fix it! static localHistory = []; // If we want the router to not alter the window history, use this instead. static useLocalHistory = false; static getCurrentPath() { let path = ""; if (this.useLocalHistory && this.localHistory.length) { // We do this to get rid of query params or hash params path = this.localHistory[this.localHistory.length - 1].split("?")[0].split("#")[0]; } else { path = location.pathname; } return path; } static parseURL(path=location.pathname) { if (!Array.isArray(path)) { path = path.split("/"); } return path.filter(str => str != ""); } static joinQueryParams(queryParams = {}) { return Object.keys(queryParams) .map((param) => `${encodeURIComponent(param)}=${encodeURIComponent(queryParams[param])}`).join("&"); } static formatURL(url) { if (Array.isArray(url)) { url = url.length ? ("/" + url.join("/")) : "/"; } if (isString(url) && url[0] !== "/") { url = "/" + url; } return url; } static changeURL(url, options = {queryParams: {}, state: {}, replaceHistory: false, forceElementUpdate: false, keepSearchParams: false}) { url = this.formatURL(url); if (options.queryParams && Object.keys(options.queryParams).length > 0) { const queryString = this.joinQueryParams(options.queryParams); url = `${url}?${queryString}`; } else if (options.keepSearchParams) { url += location.search; } if (url === window.location.pathname && !options.forceElementUpdate) { // We're already here return; } options.state = options.state || {}; const historyArgs = [options.state, PageTitleManager.getTitle(), url]; if (this.useLocalHistory) { if (options.replaceHistory) { this.localHistory.pop(); } this.localHistory.push(url); } else { if (options.replaceHistory) { window.history.replaceState(...historyArgs); } else { window.history.pushState(...historyArgs); } } this.updateURL(); } static onPopState() { this.changeURL(this.parseURL(this.getCurrentPath()), {replaceHistory: true, forceElementUpdate: true, keepSearchParams: true}); Dispatcher.Global.dispatch("externalURLChange"); } static back() { if (this.useLocalHistory) { this.localHistory.pop(); this.onPopState(); } else { window.history.back(); } } static updateURL() { this.Global.setURL(this.parseURL(this.getCurrentPath())); } static setGlobalRouter(router) { this.Global = router; window.onpopstate = () => { this.onPopState(); }; this.updateURL(); } clearCache() { this.getRoutes().clearCache(); } // TODO: should be named getRootRoute() :) getRoutes() { return this.options.routes; } getPageNotFound() { const element = UI.createElement("h1", {children: ["Can't find url, make sure you typed it correctly"]}); element.pageTitle = "Page not found"; return element; } getPageToRender(urlParts) { const result = this.getRoutes().getPage(urlParts); if (result === false) { return this.getPageNotFound(); } if (Array.isArray(result)) { this.constructor.changeURL(...result); return null; } return result; } setURL(urlParts) { urlParts = unwrapArray(urlParts); const page = this.getPageToRender(urlParts); if (!page) return; const activePage = this.getActive(); if (activePage !== page) { activePage && activePage.dispatch("urlExit"); this.setActive(page); page.dispatch("urlEnter"); } else { page.dispatch("urlReload"); } if (page && page.pageTitle) { PageTitleManager.setTitle(page.pageTitle); } this.dispatch("change", urlParts, page, activePage); } addChangeListener(callback) { return this.addListener("change", callback); } onMount() { if (!Router.Global) { this.constructor.setGlobalRouter(this); } } } export class Route { static ARG_KEY = "%s"; cachedPages = new Map(); constructor(expr, pageGenerator, subroutes = [], options = {}) { this.expr = (expr instanceof Array) ? expr : [expr]; this.pageGenerator = pageGenerator; this.subroutes = unwrapArray(subroutes); if (typeof options === "string") { options = {title: options} } this.options = options; this.cachedPages = new Map(); } clearCache() { this.cachedPages.clear(); for (const subroute of this.subroutes) { if (subroute.clearCache) { subroute.clearCache(); } } } matches(urlParts) { if (urlParts.length < this.expr.length) { return null; } let args = []; for (let i = 0; i < this.expr.length; i += 1) { const isArg = this.expr[i] === this.constructor.ARG_KEY; if (urlParts[i] != this.expr[i] && !isArg) { return null; } if (isArg) { args.push(urlParts[i]); } } return { args: args, urlParts: urlParts.slice(this.expr.length) } } getPageTitle() { return this.options.title; } getPageGuard() { return this.options.beforeEnter; } generatePage(pageGenerator, ...argsArray) { if (!pageGenerator) { return null; } const serializedArgs = argsArray.toString(); if (!this.cachedPages.has(serializedArgs)) { const args = unwrapArray(argsArray); const generatorArgs = {args, argsArray}; const page = (pageGenerator.prototype instanceof UI.Element) ? new pageGenerator(generatorArgs) : pageGenerator(generatorArgs); if (page && !page.pageTitle) { const myPageTitle = this.getPageTitle(); if (myPageTitle) { page.pageTitle = this.getPageTitle(); } } this.cachedPages.set(serializedArgs, page); } return this.cachedPages.get(serializedArgs); } matchesOwnNode(urlParts) { return urlParts.length === 0; } executeGuard() { const pageGuard = this.getPageGuard(); if (!pageGuard) { return null; } return pageGuard(this.getSnapshot()); } getPage(urlParts, router, ...argsArray) { let match; let matchingRoute = this.matchesOwnNode(urlParts) ? this : null; if (!matchingRoute) { for (const subroute of this.subroutes) { match = subroute.matches(urlParts); if (!match) { continue; } if (match.args.length) { argsArray.push(match.args); } matchingRoute = subroute; break; } } if (!matchingRoute) { return false; } const guardResult = this.executeGuard(); if (!guardResult) { return matchingRoute === this ? this.generatePage(this.pageGenerator, ...argsArray) : matchingRoute.getPage(match.urlParts, router, ...argsArray); } if (Array.isArray(guardResult)) { return guardResult; } return this.generatePage(guardResult, ...argsArray); } getSnapshot() { return { expr: this.expr, url: window.location.href, path: `${window.location.pathname}${window.location.search}`, params: new URLSearchParams(window.location.search) } } } export class TerminalRoute extends Route { constructor(expr, pageGenerator, options = {}) { super(expr, pageGenerator, [], options); } matchesOwnNode(urlParts) { return true; } getPage(urlParts, router) { const page = super.getPage(...arguments); // TODO: why is this in a setTimeout? setTimeout(() => { if (page && page.setURL) { page.setURL(urlParts); } }); return page; } }