diff --git a/src/router/Link.ts b/src/router/Link.ts index 2687c2af..c93b37c7 100644 --- a/src/router/Link.ts +++ b/src/router/Link.ts @@ -13,10 +13,10 @@ type Props = Destination; export class Link extends Component { template = LINK_TEMPLATE_NAME; - href: string = this.env.router.destToUrl(this.props); + href: string = this.env.router.destToPath(this.props); async willUpdateProps(nextProps) { - this.href = this.env.router.destToUrl(nextProps); + this.href = this.env.router.destToPath(nextProps); } get isActive() { diff --git a/src/router/Router.ts b/src/router/Router.ts index 591dba67..2b555dad 100644 --- a/src/router/Router.ts +++ b/src/router/Router.ts @@ -3,12 +3,19 @@ import { QWeb } from "../qweb/index"; import { makeDirective } from "./directive"; import { LINK_TEMPLATE, LINK_TEMPLATE_NAME } from "./Link"; -interface Route { +type NavigationGuard = (info: { + env: Env; + to: Route | null; + from: Route | null; +}) => boolean | Destination; + +export interface Route { name: string; path: string; component?: any; redirect?: Destination; params: string[]; + beforeRouteEnter?: NavigationGuard; } export type RouteParams = { [key: string]: string | number }; @@ -23,6 +30,22 @@ export interface Destination { params?: RouteParams; } +interface PositiveMatchResult { + type: "match"; + route: Route; + params: RouteParams; +} + +interface NegativeMatchResult { + type: "nomatch"; +} + +interface CancelledMatch { + type: "cancelled"; +} + +type MatchResult = PositiveMatchResult | NegativeMatchResult | CancelledMatch; + interface Options { mode: Router["mode"]; } @@ -61,38 +84,66 @@ export class Router { this.routeIds.push(partialRoute.name); } - this.checkRoute(); - - this.checkAndUpdateRoute = this.checkAndUpdateRoute.bind(this); - window.addEventListener("popstate", this.checkAndUpdateRoute); + (this as any)._listener = () => this.matchAndApplyRules(this.currentPath()); + window.addEventListener("popstate", (this as any)._listener); // setup link and directive env.qweb.addTemplate(LINK_TEMPLATE_NAME, LINK_TEMPLATE); QWeb.addDirective(makeDirective(env)); } - navigate(to: Destination): void { - const url = this.destToUrl(to); - history.pushState({}, url, location.origin + url); - this.checkAndUpdateRoute(); + //-------------------------------------------------------------------------- + // Public API + //-------------------------------------------------------------------------- + + async start() { + const result = await this.matchAndApplyRules(this.currentPath()); + if (result.type === "match") { + this.currentRoute = result.route; + this.currentParams = result.params; + } } - destToUrl(dest: Destination): string { + async navigate(to: Destination): Promise { + const path = this.destToPath(to); + const initialName = this.currentRouteName; + const result = await this.matchAndApplyRules(path); + if (result.type === "match") { + const finalPath = this.routeToPath(result.route, result.params); + const url = location.origin + finalPath; + history.pushState({}, path, url); + this.currentRoute = result.route; + this.currentParams = result.params; + } else if (result.type === "nomatch") { + this.currentRoute = null; + this.currentParams = null; + } + if (this.currentRouteName !== initialName) { + this.env.qweb.forceUpdate(); + } + } + + destToPath(dest: Destination): string { this.validateDestination(dest); - return dest.path || this.routeToURL(this.routes[dest.to!].path, dest.params!); + return dest.path || this.routeToPath(this.routes[dest.to!], dest.params!); } get currentRouteName(): string | null { return this.currentRoute && this.currentRoute.name; } + //-------------------------------------------------------------------------- + // Private helpers + //-------------------------------------------------------------------------- + private validateDestination(dest: Destination) { if ((!dest.path && !dest.to) || (dest.path && dest.to)) { throw new Error(`Invalid destination: ${JSON.stringify(dest)}`); } } - private routeToURL(path: string, params: RouteParams): string { + private routeToPath(route: Route, params: RouteParams): string { + const path = route.path; const parts = path.split("/"); const l = parts.length; for (let i = 0; i < l; i++) { @@ -106,42 +157,63 @@ export class Router { return parts.join("/"); } - private checkRoute(): void { - let currentPath = - this.mode === "history" ? window.location.pathname : window.location.hash.slice(1); - currentPath = currentPath || "/"; + private currentPath(): string { + let result = this.mode === "history" ? window.location.pathname : window.location.hash.slice(1); + return result || "/"; + } + + private match(path: string): MatchResult { for (let routeId of this.routeIds) { let route = this.routes[routeId]; - let params = this.matchRoute(route.path, currentPath); + let params = this.getRouteParams(route, path); if (params) { - if (route.redirect) { - this.navigate(route.redirect); - return; - } - this.currentRoute = route; - this.currentParams = params; - return; + return { + type: "match", + route: route, + params: params + }; } } - this.currentRoute = null; - this.currentParams = {}; + return { type: "nomatch" }; } - protected 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 async matchAndApplyRules(path: string): Promise { + const result = this.match(path); + if (result.type === "match") { + return this.applyRules(result); } + return result; } - private matchRoute(routePath: string, path: string): RouteParams | false { - if (routePath === "*") { + private async applyRules(matchResult: PositiveMatchResult): Promise { + const route = matchResult.route; + if (route.redirect) { + const path = this.destToPath(route.redirect); + return this.matchAndApplyRules(path); + } + if (route.beforeRouteEnter) { + const result = await route.beforeRouteEnter({ + env: this.env, + from: this.currentRoute, + to: route + }); + if (result === false) { + return { type: "cancelled" }; + } else if (result !== true) { + // we want to navigate to another destination + const path = this.destToPath(result); + return this.matchAndApplyRules(path); + } + } + + return matchResult; + } + + private getRouteParams(route: Route, path: string): RouteParams | false { + if (route.path === "*") { return {}; } - const descrParts = routePath.split("/"); + const descrParts = route.path.split("/"); const targetParts = path.split("/"); const l = descrParts.length; if (l !== targetParts.length) { diff --git a/tests/router/TestRouter.ts b/tests/router/TestRouter.ts index 77b0b61e..cf6064d4 100644 --- a/tests/router/TestRouter.ts +++ b/tests/router/TestRouter.ts @@ -3,7 +3,7 @@ import { QWeb } from "../../src/qweb/index"; export class TestRouter extends Router { destroy() { - window.removeEventListener("popstate", this.checkAndUpdateRoute); + window.removeEventListener("popstate", (this as any)._listener); delete QWeb.DIRECTIVE_NAMES.routecomponent; QWeb.DIRECTIVES = QWeb.DIRECTIVES.filter(d => d.name !== "routecomponent"); diff --git a/tests/router/directive.test.ts b/tests/router/directive.test.ts index aa27c316..cf9ddbb8 100644 --- a/tests/router/directive.test.ts +++ b/tests/router/directive.test.ts @@ -43,12 +43,12 @@ describe("router directive t-routecomponent", () => { ]; router = new TestRouter(env, routes, { mode: "history" }); - router.navigate({ to: "about" }); + await router.navigate({ to: "about" }); const app = new App(env); await app.mount(fixture); expect(fixture.innerHTML).toBe("
About
"); - router.navigate({ to: "users" }); + await router.navigate({ to: "users" }); await nextTick(); expect(fixture.innerHTML).toBe("
Users
"); expect(env.qweb.templates.App.fn.toString()).toMatchSnapshot(); @@ -70,7 +70,7 @@ describe("router directive t-routecomponent", () => { const routes = [{ name: "book", path: "/book/{{title}}", component: Book }]; router = new TestRouter(env, routes, { mode: "history" }); - router.navigate({ to: "book", params: { title: "1984" } }); + await router.navigate({ to: "book", params: { title: "1984" } }); const app = new App(env); await app.mount(fixture); expect(fixture.innerHTML).toBe("
Book 1984
"); @@ -97,7 +97,7 @@ describe("router directive t-routecomponent", () => { const routes = [{ name: "book", path: "/book/{{title}}/{{val.number}}", component: Book }]; router = new TestRouter(env, routes, { mode: "history" }); - router.navigate({ to: "book", params: { title: "1984", val: "123" } }); + await router.navigate({ to: "book", params: { title: "1984", val: "123" } }); const app = new App(env); await app.mount(fixture); expect(fixture.innerHTML).toBe("
Book 1984|124
"); diff --git a/tests/router/router.test.ts b/tests/router/router.test.ts index 8398667c..e1dfe3a7 100644 --- a/tests/router/router.test.ts +++ b/tests/router/router.test.ts @@ -1,4 +1,4 @@ -import { Destination, Router, RouterEnv } from "../../src/router/Router"; +import { Destination, Router, RouterEnv, Route } from "../../src/router/Router"; import { makeTestEnv } from "../helpers"; import { TestRouter } from "./TestRouter"; @@ -7,6 +7,7 @@ let router: TestRouter | null = null; beforeEach(() => { env = makeTestEnv(); + window.history.pushState({}, "/", "/"); }); afterEach(() => { @@ -26,82 +27,138 @@ describe("router miscellaneous", () => { }); }); -describe("routeToURL", () => { - const routeToURL = Router.prototype["routeToURL"]; +describe("routeToPath", () => { + const routeToPath = Router.prototype["routeToPath"]; test("simple non parameterized path", () => { - expect(routeToURL("/abc", {})).toBe("/abc"); - expect(routeToURL("/abc/def", {})).toBe("/abc/def"); - expect(routeToURL("/abc", { val: 12 })).toBe("/abc"); + expect(routeToPath({path: "/abc"} as Route, {})).toBe("/abc"); + expect(routeToPath({path: "/abc/def"} as Route, {})).toBe("/abc/def"); + expect(routeToPath({path: "/abc"} as Route, { val: 12 })).toBe("/abc"); }); test("simple parameterized path", () => { - expect(routeToURL("/abc/{{def}}", { def: 34 })).toBe("/abc/34"); + expect(routeToPath({path: "/abc/{{def}}"} as Route, { def: 34 })).toBe("/abc/34"); }); }); -describe("destToURL", () => { +describe("destToPath", () => { test("validate destination shape", () => { router = new TestRouter(env, [{ name: "someroute", path: "/some/path" }]); expect(() => { - router!.destToUrl({ abc: 123 } as Destination); + router!.destToPath({ abc: 123 } as Destination); }).toThrow('Invalid destination: {"abc":123}'); expect(() => { - router!.destToUrl({ to: "someroute" } as Destination); + router!.destToPath({ to: "someroute" } as Destination); }).not.toThrow(); expect(() => { - router!.destToUrl({ path: "/someroute", to: "otherroute" } as Destination); + router!.destToPath({ path: "/someroute", to: "otherroute" } as Destination); }).toThrow(); }); }); -describe("match routes", () => { - const matchRoute = Router.prototype["matchRoute"]; +describe("getRouteParams", () => { + const getRouteParams = Router.prototype["getRouteParams"]; test("properly match simple routes", () => { // simple route - expect(matchRoute("/home", "/home")).toEqual({}); + expect(getRouteParams({path: "/home"} as Route, "/home")).toEqual({}); // no match - expect(matchRoute("/home", "/otherpath")).toEqual(false); + expect(getRouteParams({path: "/home"} as Route, "/otherpath")).toEqual(false); // fallback route - expect(matchRoute("*", "somepath")).toEqual({}); + expect(getRouteParams({path: "*"} as Route, "somepath")).toEqual({}); }); test("match some parameterized routes", () => { - expect(matchRoute("/invoices/{{id}}", "/invoices/3")).toEqual({ + expect(getRouteParams({path: "/invoices/{{id}}"} as Route, "/invoices/3")).toEqual({ id: "3" }); }); test("can convert to number if needed", () => { - expect(matchRoute("/invoices/{{id.number}}", "/invoices/3")).toEqual({ + expect(getRouteParams({path: "/invoices/{{id.number}}"} as Route, "/invoices/3")).toEqual({ id: 3 }); }); }); describe("redirect", () => { - test("can redirect to other route", () => { + test("can redirect to other route", async () => { router = new TestRouter(env, [ { name: "routea", path: "/some/path" }, { name: "routeb", path: "/some/other/path", redirect: { to: "routea" } } ]); - router.navigate({ to: "routeb" }); + await router.start(); + expect(window.location.pathname).toBe("/"); + await router.navigate({ to: "routeb" }); expect(window.location.pathname).toBe("/some/path"); expect(router.currentRouteName).toBe("routea"); }); - test("can redirect to other path", () => { + test("can redirect to other path", async () => { router = new TestRouter(env, [ { name: "routea", path: "/some/path" }, { name: "routeb", path: "/some/other/path", redirect: { path: "/some/path" } } ]); + router.navigate = jest.fn(router.navigate); - router.navigate({ to: "routeb" }); + await router.start(); + await router.navigate({ to: "routeb" }); + + // once because we ask for it, once because it is redirected + // expect(router.navigate).toBeCalledTimes(2); expect(window.location.pathname).toBe("/some/path"); expect(router.currentRouteName).toBe("routea"); }); }); + +describe("beforeRouteEnter", () => { + test("navigation guard is called and properly handle return true", async () => { + expect(window.location.pathname).not.toBe("/some/path"); + const guard = jest.fn(() => true); + router = new TestRouter(env, [{ name: "route", path: "/some/path", beforeRouteEnter: guard }]); + + await router.start(); + await router.navigate({ to: "route" }); + expect(guard).toBeCalledTimes(1); + expect(window.location.pathname).toBe("/some/path"); + expect(router.currentRouteName).toBe("route"); + }); + + test("navigation is cancelled if guard return false", async () => { + expect(window.location.pathname).toBe("/"); + const guard = jest.fn(() => false); + router = new TestRouter(env, [ + { name: "routea", path: "/some/patha"}, + { name: "routeb", path: "/some/pathb", beforeRouteEnter: guard } + ]); + + await router.start(); + await router.navigate({ to: "routea" }); + expect(window.location.pathname).toBe("/some/patha"); + await router.navigate({ to: "routeb" }); + expect(guard).toBeCalledTimes(1); + expect(window.location.pathname).toBe("/some/patha"); + expect(router.currentRouteName).toBe("routea"); + }); + + test("navigation is redirected if guard decides so", async () => { + expect(window.location.pathname).toBe("/"); + const guard = jest.fn(() => {return {to: "routec"}}); + router = new TestRouter(env, [ + { name: "routea", path: "/some/patha"}, + { name: "routeb", path: "/some/pathb", beforeRouteEnter: guard }, + { name: "routec", path: "/some/pathc"}, + ]); + + await router.start(); + await router.navigate({ to: "routea" }); + expect(window.location.pathname).toBe("/some/patha"); + await router.navigate({ to: "routeb" }); + expect(guard).toBeCalledTimes(1); + expect(window.location.pathname).toBe("/some/pathc"); + expect(router.currentRouteName).toBe("routec"); + }); +});