feat(router): enforce usage of ... syntax for parent to child component routes

This commit is contained in:
Matias Niemelä 2015-06-17 11:57:38 -07:00
parent fa7a3e3449
commit 2d2ae9b8d8
8 changed files with 103 additions and 27 deletions

View File

@ -16,7 +16,7 @@ export class SpyLocation extends SpyObject {
constructor() { constructor() {
super(); super();
this._path = '/'; this._path = '';
this.urlChanges = []; this.urlChanges = [];
this._subject = new EventEmitter(); this._subject = new EventEmitter();
this._baseHref = ''; this._baseHref = '';

View File

@ -27,6 +27,10 @@ export class Segment {
regex: string; regex: string;
} }
export class ContinuationSegment extends Segment {
generate(params): string { return ''; }
}
class StaticSegment extends Segment { class StaticSegment extends Segment {
regex: string; regex: string;
name: string; name: string;
@ -71,7 +75,7 @@ var wildcardMatcher = RegExpWrapper.create("^\\*([^\/]+)$");
function parsePathString(route: string) { function parsePathString(route: string) {
// normalize route as not starting with a "/". Recognition will // normalize route as not starting with a "/". Recognition will
// also normalize. // also normalize.
if (route[0] === "/") { if (StringWrapper.startsWith(route, "/")) {
route = StringWrapper.substring(route, 1); route = StringWrapper.substring(route, 1);
} }
@ -93,7 +97,8 @@ function parsePathString(route: string) {
throw new BaseException(`'${route}' has more than the maximum supported number of segments.`); throw new BaseException(`'${route}' has more than the maximum supported number of segments.`);
} }
for (var i = 0; i < segments.length; i++) { var limit = segments.length - 1;
for (var i = 0; i <= limit; i++) {
var segment = segments[i], match; var segment = segments[i], match;
if (isPresent(match = RegExpWrapper.firstMatch(paramMatcher, segment))) { if (isPresent(match = RegExpWrapper.firstMatch(paramMatcher, segment))) {
@ -101,6 +106,12 @@ function parsePathString(route: string) {
specificity += (100 - i); specificity += (100 - i);
} else if (isPresent(match = RegExpWrapper.firstMatch(wildcardMatcher, segment))) { } else if (isPresent(match = RegExpWrapper.firstMatch(wildcardMatcher, segment))) {
results.push(new StarSegment(match[1])); results.push(new StarSegment(match[1]));
} else if (segment == '...') {
if (i < limit) {
// TODO (matsko): setup a proper error here `
throw new BaseException(`Unexpected "..." before the end of the path for "${route}".`);
}
results.push(new ContinuationSegment());
} else if (segment.length > 0) { } else if (segment.length > 0) {
results.push(new StaticSegment(segment)); results.push(new StaticSegment(segment));
specificity += 100 * (100 - i); specificity += 100 * (100 - i);
@ -120,6 +131,7 @@ export class PathRecognizer {
segments: List<Segment>; segments: List<Segment>;
regex: RegExp; regex: RegExp;
specificity: number; specificity: number;
terminal: boolean = true;
constructor(public path: string, public handler: any) { constructor(public path: string, public handler: any) {
this.segments = []; this.segments = [];
@ -131,7 +143,17 @@ export class PathRecognizer {
var segments = parsed['segments']; var segments = parsed['segments'];
var regexString = '^'; var regexString = '^';
ListWrapper.forEach(segments, (segment) => { regexString += '/' + segment.regex; }); ListWrapper.forEach(segments, (segment) => {
if (segment instanceof ContinuationSegment) {
this.terminal = false;
} else {
regexString += '/' + segment.regex;
}
});
if (this.terminal) {
regexString += '$';
}
this.regex = RegExpWrapper.create(regexString); this.regex = RegExpWrapper.create(regexString);
this.segments = segments; this.segments = segments;
@ -143,6 +165,10 @@ export class PathRecognizer {
var urlPart = url; var urlPart = url;
for (var i = 0; i < this.segments.length; i++) { for (var i = 0; i < this.segments.length; i++) {
var segment = this.segments[i]; var segment = this.segments[i];
if (segment instanceof ContinuationSegment) {
continue;
}
var match = RegExpWrapper.firstMatch(RegExpWrapper.create('/' + segment.regex), urlPart); var match = RegExpWrapper.firstMatch(RegExpWrapper.create('/' + segment.regex), urlPart);
urlPart = StringWrapper.substring(urlPart, match[0].length); urlPart = StringWrapper.substring(urlPart, match[0].length);
if (segment.name.length > 0) { if (segment.name.length > 0) {

View File

@ -14,7 +14,7 @@ import {
StringMapWrapper StringMapWrapper
} from 'angular2/src/facade/collection'; } from 'angular2/src/facade/collection';
import {PathRecognizer} from './path_recognizer'; import {PathRecognizer, ContinuationSegment} from './path_recognizer';
/** /**
* `RouteRecognizer` is responsible for recognizing routes for a single component. * `RouteRecognizer` is responsible for recognizing routes for a single component.
@ -32,9 +32,14 @@ export class RouteRecognizer {
this.redirects = new Map(); this.redirects = new Map();
} }
addRedirect(path: string, target: string): void { this.redirects.set(path, target); } addRedirect(path: string, target: string): void {
if (path == '/') {
path = '';
}
this.redirects.set(path, target);
}
addConfig(path: string, handler: any, alias: string = null): void { addConfig(path: string, handler: any, alias: string = null): boolean {
var recognizer = new PathRecognizer(path, handler); var recognizer = new PathRecognizer(path, handler);
MapWrapper.forEach(this.matchers, (matcher, _) => { MapWrapper.forEach(this.matchers, (matcher, _) => {
if (recognizer.regex.toString() == matcher.regex.toString()) { if (recognizer.regex.toString() == matcher.regex.toString()) {
@ -46,6 +51,7 @@ export class RouteRecognizer {
if (isPresent(alias)) { if (isPresent(alias)) {
this.names.set(alias, recognizer); this.names.set(alias, recognizer);
} }
return recognizer.terminal;
} }
@ -55,6 +61,9 @@ export class RouteRecognizer {
*/ */
recognize(url: string): List<RouteMatch> { recognize(url: string): List<RouteMatch> {
var solutions = []; var solutions = [];
if (url.length > 0 && url[url.length - 1] == '/') {
url = url.substring(0, url.length - 1);
}
MapWrapper.forEach(this.redirects, (target, path) => { MapWrapper.forEach(this.redirects, (target, path) => {
// "/" redirect case // "/" redirect case

View File

@ -53,12 +53,17 @@ export class RouteRegistry {
config, {'component': normalizeComponentDeclaration(config['component'])}); config, {'component': normalizeComponentDeclaration(config['component'])});
var component = config['component']; var component = config['component'];
this.configFromComponent(component); var terminal = recognizer.addConfig(config['path'], config, config['as']);
recognizer.addConfig(config['path'], config, config['as']); if (component['type'] == 'constructor') {
if (terminal) {
assertTerminalComponent(component['constructor'], config['path']);
} else {
this.configFromComponent(component['constructor']);
}
}
} }
/** /**
* Reads the annotations of a component and configures the registry based on them * Reads the annotations of a component and configures the registry based on them
*/ */
@ -221,3 +226,21 @@ function mostSpecific(instructions: List<Instruction>): Instruction {
} }
return mostSpecificSolution; return mostSpecificSolution;
} }
function assertTerminalComponent(component, path) {
if (!isType(component)) {
return;
}
var annotations = reflector.annotations(component);
if (isPresent(annotations)) {
for (var i = 0; i < annotations.length; i++) {
var annotation = annotations[i];
if (annotation instanceof RouteConfig) {
throw new BaseException(
`Child routes are not allowed for "${path}". Use "..." on the parent's route path.`);
}
}
}
}

View File

@ -100,7 +100,7 @@ export function main() {
it('should work with child routers', inject([AsyncTestCompleter], (async) => { it('should work with child routers', inject([AsyncTestCompleter], (async) => {
compile('outer { <router-outlet></router-outlet> }') compile('outer { <router-outlet></router-outlet> }')
.then((_) => rtr.config({'path': '/a', 'component': ParentCmp})) .then((_) => rtr.config({'path': '/a/...', 'component': ParentCmp}))
.then((_) => rtr.navigate('/a/b')) .then((_) => rtr.navigate('/a/b'))
.then((_) => { .then((_) => {
view.detectChanges(); view.detectChanges();
@ -153,7 +153,7 @@ export function main() {
it('should reuse common parent components', inject([AsyncTestCompleter], (async) => { it('should reuse common parent components', inject([AsyncTestCompleter], (async) => {
compile() compile()
.then((_) => rtr.config({'path': '/team/:id', 'component': TeamCmp})) .then((_) => rtr.config({'path': '/team/:id/...', 'component': TeamCmp}))
.then((_) => rtr.navigate('/team/angular/user/rado')) .then((_) => rtr.navigate('/team/angular/user/rado'))
.then((_) => { .then((_) => {
view.detectChanges(); view.detectChanges();

View File

@ -87,21 +87,24 @@ export function main() {
expect(solution.matchedUrl).toEqual('/bar'); expect(solution.matchedUrl).toEqual('/bar');
}); });
it('should perform a valid redirect when a slash or an empty string is being processed', () => { it('should perform a root URL redirect when only a slash or an empty string is being processed',
recognizer.addRedirect('/', '/matias'); () => {
recognizer.addRedirect('', '/fatias'); recognizer.addRedirect('/', '/matias');
recognizer.addConfig('/matias', handler);
recognizer.addConfig('/matias', handler); recognizer.addConfig('/fatias', handler);
recognizer.addConfig('/fatias', handler);
var solutions; var solutions;
solutions = recognizer.recognize('/'); solutions = recognizer.recognize('/');
expect(solutions[0].matchedUrl).toBe('/matias'); expect(solutions[0].matchedUrl).toBe('/matias');
solutions = recognizer.recognize(''); solutions = recognizer.recognize('/fatias');
expect(solutions[0].matchedUrl).toBe('/fatias'); expect(solutions[0].matchedUrl).toBe('/fatias');
});
solutions = recognizer.recognize('');
expect(solutions[0].matchedUrl).toBe('/matias');
});
it('should generate URLs', () => { it('should generate URLs', () => {
recognizer.addConfig('/app/user/:name', handler, 'user'); recognizer.addConfig('/app/user/:name', handler, 'user');

View File

@ -91,7 +91,7 @@ export function main() {
})); }));
it('should match the full URL using child components', inject([AsyncTestCompleter], (async) => { it('should match the full URL using child components', inject([AsyncTestCompleter], (async) => {
registry.config(rootHostComponent, {'path': '/first', 'component': DummyParentComp}); registry.config(rootHostComponent, {'path': '/first/...', 'component': DummyParentComp});
registry.recognize('/first/second', rootHostComponent) registry.recognize('/first/second', rootHostComponent)
.then((instruction) => { .then((instruction) => {
@ -103,7 +103,7 @@ export function main() {
it('should match the URL using async child components', it('should match the URL using async child components',
inject([AsyncTestCompleter], (async) => { inject([AsyncTestCompleter], (async) => {
registry.config(rootHostComponent, {'path': '/first', 'component': DummyAsyncComp}); registry.config(rootHostComponent, {'path': '/first/...', 'component': DummyAsyncComp});
registry.recognize('/first/second', rootHostComponent) registry.recognize('/first/second', rootHostComponent)
.then((instruction) => { .then((instruction) => {
@ -117,7 +117,7 @@ export function main() {
inject([AsyncTestCompleter], (async) => { inject([AsyncTestCompleter], (async) => {
registry.config( registry.config(
rootHostComponent, rootHostComponent,
{'path': '/first', 'component': {'loader': AsyncParentLoader, 'type': 'loader'}}); {'path': '/first/...', 'component': {'loader': AsyncParentLoader, 'type': 'loader'}});
registry.recognize('/first/second', rootHostComponent) registry.recognize('/first/second', rootHostComponent)
.then((instruction) => { .then((instruction) => {
@ -139,6 +139,21 @@ export function main() {
{'path': '/some/path', 'component': {'type': 'intentionallyWrongComponentType'}})) {'path': '/some/path', 'component': {'type': 'intentionallyWrongComponentType'}}))
.toThrowError('Invalid component type \'intentionallyWrongComponentType\''); .toThrowError('Invalid component type \'intentionallyWrongComponentType\'');
}); });
it('should throw when a parent config is missing the `...` suffix any of its children add routes',
() => {
expect(() =>
registry.config(rootHostComponent, {'path': '/', 'component': DummyParentComp}))
.toThrowError(
'Child routes are not allowed for "/". Use "..." on the parent\'s route path.');
});
it('should throw when a parent config is missing the `...` suffix any of its children add routes',
() => {
expect(() => registry.config(rootHostComponent,
{'path': '/home/.../fun/', 'component': DummyParentComp}))
.toThrowError('Unexpected "..." before the end of the path for "home/.../fun/".');
});
}); });
} }

View File

@ -122,7 +122,7 @@ class ParentCmp {
@Component({selector: 'app-cmp'}) @Component({selector: 'app-cmp'})
@View({template: `root { <router-outlet></router-outlet> }`, directives: routerDirectives}) @View({template: `root { <router-outlet></router-outlet> }`, directives: routerDirectives})
@RouteConfig([{path: '/parent', component: ParentCmp}]) @RouteConfig([{path: '/parent/...', component: ParentCmp}])
class HierarchyAppCmp { class HierarchyAppCmp {
constructor(public router: Router, public location: BrowserLocation) {} constructor(public router: Router, public location: BrowserLocation) {}
} }