From cd0762b4362b03a838ed78033804bbc0d35e77e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=A9ry=20Debongnie?= Date: Thu, 25 Jul 2019 13:24:57 +0200 Subject: [PATCH] [REF] rework router into a class --- src/index.ts | 4 +- src/router/Link.ts | 12 +- src/router/Router.ts | 165 +++++++++++++++++ src/router/directive.ts | 12 +- src/router/plugin.ts | 167 ------------------ src/utils.ts | 2 +- .../__snapshots__/directive.test.ts.snap | 12 +- tests/router/directive.test.ts | 12 +- tests/router/router.test.ts | 31 ++-- 9 files changed, 207 insertions(+), 210 deletions(-) create mode 100644 src/router/Router.ts delete mode 100644 src/router/plugin.ts diff --git a/src/index.ts b/src/index.ts index ef263afe..19444df0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -11,12 +11,12 @@ import { ConnectedComponent } from "./store/connected_component"; import { Store } from "./store/store"; import * as _utils from "./utils"; import { Link } from "./router/Link"; -import { activate } from "./router/plugin"; +import { Router } from "./router/Router"; export { Component } from "./component/component"; export { QWeb }; export const core = { EventBus, Observer }; -export const router = { activate, Link }; +export const router = { Router, Link }; export const store = { Store, ConnectedComponent }; export const utils = _utils; diff --git a/src/router/Link.ts b/src/router/Link.ts index e95f90af..2687c2af 100644 --- a/src/router/Link.ts +++ b/src/router/Link.ts @@ -1,5 +1,5 @@ import { Component } from "../component/component"; -import { Destination, RouterEnv } from "./plugin"; +import { Destination, RouterEnv } from "./Router"; export const LINK_TEMPLATE_NAME = "__owl__-router-link"; export const LINK_TEMPLATE = ` @@ -13,17 +13,17 @@ type Props = Destination; export class Link extends Component { template = LINK_TEMPLATE_NAME; - href: string = this.env.router.info.destToUrl(this.props); + href: string = this.env.router.destToUrl(this.props); async willUpdateProps(nextProps) { - this.href = this.env.router.info.destToUrl(nextProps); + this.href = this.env.router.destToUrl(nextProps); } get isActive() { - if (this.env.router.info.mode === "hash") { - return document.location.hash === this.href; + if (this.env.router.mode === "hash") { + return (document.location).hash === this.href; } - return document.location.pathname === this.href; + return (document.location).pathname === this.href; } navigate() { diff --git a/src/router/Router.ts b/src/router/Router.ts new file mode 100644 index 00000000..e08044ea --- /dev/null +++ b/src/router/Router.ts @@ -0,0 +1,165 @@ +import { Env } from "../component/component"; +import { QWeb } from "../qweb/index"; +import { makeDirective } from "./directive"; +import { LINK_TEMPLATE, LINK_TEMPLATE_NAME } from "./Link"; + +interface Route { + name: string; + path: string; + component?: any; + redirect?: Destination; + params: string[]; +} + +export type RouteParams = { [key: string]: string | number }; + +export interface RouterEnv extends Env { + router: Router; +} + +export interface Destination { + to?: string; + route?: string; + params?: RouteParams; +} + +interface Options { + mode: Router["mode"]; +} + +const paramRegexp = /\{\{(.*?)\}\}/; + +export class Router { + currentRoute: Route | null = null; + currentParams: RouteParams | null = null; + mode: "history" | "hash"; + + routes: { [id: string]: Route }; + routeIds: string[]; + env: RouterEnv; + + constructor(env: Env, routes: Partial[], options: Options = { mode: "history" }) { + env.router = this; + this.mode = options.mode; + this.env = env as RouterEnv; + + this.routes = {}; + this.routeIds = []; + let nextId = 1; + for (let partialRoute of routes) { + if (!partialRoute.name) { + partialRoute.name = "__route__" + nextId++; + } + if (partialRoute.component) { + QWeb.register("__component__" + partialRoute.name, partialRoute.component); + } + partialRoute.params = partialRoute.path ? findParams(partialRoute.path) : []; + this.routes[partialRoute.name] = partialRoute as Route; + this.routeIds.push(partialRoute.name); + } + + this.checkRoute(); + + window.addEventListener("popstate", () => this.checkAndUpdateRoute()); + + // setup link and directive + env.qweb.addTemplate(LINK_TEMPLATE_NAME, LINK_TEMPLATE); + QWeb.addDirective(makeDirective(env)); + } + + navigate(dest: Destination): void { + const to = this.destToUrl(dest); + history.pushState({}, to, location.origin + to); + this.checkAndUpdateRoute(); + } + + destToUrl(dest: Destination): string { + return dest.to || this.routeToURL(this.routes[dest.route!].path, dest.params!); + } + + get currentRouteName(): string | null{ + return this.currentRoute && this.currentRoute.name; + } + + private routeToURL(path: string, params: RouteParams): string { + const parts = path.split("/"); + const l = parts.length; + for (let i = 0; i < l; i++) { + const part = parts[i]; + const match = part.match(paramRegexp); + if (match) { + const key = match[1].split(".")[0]; + parts[i] = params[key]; + } + } + return parts.join("/"); + } + + private checkRoute(): void { + let currentPath = + this.mode === "history" ? window.location.pathname : window.location.hash.slice(1); + currentPath = currentPath || "/"; + for (let routeId of this.routeIds) { + let route = this.routes[routeId]; + let params = this.matchRoute(route.path, currentPath); + if (params) { + this.currentRoute = route; + this.currentParams = params; + return; + } + } + this.currentRoute = null; + this.currentParams = {}; + } + + private checkAndUpdateRoute(): void { + const initialName = this.currentRoute ? this.currentRoute.name : null; + this.checkRoute(); + const currentName = this.currentRoute ? this.currentRoute.name : null; + + if (currentName !== initialName) { + this.env.qweb.forceUpdate(); + } + } + + private matchRoute(routePath: string, path: string): RouteParams | false { + if (routePath === "*") { + return {}; + } + const descrParts = routePath.split("/"); + const targetParts = path.split("/"); + const l = descrParts.length; + if (l !== targetParts.length) { + return false; + } + const result = {}; + for (let i = 0; i < l; i++) { + const descr = descrParts[i]; + let target: string | number = targetParts[i]; + const match = descr.match(paramRegexp); + if (match) { + const [key, suffix] = match[1].split("."); + if (suffix === "number") { + target = parseInt(target, 10); + } + result[key] = target; + } else if (descr !== target) { + return false; + } + } + return result; + } +} + +function findParams(str: string): string[] { + const globalParamRegexp = /\{\{(.*?)\}\}/g; + const result: string[] = []; + let m; + do { + m = globalParamRegexp.exec(str); + if (m) { + result.push(m[1].split(".")[0]); + } + } while (m); + return result; +} diff --git a/src/router/directive.ts b/src/router/directive.ts index 2063d7e7..65a23ed6 100644 --- a/src/router/directive.ts +++ b/src/router/directive.ts @@ -1,4 +1,4 @@ -import { RouterEnv } from "./plugin"; +import { RouterEnv } from "./Router"; export function makeDirective(env: RouterEnv) { return { @@ -6,17 +6,17 @@ export function makeDirective(env: RouterEnv) { priority: 13, atNodeEncounter({ node }): boolean { let first = true; - const info = env.router.info; - for (let name of info.routeIds) { - const route = info.routes[name]; + const router = env.router; + for (let name of router.routeIds) { + const route = router.routes[name]; if (route.component) { // make new t t-component element const comp = node.ownerDocument.createElement("t"); comp.setAttribute("t-component", "__component__" + route.name); - comp.setAttribute(first ? "t-if" : "t-elif", `env.router.routeName === '${route.name}'`); + comp.setAttribute(first ? "t-if" : "t-elif", `env.router.currentRouteName === '${route.name}'`); first = false; for (let param of route.params) { - comp.setAttribute(param, `env.router.routeParams.${param}`); + comp.setAttribute(param, `env.router.currentParams.${param}`); } node.appendChild(comp); } diff --git a/src/router/plugin.ts b/src/router/plugin.ts deleted file mode 100644 index 1579a10d..00000000 --- a/src/router/plugin.ts +++ /dev/null @@ -1,167 +0,0 @@ -import { Env } from "../component/component"; -import { QWeb } from "../qweb/index"; -import { makeDirective } from "./directive"; -import { LINK_TEMPLATE, LINK_TEMPLATE_NAME } from "./Link"; - -interface Route { - name?: string; - path: string; - component?: any; -} - -export interface RouteDescription extends Route { - name: string; - params: string[]; -} - -export type RouteParams = { [key: string]: string | number }; - -export interface RouterEnv extends Env { - router: Router; -} - -export interface Destination { - to?: string; - route?: string; - params?: RouteParams; -} -interface Router { - navigate: (dest: Destination) => void; - routeName: string | null; - routeParams: RouteParams; - info: RouterInfo; -} - -interface RouterInfo { - mode: Options["mode"]; - routes: { [id: string]: RouteDescription }; - routeIds: string[]; - destToUrl: (dest: Destination) => string; -} - -interface Options { - mode: "history" | "hash"; -} - -const paramRegexp = /\{\{(.*?)\}\}/; - -function findParams(str: string): string[] { - const globalParamRegexp = /\{\{(.*?)\}\}/g; - const result: string[] = []; - let m; - do { - m = globalParamRegexp.exec(str); - if (m) { - result.push(m[1].split('.')[0]); - } - } while (m); - return result; -} - -export function activate(env: Env, routes: Route[], options?: Options) { - // process routes and build proper internal data structures - const mode = options ? options.mode : "hash"; - const info: RouterInfo = { routes: {}, routeIds: [], mode, destToUrl: destToURL.bind(null, env) }; - let nextId = 1; - for (let route of routes) { - if (!route.name) { - route.name = "__route__" + nextId++; - } - if (route.component) { - QWeb.register("__component__" + route.name, route.component); - } - (route).params = findParams(route.path); - info.routes[route.name] = route; - info.routeIds.push(route.name); - } - - const router: Router = { navigate, routeName: null, routeParams: {}, info }; - - env.router = router; - - checkRoute(env); - - function navigate(dest: Destination) { - const to = destToURL(env, dest); - history.pushState({}, to, location.origin + to); - checkAndUpdateRoute(env); - } - - addEventListener("popstate", () => checkAndUpdateRoute(env)); - - // setup link and directive - env.qweb.addTemplate(LINK_TEMPLATE_NAME, LINK_TEMPLATE); - QWeb.addDirective(makeDirective(env)); -} - -function checkRoute(env: RouterEnv): void { - const info = env.router.info; - let currentPath = - info.mode === "history" ? window.location.pathname : window.location.hash.slice(1); - currentPath = currentPath || "/"; - for (let routeId of info.routeIds) { - let route = info.routes[routeId]; - let params = matchRoute(route, currentPath); - if (params) { - env.router.routeName = route.name!; - env.router.routeParams = params; - return; - } - } - env.router.routeName = null; - env.router.routeParams = {}; -} - -function checkAndUpdateRoute(env: RouterEnv): void { - const currentRoute = env.router.routeName; - checkRoute(env); - if (env.router.routeName !== currentRoute) { - env.qweb.forceUpdate(); - } -} - -export function matchRoute(route: Route, path: string): RouteParams | false { - if (route.path === "*") { - return {}; - } - const descrParts = route.path.split("/"); - const targetParts = path.split("/"); - const l = descrParts.length; - if (l !== targetParts.length) { - return false; - } - const result = {}; - for (let i = 0; i < l; i++) { - const descr = descrParts[i]; - let target: string | number = targetParts[i]; - const match = descr.match(paramRegexp); - if (match) { - const [key, suffix] = match[1].split("."); - if (suffix === "number") { - target = parseInt(target, 10); - } - result[key] = target; - } else if (descr !== target) { - return false; - } - } - return result; -} - -export function destToURL(env: RouterEnv, dest: Destination): string { - return dest.to || routeToURL(env.router.info.routes[dest.route!].path, dest.params!); -} - -export function routeToURL(path: string, params: RouteParams): string { - const parts = path.split("/"); - const l = parts.length; - for (let i = 0; i < l; i++) { - const part = parts[i]; - const match = part.match(paramRegexp); - if (match) { - const key = match[1].split(".")[0]; - parts[i] = params[key]; - } - } - return parts.join("/"); -} diff --git a/src/utils.ts b/src/utils.ts index b768018e..e5d33a8b 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -10,7 +10,7 @@ * - debounce */ -export function whenReady(fn) { +export function whenReady(fn?: any) { return new Promise(function(resolve) { if (document.readyState !== "loading") { resolve(); diff --git a/tests/router/__snapshots__/directive.test.ts.snap b/tests/router/__snapshots__/directive.test.ts.snap index 3745c916..aace5ed6 100644 --- a/tests/router/__snapshots__/directive.test.ts.snap +++ b/tests/router/__snapshots__/directive.test.ts.snap @@ -10,13 +10,13 @@ exports[`router directive t-routecomponent can render parameterized route 1`] = var h = this.h; let c1 = [], p1 = {key:1}; var vn1 = h('div', p1, c1); - if (context['env'].router.routeName==='book') { + if (context['env'].router.currentRouteName==='book') { //COMPONENT let def3; let w4 = 4 in parent.__owl__.cmap ? parent.__owl__.children[parent.__owl__.cmap[4]] : false; let _2_index = c1.length; c1.push(null); - let props4 = {title:context['env'].router.routeParams.title}; + let props4 = {title:context['env'].router.currentParams.title}; if (w4 && w4.__owl__.renderPromise && !w4.__owl__.vnode) { if (utils.shallowEqual(props4, w4.__owl__.renderProps)) { def3 = w4.__owl__.renderPromise; @@ -53,13 +53,13 @@ exports[`router directive t-routecomponent can render parameterized route with s var h = this.h; let c1 = [], p1 = {key:1}; var vn1 = h('div', p1, c1); - if (context['env'].router.routeName==='book') { + if (context['env'].router.currentRouteName==='book') { //COMPONENT let def3; let w4 = 4 in parent.__owl__.cmap ? parent.__owl__.children[parent.__owl__.cmap[4]] : false; let _2_index = c1.length; c1.push(null); - let props4 = {title:context['env'].router.routeParams.title,val:context['env'].router.routeParams.val}; + let props4 = {title:context['env'].router.currentParams.title,val:context['env'].router.currentParams.val}; if (w4 && w4.__owl__.renderPromise && !w4.__owl__.vnode) { if (utils.shallowEqual(props4, w4.__owl__.renderProps)) { def3 = w4.__owl__.renderPromise; @@ -96,7 +96,7 @@ exports[`router directive t-routecomponent can render simple cases 1`] = ` var h = this.h; let c1 = [], p1 = {key:1}; var vn1 = h('div', p1, c1); - if (context['env'].router.routeName==='about') { + if (context['env'].router.currentRouteName==='about') { //COMPONENT let def3; let w4 = 4 in parent.__owl__.cmap ? parent.__owl__.children[parent.__owl__.cmap[4]] : false; @@ -125,7 +125,7 @@ exports[`router directive t-routecomponent can render simple cases 1`] = ` } extra.promises.push(def3); } - else if (context['env'].router.routeName==='users') { + else if (context['env'].router.currentRouteName==='users') { //COMPONENT let def6; let w7 = 7 in parent.__owl__.cmap ? parent.__owl__.children[parent.__owl__.cmap[7]] : false; diff --git a/tests/router/directive.test.ts b/tests/router/directive.test.ts index e57430d1..7ac68d20 100644 --- a/tests/router/directive.test.ts +++ b/tests/router/directive.test.ts @@ -1,6 +1,6 @@ import { Component } from "../../src/component/component"; import { QWeb } from "../../src/qweb/index"; -import { activate, RouterEnv } from "../../src/router/plugin"; +import { Router, RouterEnv } from "../../src/router/Router"; import { makeTestEnv, makeTestFixture, nextTick } from "../helpers"; describe("router directive t-routecomponent", () => { @@ -41,8 +41,8 @@ describe("router directive t-routecomponent", () => { { name: "about", path: "/about", component: About }, { name: "users", path: "/users", component: Users } ]; - activate(env, routes, {mode: 'history'}); - const router = env.router; + + const router = new Router(env,routes, {mode: 'history'}) router.navigate({ route: "about" }); const app = new App(env); await app.mount(fixture); @@ -69,8 +69,7 @@ describe("router directive t-routecomponent", () => { } const routes = [{ name: "book", path: "/book/{{title}}", component: Book }]; - activate(env, routes, {mode: 'history'}); - const router = env.router; + const router = new Router(env,routes, {mode: 'history'}) router.navigate({ route: "book", params: { title: "1984" } }); const app = new App(env); await app.mount(fixture); @@ -97,8 +96,7 @@ describe("router directive t-routecomponent", () => { } const routes = [{ name: "book", path: "/book/{{title}}/{{val.number}}", component: Book }]; - activate(env, routes, {mode: 'history'}); - const router = env.router; + const router = new Router(env,routes, {mode: 'history'}) router.navigate({ route: "book", params: { title: "1984", val: "123" } }); const app = new App(env); await app.mount(fixture); diff --git a/tests/router/router.test.ts b/tests/router/router.test.ts index f48a1ec7..8235ff84 100644 --- a/tests/router/router.test.ts +++ b/tests/router/router.test.ts @@ -1,38 +1,39 @@ -import { matchRoute, routeToURL } from "../../src/router/plugin"; +import { Router } from "../../src/router/Router"; describe("routeToURL", () => { - test("simple non parameterized path", () => { - expect(routeToURL("/abc", {})).toBe("/abc"); - expect(routeToURL("/abc/def", {})).toBe("/abc/def"); - expect(routeToURL("/abc", {val: 12})).toBe("/abc"); - }); - - test("simple parameterized path", () => { - expect(routeToURL("/abc/{{def}}", {def: 34})).toBe("/abc/34"); - }); + const routeToURL = Router.prototype.routeToURL; + test("simple non parameterized path", () => { + expect(routeToURL("/abc", {})).toBe("/abc"); + expect(routeToURL("/abc/def", {})).toBe("/abc/def"); + expect(routeToURL("/abc", { val: 12 })).toBe("/abc"); + }); + test("simple parameterized path", () => { + expect(routeToURL("/abc/{{def}}", { def: 34 })).toBe("/abc/34"); + }); }); describe("match routes", () => { + const matchRoute = Router.prototype.matchRoute; test("properly match simple routes", () => { // simple route - expect(matchRoute({ path: "/home", name: "someroute" }, "/home")).toEqual({}); + expect(matchRoute("/home", "/home")).toEqual({}); // no match - expect(matchRoute({ path: "/home", name: "someroute" }, "/otherpath")).toEqual(false); + expect(matchRoute("/home", "/otherpath")).toEqual(false); // fallback route - expect(matchRoute({ path: "*", name: "someroute" }, "somepath")).toEqual({}); + expect(matchRoute("*", "somepath")).toEqual({}); }); test("match some parameterized routes", () => { - expect(matchRoute({ path: "/invoices/{{id}}", name: "someroute" }, "/invoices/3")).toEqual({ + expect(matchRoute("/invoices/{{id}}", "/invoices/3")).toEqual({ id: "3" }); }); test("can convert to number if needed", () => { - expect(matchRoute({ path: "/invoices/{{id.number}}", name: "someroute" }, "/invoices/3")).toEqual({ + expect(matchRoute("/invoices/{{id.number}}", "/invoices/3")).toEqual({ id: 3 }); });