feat(router): enforce usage of ... syntax for parent to child component routes
This commit is contained in:
		
							parent
							
								
									fa7a3e3449
								
							
						
					
					
						commit
						2d2ae9b8d8
					
				| @ -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 = ''; | ||||||
|  | |||||||
| @ -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) { | ||||||
|  | |||||||
| @ -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
 | ||||||
|  | |||||||
| @ -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.`); | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | } | ||||||
|  | |||||||
| @ -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(); | ||||||
|  | |||||||
| @ -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'); | ||||||
|  | |||||||
| @ -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/".'); | ||||||
|  |        }); | ||||||
|   }); |   }); | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | |||||||
| @ -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) {} | ||||||
| } | } | ||||||
|  | |||||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user