diff --git a/modules/angular2/src/router/helpers.ts b/modules/angular2/src/router/helpers.ts new file mode 100644 index 0000000000..49db7319bd --- /dev/null +++ b/modules/angular2/src/router/helpers.ts @@ -0,0 +1,19 @@ +import {isPresent} from 'angular2/src/facade/lang'; + +export function parseAndAssignParamString(splitToken: string, paramString: string, + keyValueMap: StringMap): void { + var first = paramString[0]; + if (first == '?' || first == ';') { + paramString = paramString.substring(1); + } + + paramString.split(splitToken) + .forEach((entry) => { + var tuple = entry.split('='); + var key = tuple[0]; + if (!isPresent(keyValueMap[key])) { + var value = tuple.length > 1 ? tuple[1] : true; + keyValueMap[key] = value; + } + }); +} diff --git a/modules/angular2/src/router/instruction.ts b/modules/angular2/src/router/instruction.ts index 6d4299cc49..b8f4beb417 100644 --- a/modules/angular2/src/router/instruction.ts +++ b/modules/angular2/src/router/instruction.ts @@ -27,10 +27,9 @@ export class Instruction { reuse: boolean = false; specificity: number; - private _params: StringMap; - constructor(public component: any, public capturedUrl: string, - private _recognizer: PathRecognizer, public child: Instruction = null) { + private _recognizer: PathRecognizer, public child: Instruction = null, + private _params: StringMap = null) { this.accumulatedUrl = capturedUrl; this.specificity = _recognizer.specificity; if (isPresent(child)) { diff --git a/modules/angular2/src/router/path_recognizer.ts b/modules/angular2/src/router/path_recognizer.ts index c27ccef96b..2d9d06abe2 100644 --- a/modules/angular2/src/router/path_recognizer.ts +++ b/modules/angular2/src/router/path_recognizer.ts @@ -17,7 +17,7 @@ import { ListWrapper } from 'angular2/src/facade/collection'; import {IMPLEMENTS} from 'angular2/src/facade/lang'; - +import {parseAndAssignParamString} from 'angular2/src/router/helpers'; import {escapeRegex} from './url'; import {RouteHandler} from './route_handler'; @@ -63,19 +63,6 @@ function normalizeString(obj: any): string { } } -function parseAndAssignMatrixParams(keyValueMap, matrixString) { - if (matrixString[0] == ';') { - matrixString = matrixString.substring(1); - } - - matrixString.split(';').forEach((entry) => { - var tuple = entry.split('='); - var key = tuple[0]; - var value = tuple.length > 1 ? tuple[1] : true; - keyValueMap[key] = value; - }); -} - class ContinuationSegment extends Segment {} class StaticSegment extends Segment { @@ -198,7 +185,10 @@ export class PathRecognizer { specificity: number; terminal: boolean = true; - constructor(public path: string, public handler: RouteHandler) { + static matrixRegex: RegExp = RegExpWrapper.create('^(.*\/[^\/]+?)(;[^\/]+)?\/?$'); + static queryRegex: RegExp = RegExpWrapper.create('^(.*\/[^\/]+?)(\\?[^\/]+)?$'); + + constructor(public path: string, public handler: RouteHandler, public isRoot: boolean = false) { assertPath(path); var parsed = parsePathString(path); var specificity = parsed['specificity']; @@ -228,16 +218,16 @@ export class PathRecognizer { var containsStarSegment = segmentsLimit >= 0 && this.segments[segmentsLimit] instanceof StarSegment; - var matrixString; + var paramsString, useQueryString = this.isRoot && this.terminal; if (!containsStarSegment) { - var matches = - RegExpWrapper.firstMatch(RegExpWrapper.create('^(.*\/[^\/]+?)(;[^\/]+)?\/?$'), url); + var matches = RegExpWrapper.firstMatch( + useQueryString ? PathRecognizer.queryRegex : PathRecognizer.matrixRegex, url); if (isPresent(matches)) { url = matches[1]; - matrixString = matches[2]; + paramsString = matches[2]; } - url = StringWrapper.replaceAll(url, /(;[^\/]+)(?=(\/|\Z))/g, ''); + url = StringWrapper.replaceAll(url, /(;[^\/]+)(?=(\/|$))/g, ''); } var params = StringMapWrapper.create(); @@ -256,8 +246,11 @@ export class PathRecognizer { } } - if (isPresent(matrixString) && matrixString.length > 0 && matrixString[0] == ';') { - parseAndAssignMatrixParams(params, matrixString); + if (isPresent(paramsString) && paramsString.length > 0) { + var expectedStartingValue = useQueryString ? '?' : ';'; + if (paramsString[0] == expectedStartingValue) { + parseAndAssignParamString(expectedStartingValue, paramsString, params); + } } return params; @@ -266,6 +259,7 @@ export class PathRecognizer { generate(params: StringMap): string { var paramTokens = new TouchMap(params); var applyLeadingSlash = false; + var useQueryString = this.isRoot && this.terminal; var url = ''; for (var i = 0; i < this.segments.length; i++) { @@ -279,12 +273,23 @@ export class PathRecognizer { } var unusedParams = paramTokens.getUnused(); - StringMapWrapper.forEach(unusedParams, (value, key) => { - url += ';' + key; - if (isPresent(value)) { - url += '=' + value; - } - }); + if (!StringMapWrapper.isEmpty(unusedParams)) { + url += useQueryString ? '?' : ';'; + var paramToken = useQueryString ? '&' : ';'; + var i = 0; + StringMapWrapper.forEach(unusedParams, (value, key) => { + if (i++ > 0) { + url += paramToken; + } + url += key; + if (!isPresent(value) && useQueryString) { + value = 'true'; + } + if (isPresent(value)) { + url += '=' + value; + } + }); + } if (applyLeadingSlash) { url += '/'; diff --git a/modules/angular2/src/router/route_recognizer.ts b/modules/angular2/src/router/route_recognizer.ts index bb92271c9a..aa8657c3fc 100644 --- a/modules/angular2/src/router/route_recognizer.ts +++ b/modules/angular2/src/router/route_recognizer.ts @@ -22,6 +22,7 @@ import {RouteHandler} from './route_handler'; import {Route, AsyncRoute, Redirect, RouteDefinition} from './route_config_impl'; import {AsyncRouteHandler} from './async_route_handler'; import {SyncRouteHandler} from './sync_route_handler'; +import {parseAndAssignParamString} from 'angular2/src/router/helpers'; /** * `RouteRecognizer` is responsible for recognizing routes for a single component. @@ -33,6 +34,8 @@ export class RouteRecognizer { redirects: Map = new Map(); matchers: Map = new Map(); + constructor(public isRoot: boolean = false) {} + config(config: RouteDefinition): boolean { var handler; if (config instanceof Redirect) { @@ -44,7 +47,7 @@ export class RouteRecognizer { } else if (config instanceof AsyncRoute) { handler = new AsyncRouteHandler(config.loader); } - var recognizer = new PathRecognizer(config.path, handler); + var recognizer = new PathRecognizer(config.path, handler, this.isRoot); MapWrapper.forEach(this.matchers, (matcher, _) => { if (recognizer.regex.toString() == matcher.regex.toString()) { throw new BaseException( @@ -80,6 +83,17 @@ export class RouteRecognizer { } }); + var queryParams = StringMapWrapper.create(); + var queryString = ''; + var queryIndex = url.indexOf('?'); + if (queryIndex >= 0) { + queryString = url.substring(queryIndex + 1); + url = url.substring(0, queryIndex); + } + if (this.isRoot && queryString.length > 0) { + parseAndAssignParamString('&', queryString, queryParams); + } + MapWrapper.forEach(this.matchers, (pathRecognizer, regex) => { var match; if (isPresent(match = RegExpWrapper.firstMatch(regex, url))) { @@ -89,7 +103,12 @@ export class RouteRecognizer { matchedUrl = match[0]; unmatchedUrl = url.substring(match[0].length); } - solutions.push(new RouteMatch(pathRecognizer, matchedUrl, unmatchedUrl)); + var params = null; + if (pathRecognizer.terminal && !StringMapWrapper.isEmpty(queryParams)) { + params = queryParams; + matchedUrl += '?' + queryString; + } + solutions.push(new RouteMatch(pathRecognizer, matchedUrl, unmatchedUrl, params)); } }); @@ -109,10 +128,22 @@ export class RouteRecognizer { } export class RouteMatch { - constructor(public recognizer: PathRecognizer, public matchedUrl: string, - public unmatchedUrl: string) {} + private _params: StringMap; + private _paramsParsed: boolean = false; - params(): StringMap { return this.recognizer.parseParams(this.matchedUrl); } + constructor(public recognizer: PathRecognizer, public matchedUrl: string, + public unmatchedUrl: string, p: StringMap = null) { + this._params = isPresent(p) ? p : StringMapWrapper.create(); + } + + params(): StringMap { + if (!this._paramsParsed) { + this._paramsParsed = true; + StringMapWrapper.forEach(this.recognizer.parseParams(this.matchedUrl), + (value, key) => { StringMapWrapper.set(this._params, key, value); }); + } + return this._params; + } } function configObjToHandler(config: any): RouteHandler { diff --git a/modules/angular2/src/router/route_registry.ts b/modules/angular2/src/router/route_registry.ts index 068c68d8bf..ff0165fb42 100644 --- a/modules/angular2/src/router/route_registry.ts +++ b/modules/angular2/src/router/route_registry.ts @@ -37,13 +37,13 @@ export class RouteRegistry { /** * Given a component and a configuration object, add the route to this registry */ - config(parentComponent: any, config: RouteDefinition): void { + config(parentComponent: any, config: RouteDefinition, isRootLevelRoute: boolean = false): void { config = normalizeRouteConfig(config); var recognizer: RouteRecognizer = this._rules.get(parentComponent); if (isBlank(recognizer)) { - recognizer = new RouteRecognizer(); + recognizer = new RouteRecognizer(isRootLevelRoute); this._rules.set(parentComponent, recognizer); } @@ -61,7 +61,7 @@ export class RouteRegistry { /** * Reads the annotations of a component and configures the registry based on them */ - configFromComponent(component: any): void { + configFromComponent(component: any, isRootComponent: boolean = false): void { if (!isType(component)) { return; } @@ -77,7 +77,8 @@ export class RouteRegistry { var annotation = annotations[i]; if (annotation instanceof RouteConfig) { - ListWrapper.forEach(annotation.configs, (config) => this.config(component, config)); + ListWrapper.forEach(annotation.configs, + (config) => this.config(component, config, isRootComponent)); } } } @@ -120,7 +121,8 @@ export class RouteRegistry { if (partialMatch.unmatchedUrl.length == 0) { if (recognizer.terminal) { - return new Instruction(componentType, partialMatch.matchedUrl, recognizer); + return new Instruction(componentType, partialMatch.matchedUrl, recognizer, null, + partialMatch.params()); } else { return null; } diff --git a/modules/angular2/src/router/router.ts b/modules/angular2/src/router/router.ts index 3107a43e94..e3caae9269 100644 --- a/modules/angular2/src/router/router.ts +++ b/modules/angular2/src/router/router.ts @@ -87,8 +87,9 @@ export class Router { * ``` */ config(definitions: List): Promise { - definitions.forEach( - (routeDefinition) => { this.registry.config(this.hostComponent, routeDefinition); }); + definitions.forEach((routeDefinition) => { + this.registry.config(this.hostComponent, routeDefinition, this instanceof RootRouter); + }); return this.renavigate(); } @@ -290,7 +291,7 @@ export class RootRouter extends Router { super(registry, pipeline, null, hostComponent); this._location = location; this._location.subscribe((change) => this.navigate(change['url'])); - this.registry.configFromComponent(hostComponent); + this.registry.configFromComponent(hostComponent, true); this.navigate(location.path()); } diff --git a/modules/angular2/test/router/path_recognizer_spec.ts b/modules/angular2/test/router/path_recognizer_spec.ts index 3f6962fbb7..d4b606fdd7 100644 --- a/modules/angular2/test/router/path_recognizer_spec.ts +++ b/modules/angular2/test/router/path_recognizer_spec.ts @@ -39,6 +39,21 @@ export function main() { .toThrowError(`Path "hi//there" contains "//" which is not allowed in a route config.`); }); + describe('querystring params', () => { + it('should parse querystring params so long as the recognizer is a root', () => { + var rec = new PathRecognizer('/hello/there', mockRouteHandler, true); + var params = rec.parseParams('/hello/there?name=igor'); + expect(params).toEqual({'name': 'igor'}); + }); + + it('should return a combined map of parameters with the param expected in the URL path', + () => { + var rec = new PathRecognizer('/hello/:name', mockRouteHandler, true); + var params = rec.parseParams('/hello/paul?topic=success'); + expect(params).toEqual({'name': 'paul', 'topic': 'success'}); + }); + }); + describe('matrix params', () => { it('should recognize a trailing matrix value on a path value and assign it to the params return value', () => { diff --git a/modules/angular2/test/router/route_recognizer_spec.ts b/modules/angular2/test/router/route_recognizer_spec.ts index 9462f59ea8..5126b2ec42 100644 --- a/modules/angular2/test/router/route_recognizer_spec.ts +++ b/modules/angular2/test/router/route_recognizer_spec.ts @@ -125,6 +125,66 @@ export function main() { .toThrowError('Route generator for \'name\' was not included in parameters passed.'); }); + describe('querystring params', () => { + it('should recognize querystring parameters within the URL path', () => { + var recognizer = new RouteRecognizer(true); + recognizer.config(new Route({path: 'profile/:name', component: DummyCmpA, as: 'user'})); + + var solution = recognizer.recognize('/profile/matsko?comments=all')[0]; + var params = solution.params(); + expect(params['name']).toEqual('matsko'); + expect(params['comments']).toEqual('all'); + }); + + it('should generate and populate the given static-based route with querystring params', + () => { + var recognizer = new RouteRecognizer(true); + recognizer.config( + new Route({path: 'forum/featured', component: DummyCmpA, as: 'forum-page'})); + + var params = StringMapWrapper.create(); + params['start'] = 10; + params['end'] = 100; + + var result = recognizer.generate('forum-page', params); + expect(result['url']).toEqual('forum/featured?start=10&end=100'); + }); + + it('should place a higher priority on actual route params incase the same params are defined in the querystring', + () => { + var recognizer = new RouteRecognizer(true); + recognizer.config(new Route({path: 'profile/:name', component: DummyCmpA, as: 'user'})); + + var solution = recognizer.recognize('/profile/yegor?name=igor')[0]; + var params = solution.params(); + expect(params['name']).toEqual('yegor'); + }); + + it('should strip out any occurences of matrix params when querystring params are allowed', + () => { + var recognizer = new RouteRecognizer(true); + recognizer.config(new Route({path: '/home', component: DummyCmpA, as: 'user'})); + + var solution = recognizer.recognize('/home;showAll=true;limit=100?showAll=false')[0]; + var params = solution.params(); + + expect(params['showAll']).toEqual('false'); + expect(params['limit']).toBeFalsy(); + }); + + it('should strip out any occurences of matrix params as input data', () => { + var recognizer = new RouteRecognizer(true); + recognizer.config(new Route({path: '/home/:subject', component: DummyCmpA, as: 'user'})); + + var solution = recognizer.recognize('/home/zero;one=1?two=2')[0]; + var params = solution.params(); + + expect(params['subject']).toEqual('zero'); + expect(params['one']).toBeFalsy(); + expect(params['two']).toEqual('2'); + }); + }); + describe('matrix params', () => { it('should recognize matrix parameters within the URL path', () => { var recognizer = new RouteRecognizer(); @@ -199,6 +259,40 @@ export function main() { var result = recognizer.generate('profile-page', params); expect(result['url']).toEqual('hello/matsko'); }); + + it('should place a higher priority on actual route params incase the same params are defined in the matrix params string', + () => { + var recognizer = new RouteRecognizer(); + recognizer.config(new Route({path: 'profile/:name', component: DummyCmpA, as: 'user'})); + + var solution = recognizer.recognize('/profile/yegor;name=igor')[0]; + var params = solution.params(); + expect(params['name']).toEqual('yegor'); + }); + + it('should strip out any occurences of querystring params when matrix params are allowed', + () => { + var recognizer = new RouteRecognizer(); + recognizer.config(new Route({path: '/home', component: DummyCmpA, as: 'user'})); + + var solution = recognizer.recognize('/home;limit=100?limit=1000&showAll=true')[0]; + var params = solution.params(); + + expect(params['showAll']).toBeFalsy(); + expect(params['limit']).toEqual('100'); + }); + + it('should strip out any occurences of matrix params as input data', () => { + var recognizer = new RouteRecognizer(); + recognizer.config(new Route({path: '/home/:subject', component: DummyCmpA, as: 'user'})); + + var solution = recognizer.recognize('/home/zero;one=1?two=2')[0]; + var params = solution.params(); + + expect(params['subject']).toEqual('zero'); + expect(params['one']).toEqual('1'); + expect(params['two']).toBeFalsy(); + }); }); }); } diff --git a/modules/angular2/test/router/router_integration_spec.ts b/modules/angular2/test/router/router_integration_spec.ts index fd73cb66ce..30fb426697 100644 --- a/modules/angular2/test/router/router_integration_spec.ts +++ b/modules/angular2/test/router/router_integration_spec.ts @@ -6,6 +6,7 @@ import { describe, expect, iit, + flushMicrotasks, inject, it, xdescribe, @@ -21,7 +22,14 @@ import {DOCUMENT_TOKEN} from 'angular2/src/render/dom/dom_renderer'; import {RouteConfig, Route, Redirect} from 'angular2/src/router/route_config_decorator'; import {PromiseWrapper} from 'angular2/src/facade/async'; import {BaseException} from 'angular2/src/facade/lang'; -import {routerInjectables, Router, appBaseHrefToken, routerDirectives} from 'angular2/router'; +import { + routerInjectables, + RouteParams, + Router, + appBaseHrefToken, + routerDirectives +} from 'angular2/router'; + import {LocationStrategy} from 'angular2/src/router/location_strategy'; import {MockLocationStrategy} from 'angular2/src/mock/mock_location_strategy'; import {appComponentTypeToken} from 'angular2/src/core/application_tokens'; @@ -112,6 +120,30 @@ export function main() { }); }); // TODO: add a test in which the child component has bindings + + describe('querystring params app', () => { + beforeEachBindings( + () => { return [bind(appComponentTypeToken).toValue(QueryStringAppCmp)]; }); + + it('should recognize and return querystring params with the injected RouteParams', + inject([AsyncTestCompleter, TestComponentBuilder], (async, tcb: TestComponentBuilder) => { + tcb.createAsync(QueryStringAppCmp) + .then((rootTC) => { + var router = rootTC.componentInstance.router; + router.subscribe((_) => { + rootTC.detectChanges(); + + expect(rootTC.nativeElement).toHaveText('qParam = search-for-something'); + /* + expect(applicationRef.hostComponent.location.path()) + .toEqual('/qs?q=search-for-something');*/ + async.done(); + }); + router.navigate('/qs?q=search-for-something'); + rootTC.detectChanges(); + }); + })); + }); }); } @@ -141,6 +173,20 @@ class HierarchyAppCmp { constructor(public router: Router, public location: LocationStrategy) {} } +@Component({selector: 'qs-cmp'}) +@View({template: "qParam = {{q}}"}) +class QSCmp { + q: string; + constructor(params: RouteParams) { this.q = params.get('q'); } +} + +@Component({selector: 'app-cmp'}) +@View({template: ``, directives: routerDirectives}) +@RouteConfig([new Route({path: '/qs', component: QSCmp})]) +class QueryStringAppCmp { + constructor(public router: Router, public location: LocationStrategy) {} +} + @Component({selector: 'oops-cmp'}) @View({template: "oh no"}) class BrokenCmp { diff --git a/modules/angular2/test/router/router_spec.ts b/modules/angular2/test/router/router_spec.ts index 6c49a44653..726a423046 100644 --- a/modules/angular2/test/router/router_spec.ts +++ b/modules/angular2/test/router/router_spec.ts @@ -116,6 +116,27 @@ export function main() { expect(router.generate(['/firstCmp/secondCmp'])).toEqual('/first/second'); }); + describe('querstring params', () => { + it('should only apply querystring params if the given URL is on the root router and is terminal', + () => { + router.config([ + new Route({path: '/hi/how/are/you', component: DummyComponent, as: 'greeting-url'}) + ]); + + var path = router.generate(['/greeting-url', {'name': 'brad'}]); + expect(path).toEqual('/hi/how/are/you?name=brad'); + }); + + it('should use parameters that are not apart of the route definition as querystring params', + () => { + router.config( + [new Route({path: '/one/two/:three', component: DummyComponent, as: 'number-url'})]); + + var path = router.generate(['/number-url', {'three': 'three', 'four': 'four'}]); + expect(path).toEqual('/one/two/three?four=four'); + }); + }); + describe('matrix params', () => { it('should apply inline matrix params for each router path within the generated URL', () => { router.config(