feat(router): add support for custom url matchers

Closes #12442
Closes #12772
This commit is contained in:
vsavkin 2016-11-09 15:25:47 -08:00 committed by Victor Berchet
parent 2c110931f8
commit 73407351e7
9 changed files with 156 additions and 64 deletions

View File

@ -18,11 +18,11 @@ import {map} from 'rxjs/operator/map';
import {mergeMap} from 'rxjs/operator/mergeMap'; import {mergeMap} from 'rxjs/operator/mergeMap';
import {EmptyError} from 'rxjs/util/EmptyError'; import {EmptyError} from 'rxjs/util/EmptyError';
import {Route, Routes} from './config'; import {Route, Routes, UrlMatchResult} from './config';
import {LoadedRouterConfig, RouterConfigLoader} from './router_config_loader'; import {LoadedRouterConfig, RouterConfigLoader} from './router_config_loader';
import {NavigationCancelingError, PRIMARY_OUTLET} from './shared'; import {NavigationCancelingError, PRIMARY_OUTLET, defaultUrlMatcher} from './shared';
import {UrlSegment, UrlSegmentGroup, UrlTree} from './url_tree'; import {UrlSegment, UrlSegmentGroup, UrlTree} from './url_tree';
import {andObservables, merge, waitForMap, wrapIntoObservable} from './utils/collection'; import {andObservables, forEach, merge, waitForMap, wrapIntoObservable} from './utils/collection';
class NoMatch { class NoMatch {
constructor(public segmentGroup: UrlSegmentGroup = null) {} constructor(public segmentGroup: UrlSegmentGroup = null) {}
@ -316,34 +316,16 @@ function match(segmentGroup: UrlSegmentGroup, route: Route, segments: UrlSegment
} }
} }
const path = route.path; const matcher = route.matcher || defaultUrlMatcher;
const parts = path.split('/'); const res = matcher(segments, segmentGroup, route);
const positionalParamSegments: {[k: string]: UrlSegment} = {}; if (!res) return noMatch;
const consumedSegments: UrlSegment[] = [];
let currentIndex = 0; return {
matched: true,
for (let i = 0; i < parts.length; ++i) { consumedSegments: res.consumed,
if (currentIndex >= segments.length) return noMatch; lastChild: res.consumed.length,
const current = segments[currentIndex]; positionalParamSegments: res.posParams
};
const p = parts[i];
const isPosParam = p.startsWith(':');
if (!isPosParam && p !== current.path) return noMatch;
if (isPosParam) {
positionalParamSegments[p.substring(1)] = current;
}
consumedSegments.push(current);
currentIndex++;
}
if (route.pathMatch === 'full' &&
(segmentGroup.hasChildren() || currentIndex < segments.length)) {
return {matched: false, consumedSegments: [], lastChild: 0, positionalParamSegments: {}};
}
return {matched: true, consumedSegments, lastChild: currentIndex, positionalParamSegments};
} }
function applyRedirectCommands( function applyRedirectCommands(

View File

@ -8,7 +8,8 @@
import {Type} from '@angular/core'; import {Type} from '@angular/core';
import {Observable} from 'rxjs/Observable'; import {Observable} from 'rxjs/Observable';
import {PRIMARY_OUTLET} from './shared'; import {PRIMARY_OUTLET, Params} from './shared';
import {UrlSegment, UrlSegmentGroup} from './url_tree';
/** /**
* @whatItDoes Represents router configuration. * @whatItDoes Represents router configuration.
@ -259,6 +260,41 @@ import {PRIMARY_OUTLET} from './shared';
*/ */
export type Routes = Route[]; export type Routes = Route[];
/**
* @whatItDoes Represents the results of the URL matching.
*
* * `consumed` is an array of the consumed URL segments.
* * `posParams` is a map of positional parameters.
*
* @experimental
*/
export type UrlMatchResult = {
consumed: UrlSegment[]; posParams?: {[name: string]: UrlSegment};
};
/**
* @whatItDoes A function matching URLs
*
* @description
*
* A custom URL matcher can be provided when a combination of `path` and `pathMatch` isn't
* expressive enough.
*
* For instance, the following matcher matches html files.
*
* ```
* function htmlFiles(url: UrlSegment[]) {
* return url.length === 1 && url[0].path.endsWith('.html') ? ({consumed: url}) : null;
* }
*
* const routes = [{ matcher: htmlFiles, component: HtmlCmp }];
* ```
*
* @experimental
*/
export type UrlMatcher = (segments: UrlSegment[], group: UrlSegmentGroup, route: Route) =>
UrlMatchResult;
/** /**
* @whatItDoes Represents the static data associated with a particular route. * @whatItDoes Represents the static data associated with a particular route.
* See {@link Routes} for more details. * See {@link Routes} for more details.
@ -299,6 +335,7 @@ export type LoadChildren = string | LoadChildrenCallback;
export interface Route { export interface Route {
path?: string; path?: string;
pathMatch?: string; pathMatch?: string;
matcher?: UrlMatcher;
component?: Type<any>; component?: Type<any>;
redirectTo?: string; redirectTo?: string;
outlet?: string; outlet?: string;
@ -340,6 +377,10 @@ function validateNode(route: Route): void {
throw new Error( throw new Error(
`Invalid configuration of route '${route.path}': redirectTo and component cannot be used together`); `Invalid configuration of route '${route.path}': redirectTo and component cannot be used together`);
} }
if (!!route.path && !!route.matcher) {
throw new Error(
`Invalid configuration of route '${route.path}': path and matcher cannot be used together`);
}
if (route.redirectTo === undefined && !route.component && !route.children && if (route.redirectTo === undefined && !route.component && !route.children &&
!route.loadChildren) { !route.loadChildren) {
throw new Error( throw new Error(

View File

@ -11,11 +11,11 @@ import {Observable} from 'rxjs/Observable';
import {Observer} from 'rxjs/Observer'; import {Observer} from 'rxjs/Observer';
import {of } from 'rxjs/observable/of'; import {of } from 'rxjs/observable/of';
import {Data, ResolveData, Route, Routes} from './config'; import {Data, ResolveData, Route, Routes, UrlMatchResult} from './config';
import {ActivatedRouteSnapshot, RouterStateSnapshot, inheritedParamsDataResolve} from './router_state'; import {ActivatedRouteSnapshot, RouterStateSnapshot, inheritedParamsDataResolve} from './router_state';
import {PRIMARY_OUTLET, Params} from './shared'; import {PRIMARY_OUTLET, Params, defaultUrlMatcher} from './shared';
import {UrlSegment, UrlSegmentGroup, UrlTree, mapChildrenIntoArray} from './url_tree'; import {UrlSegment, UrlSegmentGroup, UrlTree, mapChildrenIntoArray} from './url_tree';
import {last, merge} from './utils/collection'; import {forEach, last, merge} from './utils/collection';
import {TreeNode} from './utils/tree'; import {TreeNode} from './utils/tree';
class NoMatch {} class NoMatch {}
@ -174,35 +174,15 @@ function match(segmentGroup: UrlSegmentGroup, route: Route, segments: UrlSegment
} }
} }
const path = route.path; const matcher = route.matcher || defaultUrlMatcher;
const parts = path.split('/'); const res = matcher(segments, segmentGroup, route);
const posParameters: {[key: string]: any} = {}; if (!res) throw new NoMatch();
const consumedSegments: UrlSegment[] = [];
let currentIndex = 0; const posParams: {[n: string]: string} = {};
forEach(res.posParams, (v: UrlSegment, k: string) => { posParams[k] = v.path; });
const parameters = merge(posParams, res.consumed[res.consumed.length - 1].parameters);
for (let i = 0; i < parts.length; ++i) { return {consumedSegments: res.consumed, lastChild: res.consumed.length, parameters};
if (currentIndex >= segments.length) throw new NoMatch();
const current = segments[currentIndex];
const p = parts[i];
const isPosParam = p.startsWith(':');
if (!isPosParam && p !== current.path) throw new NoMatch();
if (isPosParam) {
posParameters[p.substring(1)] = current.path;
}
consumedSegments.push(current);
currentIndex++;
}
if (route.pathMatch === 'full' &&
(segmentGroup.hasChildren() || currentIndex < segments.length)) {
throw new NoMatch();
}
const parameters = merge(posParameters, consumedSegments[consumedSegments.length - 1].parameters);
return {consumedSegments, lastChild: currentIndex, parameters};
} }
function checkOutletNameUniqueness(nodes: TreeNode<ActivatedRouteSnapshot>[]): void { function checkOutletNameUniqueness(nodes: TreeNode<ActivatedRouteSnapshot>[]): void {

View File

@ -6,6 +6,11 @@
* found in the LICENSE file at https://angular.io/license * found in the LICENSE file at https://angular.io/license
*/ */
import {Route, UrlMatchResult} from './config';
import {UrlSegment, UrlSegmentGroup} from './url_tree';
/** /**
* @whatItDoes Name of the primary outlet. * @whatItDoes Name of the primary outlet.
* *
@ -30,3 +35,35 @@ export class NavigationCancelingError extends Error {
} }
toString(): string { return this.message; } toString(): string { return this.message; }
} }
export function defaultUrlMatcher(
segments: UrlSegment[], segmentGroup: UrlSegmentGroup, route: Route): UrlMatchResult {
const path = route.path;
const parts = path.split('/');
const posParams: {[key: string]: UrlSegment} = {};
const consumed: UrlSegment[] = [];
let currentIndex = 0;
for (let i = 0; i < parts.length; ++i) {
if (currentIndex >= segments.length) return null;
const current = segments[currentIndex];
const p = parts[i];
const isPosParam = p.startsWith(':');
if (!isPosParam && p !== current.path) return null;
if (isPosParam) {
posParams[p.substring(1)] = current;
}
consumed.push(current);
currentIndex++;
}
if (route.pathMatch === 'full' &&
(segmentGroup.hasChildren() || currentIndex < segments.length)) {
return null;
} else {
return {consumed, posParams};
}
}

View File

@ -546,6 +546,26 @@ describe('applyRedirects', () => {
e => { expect(e.message).toEqual('Cannot match any routes. URL Segment: \'a/c\''); }); e => { expect(e.message).toEqual('Cannot match any routes. URL Segment: \'a/c\''); });
}); });
}); });
describe('custom path matchers', () => {
it('should use custom path matcher', () => {
const matcher = (s: any, g: any, r: any) => {
if (s[0].path === 'a') {
return {consumed: s.slice(0, 2), posParams: {id: s[1]}};
} else {
return null;
}
};
checkRedirect(
[{
matcher: matcher,
component: ComponentA,
children: [{path: 'b', component: ComponentB}]
}],
'/a/1/b', (t: UrlTree) => { compareTrees(t, tree('a/1/b')); });
});
});
}); });
function checkRedirect(config: Routes, url: string, callback: any): void { function checkRedirect(config: Routes, url: string, callback: any): void {

View File

@ -51,7 +51,14 @@ describe('config', () => {
`Invalid configuration of route 'a': redirectTo and component cannot be used together`); `Invalid configuration of route 'a': redirectTo and component cannot be used together`);
}); });
it('should throw when path is missing', () => {
it('should throw when path and mathcer are used together', () => {
expect(() => { validateConfig([{path: 'a', matcher: <any>'someFunc', children: []}]); })
.toThrowError(
`Invalid configuration of route 'a': path and matcher cannot be used together`);
});
it('should throw when path and matcher are missing', () => {
expect(() => { expect(() => {
validateConfig([{component: null, redirectTo: 'b'}]); validateConfig([{component: null, redirectTo: 'b'}]);
}).toThrowError(`Invalid route configuration: routes must have path specified`); }).toThrowError(`Invalid route configuration: routes must have path specified`);

View File

@ -731,7 +731,7 @@ describe('Integration', () => {
expect(cmp.activations.length).toEqual(1); expect(cmp.activations.length).toEqual(1);
expect(cmp.activations[0] instanceof BlankCmp).toBe(true); expect(cmp.activations[0] instanceof BlankCmp).toBe(true);
router.navigateByUrl('/simple').catch(e => console.log(e)); router.navigateByUrl('/simple');
advance(fixture); advance(fixture);
expect(cmp.activations.length).toEqual(2); expect(cmp.activations.length).toEqual(2);

View File

@ -636,6 +636,30 @@ describe('recognize', () => {
}); });
}); });
describe('custom path matchers', () => {
it('should use custom path matcher', () => {
const matcher = (s: any, g: any, r: any) => {
if (s[0].path === 'a') {
return {consumed: s.slice(0, 2), posParams: {id: s[1]}};
} else {
return null;
}
};
checkRecognize(
[{
matcher: matcher,
component: ComponentA,
children: [{path: 'b', component: ComponentB}]
}],
'/a/1;p=99/b', (s: RouterStateSnapshot) => {
const a = s.root.firstChild;
checkActivatedRoute(a, 'a/1', {id: '1', p: '99'}, ComponentA);
checkActivatedRoute(a.firstChild, 'b', {}, ComponentB);
});
});
});
describe('query parameters', () => { describe('query parameters', () => {
it('should support query params', () => { it('should support query params', () => {
const config = [{path: 'a', component: ComponentA}]; const config = [{path: 'a', component: ComponentA}];

View File

@ -187,6 +187,7 @@ export interface Route {
component?: Type<any>; component?: Type<any>;
data?: Data; data?: Data;
loadChildren?: LoadChildren; loadChildren?: LoadChildren;
matcher?: UrlMatcher;
outlet?: string; outlet?: string;
path?: string; path?: string;
pathMatch?: string; pathMatch?: string;