[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, {}> {
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() {
+108 -36
View File
@@ -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(<RouterEnv>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<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);
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<MatchResult> {
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<MatchResult> {
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) {
+1 -1
View File
@@ -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");
+4 -4
View File
@@ -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("<div><span>About</span></div>");
router.navigate({ to: "users" });
await router.navigate({ to: "users" });
await nextTick();
expect(fixture.innerHTML).toBe("<div><span>Users</span></div>");
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("<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 }];
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("<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 { TestRouter } from "./TestRouter";
@@ -7,6 +7,7 @@ let router: TestRouter | null = null;
beforeEach(() => {
env = <RouterEnv>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");
});
});