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 {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(
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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};
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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`);
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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}];
|
||||
|
|
|
@ -187,6 +187,7 @@ export interface Route {
|
|||
component?: Type<any>;
|
||||
data?: Data;
|
||||
loadChildren?: LoadChildren;
|
||||
matcher?: UrlMatcher;
|
||||
outlet?: string;
|
||||
path?: string;
|
||||
pathMatch?: string;
|
||||
|
|
Loading…
Reference in New Issue