fix(ion-router): fixes routing algorithm

This commit is contained in:
Manu Mtz.-Almeida
2018-03-06 22:35:47 +01:00
parent dc56a598b1
commit c8a27b7a19
15 changed files with 840 additions and 480 deletions

View File

@@ -1,229 +0,0 @@
export interface NavOutlet {
setRouteId(id: any, data: any, direction: number): Promise<boolean>;
getRouteId(): string;
getContentElement(): HTMLElement | null;
}
export type NavOutletElement = NavOutlet & HTMLStencilElement;
export interface RouterEntry {
id: any;
path: string[];
subroutes: RouterEntries;
props?: any;
}
export type RouterEntries = RouterEntry[];
export class RouterSegments {
constructor(
private path: string[]
) {}
next(): string {
if (this.path.length > 0) {
return this.path.shift() as string;
}
return '';
}
}
export function writeNavState(root: HTMLElement, chain: RouterEntries, index: number, direction: number): Promise<void> {
if (index >= chain.length) {
return Promise.resolve();
}
const route = chain[index];
const node = breadthFirstSearch(root);
if (!node) {
return Promise.resolve();
}
return node.componentOnReady()
.then(() => node.setRouteId(route.id, route.props, direction))
.then(changed => {
if (changed) {
direction = 0;
}
const nextEl = node.getContentElement();
if (nextEl) {
return writeNavState(nextEl, chain, index + 1, direction);
}
return null;
});
}
export function readNavState(node: HTMLElement) {
const stack: string[] = [];
let pivot: NavOutlet|null;
while (true) {
pivot = breadthFirstSearch(node);
if (pivot) {
const cmp = pivot.getRouteId();
if (cmp) {
node = pivot.getContentElement();
stack.push(cmp.toLowerCase());
} else {
break;
}
} else {
break;
}
}
return {
stack: stack,
pivot: pivot,
};
}
export function matchPath(stack: string[], routes: RouterEntries) {
const path: string[] = [];
for (const id of stack) {
const route = routes.find(r => r.id === id);
if (route) {
path.push(...route.path);
routes = route.subroutes;
} else {
break;
}
}
return {
path: path,
routes: routes,
};
}
export function matchRouteChain(path: string[], routes: RouterEntries): RouterEntries {
const chain = [];
const segments = new RouterSegments(path);
while (routes.length > 0) {
const route = matchRoute(segments, routes);
if (!route) {
break;
}
chain.push(route);
routes = route.subroutes;
}
return chain;
}
export function matchRoute(segments: RouterSegments, routes: RouterEntries): RouterEntry | null {
if (!routes) {
return null;
}
let index = 0;
let selectedRoute: RouterEntry|null = null;
let ambiguous = false;
let segment: string;
let l: number;
while (true) {
routes = routes.filter(r => r.path.length > index);
if (routes.length === 0) {
break;
}
segment = segments.next();
routes = routes.filter(r => r.path[index] === segment);
l = routes.length;
if (l === 0) {
selectedRoute = null;
ambiguous = false;
} else {
selectedRoute = routes[0];
ambiguous = l > 1;
}
index++;
}
if (ambiguous) {
throw new Error('ambiguious match');
}
return selectedRoute;
}
export function readRoutes(root: Element): RouterEntries {
return (Array.from(root.children) as HTMLIonRouteElement[])
.filter(el => el.tagName === 'ION-ROUTE')
.map(el => ({
path: parsePath(el.path),
id: el.component,
props: el.props,
subroutes: readRoutes(el)
}));
}
export function generatePath(segments: string[]): string {
const path = segments
.filter(s => s.length > 0)
.join('/');
return '/' + path;
}
export function parsePath(path: string): string[] {
if (path === null || path === undefined) {
return [''];
}
const segments = path.split('/')
.map(s => s.trim())
.filter(s => s.length > 0);
if (segments.length === 0) {
return [''];
} else {
return segments;
}
}
const navs = ['ION-NAV', 'ION-TABS'];
export function breadthFirstSearch(root: HTMLElement): NavOutletElement | null {
if (!root) {
console.error('search root is null');
return null;
}
// we do a Breadth-first search
// Breadth-first search (BFS) is an algorithm for traversing or searching tree
// or graph data structures.It starts at the tree root(or some arbitrary node of a graph,
// sometimes referred to as a 'search key'[1]) and explores the neighbor nodes
// first, before moving to the next level neighbours.
const queue = [root];
let node: HTMLElement | undefined;
while (node = queue.shift()) {
// visit node
if (navs.indexOf(node.tagName) >= 0) {
return node as NavOutletElement;
}
// queue children
const children = node.children;
for (let i = 0; i < children.length; i++) {
queue.push(children[i] as NavOutletElement);
}
}
return null;
}
export function writePath(history: History, base: string, usePath: boolean, path: string[], isPop: boolean, state: number) {
path = [base, ...path];
let url = generatePath(path);
if (usePath) {
url = '#' + url;
}
state++;
if (isPop) {
history.back();
history.replaceState(state, null, url);
} else {
history.pushState(state, null, url);
}
return state;
}
export function readPath(loc: Location, base: string, useHash: boolean): string[] | null {
const path = useHash
? loc.hash.substr(1)
: loc.pathname;
if (path.startsWith(base)) {
return parsePath(path.slice(base.length));
}
return null;
}

View File

