feat(router): reverse lookup with params

This commit is contained in:
Manu Mtz.-Almeida
2018-03-08 13:12:50 +01:00
parent d1263a8b9c
commit 3ce8a67409
17 changed files with 167 additions and 133 deletions

View File

@ -25,6 +25,7 @@ import { Transition } from './transition';
import iosTransitionAnimation from './animations/ios.transition'; import iosTransitionAnimation from './animations/ios.transition';
import mdTransitionAnimation from './animations/md.transition'; import mdTransitionAnimation from './animations/md.transition';
import { RouteID } from '../router/utils/interfaces';
const TrnsCtrl = new TransitionController(); const TrnsCtrl = new TransitionController();
@ -215,16 +216,19 @@ export class NavControllerBase implements NavOutlet {
} }
@Method() @Method()
getRouteId(): string | null { getRouteId(): RouteID|null {
const element = this.getContentElement(); const active = this.getActive();
if (element) { if (active) {
return element.tagName; return {
id: active.element.tagName,
params: active.data
};
} }
return null; return null;
} }
@Method() @Method()
getContentElement(): HTMLElement { getContainerEl(): HTMLElement {
const active = this.getActive(); const active = this.getActive();
if (active) { if (active) {
return active.element; return active.element;

View File

@ -48,7 +48,7 @@ boolean
#### getByIndex() #### getByIndex()
#### getContentElement() #### getContainerEl()
#### getPrevious() #### getPrevious()

View File

@ -52,16 +52,20 @@ export class Router {
} }
console.debug('[IN] nav changed -> update URL'); console.debug('[IN] nav changed -> update URL');
const { ids, pivot } = this.readNavState(); const { ids, pivot } = this.readNavState();
const { chain, matches } = routerIDsToChain(ids, this.routes); const chain = routerIDsToChain(ids, this.routes);
if (chain.length > matches) { if (chain) {
// readNavState() found a pivot that is not initialized if (chain.length > ids.length) {
console.debug('[IN] pivot uninitialized -> write partial nav state'); // readNavState() found a pivot that is not initialized
this.writeNavState(pivot, chain.slice(matches), 0); console.debug('[IN] pivot uninitialized -> write partial nav state');
} this.writeNavState(pivot, chain.slice(ids.length), 0);
}
const isPop = ev.detail.isPop === true; const isPop = ev.detail.isPop === true;
const path = chainToPath(chain); const path = chainToPath(chain);
this.writePath(path, isPop); this.writePath(path, isPop);
} else {
console.warn('no matching URL for ', ids.map(i => i.id));
}
} }
@Method() @Method()
@ -80,7 +84,7 @@ export class Router {
} }
const direction = window.history.state >= this.state ? 1 : -1; const direction = window.history.state >= this.state ? 1 : -1;
const node = document.querySelector('ion-app'); const node = document.querySelector('ion-app');
const {chain} = routerPathToChain(currentPath, this.routes); const chain = routerPathToChain(currentPath, this.routes);
return this.writeNavState(node, chain, direction); return this.writeNavState(node, chain, direction);
} }
@ -100,14 +104,12 @@ export class Router {
} }
private writePath(path: string[], isPop: boolean) { private writePath(path: string[], isPop: boolean) {
this.state = writePath(window.history, this.base, this.useHash, path, isPop, this.state); // busyURL is used to prevent reentering in the popstate event
this.state++;
writePath(window.history, this.base, this.useHash, path, isPop, this.state);
} }
private readPath(): string[] | null { private readPath(): string[] | null {
return readPath(window.location, this.base, this.useHash); return readPath(window.location, this.base, this.useHash);
} }
render() {
return <slot/>;
}
} }

View File

