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 {EmptyError} from 'rxjs/util/EmptyError';
import {Route, Routes} from './config';
import {Route, Routes, UrlMatchResult} from './config';
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 {andObservables, merge, waitForMap, wrapIntoObservable} from './utils/collection';
import {andObservables, forEach, merge, waitForMap, wrapIntoObservable} from './utils/collection';
class NoMatch {
constructor(public segmentGroup: UrlSegmentGroup = null) {}
@ -316,34 +316,16 @@ function match(segmentGroup: UrlSegmentGroup, route: Route, segments: UrlSegment
}
}
const path = route.path;
const parts = path.split('/');
const positionalParamSegments: {[k: string]: UrlSegment} = {};
const consumedSegments: UrlSegment[] = [];
const matcher = route.matcher || defaultUrlMatcher;
const res = matcher(segments, segmentGroup, route);
if (!res) return noMatch;
let currentIndex = 0;
for (let i = 0; i < parts.length; ++i) {
if (currentIndex >= segments.length) return noMatch;
const current = segments[currentIndex];
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};
return {
matched: true,
consumedSegments: res.consumed,
lastChild: res.consumed.length,
positionalParamSegments: res.posParams
};
}
function applyRedirectCommands(

View File

@ -8,7 +8,8 @@
import {Type} from '@angular/core';
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.
@ -259,6 +260,41 @@ import {PRIMARY_OUTLET} from './shared';
*/
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.
* See {@link Routes} for more details.
@ -269,7 +305,7 @@ export type Data = {
};
/**
* @whatItDoes Represents the resolved data associated with a particular route.
* @whatItDoes Represents the resolved data associated with a particular route.
* See {@link Routes} for more details.
* @stable
*/
@ -299,6 +335,7 @@ export type LoadChildren = string | LoadChildrenCallback;
export interface Route {
path?: string;
pathMatch?: string;
matcher?: UrlMatcher;
component?: Type<any>;
redirectTo?: string;
outlet?: string;
@ -340,6 +377,10 @@ function validateNode(route: Route): void {
throw new Error(
`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 &&
!route.loadChildren) {
throw new Error(

View File

@ -11,11 +11,11 @@ import {Observable} from 'rxjs/Observable';
import {Observer} from 'rxjs/Observer';
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 {PRIMARY_OUTLET, Params} from './shared';
import {PRIMARY_OUTLET, Params, defaultUrlMatcher} from './shared';
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';
class NoMatch {}
@ -174,35 +174,15 @@ function match(segmentGroup: UrlSegmentGroup, route: Route, segments: UrlSegment
}
}
const path = route.path;
const parts = path.split('/');
const posParameters: {[key: string]: any} = {};
const consumedSegments: UrlSegment[] = [];
const matcher = route.matcher || defaultUrlMatcher;
const res = matcher(segments, segmentGroup, route);
if (!res) throw new NoMatch();
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) {
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};
return {consumedSegments: res.consumed, lastChild: res.consumed.length, parameters};
}
function checkOutletNameUniqueness(nodes: TreeNode<ActivatedRouteSnapshot>[]): void {

View File

@ -6,6 +6,11 @@
* 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.
*
@ -30,3 +35,35 @@ export class NavigationCancelingError extends Error {
}
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\''); });
});
});
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 {

View File

@ -51,7 +51,14 @@ describe('config', () => {
`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(() => {
validateConfig([{component: null, redirectTo: 'b'}]);
}).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[0] instanceof BlankCmp).toBe(true);
router.navigateByUrl('/simple').catch(e => console.log(e));
router.navigateByUrl('/simple');
advance(fixture);
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', () => {
it('should support query params', () => {
const config = [{path: 'a', component: ComponentA}];

View File

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