@@ -1,6 +1,10 @@
import { Component, Element, Listen, Prop } from '@stencil/core';
import { RouterEntries, matchPath, matchRouteChain, readNavState, readPath, readRoutes, writeNavState, writePath } from './router-utils';
import { Config, DomController } from '../../index';
import { flattenRouterTree, readRoutes } from './utils/parser';
import { readNavState, writeNavState } from './utils/dom';
import { chainToPath, readPath, writePath } from './utils/path';
import { RouteChain } from './utils/interfaces';
import { routerIDsToChain, routerPathToChain } from './utils/matching';
@Component({
@@ -8,7 +12,7 @@ import { Config, DomController } from '../../index';
})
export class Router {
private routes: RouterEntries;
private routes: RouteChain[];
private busy = false;
private state = 0;
@@ -22,7 +26,8 @@ export class Router {
componentDidLoad() {
// read config
this.routes = readRoutes(this.el);
const tree = readRoutes(this.el);
this.routes = flattenRouterTree(tree);
// perform first write
this.dom.raf(() => {
@@ -49,16 +54,16 @@ export class Router {
return;
}
console.debug('[IN] nav changed -> update URL');
const { stack, pivot } = this.readNavState();
const { path, routes } = matchPath(stack, this.routes);
if (pivot) {
const { ids, pivot } = this.readNavState();
const { chain, matches } = routerIDsToChain(ids, this.routes);
if (chain.length > matches) {
// readNavState() found a pivot that is not initialized
console.debug('[IN] pivot uninitialized -> write partial nav state');
this.writeNavState(pivot, [], routes, 0);
this.writeNavState(pivot, chain.slice(matches), 0);
}
const isPop = ev.detail.isPop === true;
this.writePath(path, isPop);
this.writePath(chain, isPop);
}
private writeNavStateRoot(): Promise<any> {
@@ -66,14 +71,13 @@ export class Router {
const currentPath = this.readPath();
const direction = window.history.state >= this.state ? 1 : -1;
if (currentPath) {
return this.writeNavState(node, currentPath, this.routes, direction);
const {chain} = routerPathToChain(currentPath, this.routes);
return this.writeNavState(node, chain, direction);
}
return Promise.resolve();
}
private writeNavState(node: any, path: string[], routes: RouterEntries, direction: number): Promise<any> {
const chain = matchRouteChain(path, routes);
private writeNavState(node: any, chain: RouteChain, direction: number): Promise<any> {
this.busy = true;
return writeNavState(node, chain, 0, direction)
.catch(err => console.error(err))
@@ -85,7 +89,8 @@ export class Router {
return readNavState(root);
}
private writePath(path: string[], isPop: boolean) {
private writePath(chain: RouteChain, isPop: boolean) {
const path = chainToPath(chain);
this.state = writePath(window.history, this.base, this.useHash, path, isPop, this.state);
}

View File

@@ -0,0 +1,47 @@
import { RouterSegments, breadthFirstSearch } from '../utils/common';
describe('RouterSegments', () => {
it ('should initialize with empty array', () => {
const s = new RouterSegments([]);
expect(s.next()).toEqual('');
expect(s.next()).toEqual('');
expect(s.next()).toEqual('');
expect(s.next()).toEqual('');
expect(s.next()).toEqual('');
});
it ('should initialize with array', () => {
const s = new RouterSegments(['', 'path', 'to', 'destination']);
expect(s.next()).toEqual('');
expect(s.next()).toEqual('path');
expect(s.next()).toEqual('to');
expect(s.next()).toEqual('destination');
expect(s.next()).toEqual('');
expect(s.next()).toEqual('');
});
});
describe('breadthFirstSearch', () => {
it('should search in order', () => {
const n1 = { tagName: 'ION-TABS', children: [] as any };
const n2 = { tagName: 'DIV', children: [n1] };
const n3 = { tagName: 'ION-NAV', children: [n2] };
const n4 = { tagName: 'ION-TABS', children: [] as any };
const n5 = { tagName: 'DIV', children: [n4] };
const n6 = { tagName: 'DIV', children: [n5, n3] };
const n7 = { tagName: 'DIV', children: [] as any };
const n8 = { tagName: 'DIV', children: [n6] };
const n9 = { tagName: 'DIV', children: [n8, n7] };
expect(breadthFirstSearch(n9 as any)).toBe(n3);
expect(breadthFirstSearch(n8 as any)).toBe(n3);
expect(breadthFirstSearch(n7 as any)).toBe(null);
expect(breadthFirstSearch(n6 as any)).toBe(n3);
expect(breadthFirstSearch(n5 as any)).toBe(n4);
expect(breadthFirstSearch(n4 as any)).toBe(n4);
expect(breadthFirstSearch(n3 as any)).toBe(n3);
expect(breadthFirstSearch(n2 as any)).toBe(n1);
expect(breadthFirstSearch(n1 as any)).toBe(n1);
});
});

View File

@@ -0,0 +1,65 @@
import { RouteChain } from '../utils/interfaces';
import { routerIDsToChain, routerPathToChain } from '../utils/matching';
import { mockRouteElement } from './parser.spec';
import { chainToPath, generatePath, parsePath } from '../utils/path';
import { flattenRouterTree, readRoutes } from '../utils/parser';
import { mockElement } from '@stencil/core/dist/testing';
describe('ionic-conference-app', () => {
it('should match conference-app routing', () => {
const root = conferenceAppRouting();
const tree = readRoutes(root);
const routes = flattenRouterTree(tree);
expect(getRouteIDs('/', routes)).toEqual(['page-tabs', 'tab-schedule', 'page-schedule']);
expect(getRouteIDs('/speaker', routes)).toEqual(['page-tabs', 'tab-speaker', 'page-speaker-list']);
expect(getRouteIDs('/map', routes)).toEqual(['page-tabs', 'page-map']);
expect(getRouteIDs('/about', routes)).toEqual(['page-tabs', 'page-about']);
expect(getRouteIDs('/tutorial', routes)).toEqual(['page-tutorial']);
expect(getRoutePaths(['page-tabs', 'tab-schedule', 'page-schedule'], routes)).toEqual('/');
expect(getRoutePaths(['page-tabs', 'tab-speaker', 'page-speaker-list'], routes)).toEqual('/speaker');
expect(getRoutePaths(['page-tabs', 'page-map'], routes)).toEqual('/map');
expect(getRoutePaths(['page-tabs', 'page-about'], routes)).toEqual('/about');
expect(getRoutePaths(['page-tutorial'], routes)).toEqual('/tutorial');
});
});
function conferenceAppRouting() {
const p2 = mockRouteElement('/', 'tab-schedule');
const p3 = mockRouteElement('/', 'page-schedule');
p2.appendChild(p3);
const p4 = mockRouteElement('/speaker', 'tab-speaker');
const p5 = mockRouteElement('/', 'page-speaker-list');
p4.appendChild(p5);
const p6 = mockRouteElement('/map', 'page-map');
const p7 = mockRouteElement('/about', 'page-about');
const p1 = mockRouteElement('/', 'page-tabs');
p1.appendChild(p2);
p1.appendChild(p4);
p1.appendChild(p6);
p1.appendChild(p7);
const p8 = mockRouteElement('/tutorial', 'page-tutorial');
const container = mockElement('div');
container.appendChild(p1);
container.appendChild(p8);
return container;
}
function getRouteIDs(path: string, routes: RouteChain[]): string[] {
return routerPathToChain(parsePath(path), routes).chain.map(r => r.id);
}
function getRoutePaths(ids: string[], routes: RouteChain[]): string {
return generatePath(chainToPath(routerIDsToChain(ids, routes).chain));
}

View File

@@ -0,0 +1,279 @@
import { RouteChain } from '../utils/interfaces';
import { matchesIDs, matchesPath, routerPathToChain } from '../utils/matching';
import { mockRouteElement } from './parser.spec';
import { mockElement } from '@stencil/core/dist/testing';
const CHAIN_1: RouteChain = [
{ id: '2', path: ['to'], props: undefined },
{ id: '1', path: ['path'], props: undefined },
{ id: '3', path: ['segment'], props: undefined },
{ id: '4', path: [''], props: undefined },
];
const CHAIN_2: RouteChain = [
{ id: '2', path: [''], props: undefined },
{ id: '1', path: [''], props: undefined },
{ id: '3', path: ['segment', 'to'], props: undefined },
{ id: '4', path: [''], props: undefined },
{ id: '5', path: ['hola'], props: undefined },
{ id: '6', path: [''], props: undefined },
{ id: '7', path: [''], props: undefined },
{ id: '8', path: ['adios', 'que', 'tal'], props: undefined },
];
const CHAIN_3: RouteChain = [
{ id: '2', path: ['this', 'to'], props: undefined },
{ id: '1', path: ['path'], props: undefined },
{ id: '3', path: ['segment', 'to', 'element'], props: undefined },
{ id: '4', path: [''], props: undefined },
];
describe('matchesIDs', () => {
it('should match simple set of ids', () => {
const chain: RouteChain = CHAIN_1;
expect(matchesIDs(['2'], chain)).toBe(1);
expect(matchesIDs(['2', '1'], chain)).toBe(2);
expect(matchesIDs(['2', '1', '3'], chain)).toBe(3);
expect(matchesIDs(['2', '1', '3', '4'], chain)).toBe(4);
expect(matchesIDs(['2', '1', '3', '4', '5'], chain)).toBe(4);
expect(matchesIDs([], chain)).toBe(0);
expect(matchesIDs(['1'], chain)).toBe(0);
});
});
describe('matchesPath', () => {
it('should match simple path', () => {
const chain: RouteChain = CHAIN_3;
expect(matchesPath(['this'], chain)).toBe(false);
expect(matchesPath(['this', 'to'], chain)).toBe(false);
expect(matchesPath(['this', 'to', 'path'], chain)).toBe(false);
expect(matchesPath(['this', 'to', 'path', 'segment'], chain)).toBe(false);
expect(matchesPath(['this', 'to', 'path', 'segment', 'to'], chain)).toBe(false);
expect(matchesPath(['this', 'to', 'path', 'segment', 'to', 'element'], chain)).toBe(true);
expect(matchesPath(['this', 'to', 'path', 'segment', 'to', 'element', 'more'], chain)).toBe(false);
expect(matchesPath([], chain)).toBe(false);
expect(matchesPath([''], chain)).toBe(false);
expect(matchesPath(['path'], chain)).toBe(false);
});
it('should match simple default route', () => {
const chain: RouteChain = CHAIN_2;
expect(matchesPath([''], chain)).toBe(false);
expect(matchesPath(['segment'], chain)).toBe(false);
expect(matchesPath(['segment', 'to'], chain)).toBe(false);
expect(matchesPath(['segment', 'to', 'hola'], chain)).toBe(false);
expect(matchesPath(['segment', 'to', 'hola', 'adios'], chain)).toBe(false);
expect(matchesPath(['segment', 'to', 'hola', 'adios', 'que'], chain)).toBe(false);
expect(matchesPath(['segment', 'to', 'hola', 'adios', 'que', 'tal'], chain)).toBe(true);
expect(matchesPath(['to'], chain)).toBe(false);
expect(matchesPath(['path', 'to'], chain)).toBe(false);
});
it('should match simple route 2', () => {
const chain: RouteChain = [{ id: '5', path: ['hola'], props: undefined }];
expect(matchesPath([''], chain)).toBe(false);
expect(matchesPath(['hola'], chain)).toBe(true);
expect(matchesPath(['hola', 'hola'], chain)).toBe(true);
expect(matchesPath(['hola', 'adios'], chain)).toBe(true);
});
it('should match simple route 3', () => {
const chain: RouteChain = [{ id: '5', path: ['hola', 'adios'], props: undefined }];
expect(matchesPath([''], chain)).toBe(false);
expect(matchesPath(['hola'], chain)).toBe(false);
expect(matchesPath(['hola', 'hola'], chain)).toBe(false);
expect(matchesPath(['hola', 'adios'], chain)).toBe(true);
});
it('should match simple route 4', () => {
const chain: RouteChain = [
{ id: '5', path: ['hola'], props: undefined },
{ id: '5', path: ['adios'], props: undefined }];
expect(matchesPath([''], chain)).toBe(false);
expect(matchesPath(['hola'], chain)).toBe(false);
expect(matchesPath(['hola', 'hola'], chain)).toBe(false);
expect(matchesPath(['hola', 'adios'], chain)).toBe(true);
});
});
describe('routerPathToChain', () => {
it('should match the route with higher priority', () => {
const chain3: RouteChain = [{ id: '5', path: ['hola'], props: undefined }];
const chain4: RouteChain = [
{ id: '5', path: ['hola'], props: undefined },
{ id: '5', path: ['adios'], props: undefined }];
const routes: RouteChain[] = [
CHAIN_1,
CHAIN_2,
chain3,
chain4
];
expect(routerPathToChain(['to'], routes)).toEqual({
chain: null,
matches: 0,
});
expect(routerPathToChain([''], routes)).toEqual({
chain: null,
matches: 0,
});
expect(routerPathToChain(['segment', 'to'], routes)).toEqual({
chain: null,
matches: 0,
});
expect(routerPathToChain(['hola'], routes)).toEqual({
chain: chain3,
matches: 1,
});
expect(routerPathToChain(['hola', 'hola'], routes)).toEqual({
chain: chain3,
matches: 1,
});
expect(routerPathToChain(['hola', 'adios'], routes)).toEqual({
chain: chain4,
matches: 2,
});
});
it('should match the default route', () => {
const chain1: RouteChain = [
{ id: 'tabs', path: [''], props: undefined },
{ id: 'tab1', path: [''], props: undefined },
{ id: 'schedule', path: [''], props: undefined }
];
const chain2: RouteChain = [
{ id: 'tabs', path: [''], props: undefined },
{ id: 'tab2', path: ['tab2'], props: undefined },
{ id: 'page2', path: [''], props: undefined }
];
expect(routerPathToChain([''], [chain1])).toEqual({chain: chain1, matches: 3});
expect(routerPathToChain(['tab2'], [chain1])).toEqual({chain: null, matches: 0});
expect(routerPathToChain([''], [chain2])).toEqual({chain: null, matches: 0});
expect(routerPathToChain(['tab2'], [chain2])).toEqual({chain: chain2, matches: 3});
});
});
// describe('matchRoute', () => {
// it('should match simple route', () => {
// const path = ['path', 'to', 'component'];
// const routes: RouteChain[] = [
// [{ id: 2, path: ['to'], props: undefined }],
// [{ id: 1, path: ['path'], props: undefined }],
// [{ id: 3, path: ['segment'], props: undefined }],
// [{ id: 4, path: [''], props: undefined }],
// ];
// const match = routerPathToChain(path, routes);
// expect(match).toEqual({ id: 1, path: ['path'], children: [] });
// expect(seg.next()).toEqual('to');
// });
// it('should match default route', () => {
// const routes: RouteTree = [
// { id: 2, path: ['to'], children: [], props: undefined },
// { id: 1, path: ['path'], children: [], props: undefined },
// { id: 3, path: ['segment'], children: [], props: undefined },
// { id: 4, path: [''], children: [], props: undefined },
// ];
// const seg = new RouterSegments(['hola', 'path']);
// let match = matchRoute(seg, routes);
// expect(match).toBeNull();
// match = matchRoute(seg, routes);
// expect(match.id).toEqual(1);
// for (let i = 0; i < 20; i++) {
// match = matchRoute(seg, routes);
// expect(match.id).toEqual(4);
// }
// });
// it('should not match any route', () => {
// const routes: RouteTree = [
// { id: 2, path: ['to', 'to', 'to'], children: [], props: undefined },
// { id: 1, path: ['adam', 'manu'], children: [], props: undefined },
// { id: 3, path: ['hola', 'adam'], children: [], props: undefined },
// { id: 4, path: [''], children: [], props: undefined },
// ];
// const seg = new RouterSegments(['hola', 'manu', 'adam']);
// const match = matchRoute(seg, routes);
// expect(match).toBeNull();
// });
// it('should not match if there are not routes', () => {
// const routes: RouteTree = [];
// const seg = new RouterSegments(['adam']);
// expect(matchRoute(seg, routes)).toBeNull();
// expect(matchRoute(seg, routes)).toBeNull();
// expect(matchRoute(seg, routes)).toBeNull();
// });
// it('should not match any route (2)', () => {
// const routes: RouteTree = [
// { id: 1, path: ['adam', 'manu'], children: [], props: undefined },
// { id: 3, path: ['hola', 'adam'], children: [], props: undefined },
// ];
// const seg = new RouterSegments(['adam']);
// expect(matchRoute(seg, routes)).toBeNull();
// expect(matchRoute(seg, routes)).toBeNull();
// expect(matchRoute(seg, routes)).toBeNull();
// });
// it ('should match multiple segments', () => {
// const routes: RouteTree = [
// { id: 1, path: ['adam', 'manu'], children: [], props: undefined },
// { id: 2, path: ['manu', 'hello'], children: [], props: undefined },
// { id: 3, path: ['hello'], children: [], props: undefined },
// { id: 4, path: [''], children: [], props: undefined },
// ];
// const seg = new RouterSegments(['adam', 'manu', 'hello', 'manu', 'hello']);
// let match = matchRoute(seg, routes);
// expect(match.id).toEqual(1);
// match = matchRoute(seg, routes);
// expect(match.id).toEqual(3);
// match = matchRoute(seg, routes);
// expect(match.id).toEqual(2);
// match = matchRoute(seg, routes);
// expect(match.id).toEqual(4);
// match = matchRoute(seg, routes);
// expect(match.id).toEqual(4);
// });
// it('should match long multi segments', () => {
// const routes: RouteTree = [
// { id: 1, path: ['adam', 'manu', 'hello', 'menu', 'hello'], children: [], props: undefined },
// { id: 2, path: ['adam', 'manu', 'hello', 'menu'], children: [], props: undefined },
// { id: 3, path: ['adam', 'manu'], children: [], props: undefined },
// ];
// const seg = new RouterSegments(['adam', 'manu', 'hello', 'menu', 'hello']);
// const match = matchRoute(seg, routes);
// expect(match.id).toEqual(1);
// expect(matchRoute(seg, routes)).toBeNull();
// });
// it('should match long multi segments', () => {
// let match = matchRoute(new RouterSegments(['']), null);
// expect(match).toBeNull();
// match = matchRoute(new RouterSegments(['hola']), null);
// expect(match).toBeNull();
// });
// });

View File

@@ -0,0 +1,66 @@
import { mockElement } from '@stencil/core/testing';
import { flattenRouterTree, readRoutes } from '../utils/parser';
import { RouteTree } from '../utils/interfaces';
describe('readRoutes', () => {
it('should read URL', () => {
const root = mockElement('div');
const r1 = mockRouteElement('/', 'main-page');
const r2 = mockRouteElement('/one-page', 'one-page');
const r3 = mockRouteElement('secondpage', 'second-page');
const r4 = mockRouteElement('/5/hola', '4');
const r5 = mockRouteElement('/path/to/five', '5');
const r6 = mockRouteElement('/path/to/five2', '6');
root.appendChild(r1);
root.appendChild(r2);
root.appendChild(r3);
r3.appendChild(r4);
r4.appendChild(r5);
r4.appendChild(r6);
const expected: RouteTree = [
{ path: [''], id: 'main-page', children: [], props: undefined },
{ path: ['one-page'], id: 'one-page', children: [], props: undefined },
{ path: ['secondpage'], id: 'second-page', props: undefined, children: [
{ path: ['5', 'hola'], id: '4', props: undefined, children: [
{ path: ['path', 'to', 'five'], id: '5', children: [], props: undefined },
{ path: ['path', 'to', 'five2'], id: '6', children: [], props: undefined }
] }
] }
];
expect(readRoutes(root)).toEqual(expected);
});
});
describe('flattenRouterTree', () => {
it('should process routes', () => {
const entries: RouteTree = [
{ path: [''], id: 'hola', children: [], props: undefined },
{ path: ['one-page'], id: 'one-page', children: [], props: undefined },
{ path: ['secondpage'], id: 'second-page', props: undefined, children: [
{ path: ['5', 'hola'], id: '4', props: undefined, children: [
{ path: ['path', 'to', 'five'], id: '5', children: [], props: undefined },
{ path: ['path', 'to', 'five2'], id: '6', children: [], props: undefined }
] }
] }
];
const routes = flattenRouterTree(entries);
expect(routes).toEqual([
[{ path: [''], id: 'hola' }],
[{ path: ['one-page'], id: 'one-page' }],
[{ path: ['secondpage'], id: 'second-page'}, { path: ['5', 'hola'], id: '4'}, { path: ['path', 'to', 'five'], id: '5'}],
[{ path: ['secondpage'], id: 'second-page'}, { path: ['5', 'hola'], id: '4'}, { path: ['path', 'to', 'five2'], id: '6'}],
]);
});
});
export function mockRouteElement(path: string, component: string) {
const el = mockElement('ion-route');
el.setAttribute('path', path);
(el as any).component = component;
return el;
}

View File

@@ -0,0 +1,55 @@
import { generatePath, parsePath } from '../utils/path';
describe('parseURL', () => {
it('should parse empty path', () => {
expect(parsePath('')).toEqual(['']);
});
it('should parse empty path (2)', () => {
expect(parsePath(' ')).toEqual(['']);
});
it('should parse null path', () => {
expect(parsePath(null)).toEqual(['']);
});
it('should parse undefined path', () => {
expect(parsePath(undefined)).toEqual(['']);
});
it('should parse relative path', () => {
expect(parsePath('path/to/file.js')).toEqual(['path', 'to', 'file.js']);
});
it('should parse absolute path', () => {
expect(parsePath('/path/to/file.js')).toEqual(['path', 'to', 'file.js']);
});
it('should parse relative path', () => {
expect(parsePath('/PATH///to//file.js//')).toEqual(['PATH', 'to', 'file.js']);
});
});
describe('generatePath', () => {
it('should generate an empty URL', () => {
expect(generatePath([])).toEqual('/');
expect(generatePath([{ path: '' } as any])).toEqual('/');
expect(generatePath([{ path: '/' } as any])).toEqual('/');
expect(generatePath([{ path: '//' } as any])).toEqual('/');
expect(generatePath([{ path: ' ' } as any])).toEqual('/');
});
it('should genenerate a basic url', () => {
const stack = [
'',
'',
'',
'path/to',
'page',
'number-TWO',
''
];
expect(generatePath(stack)).toEqual('/path/to/page/number-TWO');
});
});

View File

@@ -1,235 +0,0 @@
import {
RouterEntries, RouterSegments, breadthFirstSearch,
generatePath, matchRoute, parsePath
} from '../router-utils';
describe('RouterSegments', () => {
it ('should initialize with empty array', () => {
const s = new RouterSegments([]);
expect(s.next()).toEqual('');
expect(s.next()).toEqual('');
expect(s.next()).toEqual('');
expect(s.next()).toEqual('');
expect(s.next()).toEqual('');
});
it ('should initialize with array', () => {
const s = new RouterSegments(['', 'path', 'to', 'destination']);
expect(s.next()).toEqual('');
expect(s.next()).toEqual('path');
expect(s.next()).toEqual('to');
expect(s.next()).toEqual('destination');
expect(s.next()).toEqual('');
expect(s.next()).toEqual('');
});
});
describe('parseURL', () => {
it('should parse empty path', () => {
expect(parsePath('')).toEqual(['']);
});
it('should parse empty path (2)', () => {
expect(parsePath(' ')).toEqual(['']);
});
it('should parse null path', () => {
expect(parsePath(null)).toEqual(['']);
});
it('should parse undefined path', () => {
expect(parsePath(undefined)).toEqual(['']);
});
it('should parse relative path', () => {
expect(parsePath('path/to/file.js')).toEqual(['path', 'to', 'file.js']);
});
it('should parse absolute path', () => {
expect(parsePath('/path/to/file.js')).toEqual(['path', 'to', 'file.js']);
});
it('should parse relative path', () => {
expect(parsePath('/PATH///to//file.js//')).toEqual(['PATH', 'to', 'file.js']);
});
});
// describe('readRoutes', () => {
// it('should read URL', () => {
// const node = (<div>
// <ion-route path='/' component='main-page'/>
// <ion-route path='/one-page' component='one-page'/>
// <ion-route path='secondpage' component='second-page'/>
// <ion-route path='/5/hola' component='4'/>
// <ion-route path='path/to/five' component='5'/>
// </div>) as any;
// node.children = node.vchildren;
// expect(readRoutes(node)).toEqual([
// { path: [''], id: 'hola', subroutes: [] },
// { path: ['one-page'], id: 'one-page', subroutes: [] },
// { path: ['secondpage'], id: 'second-page', subroutes: [] },
// { path: ['5', 'hola'], id: '4', subroutes: [] },
// { path: ['path', 'to', 'five'], id: '5', subroutes: [] }
// ]);
// });
// });
describe('matchRoute', () => {
it('should match simple route', () => {
const seg = new RouterSegments(['path', 'to', 'component']);
const routes: RouterEntries = [
{ id: 2, path: ['to'], subroutes: [] },
{ id: 1, path: ['path'], subroutes: [] },
{ id: 3, path: ['segment'], subroutes: [] },
{ id: 4, path: [''], subroutes: [] },
];
const match = matchRoute(seg, routes);
expect(match).toEqual({ id: 1, path: ['path'], subroutes: [] });
expect(seg.next()).toEqual('to');
});
it('should match default route', () => {
const routes: RouterEntries = [
{ id: 2, path: ['to'], subroutes: [] },
{ id: 1, path: ['path'], subroutes: [] },
{ id: 3, path: ['segment'], subroutes: [] },
{ id: 4, path: [''], subroutes: [] },
];
const seg = new RouterSegments(['hola', 'path']);
let match = matchRoute(seg, routes);
expect(match).toBeNull();
match = matchRoute(seg, routes);
expect(match.id).toEqual(1);
for (let i = 0; i < 20; i++) {
match = matchRoute(seg, routes);
expect(match.id).toEqual(4);
}
});
it('should not match any route', () => {
const routes: RouterEntries = [
{ id: 2, path: ['to', 'to', 'to'], subroutes: [] },
{ id: 1, path: ['adam', 'manu'], subroutes: [] },
{ id: 3, path: ['hola', 'adam'], subroutes: [] },
{ id: 4, path: [''], subroutes: [] },
];
const seg = new RouterSegments(['hola', 'manu', 'adam']);
const match = matchRoute(seg, routes);
expect(match).toBeNull();
});
it('should not match if there are not routes', () => {
const routes: RouterEntries = [];
const seg = new RouterSegments(['adam']);
expect(matchRoute(seg, routes)).toBeNull();
expect(matchRoute(seg, routes)).toBeNull();
expect(matchRoute(seg, routes)).toBeNull();
});
it('should not match any route (2)', () => {
const routes: RouterEntries = [
{ id: 1, path: ['adam', 'manu'], subroutes: [] },
{ id: 3, path: ['hola', 'adam'], subroutes: [] },
];
const seg = new RouterSegments(['adam']);
expect(matchRoute(seg, routes)).toBeNull();
expect(matchRoute(seg, routes)).toBeNull();
expect(matchRoute(seg, routes)).toBeNull();
});
it ('should match multiple segments', () => {
const routes: RouterEntries = [
{ id: 1, path: ['adam', 'manu'], subroutes: [] },
{ id: 2, path: ['manu', 'hello'], subroutes: [] },
{ id: 3, path: ['hello'], subroutes: [] },
{ id: 4, path: [''], subroutes: [] },
];
const seg = new RouterSegments(['adam', 'manu', 'hello', 'manu', 'hello']);
let match = matchRoute(seg, routes);
expect(match.id).toEqual(1);
match = matchRoute(seg, routes);
expect(match.id).toEqual(3);
match = matchRoute(seg, routes);
expect(match.id).toEqual(2);
match = matchRoute(seg, routes);
expect(match.id).toEqual(4);
match = matchRoute(seg, routes);
expect(match.id).toEqual(4);
});
it('should match long multi segments', () => {
const routes: RouterEntries = [
{ id: 1, path: ['adam', 'manu', 'hello', 'menu', 'hello'], subroutes: [] },
{ id: 2, path: ['adam', 'manu', 'hello', 'menu'], subroutes: [] },
{ id: 3, path: ['adam', 'manu'], subroutes: [] },
];
const seg = new RouterSegments(['adam', 'manu', 'hello', 'menu', 'hello']);
const match = matchRoute(seg, routes);
expect(match.id).toEqual(1);
expect(matchRoute(seg, routes)).toBeNull();
});
it('should match long multi segments', () => {
let match = matchRoute(new RouterSegments(['']), null);
expect(match).toBeNull();
match = matchRoute(new RouterSegments(['hola']), null);
expect(match).toBeNull();
});
});
describe('generatePath', () => {
it('should generate an empty URL', () => {
expect(generatePath([])).toEqual('/');
expect(generatePath([{ path: '' } as any])).toEqual('/');
expect(generatePath([{ path: '/' } as any])).toEqual('/');
expect(generatePath([{ path: '//' } as any])).toEqual('/');
expect(generatePath([{ path: ' ' } as any])).toEqual('/');
});
it('should genenerate a basic url', () => {
const stack = [
'',
'',
'',
'path/to',
'page',
'number-TWO',
''
];
expect(generatePath(stack)).toEqual('/path/to/page/number-TWO');
});
});
describe('breadthFirstSearch', () => {
it('should search in order', () => {
const n1 = { tagName: 'ION-TABS', children: [] as any };
const n2 = { tagName: 'DIV', children: [n1] };
const n3 = { tagName: 'ION-NAV', children: [n2] };
const n4 = { tagName: 'ION-TABS', children: [] as any };
const n5 = { tagName: 'DIV', children: [n4] };
const n6 = { tagName: 'DIV', children: [n5, n3] };
const n7 = { tagName: 'DIV', children: [] as any };
const n8 = { tagName: 'DIV', children: [n6] };
const n9 = { tagName: 'DIV', children: [n8, n7] };
expect(breadthFirstSearch(n9 as any)).toBe(n3);
expect(breadthFirstSearch(n8 as any)).toBe(n3);
expect(breadthFirstSearch(n7 as any)).toBe(null);
expect(breadthFirstSearch(n6 as any)).toBe(n3);
expect(breadthFirstSearch(n5 as any)).toBe(n4);
expect(breadthFirstSearch(n4 as any)).toBe(n4);
expect(breadthFirstSearch(n3 as any)).toBe(n3);
expect(breadthFirstSearch(n2 as any)).toBe(n1);
expect(breadthFirstSearch(n1 as any)).toBe(n1);
});
});

View File

@@ -0,0 +1,51 @@
import { NavOutletElement } from './interfaces';
export class RouterSegments {
private path: string[];
constructor(path: string[]) {
this.path = path.slice();
}
isDefault(): boolean {
if (this.path.length > 0) {
return this.path[0] === '';
}
return true;
}
next(): string {
if (this.path.length > 0) {
return this.path.shift() as string;
}
return '';
}
}
const navs = ['ION-NAV', 'ION-TABS'];
export function breadthFirstSearch(root: HTMLElement): NavOutletElement | null {
if (!root) {
console.error('search root is null');
return null;
}
// we do a Breadth-first search
// Breadth-first search (BFS) is an algorithm for traversing or searching tree
// or graph data structures.It starts at the tree root(or some arbitrary node of a graph,
// sometimes referred to as a 'search key'[1]) and explores the neighbor nodes
// first, before moving to the next level neighbours.
const queue = [root];
let node: HTMLElement | undefined;
while (node = queue.shift()) {
// visit node
if (navs.indexOf(node.tagName) >= 0) {
return node as NavOutletElement;
}
// queue children
const children = node.children;
for (let i = 0; i < children.length; i++) {
queue.push(children[i] as NavOutletElement);
}
}
return null;
}

View File

@@ -0,0 +1,48 @@
import { breadthFirstSearch } from './common';
import { NavOutlet, RouteChain } from './interfaces';
export function writeNavState(root: HTMLElement, chain: RouteChain, index: number, direction: number): Promise<void> {
if (index >= chain.length) {
return Promise.resolve();
}
const route = chain[index];
const node = breadthFirstSearch(root);
if (!node) {
return Promise.resolve();
}
return node.componentOnReady()
.then(() => node.setRouteId(route.id, route.props, direction))
.then(changed => {
if (changed) {
direction = 0;
}
const nextEl = node.getContentElement();
if (nextEl) {
return writeNavState(nextEl, chain, index + 1, direction);
}
return null;
});
}
export function readNavState(node: HTMLElement) {
const stack: string[] = [];
let pivot: NavOutlet|null;
while (true) {
pivot = breadthFirstSearch(node);
if (pivot) {
const cmp = pivot.getRouteId();
if (cmp) {
node = pivot.getContentElement();
stack.push(cmp.toLowerCase());
} else {
break;
}
} else {
break;
}
}
return {
ids: stack,
pivot: pivot,
};
}

View File

@@ -0,0 +1,26 @@
export interface NavOutlet {
setRouteId(id: any, data: any, direction: number): Promise<boolean>;
getRouteId(): string;
getContentElement(): HTMLElement | null;
}
export interface RouteMatch {
chain: RouteChain;
matches: number;
}
export type NavOutletElement = NavOutlet & HTMLStencilElement;
export interface RouteEntry {
id: string;
path: string[];
props: any|undefined;
}
export interface RouteNode extends RouteEntry {
children: RouteTree;
}
export type RouteChain = RouteEntry[];
export type RouteTree = RouteNode[];

View File

@@ -0,0 +1,71 @@
import { RouterSegments } from './common';
import { RouteChain, RouteMatch } from './interfaces';
export function matchesIDs(ids: string[], chain: RouteChain): number {
const len = Math.min(ids.length, chain.length);
let i = 0;
for (; i < len; i++) {
if (ids[i] !== chain[i].id) {
break;
}
}
return i;
}
export function matchesPath(path: string[], chain: RouteChain): boolean {
const segments = new RouterSegments(path);
let matchesDefault = false;
for (let i = 0; i < chain.length; i++) {
const route = chain[i];
if (route.path[0] !== '') {
for (const segment of route.path) {
if (segments.next() !== segment) {
return false;
}
}
matchesDefault = false;
} else {
matchesDefault = true;
}
}
if (matchesDefault) {
return matchesDefault === segments.isDefault();
}
return true;
}
export function routerIDsToChain(ids: string[], chains: RouteChain[]): RouteMatch {
let match: RouteChain|null = null;
let maxMatches = 0;
for (const chain of chains) {
const score = matchesIDs(ids, chain);
if (score > maxMatches) {
match = chain;
maxMatches = score;
}
}
return {
chain: match,
matches: maxMatches,
};
}
export function routerPathToChain(path: string[], chains: RouteChain[]): RouteMatch|null {
let match: RouteChain = null;
let matches = 0;
for (const chain of chains) {
if (matchesPath(path, chain)) {
if (chain.length > matches) {
matches = chain.length;
match = chain;
}
}
}
return {
chain: match,
matches,
};
}

View File

@@ -0,0 +1,49 @@
import { RouteChain, RouteNode, RouteTree } from './interfaces';
import { parsePath } from './path';
export function readRoutes(root: Element): RouteTree {
return (Array.from(root.children) as HTMLIonRouteElement[])
.filter(el => el.tagName === 'ION-ROUTE')
.map(el => ({
path: parsePath(readProp(el, 'path')),
id: readProp(el, 'component'),
props: readProp(el, 'props'),
children: readRoutes(el)
}));
}
export function readProp(el: HTMLElement, prop: string): string|undefined {
if (prop in el) {
return (el as any)[prop];
}
if (el.hasAttribute(prop)) {
return el.getAttribute(prop);
}
return undefined;
}
export function flattenRouterTree(nodes: RouteTree): RouteChain[] {
const routes: RouteChain[] = [];
for (const node of nodes) {
flattenNode([], routes, node);
}
return routes;
}
function flattenNode(chain: RouteChain, routes: RouteChain[], node: RouteNode) {
const s = chain.slice();
s.push({
id: node.id,
path: node.path,
props: node.props
});
if (node.children.length === 0) {
routes.push(s);
return;
}
for (const sub of node.children) {
flattenNode(s, routes, sub);
}
}

View File

@@ -0,0 +1,62 @@
import { RouteChain } from './interfaces';
export function generatePath(segments: string[]): string {
const path = segments
.filter(s => s.length > 0)
.join('/');
return '/' + path;
}
export function chainToPath(chain: RouteChain): string[] {
const path = [];
for (const route of chain) {
if (route.path[0] !== '') {
path.push(...route.path);
}
}
return path;
}
export function writePath(history: History, base: string, usePath: boolean, path: string[], isPop: boolean, state: number) {
path = [base, ...path];
let url = generatePath(path);
if (usePath) {
url = '#' + url;
}
state++;
if (isPop) {
history.back();
history.replaceState(state, null, url);
} else {
history.pushState(state, null, url);
}
return state;
}
export function readPath(loc: Location, base: string, useHash: boolean): string[] | null {
const path = useHash
? loc.hash.substr(1)
: loc.pathname;
if (path.startsWith(base)) {
return parsePath(path.slice(base.length));
}
return null;
}
export function parsePath(path: string): string[] {
if (path === null || path === undefined) {
return [''];
}
const segments = path.split('/')
.map(s => s.trim())
.filter(s => s.length > 0);
if (segments.length === 0) {
return [''];
} else {
return segments;
}
}

View File

@@ -74,10 +74,10 @@ export { Range, RangeEvent } from './components/range/range';
export { RangeKnob } from './components/range-knob/range-knob';
export { ReorderGroup } from './components/reorder-group/reorder-group';
export {
RouterEntry,
RouterEntries,
RouteNode,
RouteTree,
NavOutlet
} from './components/router/router-utils';
} from './components/router/utils/interfaces';
export { Row } from './components/row/row';
export { Reorder } from './components/reorder/reorder';
export { Scroll } from './components/scroll/scroll';