@ -1,4 +1,4 @@
import { RouteChain } from '../utils/interfaces'; import { RouteChain, RouteID } from '../utils/interfaces';
import { routerIDsToChain, routerPathToChain } from '../utils/matching'; import { routerIDsToChain, routerPathToChain } from '../utils/matching';
import { mockRouteElement } from './parser.spec'; import { mockRouteElement } from './parser.spec';
import { chainToPath, generatePath, parsePath } from '../utils/path'; import { chainToPath, generatePath, parsePath } from '../utils/path';
@ -18,19 +18,37 @@ describe('ionic-conference-app', () => {
expect(getRouteIDs('/about', routes)).toEqual(['page-tabs', 'page-about']); expect(getRouteIDs('/about', routes)).toEqual(['page-tabs', 'page-about']);
expect(getRouteIDs('/tutorial', routes)).toEqual(['page-tutorial']); expect(getRouteIDs('/tutorial', routes)).toEqual(['page-tutorial']);
expect(getRoutePaths(['page-tabs', 'tab-schedule', 'page-schedule'], routes)).toEqual('/'); expect(getRoutePath([
expect(getRoutePaths(['page-tabs', 'tab-speaker', 'page-speaker-list'], routes)).toEqual('/speaker'); {id: 'PAGE-TABS'},
expect(getRoutePaths(['page-tabs', 'page-map'], routes)).toEqual('/map'); {id: 'tab-schedule'},
expect(getRoutePaths(['page-tabs', 'page-about'], routes)).toEqual('/about'); {id: 'page-schedule'}], routes)).toEqual('/');
expect(getRoutePaths(['page-tutorial'], routes)).toEqual('/tutorial');
expect(getRoutePath([
{id: 'page-tabs'},
{id: 'TAB-SPEAKER'}], routes)).toEqual('/speaker');
expect(getRoutePath([
{id: 'page-tabs'},
{id: 'TAB-SPEAKER'},
{id: 'page-speaker-list'}], routes)).toEqual('/speaker');
expect(getRoutePath([
{id: 'page-tabs'},
{id: 'PAGE-MAP'}], routes)).toEqual('/map');
expect(getRoutePath([
{id: 'page-tabs'},
{id: 'page-about'}], routes)).toEqual('/about');
expect(getRoutePath([
{id: 'page-tutorial'}], routes)).toEqual('/tutorial');
}); });
}); });
function conferenceAppRouting() { function conferenceAppRouting() {
const p2 = mockRouteElement('/', 'tab-schedule'); const p2 = mockRouteElement('/', 'tab-schedule');
const p3 = mockRouteElement('/', 'page-schedule'); const p3 = mockRouteElement('/', 'PAGE-SCHEDULE');
p2.appendChild(p3); p2.appendChild(p3);
const p4 = mockRouteElement('/speaker', 'tab-speaker'); const p4 = mockRouteElement('/speaker', 'tab-speaker');
@ -56,10 +74,10 @@ function conferenceAppRouting() {
function getRouteIDs(path: string, routes: RouteChain[]): string[] { function getRouteIDs(path: string, routes: RouteChain[]): string[] {
return routerPathToChain(parsePath(path), routes).chain.map(r => r.id); return routerPathToChain(parsePath(path), routes).map(r => r.id);
} }
function getRoutePaths(ids: string[], routes: RouteChain[]): string { function getRoutePath(ids: RouteID[], routes: RouteChain[]): string {
return generatePath(chainToPath(routerIDsToChain(ids, routes).chain)); return generatePath(chainToPath(routerIDsToChain(ids, routes)));
} }

View File

@ -132,34 +132,12 @@ describe('routerPathToChain', () => {
chain3, chain3,
chain4 chain4
]; ];
expect(routerPathToChain(['to'], routes)).toEqual({ expect(routerPathToChain(['to'], routes)).toEqual(null);
chain: null, expect(routerPathToChain([''], routes)).toEqual(null);
matches: 0, expect(routerPathToChain(['segment', 'to'], routes)).toEqual(null);
}); expect(routerPathToChain(['hola'], routes)).toEqual(chain3);
expect(routerPathToChain(['hola', 'hola'], routes)).toEqual(chain3);
expect(routerPathToChain([''], routes)).toEqual({ expect(routerPathToChain(['hola', 'adios'], routes)).toEqual(chain4);
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', () => { it('should match the default route', () => {
@ -174,11 +152,11 @@ describe('routerPathToChain', () => {
{ id: 'page2', path: [''], params: undefined } { id: 'page2', path: [''], params: undefined }
]; ];
expect(routerPathToChain([''], [chain1])).toEqual({chain: chain1, matches: 3}); expect(routerPathToChain([''], [chain1])).toEqual(chain1);
expect(routerPathToChain(['tab2'], [chain1])).toEqual({chain: null, matches: 0}); expect(routerPathToChain(['tab2'], [chain1])).toEqual(null);
expect(routerPathToChain([''], [chain2])).toEqual({chain: null, matches: 0}); expect(routerPathToChain([''], [chain2])).toEqual(null);
expect(routerPathToChain(['tab2'], [chain2])).toEqual({chain: chain2, matches: 3}); expect(routerPathToChain(['tab2'], [chain2])).toEqual(chain2);
}); });
}); });

