feat(router): add support for custom url matchers
Closes #12442 Closes #12772
This commit is contained in:
parent
2c110931f8
commit
73407351e7
|
@ -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(
|
||||||
|
|
|
@ -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(
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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`);
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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}];
|
||||||
|
|
|
@ -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;
|
||||||
|
|
Loading…
Reference in New Issue