[ADD] router: initial implementation

This commit is contained in:
Géry Debongnie
2019-07-16 16:33:20 +02:00
parent e5b1ba24d8
commit ca920a0091
8 changed files with 537 additions and 0 deletions
+1
View File
@@ -30,6 +30,7 @@ import "./props_validation";
*/
export interface Env {
qweb: QWeb;
[key: string]: any;
}
/**
+3
View File
@@ -10,10 +10,13 @@ import { QWeb } from "./qweb/index";
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";
export { Component } from "./component/component";
export { QWeb };
export const core = { EventBus, Observer };
export const router = { activate, Link };
export const store = { Store, ConnectedComponent };
export const utils = _utils;
+32
View File
@@ -0,0 +1,32 @@
import { Component } from "../component/component";
import { Destination, RouterEnv } from "./plugin";
export const LINK_TEMPLATE_NAME = "__owl__-router-link";
export const LINK_TEMPLATE = `
<a t-att-class="{'router-link-active': isActive }"
t-att-href="href"
t-on-click.prevent="navigate">
<t t-slot="default"/>
</a>`;
type Props = Destination;
export class Link<Env extends RouterEnv> extends Component<Env, Props, {}> {
template = LINK_TEMPLATE_NAME;
href: string = this.env.router.info.destToUrl(this.props);
async willUpdateProps(nextProps) {
this.href = this.env.router.info.destToUrl(nextProps);
}
get isActive() {
if (this.env.router.info.mode === "hash") {
return document.location.hash === this.href;
}
return document.location.pathname === this.href;
}
navigate() {
this.env.router.navigate(this.props);
}
}
+28
View File
@@ -0,0 +1,28 @@
import { RouterEnv } from "./plugin";
export function makeDirective(env: RouterEnv) {
return {
name: "routecomponent",
priority: 13,
atNodeEncounter({ node }): boolean {
let first = true;
const info = env.router.info;
for (let name of info.routeIds) {
const route = info.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}'`);
first = false;
for (let param of route.params) {
comp.setAttribute(param, `env.router.routeParams.${param}`);
}
node.appendChild(comp);
}
}
node.removeAttribute("t-routecomponent");
return false;
}
};
}
+167
View File
@@ -0,0 +1,167 @@
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);
}
(<RouteDescription>route).params = findParams(route.path);
info.routes[route.name] = <RouteDescription>route;
info.routeIds.push(route.name);
}
const router: Router = { navigate, routeName: null, routeParams: {}, info };
env.router = router;
checkRoute(<RouterEnv>env);
function navigate(dest: Destination) {
const to = destToURL(<RouterEnv>env, dest);
history.pushState({}, to, location.origin + to);
checkAndUpdateRoute(<RouterEnv>env);
}
addEventListener("popstate", () => checkAndUpdateRoute(<RouterEnv>env));
// setup link and directive
env.qweb.addTemplate(LINK_TEMPLATE_NAME, LINK_TEMPLATE);
QWeb.addDirective(makeDirective(<RouterEnv>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] = <string>params[key];
}
}
return parts.join("/");
}
@@ -0,0 +1,159 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`router directive t-routecomponent can render parameterized route 1`] = `
"function anonymous(context,extra
) {
let utils = this.constructor.utils;
let QWeb = this.constructor;
let parent = context;
let owner = context;
var h = this.h;
let c1 = [], p1 = {key:1};
var vn1 = h('div', p1, c1);
if (context['env'].router.routeName==='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};
if (w4 && w4.__owl__.renderPromise && !w4.__owl__.vnode) {
if (utils.shallowEqual(props4, w4.__owl__.renderProps)) {
def3 = w4.__owl__.renderPromise;
} else {
w4.destroy();
w4 = false;
}
}
if (!w4) {
let componentKey4 = \`__component__book\`;
let W4 = context.components && context.components[componentKey4] || QWeb.components[componentKey4];
if (!W4) {throw new Error('Cannot find the definition of component \\"' + componentKey4 + '\\"')}
w4 = new W4(parent, props4);
parent.__owl__.cmap[4] = w4.__owl__.id;
def3 = w4.__prepare();
def3 = def3.then(vnode=>{let pvnode=h(vnode.sel, {key: 4, hook: {insert(vn) {let nvn=w4.__mount(vnode, pvnode.elm);pvnode.elm=nvn.elm;},remove() {},destroy(vn) {w4.destroy();}}});c1[_2_index]=pvnode;w4.__owl__.pvnode = pvnode;});
} else {
def3 = def3 || w4.__updateProps(props4, extra.forceUpdate, extra.patchQueue);
def3 = def3.then(()=>{if (w4.__owl__.isDestroyed) {return};let pvnode=w4.__owl__.pvnode;c1[_2_index]=pvnode;});
}
extra.promises.push(def3);
}
return vn1;
}"
`;
exports[`router directive t-routecomponent can render parameterized route with suffixes 1`] = `
"function anonymous(context,extra
) {
let utils = this.constructor.utils;
let QWeb = this.constructor;
let parent = context;
let owner = context;
var h = this.h;
let c1 = [], p1 = {key:1};
var vn1 = h('div', p1, c1);
if (context['env'].router.routeName==='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};
if (w4 && w4.__owl__.renderPromise && !w4.__owl__.vnode) {
if (utils.shallowEqual(props4, w4.__owl__.renderProps)) {
def3 = w4.__owl__.renderPromise;
} else {
w4.destroy();
w4 = false;
}
}
if (!w4) {
let componentKey4 = \`__component__book\`;
let W4 = context.components && context.components[componentKey4] || QWeb.components[componentKey4];
if (!W4) {throw new Error('Cannot find the definition of component \\"' + componentKey4 + '\\"')}
w4 = new W4(parent, props4);
parent.__owl__.cmap[4] = w4.__owl__.id;
def3 = w4.__prepare();
def3 = def3.then(vnode=>{let pvnode=h(vnode.sel, {key: 4, hook: {insert(vn) {let nvn=w4.__mount(vnode, pvnode.elm);pvnode.elm=nvn.elm;},remove() {},destroy(vn) {w4.destroy();}}});c1[_2_index]=pvnode;w4.__owl__.pvnode = pvnode;});
} else {
def3 = def3 || w4.__updateProps(props4, extra.forceUpdate, extra.patchQueue);
def3 = def3.then(()=>{if (w4.__owl__.isDestroyed) {return};let pvnode=w4.__owl__.pvnode;c1[_2_index]=pvnode;});
}
extra.promises.push(def3);
}
return vn1;
}"
`;
exports[`router directive t-routecomponent can render simple cases 1`] = `
"function anonymous(context,extra
) {
let utils = this.constructor.utils;
let QWeb = this.constructor;
let parent = context;
let owner = context;
var h = this.h;
let c1 = [], p1 = {key:1};
var vn1 = h('div', p1, c1);
if (context['env'].router.routeName==='about') {
//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 = {};
if (w4 && w4.__owl__.renderPromise && !w4.__owl__.vnode) {
if (utils.shallowEqual(props4, w4.__owl__.renderProps)) {
def3 = w4.__owl__.renderPromise;
} else {
w4.destroy();
w4 = false;
}
}
if (!w4) {
let componentKey4 = \`__component__about\`;
let W4 = context.components && context.components[componentKey4] || QWeb.components[componentKey4];
if (!W4) {throw new Error('Cannot find the definition of component \\"' + componentKey4 + '\\"')}
w4 = new W4(parent, props4);
parent.__owl__.cmap[4] = w4.__owl__.id;
def3 = w4.__prepare();
def3 = def3.then(vnode=>{let pvnode=h(vnode.sel, {key: 4, hook: {insert(vn) {let nvn=w4.__mount(vnode, pvnode.elm);pvnode.elm=nvn.elm;},remove() {},destroy(vn) {w4.destroy();}}});c1[_2_index]=pvnode;w4.__owl__.pvnode = pvnode;});
} else {
def3 = def3 || w4.__updateProps(props4, extra.forceUpdate, extra.patchQueue);
def3 = def3.then(()=>{if (w4.__owl__.isDestroyed) {return};let pvnode=w4.__owl__.pvnode;c1[_2_index]=pvnode;});
}
extra.promises.push(def3);
}
else if (context['env'].router.routeName==='users') {
//COMPONENT
let def6;
let w7 = 7 in parent.__owl__.cmap ? parent.__owl__.children[parent.__owl__.cmap[7]] : false;
let _5_index = c1.length;
c1.push(null);
let props7 = {};
if (w7 && w7.__owl__.renderPromise && !w7.__owl__.vnode) {
if (utils.shallowEqual(props7, w7.__owl__.renderProps)) {
def6 = w7.__owl__.renderPromise;
} else {
w7.destroy();
w7 = false;
}
}
if (!w7) {
let componentKey7 = \`__component__users\`;
let W7 = context.components && context.components[componentKey7] || QWeb.components[componentKey7];
if (!W7) {throw new Error('Cannot find the definition of component \\"' + componentKey7 + '\\"')}
w7 = new W7(parent, props7);
parent.__owl__.cmap[7] = w7.__owl__.id;
def6 = w7.__prepare();
def6 = def6.then(vnode=>{let pvnode=h(vnode.sel, {key: 7, hook: {insert(vn) {let nvn=w7.__mount(vnode, pvnode.elm);pvnode.elm=nvn.elm;},remove() {},destroy(vn) {w7.destroy();}}});c1[_5_index]=pvnode;w7.__owl__.pvnode = pvnode;});
} else {
def6 = def6 || w7.__updateProps(props7, extra.forceUpdate, extra.patchQueue);
def6 = def6.then(()=>{if (w7.__owl__.isDestroyed) {return};let pvnode=w7.__owl__.pvnode;c1[_5_index]=pvnode;});
}
extra.promises.push(def6);
}
return vn1;
}"
`;
+108
View File
@@ -0,0 +1,108 @@
import { Component } from "../../src/component/component";
import { QWeb } from "../../src/qweb/index";
import { activate, RouterEnv } from "../../src/router/plugin";
import { makeTestEnv, makeTestFixture, nextTick } from "../helpers";
describe("router directive t-routecomponent", () => {
let fixture: HTMLElement;
let env: RouterEnv;
beforeEach(() => {
fixture = makeTestFixture();
env = <RouterEnv>makeTestEnv();
delete QWeb.DIRECTIVE_NAMES.routecomponent;
QWeb.DIRECTIVES = QWeb.DIRECTIVES.filter(d => d.name !== "routecomponent");
for (let key in QWeb.components) {
delete QWeb.components[key];
}
});
afterEach(() => {
fixture.remove();
});
test("can render simple cases", async () => {
env.qweb.addTemplates(`
<templates>
<div t-name="App">
<t t-routecomponent="1" />
</div>
<span t-name="About">About</span>
<span t-name="Users">Users</span>
</templates>
`);
class About extends Component<any, any, any> {}
class Users extends Component<any, any, any> {}
class App extends Component<any, any, any> {
components = { About, Users };
}
const routes = [
{ name: "about", path: "/about", component: About },
{ name: "users", path: "/users", component: Users }
];
activate(env, routes, {mode: 'history'});
const router = env.router;
router.navigate({ route: "about" });
const app = new App(env);
await app.mount(fixture);
expect(fixture.innerHTML).toBe("<div><span>About</span></div>");
router.navigate({ route: "users" });
await nextTick();
expect(fixture.innerHTML).toBe("<div><span>Users</span></div>");
expect(env.qweb.templates.App.fn.toString()).toMatchSnapshot();
});
test("can render parameterized route", async () => {
env.qweb.addTemplates(`
<templates>
<div t-name="App">
<t t-routecomponent="1" />
</div>
<span t-name="Book">Book <t t-esc="props.title"/></span>
</templates>
`);
class Book extends Component<any, any, any> {}
class App extends Component<any, any, any> {
components = { Book };
}
const routes = [{ name: "book", path: "/book/{{title}}", component: Book }];
activate(env, routes, {mode: 'history'});
const router = env.router;
router.navigate({ route: "book", params: { title: "1984" } });
const app = new App(env);
await app.mount(fixture);
expect(fixture.innerHTML).toBe("<div><span>Book 1984</span></div>");
expect(env.qweb.templates.App.fn.toString()).toMatchSnapshot();
});
test("can render parameterized route with suffixes", async () => {
env.qweb.addTemplates(`
<templates>
<div t-name="App">
<t t-routecomponent="1" />
</div>
<span t-name="Book">Book <t t-esc="props.title"/>|<t t-esc="incVal"/></span>
</templates>
`);
class Book extends Component<any, any, any> {
get incVal() {
return this.props.val + 1;
}
}
class App extends Component<any, any, any> {
components = { Book };
}
const routes = [{ name: "book", path: "/book/{{title}}/{{val.number}}", component: Book }];
activate(env, routes, {mode: 'history'});
const router = env.router;
router.navigate({ route: "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>");
expect(env.qweb.templates.App.fn.toString()).toMatchSnapshot();
});
});
+39
View File
@@ -0,0 +1,39 @@
import { matchRoute, routeToURL } from "../../src/router/plugin";
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");
});
});
describe("match routes", () => {
test("properly match simple routes", () => {
// simple route
expect(matchRoute({ path: "/home", name: "someroute" }, "/home")).toEqual({});
// no match
expect(matchRoute({ path: "/home", name: "someroute" }, "/otherpath")).toEqual(false);
// fallback route
expect(matchRoute({ path: "*", name: "someroute" }, "somepath")).toEqual({});
});
test("match some parameterized routes", () => {
expect(matchRoute({ path: "/invoices/{{id}}", name: "someroute" }, "/invoices/3")).toEqual({
id: "3"
});
});
test("can convert to number if needed", () => {
expect(matchRoute({ path: "/invoices/{{id.number}}", name: "someroute" }, "/invoices/3")).toEqual({
id: 3
});
});
});