mirror of
https://github.com/odoo/owl.git
synced 2025-10-06 19:59:41 +07:00
[MERGE] router: add owl router
First implementation of a declarative router component/directive/plugin
This commit is contained in:
@@ -7,6 +7,7 @@
|
||||
"build:js": "tsc --target esnext --module es6 --outDir dist/owl",
|
||||
"build:bundle": "rollup -c",
|
||||
"build": "npm run build:js && npm run build:bundle",
|
||||
"buildcommonjs": "npm run build:js && npm run build:bundle -- -f cjs",
|
||||
"minify": "uglifyjs dist/owl.js -o dist/owl.min.js --compress --mangle",
|
||||
"test": "jest",
|
||||
"test:watch": "jest --watch",
|
||||
|
||||
@@ -30,6 +30,7 @@ import "./props_validation";
|
||||
*/
|
||||
export interface Env {
|
||||
qweb: QWeb;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -305,9 +306,9 @@ export class Component<T extends Env, Props extends {}, State extends {}> {
|
||||
if (shouldPatch) {
|
||||
patchQueue = [];
|
||||
}
|
||||
const renderVDom = this.__render(force, patchQueue, scope, vars);
|
||||
__owl__.renderId++
|
||||
const renderId = __owl__.renderId;
|
||||
await renderVDom;
|
||||
await this.__render(force, patchQueue, scope, vars);
|
||||
|
||||
if (shouldPatch && __owl__.isMounted && renderId === __owl__.renderId) {
|
||||
// we only update the vnode and the actual DOM if no other rendering
|
||||
@@ -522,7 +523,6 @@ export class Component<T extends Env, Props extends {}, State extends {}> {
|
||||
vars?: any
|
||||
): Promise<VNode> {
|
||||
const __owl__ = this.__owl__;
|
||||
__owl__.renderId++;
|
||||
const promises: Promise<void>[] = [];
|
||||
const patch: any[] = [this];
|
||||
if (__owl__.isMounted) {
|
||||
|
||||
@@ -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 { Router } from "./router/Router";
|
||||
|
||||
export { Component } from "./component/component";
|
||||
export { QWeb };
|
||||
export const core = { EventBus, Observer };
|
||||
export const router = { Router, Link };
|
||||
export const store = { Store, ConnectedComponent };
|
||||
export const utils = _utils;
|
||||
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { Component } from "../component/component";
|
||||
import { Destination, RouterEnv } from "./Router";
|
||||
|
||||
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="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.destToPath(this.props);
|
||||
|
||||
async willUpdateProps(nextProps) {
|
||||
this.href = this.env.router.destToPath(nextProps);
|
||||
}
|
||||
|
||||
get isActive() {
|
||||
if (this.env.router.mode === "hash") {
|
||||
return (<any>document.location).hash === this.href;
|
||||
}
|
||||
return (<any>document.location).pathname === this.href;
|
||||
}
|
||||
|
||||
navigate(ev) {
|
||||
// don't redirect with control keys
|
||||
if (ev.metaKey || ev.altKey || ev.ctrlKey || ev.shiftKey) {
|
||||
return;
|
||||
}
|
||||
// don't redirect on right click
|
||||
if (ev.button !== undefined && ev.button !== 0) {
|
||||
return;
|
||||
}
|
||||
// don't redirect if `target="_blank"`
|
||||
if (ev.currentTarget && ev.currentTarget.getAttribute) {
|
||||
const target = ev.currentTarget.getAttribute("target");
|
||||
if (/\b_blank\b/i.test(target)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
ev.preventDefault();
|
||||
this.env.router.navigate(this.props);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
import { Env } from "../component/component";
|
||||
import { QWeb } from "../qweb/index";
|
||||
import { makeDirective } from "./directive";
|
||||
import { LINK_TEMPLATE, LINK_TEMPLATE_NAME } from "./Link";
|
||||
|
||||
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 };
|
||||
|
||||
export interface RouterEnv extends Env {
|
||||
router: Router;
|
||||
}
|
||||
|
||||
export interface Destination {
|
||||
path?: string;
|
||||
to?: string;
|
||||
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"];
|
||||
}
|
||||
|
||||
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<Route>[], 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);
|
||||
}
|
||||
if (partialRoute.redirect) {
|
||||
this.validateDestination(partialRoute.redirect);
|
||||
}
|
||||
partialRoute.params = partialRoute.path ? findParams(partialRoute.path) : [];
|
||||
this.routes[partialRoute.name] = partialRoute as Route;
|
||||
this.routeIds.push(partialRoute.name);
|
||||
}
|
||||
|
||||
(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));
|
||||
}
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Public API
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
async start() {
|
||||
const result = await this.matchAndApplyRules(this.currentPath());
|
||||
if (result.type === "match") {
|
||||
this.currentRoute = result.route;
|
||||
this.currentParams = result.params;
|
||||
const currentPath = this.routeToPath(result.route, result.params);
|
||||
if (currentPath !== this.currentPath()) {
|
||||
this.setUrlFromPath(currentPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async navigate(to: Destination): Promise<boolean> {
|
||||
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);
|
||||
this.setUrlFromPath(finalPath);
|
||||
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();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
destToPath(dest: Destination): string {
|
||||
this.validateDestination(dest);
|
||||
return dest.path || this.routeToPath(this.routes[dest.to!], dest.params!);
|
||||
}
|
||||
|
||||
get currentRouteName(): string | null {
|
||||
return this.currentRoute && this.currentRoute.name;
|
||||
}
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Private helpers
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
private setUrlFromPath(path: string) {
|
||||
const url = location.origin + path;
|
||||
window.history.pushState({}, path, url);
|
||||
}
|
||||
|
||||
private validateDestination(dest: Destination) {
|
||||
if ((!dest.path && !dest.to) || (dest.path && dest.to)) {
|
||||
throw new Error(`Invalid destination: ${JSON.stringify(dest)}`);
|
||||
}
|
||||
}
|
||||
|
||||
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++) {
|
||||
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("/");
|
||||
}
|
||||
|
||||
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.getRouteParams(route, path);
|
||||
if (params) {
|
||||
return {
|
||||
type: "match",
|
||||
route: route,
|
||||
params: params
|
||||
};
|
||||
}
|
||||
}
|
||||
return { type: "nomatch" };
|
||||
}
|
||||
|
||||
private async matchAndApplyRules(path: string): Promise<MatchResult> {
|
||||
const result = this.match(path);
|
||||
if (result.type === "match") {
|
||||
return this.applyRules(result);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
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 = 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;
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { RouterEnv } from "./Router";
|
||||
|
||||
export function makeDirective(env: RouterEnv) {
|
||||
return {
|
||||
name: "routecomponent",
|
||||
priority: 13,
|
||||
atNodeEncounter({ node }): boolean {
|
||||
let first = true;
|
||||
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.currentRouteName === '${route.name}'`);
|
||||
first = false;
|
||||
for (let param of route.params) {
|
||||
comp.setAttribute(param, `env.router.currentParams.${param}`);
|
||||
}
|
||||
node.appendChild(comp);
|
||||
}
|
||||
}
|
||||
node.removeAttribute("t-routecomponent");
|
||||
return false;
|
||||
}
|
||||
};
|
||||
}
|
||||
+1
-1
@@ -10,7 +10,7 @@
|
||||
* - debounce
|
||||
*/
|
||||
|
||||
export function whenReady(fn) {
|
||||
export function whenReady(fn?: any) {
|
||||
return new Promise(function(resolve) {
|
||||
if (document.readyState !== "loading") {
|
||||
resolve();
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { Component } from "../../src/component/component";
|
||||
import { Link, LINK_TEMPLATE_NAME } from "../../src/router/Link";
|
||||
import { RouterEnv } from "../../src/router/Router";
|
||||
import { makeTestEnv, makeTestFixture, nextTick } from "../helpers";
|
||||
import { TestRouter } from "./TestRouter";
|
||||
|
||||
describe("Link component", () => {
|
||||
let fixture: HTMLElement;
|
||||
let env: RouterEnv;
|
||||
let router: TestRouter | null = null;
|
||||
|
||||
beforeEach(() => {
|
||||
fixture = makeTestFixture();
|
||||
env = <RouterEnv>makeTestEnv();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fixture.remove();
|
||||
if (router) {
|
||||
router.destroy();
|
||||
}
|
||||
router = null;
|
||||
});
|
||||
|
||||
test("can render simple cases", async () => {
|
||||
env.qweb.addTemplates(`
|
||||
<templates>
|
||||
<div t-name="App">
|
||||
<Link to="'about'">About</Link>
|
||||
</div>
|
||||
</templates>
|
||||
`);
|
||||
class App extends Component<any, any, any> {
|
||||
components = { Link: Link };
|
||||
}
|
||||
|
||||
const routes = [{ name: "about", path: "/about" }, { name: "users", path: "/users" }];
|
||||
|
||||
router = new TestRouter(env, routes, { mode: "history" });
|
||||
router.navigate({ to: "users" });
|
||||
const app = new App(env);
|
||||
await app.mount(fixture);
|
||||
expect(fixture.innerHTML).toBe('<div><a href="/about">About</a></div>');
|
||||
|
||||
expect(window.location.pathname).toBe("/users");
|
||||
fixture.querySelector("a")!.click();
|
||||
await nextTick();
|
||||
expect(window.location.pathname).toBe("/about");
|
||||
expect(fixture.innerHTML).toBe(
|
||||
'<div><a href="/about" class="router-link-active">About</a></div>'
|
||||
);
|
||||
|
||||
expect(env.qweb.templates[LINK_TEMPLATE_NAME].fn.toString()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test("do not redirect if right clicking", async () => {
|
||||
env.qweb.addTemplates(`
|
||||
<templates>
|
||||
<div t-name="App">
|
||||
<Link to="'about'">About</Link>
|
||||
</div>
|
||||
</templates>
|
||||
`);
|
||||
class App extends Component<any, any, any> {
|
||||
components = { Link: Link };
|
||||
}
|
||||
|
||||
const routes = [{ name: "about", path: "/about" }, { name: "users", path: "/users" }];
|
||||
|
||||
router = new TestRouter(env, routes, { mode: "history" });
|
||||
router.navigate({ to: "users" });
|
||||
const app = new App(env);
|
||||
await app.mount(fixture);
|
||||
|
||||
expect(window.location.pathname).toBe("/users");
|
||||
var evt = new MouseEvent("click", {
|
||||
button: 1
|
||||
});
|
||||
|
||||
fixture.querySelector("a")!.dispatchEvent(evt);
|
||||
await nextTick();
|
||||
expect(window.location.pathname).toBe("/users");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Router } from "../../src/router/Router";
|
||||
import { QWeb } from "../../src/qweb/index";
|
||||
|
||||
export class TestRouter extends Router {
|
||||
destroy() {
|
||||
window.removeEventListener("popstate", (this as any)._listener);
|
||||
delete QWeb.DIRECTIVE_NAMES.routecomponent;
|
||||
QWeb.DIRECTIVES = QWeb.DIRECTIVES.filter(d => d.name !== "routecomponent");
|
||||
|
||||
// remove component defined inroutes
|
||||
for (let key in QWeb.components) {
|
||||
if (key.startsWith("__component__")) {
|
||||
delete QWeb.components[key];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`Link component can render simple cases 1`] = `
|
||||
"function anonymous(context,extra
|
||||
) {
|
||||
let utils = this.constructor.utils;
|
||||
let owner = context;
|
||||
var h = this.h;
|
||||
let _1 = utils.toObj({'router-link-active':context['isActive']});
|
||||
var _2 = context['href'];
|
||||
let c3 = [], p3 = {key:3,attrs:{href: _2},class:_1,on:{}};
|
||||
var vn3 = h('a', p3, c3);
|
||||
if (!context['navigate']) {
|
||||
throw new Error('Missing handler \\\\'' + 'navigate' + \`\\\\' when evaluating template '__owl__-router-link'\`)
|
||||
}
|
||||
extra.handlers['click' + 3] = extra.handlers['click' + 3] || context['navigate'].bind(owner);
|
||||
p3.on['click'] = extra.handlers['click' + 3];
|
||||
const slot4 = this.slots[context.__owl__.slotId + '_' + 'default'];
|
||||
if (slot4) {
|
||||
slot4(context.__owl__.parent, Object.assign({}, extra, {parentNode: c3, vars: extra.vars, parent: owner}));
|
||||
}
|
||||
return vn3;
|
||||
}"
|
||||
`;
|
||||
@@ -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.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.currentParams.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.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.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;
|
||||
} 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.currentRouteName==='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.currentRouteName==='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;
|
||||
}"
|
||||
`;
|
||||
@@ -0,0 +1,106 @@
|
||||
import { Component } from "../../src/component/component";
|
||||
import { RouterEnv } from "../../src/router/Router";
|
||||
import { makeTestEnv, makeTestFixture, nextTick } from "../helpers";
|
||||
import { TestRouter } from "./TestRouter";
|
||||
|
||||
describe("router directive t-routecomponent", () => {
|
||||
let fixture: HTMLElement;
|
||||
let env: RouterEnv;
|
||||
let router: TestRouter | null = null;
|
||||
|
||||
beforeEach(() => {
|
||||
fixture = makeTestFixture();
|
||||
env = <RouterEnv>makeTestEnv();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fixture.remove();
|
||||
if (router) {
|
||||
router.destroy();
|
||||
}
|
||||
router = null;
|
||||
});
|
||||
|
||||
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 }
|
||||
];
|
||||
|
||||
router = new TestRouter(env, routes, { mode: "history" });
|
||||
await router.navigate({ to: "about" });
|
||||
const app = new App(env);
|
||||
await app.mount(fixture);
|
||||
expect(fixture.innerHTML).toBe("<div><span>About</span></div>");
|
||||
|
||||
await router.navigate({ to: "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 }];
|
||||
router = new TestRouter(env, routes, { mode: "history" });
|
||||
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>");
|
||||
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 }];
|
||||
router = new TestRouter(env, routes, { mode: "history" });
|
||||
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>");
|
||||
expect(env.qweb.templates.App.fn.toString()).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,180 @@
|
||||
import { Destination, Router, RouterEnv, Route } from "../../src/router/Router";
|
||||
import { makeTestEnv } from "../helpers";
|
||||
import { TestRouter } from "./TestRouter";
|
||||
|
||||
let env: RouterEnv;
|
||||
let router: TestRouter | null = null;
|
||||
|
||||
beforeEach(() => {
|
||||
env = <RouterEnv>makeTestEnv();
|
||||
window.history.pushState({}, "/", "/");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (router) {
|
||||
router.destroy();
|
||||
}
|
||||
router = null;
|
||||
});
|
||||
|
||||
describe("router miscellaneous", () => {
|
||||
test("validate routes shape", () => {
|
||||
expect(() => {
|
||||
router = new TestRouter(env, [
|
||||
{ name: "someroute", path: "/some/path", redirect: { abc: "hey" } as Destination }
|
||||
]);
|
||||
}).toThrow(`Invalid destination: {"abc":"hey"}`);
|
||||
});
|
||||
});
|
||||
|
||||
describe("routeToPath", () => {
|
||||
const routeToPath = Router.prototype["routeToPath"];
|
||||
test("simple non parameterized path", () => {
|
||||
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(routeToPath({path: "/abc/{{def}}"} as Route, { def: 34 })).toBe("/abc/34");
|
||||
});
|
||||
});
|
||||
|
||||
describe("destToPath", () => {
|
||||
test("validate destination shape", () => {
|
||||
router = new TestRouter(env, [{ name: "someroute", path: "/some/path" }]);
|
||||
expect(() => {
|
||||
router!.destToPath({ abc: 123 } as Destination);
|
||||
}).toThrow('Invalid destination: {"abc":123}');
|
||||
|
||||
expect(() => {
|
||||
router!.destToPath({ to: "someroute" } as Destination);
|
||||
}).not.toThrow();
|
||||
|
||||
expect(() => {
|
||||
router!.destToPath({ path: "/someroute", to: "otherroute" } as Destination);
|
||||
}).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getRouteParams", () => {
|
||||
const getRouteParams = Router.prototype["getRouteParams"];
|
||||
test("properly match simple routes", () => {
|
||||
// simple route
|
||||
expect(getRouteParams({path: "/home"} as Route, "/home")).toEqual({});
|
||||
|
||||
// no match
|
||||
expect(getRouteParams({path: "/home"} as Route, "/otherpath")).toEqual(false);
|
||||
|
||||
// fallback route
|
||||
expect(getRouteParams({path: "*"} as Route, "somepath")).toEqual({});
|
||||
});
|
||||
|
||||
test("match some parameterized routes", () => {
|
||||
expect(getRouteParams({path: "/invoices/{{id}}"} as Route, "/invoices/3")).toEqual({
|
||||
id: "3"
|
||||
});
|
||||
});
|
||||
|
||||
test("can convert to number if needed", () => {
|
||||
expect(getRouteParams({path: "/invoices/{{id.number}}"} as Route, "/invoices/3")).toEqual({
|
||||
id: 3
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("redirect", () => {
|
||||
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" } }
|
||||
]);
|
||||
|
||||
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", 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);
|
||||
|
||||
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");
|
||||
const result = await router.navigate({ to: "routeb" });
|
||||
expect(result).toBe(false);
|
||||
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();
|
||||
const result = await router.navigate({ to: "routea" });
|
||||
expect(result).toBe(true);
|
||||
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");
|
||||
});
|
||||
|
||||
test("navigation is initially redirected if guard decides so", async () => {
|
||||
expect(window.location.pathname).toBe("/");
|
||||
const guard = jest.fn(() => {return {to: "otherroute"}});
|
||||
router = new TestRouter(env, [
|
||||
{ name: "landing", path: "/", beforeRouteEnter: guard},
|
||||
{ name: "otherroute", path: "/some/other/route"}
|
||||
]);
|
||||
|
||||
expect(window.location.pathname).toBe("/");
|
||||
|
||||
await router.start();
|
||||
expect(window.location.pathname).toBe("/some/other/route");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user