From 5677bf73ca63c4d4c8e03cfcb4dfd115d2d72a39 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matias=20Niemel=C3=A4?= Date: Wed, 8 Jul 2015 10:57:38 -0700 Subject: [PATCH] feat(router): introduce matrix params Closes #2774 Closes #2989 --- .../angular2/src/router/path_recognizer.ts | 117 +++++++++++++++--- .../test/router/path_recognizer_spec.ts | 72 +++++++++++ .../test/router/route_recognizer_spec.ts | 75 +++++++++++ modules/angular2/test/router/router_spec.ts | 20 +++ 4 files changed, 270 insertions(+), 14 deletions(-) create mode 100644 modules/angular2/test/router/path_recognizer_spec.ts diff --git a/modules/angular2/src/router/path_recognizer.ts b/modules/angular2/src/router/path_recognizer.ts index 043d4a01b1..537aecc167 100644 --- a/modules/angular2/src/router/path_recognizer.ts +++ b/modules/angular2/src/router/path_recognizer.ts @@ -26,6 +26,33 @@ import {RouteHandler} from './route_handler'; export class Segment { name: string; regex: string; + generate(params: TouchMap): string { return ''; } +} + +class TouchMap { + map: StringMap = StringMapWrapper.create(); + keys: StringMap = StringMapWrapper.create(); + + constructor(map: StringMap) { + if (isPresent(map)) { + StringMapWrapper.forEach(map, (value, key) => { + this.map[key] = isPresent(value) ? value.toString() : null; + this.keys[key] = true; + }); + } + } + + get(key: string): string { + StringMapWrapper.delete(this.keys, key); + return this.map[key]; + } + + getUnused(): StringMap { + var unused: StringMap = StringMapWrapper.create(); + var keys = StringMapWrapper.keys(this.keys); + ListWrapper.forEach(keys, (key) => { unused[key] = StringMapWrapper.get(this.map, key); }); + return unused; + } } function normalizeString(obj: any): string { @@ -36,10 +63,21 @@ function normalizeString(obj: any): string { } } -class ContinuationSegment extends Segment { - generate(params): string { return ''; } +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 { regex: string; name: string = ''; @@ -47,9 +85,14 @@ class StaticSegment extends Segment { constructor(public string: string) { super(); this.regex = escapeRegex(string); + + // we add this property so that the route matcher still sees + // this segment as a valid path even if do not use the matrix + // parameters + this.regex += '(;[^\/]+)?'; } - generate(params): string { return this.string; } + generate(params: TouchMap): string { return this.string; } } @IMPLEMENTS(Segment) @@ -58,23 +101,22 @@ class DynamicSegment { constructor(public name: string) {} - generate(params: StringMap): string { - if (!StringMapWrapper.contains(params, this.name)) { + generate(params: TouchMap): string { + if (!StringMapWrapper.contains(params.map, this.name)) { throw new BaseException( `Route generator for '${this.name}' was not included in parameters passed.`); } - return normalizeString(StringMapWrapper.get(params, this.name)); + return normalizeString(params.get(this.name)); } } class StarSegment { regex: string = "(.+)"; + constructor(public name: string) {} - generate(params: StringMap): string { - return normalizeString(StringMapWrapper.get(params, this.name)); - } + generate(params: TouchMap): string { return normalizeString(params.get(this.name)); } } @@ -168,9 +210,27 @@ export class PathRecognizer { } parseParams(url: string): StringMap { + // the last segment is always the star one since it's terminal + var segmentsLimit = this.segments.length - 1; + var containsStarSegment = + segmentsLimit >= 0 && this.segments[segmentsLimit] instanceof StarSegment; + + var matrixString; + if (!containsStarSegment) { + var matches = + RegExpWrapper.firstMatch(RegExpWrapper.create('^(.*\/[^\/]+?)(;[^\/]+)?\/?$'), url); + if (isPresent(matches)) { + url = matches[1]; + matrixString = matches[2]; + } + + url = StringWrapper.replaceAll(url, /(;[^\/]+)(?=(\/|\Z))/g, ''); + } + var params = StringMapWrapper.create(); var urlPart = url; - for (var i = 0; i < this.segments.length; i++) { + + for (var i = 0; i <= segmentsLimit; i++) { var segment = this.segments[i]; if (segment instanceof ContinuationSegment) { continue; @@ -179,16 +239,45 @@ export class PathRecognizer { var match = RegExpWrapper.firstMatch(RegExpWrapper.create('/' + segment.regex), urlPart); urlPart = StringWrapper.substring(urlPart, match[0].length); if (segment.name.length > 0) { - StringMapWrapper.set(params, segment.name, match[1]); + params[segment.name] = match[1]; } } + if (isPresent(matrixString) && matrixString.length > 0 && matrixString[0] == ';') { + parseAndAssignMatrixParams(params, matrixString); + } + return params; } - generate(params: StringMap): string { - return ListWrapper.join(ListWrapper.map(this.segments, (segment) => segment.generate(params)), - '/'); + generate(params: StringMap): string { + var paramTokens = new TouchMap(params); + var applyLeadingSlash = false; + + var url = ''; + for (var i = 0; i < this.segments.length; i++) { + let segment = this.segments[i]; + let s = segment.generate(paramTokens); + applyLeadingSlash = applyLeadingSlash || (segment instanceof ContinuationSegment); + + if (s.length > 0) { + url += (i > 0 ? '/' : '') + s; + } + } + + var unusedParams = paramTokens.getUnused(); + StringMapWrapper.forEach(unusedParams, (value, key) => { + url += ';' + key; + if (isPresent(value)) { + url += '=' + value; + } + }); + + if (applyLeadingSlash) { + url += '/'; + } + + return url; } resolveComponentType(): Promise { return this.handler.resolveComponentType(); } diff --git a/modules/angular2/test/router/path_recognizer_spec.ts b/modules/angular2/test/router/path_recognizer_spec.ts new file mode 100644 index 0000000000..da9a6e4658 --- /dev/null +++ b/modules/angular2/test/router/path_recognizer_spec.ts @@ -0,0 +1,72 @@ +import { + AsyncTestCompleter, + describe, + it, + iit, + ddescribe, + expect, + inject, + beforeEach, + SpyObject +} from 'angular2/test_lib'; + +import {PathRecognizer} from 'angular2/src/router/path_recognizer'; +import {SyncRouteHandler} from 'angular2/src/router/sync_route_handler'; + +class DummyClass { + constructor() {} +} + +var mockRouteHandler = new SyncRouteHandler(DummyClass); + +export function main() { + describe('PathRecognizer', () => { + describe('matrix params', () => { + it('should recognize a trailing matrix value on a path value and assign it to the params return value', + () => { + var rec = new PathRecognizer('/hello/:id', mockRouteHandler); + var params = rec.parseParams('/hello/matias;key=value'); + + expect(params['id']).toEqual('matias'); + expect(params['key']).toEqual('value'); + }); + + it('should recognize and parse multiple matrix params separated by a colon value', () => { + var rec = new PathRecognizer('/jello/:sid', mockRouteHandler); + var params = rec.parseParams('/jello/man;color=red;height=20'); + + expect(params['sid']).toEqual('man'); + expect(params['color']).toEqual('red'); + expect(params['height']).toEqual('20'); + }); + + it('should recognize a matrix param value on a static path value', () => { + var rec = new PathRecognizer('/static/man', mockRouteHandler); + var params = rec.parseParams('/static/man;name=dave'); + expect(params['name']).toEqual('dave'); + }); + + it('should not parse matrix params when a wildcard segment is used', () => { + var rec = new PathRecognizer('/wild/*everything', mockRouteHandler); + var params = rec.parseParams('/wild/super;variable=value'); + expect(params['everything']).toEqual('super;variable=value'); + }); + + it('should set matrix param values to true when no value is present within the path string', + () => { + var rec = new PathRecognizer('/path', mockRouteHandler); + var params = rec.parseParams('/path;one;two;three=3'); + expect(params['one']).toEqual(true); + expect(params['two']).toEqual(true); + expect(params['three']).toEqual('3'); + }); + + it('should ignore earlier instances of matrix params and only consider the ones at the end of the path', + () => { + var rec = new PathRecognizer('/one/two/three', mockRouteHandler); + var params = rec.parseParams('/one;a=1/two;b=2/three;c=3'); + expect(params).toEqual({'c': '3'}); + }); + }); + }); +} diff --git a/modules/angular2/test/router/route_recognizer_spec.ts b/modules/angular2/test/router/route_recognizer_spec.ts index 5053d68b56..44fb7631ad 100644 --- a/modules/angular2/test/router/route_recognizer_spec.ts +++ b/modules/angular2/test/router/route_recognizer_spec.ts @@ -10,6 +10,8 @@ import { SpyObject } from 'angular2/test_lib'; +import {Map, StringMap, StringMapWrapper} from 'angular2/src/facade/collection'; + import {RouteRecognizer, RouteMatch} from 'angular2/src/router/route_recognizer'; export function main() { @@ -122,6 +124,79 @@ export function main() { expect(() => recognizer.generate('user', {})['url']) .toThrowError('Route generator for \'name\' was not included in parameters passed.'); }); + + describe('matrix params', () => { + it('should recognize matrix parameters within the URL path', () => { + var recognizer = new RouteRecognizer(); + recognizer.addConfig('profile/:name', handler, '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 recognize multiple matrix params and set parameters that contain no value to true', + () => { + var recognizer = new RouteRecognizer(); + recognizer.addConfig('/profile/hello', handler, 'user'); + + var solution = + recognizer.recognize('/profile/hello;modal;showAll=true;hideAll=false')[0]; + var params = solution.params(); + + expect(params['modal']).toEqual(true); + expect(params['showAll']).toEqual('true'); + expect(params['hideAll']).toEqual('false'); + }); + + it('should only consider the matrix parameters at the end of the path handler', () => { + var recognizer = new RouteRecognizer(); + recognizer.addConfig('/profile/hi/:name', handler, 'user'); + + var solution = recognizer.recognize('/profile;a=1/hi;b=2;c=3/william;d=4')[0]; + var params = solution.params(); + + expect(params).toEqual({'name': 'william', 'd': '4'}); + }); + + it('should generate and populate the given static-based route with matrix params', () => { + var recognizer = new RouteRecognizer(); + recognizer.addConfig('forum/featured', handler, '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 generate and populate the given dynamic-based route with matrix params', () => { + var recognizer = new RouteRecognizer(); + recognizer.addConfig('forum/:topic', handler, 'forum-page'); + + var params = StringMapWrapper.create(); + params['topic'] = 'crazy'; + params['total-posts'] = 100; + params['moreDetail'] = null; + + var result = recognizer.generate('forum-page', params); + expect(result['url']).toEqual('forum/crazy;total-posts=100;moreDetail'); + }); + + it('should not apply any matrix params if a dynamic route segment takes up the slot when a path is generated', + () => { + var recognizer = new RouteRecognizer(); + recognizer.addConfig('hello/:name', handler, 'profile-page'); + + var params = StringMapWrapper.create(); + params['name'] = 'matsko'; + + var result = recognizer.generate('profile-page', params); + expect(result['url']).toEqual('hello/matsko'); + }); + }); }); } diff --git a/modules/angular2/test/router/router_spec.ts b/modules/angular2/test/router/router_spec.ts index 470cfa43d7..54bd50e5b8 100644 --- a/modules/angular2/test/router/router_spec.ts +++ b/modules/angular2/test/router/router_spec.ts @@ -115,6 +115,26 @@ export function main() { expect(router.generate(['/firstCmp', 'secondCmp'])).toEqual('/first/second'); expect(router.generate(['/firstCmp/secondCmp'])).toEqual('/first/second'); }); + + describe('matrix params', () => { + it('should apply inline matrix params for each router path within the generated URL', () => { + router.config({'path': '/first/...', 'component': DummyParentComp, 'as': 'firstCmp'}); + + var path = + router.generate(['/firstCmp', {'key': 'value'}, 'secondCmp', {'project': 'angular'}]); + expect(path).toEqual('/first;key=value/second;project=angular'); + }); + + it('should apply inline matrix params for each router path within the generated URL and also include named params', + () => { + router.config( + {'path': '/first/:token/...', 'component': DummyParentComp, 'as': 'firstCmp'}); + + var path = + router.generate(['/firstCmp', {'token': 'min'}, 'secondCmp', {'author': 'max'}]); + expect(path).toEqual('/first/min/second;author=max'); + }); + }); }); }