View File

@ -5,7 +5,7 @@ import { RouteTree } from '../utils/interfaces';
describe('readRoutes', () => { describe('readRoutes', () => {
it('should read URL', () => { it('should read URL', () => {
const root = mockElement('div'); const root = mockElement('div');
const r1 = mockRouteElement('/', 'main-page'); const r1 = mockRouteElement('/', 'MAIN-PAGE');
const r2 = mockRouteElement('/one-page', 'one-page'); const r2 = mockRouteElement('/one-page', 'one-page');
const r3 = mockRouteElement('secondpage', 'second-page'); const r3 = mockRouteElement('secondpage', 'second-page');
const r4 = mockRouteElement('/5/hola', '4'); const r4 = mockRouteElement('/5/hola', '4');
@ -20,12 +20,12 @@ describe('readRoutes', () => {
r4.appendChild(r6); r4.appendChild(r6);
const expected: RouteTree = [ const expected: RouteTree = [
{ path: [''], id: 'main-page', children: [], props: undefined }, { path: [''], id: 'main-page', children: [], params: undefined },
{ path: ['one-page'], id: 'one-page', children: [], props: undefined }, { path: ['one-page'], id: 'one-page', children: [], params: undefined },
{ path: ['secondpage'], id: 'second-page', props: undefined, children: [ { path: ['secondpage'], id: 'second-page', params: undefined, children: [
{ path: ['5', 'hola'], id: '4', props: undefined, children: [ { path: ['5', 'hola'], id: '4', params: undefined, children: [
{ path: ['path', 'to', 'five'], id: '5', children: [], props: undefined }, { path: ['path', 'to', 'five'], id: '5', children: [], params: undefined },
{ path: ['path', 'to', 'five2'], id: '6', children: [], props: undefined } { path: ['path', 'to', 'five2'], id: '6', children: [], params: undefined }
] } ] }
] } ] }
]; ];
@ -36,12 +36,12 @@ describe('readRoutes', () => {
describe('flattenRouterTree', () => { describe('flattenRouterTree', () => {
it('should process routes', () => { it('should process routes', () => {
const entries: RouteTree = [ const entries: RouteTree = [
{ path: [''], id: 'hola', children: [], props: undefined }, { path: [''], id: 'hola', children: [], params: undefined },
{ path: ['one-page'], id: 'one-page', children: [], props: undefined }, { path: ['one-page'], id: 'one-page', children: [], params: undefined },
{ path: ['secondpage'], id: 'second-page', props: undefined, children: [ { path: ['secondpage'], id: 'second-page', params: undefined, children: [
{ path: ['5', 'hola'], id: '4', props: undefined, children: [ { path: ['5', 'hola'], id: '4', params: undefined, children: [
{ path: ['path', 'to', 'five'], id: '5', children: [], props: undefined }, { path: ['path', 'to', 'five'], id: '5', children: [], params: undefined },
{ path: ['path', 'to', 'five2'], id: '6', children: [], props: undefined } { path: ['path', 'to', 'five2'], id: '6', children: [], params: undefined }
] } ] }
] } ] }
]; ];

View File

@ -1,4 +1,5 @@
import { generatePath, parsePath } from '../utils/path'; import { chainToPath, generatePath, parsePath } from '../utils/path';
import { RouteChain } from '../utils/interfaces';
describe('parseURL', () => { describe('parseURL', () => {
it('should parse empty path', () => { it('should parse empty path', () => {
@ -53,3 +54,39 @@ describe('generatePath', () => {
}); });
}); });
describe('chainToPath', () => {
it('should generate a simple URL', () => {
const chain: RouteChain = [
{ id: '2', path: [''], params: undefined },
{ id: '1', path: [''], params: undefined },
{ id: '3', path: ['segment', 'to'], params: undefined },
{ id: '4', path: [''], params: undefined },
{ id: '5', path: ['hola', '', 'hey'], params: undefined },
{ id: '6', path: [''], params: undefined },
{ id: '7', path: [':param'], params: {param: 'name'} },
{ id: '8', path: ['adios', ':name', ':id'], params: {name: 'manu', id: '123'} },
];
expect(chainToPath(chain)).toEqual(
['segment', 'to', 'hola', 'hey', 'name', 'adios', 'manu', '123']
);
});
it('should raise an exception', () => {
const chain: RouteChain = [
{ id: '3', path: ['segment'], params: undefined },
{ id: '8', path: [':name'], params: undefined },
];
expect(() => chainToPath(chain)).toThrowError('missing param name');
});
it('should raise an exception 2', () => {
const chain: RouteChain = [
{ id: '3', path: ['segment'], params: undefined },
{ id: '8', path: [':name', ':id'], params: {name: 'hey'} },
];
expect(() => chainToPath(chain)).toThrowError('missing param id');
});
});

View File

@ -6,13 +6,6 @@ export class RouterSegments {
this.path = path.slice(); this.path = path.slice();
} }
isDefault(): boolean {
if (this.path.length > 0) {
return this.path[0] === '';
}
return true;
}
next(): string { next(): string {
if (this.path.length > 0) { if (this.path.length > 0) {
return this.path.shift() as string; return this.path.shift() as string;

View File

@ -1,5 +1,5 @@
import { breadthFirstSearch } from './common'; import { breadthFirstSearch } from './common';
import { NavOutlet, RouteChain } from './interfaces'; import { NavOutlet, RouteChain, RouteID } from './interfaces';
export function writeNavState(root: HTMLElement, chain: RouteChain|null, index: number, direction: number): Promise<void> { export function writeNavState(root: HTMLElement, chain: RouteChain|null, index: number, direction: number): Promise<void> {
if (!chain || index >= chain.length) { if (!chain || index >= chain.length) {
@ -16,7 +16,7 @@ export function writeNavState(root: HTMLElement, chain: RouteChain|null, index:
if (changed) { if (changed) {
direction = 0; direction = 0;
} }
const nextEl = node.getContentElement(); const nextEl = node.getContainerEl();
const promise = (nextEl) const promise = (nextEl)
? writeNavState(nextEl, chain, index + 1, direction) ? writeNavState(nextEl, chain, index + 1, direction)
: Promise.resolve(); : Promise.resolve();
@ -29,15 +29,15 @@ export function writeNavState(root: HTMLElement, chain: RouteChain|null, index:
} }
export function readNavState(node: HTMLElement) { export function readNavState(node: HTMLElement) {
const stack: string[] = []; const ids: RouteID[] = [];
let pivot: NavOutlet|null; let pivot: NavOutlet|null;
while (true) { while (true) {
pivot = breadthFirstSearch(node); pivot = breadthFirstSearch(node);
if (pivot) { if (pivot) {
const cmp = pivot.getRouteId(); const id = pivot.getRouteId();
if (cmp) { if (id) {
node = pivot.getContentElement(); node = pivot.getContainerEl();
stack.push(cmp.toLowerCase()); ids.push(id);
} else { } else {
break; break;
} }
@ -45,8 +45,5 @@ export function readNavState(node: HTMLElement) {
break; break;
} }
} }
return { return {ids, pivot};
ids: stack,
pivot: pivot,
};
} }

View File

@ -2,14 +2,14 @@
export interface NavOutlet { export interface NavOutlet {
setRouteId(id: string, data: any, direction: number): Promise<boolean>; setRouteId(id: string, data: any, direction: number): Promise<boolean>;
markVisible?(): Promise<void>; markVisible?(): Promise<void>;
getRouteId(): string; getRouteId(): RouteID|null;
getContentElement(): HTMLElement | null; getContainerEl(): HTMLElement | null;
} }
export interface RouteMatch { export interface RouteID {
chain: RouteChain; id: string;
matches: number; params?: any;
} }
export type NavOutletElement = NavOutlet & HTMLStencilElement; export type NavOutletElement = NavOutlet & HTMLStencilElement;

View File

@ -1,11 +1,11 @@
import { RouterSegments } from './common'; import { RouterSegments } from './common';
import { RouteChain, RouteMatch } from './interfaces'; import { RouteChain, RouteID } from './interfaces';
export function matchesIDs(ids: string[], chain: RouteChain): number { export function matchesIDs(ids: string[], chain: RouteChain): number {
const len = Math.min(ids.length, chain.length); const len = Math.min(ids.length, chain.length);
let i = 0; let i = 0;
for (; i < len; i++) { for (; i < len; i++) {
if (ids[i] !== chain[i].id) { if (ids[i].toLowerCase() !== chain[i].id) {
break; break;
} }
} }
@ -40,7 +40,7 @@ export function matchesPath(path: string[], chain: RouteChain): RouteChain | nul
} }
} }
const matches = (matchesDefault) const matches = (matchesDefault)
? matchesDefault === segments.isDefault() ? matchesDefault === (segments.next() === '')
: true; : true;
if (!matches) { if (!matches) {
@ -71,24 +71,29 @@ export function mergeParams(a: any, b: any): any {
} }
export function routerIDsToChain(ids: string[], chains: RouteChain[]): RouteMatch { export function routerIDsToChain(ids: RouteID[], chains: RouteChain[]): RouteChain|null {
let match: RouteChain|null = null; let match: RouteChain|null = null;
let maxMatches = 0; let maxMatches = 0;
const plainIDs = ids.map(i => i.id);
for (const chain of chains) { for (const chain of chains) {
const score = matchesIDs(ids, chain); const score = matchesIDs(plainIDs, chain);
if (score > maxMatches) { if (score > maxMatches) {
match = chain; match = chain;
maxMatches = score; maxMatches = score;
} }
} }
return { if (match) {
chain: match, return match.map((route, i) => ({
matches: maxMatches id: route.id,
}; path: route.path,
params: mergeParams(route.params, ids[i] && ids[i].params)
}));
}
return null;
} }
export function routerPathToChain(path: string[], chains: RouteChain[]): RouteMatch|null { export function routerPathToChain(path: string[], chains: RouteChain[]): RouteChain|null {
let match: RouteChain = null; let match: RouteChain = null;
let matches = 0; let matches = 0;
for (const chain of chains) { for (const chain of chains) {
@ -100,8 +105,5 @@ export function routerPathToChain(path: string[], chains: RouteChain[]): RouteMa
} }
} }
} }
return { return match;
chain: match,
matches,
};
} }

View File

@ -7,7 +7,7 @@ export function readRoutes(root: Element): RouteTree {
.filter(el => el.tagName === 'ION-ROUTE') .filter(el => el.tagName === 'ION-ROUTE')
.map(el => ({ .map(el => ({
path: parsePath(readProp(el, 'path')), path: parsePath(readProp(el, 'path')),
id: readProp(el, 'component'), id: readProp(el, 'component').toLowerCase(),
params: el.params, params: el.params,
children: readRoutes(el) children: readRoutes(el)
})); }));

View File

@ -11,8 +11,16 @@ export function generatePath(segments: string[]): string {
export function chainToPath(chain: RouteChain): string[] { export function chainToPath(chain: RouteChain): string[] {
const path = []; const path = [];
for (const route of chain) { for (const route of chain) {
if (route.path[0] !== '') { for (const segment of route.path) {
path.push(...route.path); if (segment[0] === ':') {
const param = route.params && route.params[segment.slice(1)];
if (!param) {
throw new Error(`missing param ${segment.slice(1)}`);
}
path.push(param);
} else if (segment !== '') {
path.push(segment);
}
} }
} }
return path; return path;
@ -24,14 +32,12 @@ export function writePath(history: History, base: string, usePath: boolean, path
if (usePath) { if (usePath) {
url = '#' + url; url = '#' + url;
} }
state++;
if (isPop) { if (isPop) {
history.back(); // history.back();
history.replaceState(state, null, url); history.replaceState(state, null, url);
} else { } else {
history.pushState(state, null, url); history.pushState(state, null, url);
} }
return state;
} }
export function readPath(loc: Location, base: string, useHash: boolean): string[] | null { export function readPath(loc: Location, base: string, useHash: boolean): string[] | null {

View File

@ -235,7 +235,7 @@ Emitted when the current tab is selected.
## Methods ## Methods
#### getRouteId() #### getTabId()
#### setActive() #### setActive()

View File

@ -1,6 +1,4 @@
import { Component, Element, Event, EventEmitter, Method, Prop, State, Watch } from '@stencil/core'; import { Component, Element, Event, EventEmitter, Method, Prop, State, Watch } from '@stencil/core';
import { asyncRaf } from '../../utils/helpers';
@Component({ @Component({
tag: 'ion-tab', tag: 'ion-tab',
@ -86,7 +84,7 @@ export class Tab {
@Event() ionSelect: EventEmitter<void>; @Event() ionSelect: EventEmitter<void>;
@Method() @Method()
getRouteId(): string|null { getTabId(): string|null {
if (this.name) { if (this.name) {
return this.name; return this.name;
} }
@ -104,7 +102,7 @@ export class Tab {
private prepareLazyLoaded(): Promise<any> { private prepareLazyLoaded(): Promise<any> {
if (!this.loaded && this.component) { if (!this.loaded && this.component) {
this.loaded = true; this.loaded = true;
return attachViewToDom(this.el, this.component).then(() => asyncRaf()); return attachViewToDom(this.el, this.component);
} }
return Promise.resolve(); return Promise.resolve();
} }

View File

@ -222,7 +222,7 @@ Emitted when the tab changes.
## Methods ## Methods
#### getContentElement() #### getContainerEl()
#### getRouteId() #### getRouteId()

View File

@ -1,5 +1,6 @@
import { Component, Element, Event, EventEmitter, Listen, Method, Prop, State } from '@stencil/core'; import { Component, Element, Event, EventEmitter, Listen, Method, Prop, State } from '@stencil/core';
import { Config, NavOutlet } from '../../index'; import { Config, NavOutlet } from '../../index';
import { RouteID } from '../router/utils/interfaces';
@Component({ @Component({
@ -101,7 +102,7 @@ export class Tabs implements NavOutlet {
@Method() @Method()
getTab(tabOrIndex: string | number | HTMLIonTabElement): HTMLIonTabElement { getTab(tabOrIndex: string | number | HTMLIonTabElement): HTMLIonTabElement {
if (typeof tabOrIndex === 'string') { if (typeof tabOrIndex === 'string') {
return this.tabs.find(tab => tab.getRouteId() === tabOrIndex); return this.tabs.find(tab => tab.getTabId() === tabOrIndex);
} }
if (typeof tabOrIndex === 'number') { if (typeof tabOrIndex === 'number') {
return this.tabs[tabOrIndex]; return this.tabs[tabOrIndex];
@ -136,16 +137,14 @@ export class Tabs implements NavOutlet {
} }
@Method() @Method()
getRouteId(): string|null { getRouteId(): RouteID|null {
if (this.selectedTab) { const id = this.selectedTab && this.selectedTab.getTabId();
return this.selectedTab.getRouteId(); return id ? {id} : null;
}
return null;
} }
@Method() @Method()
getContentElement(): HTMLElement { getContainerEl(): HTMLElement {
return this.routingView || this.selectedTab; return this.routingView || this.selectedTab;
} }