[IMP] router: basic implementation of navigation guards

This commit is contained in:
Géry Debongnie
2019-08-01 07:42:58 +02:00
parent d593131cc1
commit 0a276fbf1d
5 changed files with 194 additions and 65 deletions
+2 -2
View File
@@ -13,10 +13,10 @@ type Props = Destination;
export class Link<Env extends RouterEnv> extends Component<Env, Props, {}> { export class Link<Env extends RouterEnv> extends Component<Env, Props, {}> {
template = LINK_TEMPLATE_NAME; template = LINK_TEMPLATE_NAME;
href: string = this.env.router.destToUrl(this.props); href: string = this.env.router.destToPath(this.props);
async willUpdateProps(nextProps) { async willUpdateProps(nextProps) {
this.href = this.env.router.destToUrl(nextProps); this.href = this.env.router.destToPath(nextProps);
} }
get isActive() { get isActive() {
+108 -36
View File
@@ -3,12 +3,19 @@ import { QWeb } from "../qweb/index";
import { makeDirective } from "./directive"; import { makeDirective } from "./directive";
import { LINK_TEMPLATE, LINK_TEMPLATE_NAME } from "./Link"; 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; name: string;
path: string; path: string;
component?: any; component?: any;
redirect?: Destination; redirect?: Destination;
params: string[]; params: string[];
beforeRouteEnter?: NavigationGuard;
} }
export type RouteParams = { [key: string]: string | number }; export type RouteParams = { [key: string]: string | number };
@@ -23,6 +30,22 @@ export interface Destination {
params?: RouteParams; 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 { interface Options {
mode: Router["mode"]; mode: Router["mode"];
} }
@@ -61,38 +84,66 @@ export class Router {
this.routeIds.push(partialRoute.name); this.routeIds.push(partialRoute.name);
} }
this.checkRoute(); (this as any)._listener = () => this.matchAndApplyRules(this.currentPath());
window.addEventListener("popstate", (this as any)._listener);
this.checkAndUpdateRoute = this.checkAndUpdateRoute.bind(this);
window.addEventListener("popstate", this.checkAndUpdateRoute);
// setup link and directive // setup link and directive
env.qweb.addTemplate(LINK_TEMPLATE_NAME, LINK_TEMPLATE); env.qweb.addTemplate(LINK_TEMPLATE_NAME, LINK_TEMPLATE);
QWeb.addDirective(makeDirective(<RouterEnv>env)); QWeb.addDirective(makeDirective(<RouterEnv>env));
} }
navigate(to: Destination): void { //--------------------------------------------------------------------------
const url = this.destToUrl(to); // Public API
history.pushState({}, url, location.origin + url); //--------------------------------------------------------------------------
this.checkAndUpdateRoute();
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<void> {
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); 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 { get currentRouteName(): string | null {
return this.currentRoute && this.currentRoute.name; return this.currentRoute && this.currentRoute.name;
} }
//--------------------------------------------------------------------------
// Private helpers
//--------------------------------------------------------------------------
private validateDestination(dest: Destination) { private validateDestination(dest: Destination) {
if ((!dest.path && !dest.to) || (dest.path && dest.to)) { if ((!dest.path && !dest.to) || (dest.path && dest.to)) {
throw new Error(`Invalid destination: ${JSON.stringify(dest)}`); 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 parts = path.split("/");
const l = parts.length; const l = parts.length;
for (let i = 0; i < l; i++) { for (let i = 0; i < l; i++) {
@@ -106,42 +157,63 @@ export class Router {
return parts.join("/"); return parts.join("/");
} }
private checkRoute(): void { private currentPath(): string {
let currentPath = let result = this.mode === "history" ? window.location.pathname : window.location.hash.slice(1);
this.mode === "history" ? window.location.pathname : window.location.hash.slice(1); return result || "/";
currentPath = currentPath || "/"; }
private match(path: string): MatchResult {
for (let routeId of this.routeIds) { for (let routeId of this.routeIds) {
let route = this.routes[routeId]; let route = this.routes[routeId];
let params = this.matchRoute(route.path, currentPath); let params = this.getRouteParams(route, path);
if (params) { if (params) {
if (route.redirect) { return {
this.navigate(route.redirect); type: "match",
return; route: route,
} params: params
this.currentRoute = route; };
this.currentParams = params;
return;
} }
} }
this.currentRoute = null; return { type: "nomatch" };
this.currentParams = {};
} }
protected checkAndUpdateRoute(): void { private async matchAndApplyRules(path: string): Promise<MatchResult> {
const initialName = this.currentRoute ? this.currentRoute.name : null; const result = this.match(path);
this.checkRoute(); if (result.type === "match") {
const currentName = this.currentRoute ? this.currentRoute.name : null; return this.applyRules(result);
if (currentName !== initialName) {
this.env.qweb.forceUpdate();
} }
return result;
} }
private matchRoute(routePath: string, path: string): RouteParams | false { private async applyRules(matchResult: PositiveMatchResult): Promise<MatchResult> {
if (routePath === "*") { 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 {}; return {};
} }
const descrParts = routePath.split("/"); const descrParts = route.path.split("/");
const targetParts = path.split("/"); const targetParts = path.split("/");
const l = descrParts.length; const l = descrParts.length;
if (l !== targetParts.length) { if (l !== targetParts.length) {
+1 -1
View File
@@ -3,7 +3,7 @@ import { QWeb } from "../../src/qweb/index";
export class TestRouter extends Router { export class TestRouter extends Router {
destroy() { destroy() {
window.removeEventListener("popstate", this.checkAndUpdateRoute); window.removeEventListener("popstate", (this as any)._listener);
delete QWeb.DIRECTIVE_NAMES.routecomponent; delete QWeb.DIRECTIVE_NAMES.routecomponent;
QWeb.DIRECTIVES = QWeb.DIRECTIVES.filter(d => d.name !== "routecomponent"); QWeb.DIRECTIVES = QWeb.DIRECTIVES.filter(d => d.name !== "routecomponent");
+4 -4
View File
@@ -43,12 +43,12 @@ describe("router directive t-routecomponent", () => {
]; ];
router = new TestRouter(env, routes, { mode: "history" }); router = new TestRouter(env, routes, { mode: "history" });
router.navigate({ to: "about" }); await router.navigate({ to: "about" });
const app = new App(env); const app = new App(env);
await app.mount(fixture); await app.mount(fixture);
expect(fixture.innerHTML).toBe("<div><span>About</span></div>"); expect(fixture.innerHTML).toBe("<div><span>About</span></div>");
router.navigate({ to: "users" }); await router.navigate({ to: "users" });
await nextTick(); await nextTick();
expect(fixture.innerHTML).toBe("<div><span>Users</span></div>"); expect(fixture.innerHTML).toBe("<div><span>Users</span></div>");
expect(env.qweb.templates.App.fn.toString()).toMatchSnapshot(); 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 }]; const routes = [{ name: "book", path: "/book/{{title}}", component: Book }];
router = new TestRouter(env, routes, { mode: "history" }); 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); const app = new App(env);
await app.mount(fixture); await app.mount(fixture);
expect(fixture.innerHTML).toBe("<div><span>Book 1984</span></div>"); expect(fixture.innerHTML).toBe("<div><span>Book 1984</span></div>");
@@ -97,7 +97,7 @@ describe("router directive t-routecomponent", () => {
const routes = [{ name: "book", path: "/book/{{title}}/{{val.number}}", component: Book }]; const routes = [{ name: "book", path: "/book/{{title}}/{{val.number}}", component: Book }];
router = new TestRouter(env, routes, { mode: "history" }); 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); const app = new App(env);
await app.mount(fixture); await app.mount(fixture);
expect(fixture.innerHTML).toBe("<div><span>Book 1984|124</span></div>"); expect(fixture.innerHTML).toBe("<div><span>Book 1984|124</span></div>");
+79 -22
View File
@@ -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 { makeTestEnv } from "../helpers";
import { TestRouter } from "./TestRouter"; import { TestRouter } from "./TestRouter";
@@ -7,6 +7,7 @@ let router: TestRouter | null = null;
beforeEach(() => { beforeEach(() => {
env = <RouterEnv>makeTestEnv(); env = <RouterEnv>makeTestEnv();
window.history.pushState({}, "/", "/");
}); });
afterEach(() => { afterEach(() => {
@@ -26,82 +27,138 @@ describe("router miscellaneous", () => {
}); });
}); });
describe("routeToURL", () => { describe("routeToPath", () => {
const routeToURL = Router.prototype["routeToURL"]; const routeToPath = Router.prototype["routeToPath"];
test("simple non parameterized path", () => { test("simple non parameterized path", () => {
expect(routeToURL("/abc", {})).toBe("/abc"); expect(routeToPath({path: "/abc"} as Route, {})).toBe("/abc");
expect(routeToURL("/abc/def", {})).toBe("/abc/def"); expect(routeToPath({path: "/abc/def"} as Route, {})).toBe("/abc/def");
expect(routeToURL("/abc", { val: 12 })).toBe("/abc"); expect(routeToPath({path: "/abc"} as Route, { val: 12 })).toBe("/abc");
}); });
test("simple parameterized path", () => { 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", () => { test("validate destination shape", () => {
router = new TestRouter(env, [{ name: "someroute", path: "/some/path" }]); router = new TestRouter(env, [{ name: "someroute", path: "/some/path" }]);
expect(() => { expect(() => {
router!.destToUrl({ abc: 123 } as Destination); router!.destToPath({ abc: 123 } as Destination);
}).toThrow('Invalid destination: {"abc":123}'); }).toThrow('Invalid destination: {"abc":123}');
expect(() => { expect(() => {
router!.destToUrl({ to: "someroute" } as Destination); router!.destToPath({ to: "someroute" } as Destination);
}).not.toThrow(); }).not.toThrow();
expect(() => { expect(() => {
router!.destToUrl({ path: "/someroute", to: "otherroute" } as Destination); router!.destToPath({ path: "/someroute", to: "otherroute" } as Destination);
}).toThrow(); }).toThrow();
}); });
}); });
describe("match routes", () => { describe("getRouteParams", () => {
const matchRoute = Router.prototype["matchRoute"]; const getRouteParams = Router.prototype["getRouteParams"];
test("properly match simple routes", () => { test("properly match simple routes", () => {
// simple route // simple route
expect(matchRoute("/home", "/home")).toEqual({}); expect(getRouteParams({path: "/home"} as Route, "/home")).toEqual({});
// no match // no match
expect(matchRoute("/home", "/otherpath")).toEqual(false); expect(getRouteParams({path: "/home"} as Route, "/otherpath")).toEqual(false);
// fallback route // fallback route
expect(matchRoute("*", "somepath")).toEqual({}); expect(getRouteParams({path: "*"} as Route, "somepath")).toEqual({});
}); });
test("match some parameterized routes", () => { test("match some parameterized routes", () => {
expect(matchRoute("/invoices/{{id}}", "/invoices/3")).toEqual({ expect(getRouteParams({path: "/invoices/{{id}}"} as Route, "/invoices/3")).toEqual({
id: "3" id: "3"
}); });
}); });
test("can convert to number if needed", () => { 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 id: 3
}); });
}); });
}); });
describe("redirect", () => { describe("redirect", () => {
test("can redirect to other route", () => { test("can redirect to other route", async () => {
router = new TestRouter(env, [ router = new TestRouter(env, [
{ name: "routea", path: "/some/path" }, { name: "routea", path: "/some/path" },
{ name: "routeb", path: "/some/other/path", redirect: { to: "routea" } } { 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(window.location.pathname).toBe("/some/path");
expect(router.currentRouteName).toBe("routea"); expect(router.currentRouteName).toBe("routea");
}); });
test("can redirect to other path", () => { test("can redirect to other path", async () => {
router = new TestRouter(env, [ router = new TestRouter(env, [
{ name: "routea", path: "/some/path" }, { name: "routea", path: "/some/path" },
{ name: "routeb", path: "/some/other/path", redirect: { 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(window.location.pathname).toBe("/some/path");
expect(router.currentRouteName).toBe("routea"); 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");
});
});