diff --git a/modules/playground/src/routing/app/inbox-detail.ts b/modules/playground/src/routing/app/inbox-detail.ts index f0d85c1f6a937a..584ccc9e8030e9 100644 --- a/modules/playground/src/routing/app/inbox-detail.ts +++ b/modules/playground/src/routing/app/inbox-detail.ts @@ -17,8 +17,8 @@ export class InboxDetailCmp { private ready: boolean = false; constructor(db: DbService, route: ActivatedRoute) { - route.params.forEach( - p => { db.email(p['id']).then((data) => { this.record.setData(data); }); }); + route.paramMap.forEach( + p => { db.email(p.get('id')).then((data) => { this.record.setData(data); }); }); } } diff --git a/packages/router/src/index.ts b/packages/router/src/index.ts index d1bd73dd128cae..d21e6151d9ea36 100644 --- a/packages/router/src/index.ts +++ b/packages/router/src/index.ts @@ -20,7 +20,7 @@ export {ExtraOptions, ROUTER_CONFIGURATION, ROUTER_INITIALIZER, RouterModule, pr export {RouterOutletMap} from './router_outlet_map'; export {NoPreloading, PreloadAllModules, PreloadingStrategy, RouterPreloader} from './router_preloader'; export {ActivatedRoute, ActivatedRouteSnapshot, RouterState, RouterStateSnapshot} from './router_state'; -export {PRIMARY_OUTLET, Params} from './shared'; +export {PRIMARY_OUTLET, ParamMap, Params} from './shared'; export {UrlHandlingStrategy} from './url_handling_strategy'; export {DefaultUrlSerializer, UrlSegment, UrlSegmentGroup, UrlSerializer, UrlTree} from './url_tree'; export {VERSION} from './version'; diff --git a/packages/router/src/router_state.ts b/packages/router/src/router_state.ts index 7fa52a43894ed0..ee5cbf9c420690 100644 --- a/packages/router/src/router_state.ts +++ b/packages/router/src/router_state.ts @@ -9,13 +9,15 @@ import {Type} from '@angular/core'; import {BehaviorSubject} from 'rxjs/BehaviorSubject'; import {Observable} from 'rxjs/Observable'; +import {map} from 'rxjs/operator/map'; import {Data, ResolveData, Route} from './config'; -import {PRIMARY_OUTLET, Params} from './shared'; +import {PRIMARY_OUTLET, ParamMap, Params, convertToParamMap} from './shared'; import {UrlSegment, UrlSegmentGroup, UrlTree, equalSegments} from './url_tree'; import {merge, shallowEqual, shallowEqualArrays} from './utils/collection'; import {Tree, TreeNode} from './utils/tree'; + /** * @whatItDoes Represents the state of the router. * @@ -110,6 +112,10 @@ export class ActivatedRoute { _futureSnapshot: ActivatedRouteSnapshot; /** @internal */ _routerState: RouterState; + /** @internal */ + _paramMap: Observable; + /** @internal */ + _queryParamMap: Observable; /** @internal */ constructor( @@ -149,6 +155,21 @@ export class ActivatedRoute { /** The path from the root of the router state tree to this route */ get pathFromRoot(): ActivatedRoute[] { return this._routerState.pathFromRoot(this); } + get paramMap(): Observable { + if (!this._paramMap) { + this._paramMap = map.call(this.params, (p: Params): ParamMap => convertToParamMap(p)); + } + return this._paramMap; + } + + get queryParamMap(): Observable { + if (!this._queryParamMap) { + this._queryParamMap = + map.call(this.queryParams, (p: Params): ParamMap => convertToParamMap(p)); + } + return this._queryParamMap; + } + toString(): string { return this.snapshot ? this.snapshot.toString() : `Future(${this._futureSnapshot})`; } @@ -225,6 +246,10 @@ export class ActivatedRouteSnapshot { _resolvedData: Data; /** @internal */ _routerState: RouterStateSnapshot; + /** @internal */ + _paramMap: ParamMap; + /** @internal */ + _queryParamMap: ParamMap; /** @internal */ constructor( @@ -267,6 +292,20 @@ export class ActivatedRouteSnapshot { /** The path from the root of the router state tree to this route */ get pathFromRoot(): ActivatedRouteSnapshot[] { return this._routerState.pathFromRoot(this); } + get paramMap(): ParamMap { + if (!this._paramMap) { + this._paramMap = convertToParamMap(this.params); + } + return this._paramMap; + } + + get queryParamMap(): ParamMap { + if (!this._queryParamMap) { + this._queryParamMap = convertToParamMap(this.queryParams); + } + return this._queryParamMap; + } + toString(): string { const url = this.url.map(segment => segment.toString()).join('/'); const matched = this._routeConfig ? this._routeConfig.path : ''; diff --git a/packages/router/src/shared.ts b/packages/router/src/shared.ts index e98940baca2dd8..7f2efba1401720 100644 --- a/packages/router/src/shared.ts +++ b/packages/router/src/shared.ts @@ -27,6 +27,65 @@ export type Params = { [key: string]: any }; +/** + * Matrix and Query parameters. + * + * `ParamMap` makes it easier to work with parameters as they could have either a single value or + * multiple value. Because this should be know by the user calling `get` or `getAll` returns the + * correct type (either `string` or `string[]`). + * + * The API is inspired by the URLSearchParams interface. + * see https://developer.mozilla.org/en-US/docs/Web/API/URLSearchParams + * + * @stable + */ +export interface ParamMap { + has(name: string): boolean; + /** + * Return a single value for the given parameter name: + * - the value when the parameter has a single value, + * - the first value if the parameter has multiple values, + * - `null` when there is no such parameter. + */ + get(name: string): string|null; + /** + * Return an array of values for the given parameter name. + * + * If there is no such parameter, an empty array is returned. + */ + getAll(name: string): string[]; +} + +class ParamsAsMap implements ParamMap { + private params: Params; + + constructor(params: Params) { this.params = params || {}; } + + has(name: string): boolean { return this.params.hasOwnProperty(name); } + + get(name: string): string|null { + if (this.has(name)) { + const v = this.params[name]; + return Array.isArray(v) ? v[0] : v; + } + + return null; + } + + getAll(name: string): string[] { + if (this.has(name)) { + const v = this.params[name]; + return Array.isArray(v) ? v : [v]; + } + + return []; + } +} + +export function convertToParamMap(params: Params): ParamMap { + return new ParamsAsMap(params); +} + const NAVIGATION_CANCELING_ERROR = 'ngNavigationCancelingError'; export function navigationCancelingError(message: string) { diff --git a/packages/router/src/url_tree.ts b/packages/router/src/url_tree.ts index ae9e6fbe042042..e03cc058f02eb2 100644 --- a/packages/router/src/url_tree.ts +++ b/packages/router/src/url_tree.ts @@ -6,7 +6,7 @@ * found in the LICENSE file at https://angular.io/license */ -import {PRIMARY_OUTLET} from './shared'; +import {PRIMARY_OUTLET, ParamMap, convertToParamMap} from './shared'; import {forEach, shallowEqual} from './utils/collection'; export function createEmptyUrlTree() { @@ -103,6 +103,9 @@ function containsSegmentGroupHelper( * @stable */ export class UrlTree { + /** @internal */ + _queryParamMap: ParamMap; + /** @internal */ constructor( /** The root segment group of the URL tree */ @@ -112,6 +115,13 @@ export class UrlTree { /** The fragment of the URL */ public fragment: string) {} + get queryParamMap() { + if (!this._queryParamMap) { + this._queryParamMap = convertToParamMap(this.queryParams); + } + return this._queryParamMap; + } + /** @docsNotRequired */ toString(): string { return new DefaultUrlSerializer().serialize(this); } } @@ -176,6 +186,9 @@ export class UrlSegmentGroup { * @stable */ export class UrlSegment { + /** @internal */ + _parameterMap: ParamMap; + constructor( /** The path part of a URL segment */ public path: string, @@ -183,6 +196,13 @@ export class UrlSegment { /** The matrix parameters associated with a segment */ public parameters: {[name: string]: string}) {} + get parameterMap() { + if (!this._parameterMap) { + this._parameterMap = convertToParamMap(this.parameters); + } + return this._parameterMap; + } + /** @docsNotRequired */ toString(): string { return serializePath(this); } } diff --git a/packages/router/test/create_router_state.spec.ts b/packages/router/test/create_router_state.spec.ts index e5a79fad5a96c0..b31247b4fc696e 100644 --- a/packages/router/test/create_router_state.spec.ts +++ b/packages/router/test/create_router_state.spec.ts @@ -84,6 +84,8 @@ describe('create router state', () => { const currC = state.children(currP); expect(currP._futureSnapshot.params).toEqual({id: '2', p: '22'}); + expect(currP._futureSnapshot.paramMap.get('id')).toEqual('2'); + expect(currP._futureSnapshot.paramMap.get('p')).toEqual('22'); checkActivatedRoute(currC[0], ComponentA); checkActivatedRoute(currC[1], ComponentB, 'right'); }); diff --git a/packages/router/test/create_url_tree.spec.ts b/packages/router/test/create_url_tree.spec.ts index 0e40e875e5d0ce..bb7a2b3b008403 100644 --- a/packages/router/test/create_url_tree.spec.ts +++ b/packages/router/test/create_url_tree.spec.ts @@ -31,12 +31,14 @@ describe('createUrlTree', () => { const p = serializer.parse('/'); const t = createRoot(p, [], {a: 'hey'}); expect(t.queryParams).toEqual({a: 'hey'}); + expect(t.queryParamMap.get('a')).toEqual('hey'); }); it('should stringify query params', () => { const p = serializer.parse('/'); const t = createRoot(p, [], {a: 1}); expect(t.queryParams).toEqual({a: '1'}); + expect(t.queryParamMap.get('a')).toEqual('1'); }); }); diff --git a/packages/router/test/integration.spec.ts b/packages/router/test/integration.spec.ts index 7c509f878f5e31..d49f5a6ff9be5a 100644 --- a/packages/router/test/integration.spec.ts +++ b/packages/router/test/integration.spec.ts @@ -7,16 +7,16 @@ */ import {CommonModule, Location} from '@angular/common'; -import {Component, Inject, Injectable, NgModule, NgModuleFactoryLoader, NgModuleRef} from '@angular/core'; +import {Component, Injectable, NgModule, NgModuleFactoryLoader, NgModuleRef} from '@angular/core'; import {ComponentFixture, TestBed, fakeAsync, inject, tick} from '@angular/core/testing'; import {By} from '@angular/platform-browser/src/dom/debug/by'; import {expect} from '@angular/platform-browser/testing/src/matchers'; import {Observable} from 'rxjs/Observable'; import {map} from 'rxjs/operator/map'; -import {ActivatedRoute, ActivatedRouteSnapshot, CanActivate, CanDeactivate, DetachedRouteHandle, Event, NavigationCancel, NavigationEnd, NavigationError, NavigationStart, PRIMARY_OUTLET, Params, PreloadAllModules, PreloadingStrategy, Resolve, RouteConfigLoadEnd, RouteConfigLoadStart, RouteReuseStrategy, Router, RouterModule, RouterStateSnapshot, RoutesRecognized, UrlHandlingStrategy, UrlSegmentGroup, UrlTree} from '../index'; +import {ActivatedRoute, ActivatedRouteSnapshot, CanActivate, CanDeactivate, DetachedRouteHandle, Event, NavigationCancel, NavigationEnd, NavigationError, NavigationStart, PRIMARY_OUTLET, ParamMap, Params, PreloadAllModules, PreloadingStrategy, Resolve, RouteConfigLoadEnd, RouteConfigLoadStart, RouteReuseStrategy, Router, RouterModule, RouterStateSnapshot, RoutesRecognized, UrlHandlingStrategy, UrlSegmentGroup, UrlTree} from '../index'; import {RouterPreloader} from '../src/router_preloader'; -import {forEach, shallowEqual} from '../src/utils/collection'; +import {forEach} from '../src/utils/collection'; import {RouterTestingModule, SpyNgModuleFactoryLoader} from '../testing'; describe('Integration', () => { @@ -1443,7 +1443,7 @@ describe('Integration', () => { providers: [{ provide: 'CanActivate', useValue: (a: ActivatedRouteSnapshot, b: RouterStateSnapshot) => { - if (a.params['id'] == '22') { + if (a.params['id'] === '22') { return Promise.resolve(true); } else { return Promise.resolve(false); @@ -1995,7 +1995,7 @@ describe('Integration', () => { TestBed.configureTestingModule({ providers: [{ provide: 'alwaysFalse', - useValue: (a: any, b: any) => a.params.id === '22', + useValue: (a: any, b: any) => a.paramMap.get('id') === '22', }] }); }); @@ -3233,7 +3233,9 @@ class AbsoluteLinkCmp { }) class DummyLinkCmp { private exact: boolean; - constructor(route: ActivatedRoute) { this.exact = (route.snapshot.params).exact === 'true'; } + constructor(route: ActivatedRoute) { + this.exact = route.snapshot.paramMap.get('exact') === 'true'; + } } @Component({selector: 'link-cmp', template: `link`}) @@ -3326,7 +3328,7 @@ class QueryParamsAndFragmentCmp { fragment: Observable; constructor(route: ActivatedRoute) { - this.name = map.call(route.queryParams, (p: any) => p['name']); + this.name = map.call(route.queryParamMap, (p: ParamMap) => p.get('name')); this.fragment = route.fragment; } } diff --git a/packages/router/test/recognize.spec.ts b/packages/router/test/recognize.spec.ts index 7821126961f013..cf63d18b53f8a6 100644 --- a/packages/router/test/recognize.spec.ts +++ b/packages/router/test/recognize.spec.ts @@ -665,6 +665,7 @@ describe('recognize', () => { const config = [{path: 'a', component: ComponentA}]; checkRecognize(config, 'a?q=11', (s: RouterStateSnapshot) => { expect(s.root.queryParams).toEqual({q: '11'}); + expect(s.root.queryParamMap.get('q')).toEqual('11'); }); }); diff --git a/packages/router/test/shared.spec.ts b/packages/router/test/shared.spec.ts new file mode 100644 index 00000000000000..3e2edfed094a2d --- /dev/null +++ b/packages/router/test/shared.spec.ts @@ -0,0 +1,40 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {ParamMap, convertToParamMap} from '../src/shared'; + +describe('ParamsMap', () => { + it('should returns whether a parameter is present', () => { + const map = convertToParamMap({single: 's', multiple: ['m1', 'm2']}); + expect(map.has('single')).toEqual(true); + expect(map.has('multiple')).toEqual(true); + expect(map.has('not here')).toEqual(false); + }); + + it('should support single valued parameters', () => { + const map = convertToParamMap({single: 's', multiple: ['m1', 'm2']}); + expect(map.get('single')).toEqual('s'); + expect(map.get('multiple')).toEqual('m1'); + }); + + it('should support multiple valued parameters', () => { + const map = convertToParamMap({single: 's', multiple: ['m1', 'm2']}); + expect(map.getAll('single')).toEqual(['s']); + expect(map.getAll('multiple')).toEqual(['m1', 'm2']); + }); + + it('should return `null` when a single valued element is absent', () => { + const map = convertToParamMap({}); + expect(map.get('name')).toEqual(null); + }); + + it('should return `[]` when a mulitple valued element is absent', () => { + const map = convertToParamMap({}); + expect(map.getAll('name')).toEqual([]); + }); +}); \ No newline at end of file diff --git a/packages/router/test/url_serializer.spec.ts b/packages/router/test/url_serializer.spec.ts index 3ea8a18414534c..3314f9fc69964b 100644 --- a/packages/router/test/url_serializer.spec.ts +++ b/packages/router/test/url_serializer.spec.ts @@ -163,6 +163,8 @@ describe('url serializer', () => { it('should handle multiple query params of the same name into an array', () => { const tree = url.parse('/one?a=foo&a=bar&a=swaz'); expect(tree.queryParams).toEqual({a: ['foo', 'bar', 'swaz']}); + expect(tree.queryParamMap.get('a')).toEqual('foo'); + expect(tree.queryParamMap.getAll('a')).toEqual(['foo', 'bar', 'swaz']); expect(url.serialize(tree)).toEqual('/one?a=foo&a=bar&a=swaz'); }); @@ -199,8 +201,11 @@ describe('url serializer', () => { it('should encode/decode "slash" in path segments and parameters', () => { const u = `/${encode("one/two")};${encode("p/1")}=${encode("v/1")}/three`; const tree = url.parse(u); - expect(tree.root.children[PRIMARY_OUTLET].segments[0].path).toEqual('one/two'); - expect(tree.root.children[PRIMARY_OUTLET].segments[0].parameters).toEqual({['p/1']: 'v/1'}); + const segment = tree.root.children[PRIMARY_OUTLET].segments[0]; + expect(segment.path).toEqual('one/two'); + expect(segment.parameters).toEqual({'p/1': 'v/1'}); + expect(segment.parameterMap.get('p/1')).toEqual('v/1'); + expect(segment.parameterMap.getAll('p/1')).toEqual(['v/1']); expect(url.serialize(tree)).toEqual(u); }); @@ -208,7 +213,9 @@ describe('url serializer', () => { const u = `/one?${encode("p 1")}=${encode("v 1")}&${encode("p 2")}=${encode("v 2")}`; const tree = url.parse(u); - expect(tree.queryParams).toEqual({['p 1']: 'v 1', ['p 2']: 'v 2'}); + expect(tree.queryParams).toEqual({'p 1': 'v 1', 'p 2': 'v 2'}); + expect(tree.queryParamMap.get('p 1')).toEqual('v 1'); + expect(tree.queryParamMap.get('p 2')).toEqual('v 2'); expect(url.serialize(tree)).toEqual(u); }); diff --git a/tools/public_api_guard/router/router.d.ts b/tools/public_api_guard/router/router.d.ts index 08bc632212eed6..42a53a6f06112a 100644 --- a/tools/public_api_guard/router/router.d.ts +++ b/tools/public_api_guard/router/router.d.ts @@ -6,9 +6,11 @@ export declare class ActivatedRoute { readonly firstChild: ActivatedRoute; fragment: Observable; outlet: string; + readonly paramMap: Observable; params: Observable; readonly parent: ActivatedRoute; readonly pathFromRoot: ActivatedRoute[]; + readonly queryParamMap: Observable; queryParams: Observable; readonly root: ActivatedRoute; readonly routeConfig: Route; @@ -25,9 +27,11 @@ export declare class ActivatedRouteSnapshot { readonly firstChild: ActivatedRouteSnapshot; fragment: string; outlet: string; + readonly paramMap: ParamMap; params: Params; readonly parent: ActivatedRouteSnapshot; readonly pathFromRoot: ActivatedRouteSnapshot[]; + readonly queryParamMap: ParamMap; queryParams: Params; readonly root: ActivatedRouteSnapshot; readonly routeConfig: Route; @@ -150,6 +154,13 @@ export declare class NoPreloading implements PreloadingStrategy { preload(route: Route, fn: () => Observable): Observable; } +/** @stable */ +export interface ParamMap { + get(name: string): string | null; + getAll(name: string): string[]; + has(name: string): boolean; +} + /** @stable */ export declare type Params = { [key: string]: any; @@ -390,6 +401,7 @@ export declare abstract class UrlHandlingStrategy { /** @stable */ export declare class UrlSegment { + readonly parameterMap: ParamMap; parameters: { [name: string]: string; }; @@ -428,6 +440,7 @@ export declare abstract class UrlSerializer { /** @stable */ export declare class UrlTree { fragment: string; + readonly queryParamMap: ParamMap; queryParams: { [key: string]: string; };