feat(router): introduce `ParamMap` to access parameters

The Router use the type `Params` for all of:
- position parameters,
- matrix parameters,
- query parameters.

`Params` is defined as follow `type Params = {[key: string]: any}`

Because parameters can either have single or multiple values, the type should
actually be `type Params = {[key: string]: string | string[]}`.

The client code often assumes that parameters have single values, as in the
following exemple:

```
class MyComponent {
sessionId: Observable<string>;

constructor(private route: ActivatedRoute) {}

ngOnInit() {
    this.sessionId = this.route
      .queryParams
      .map(params => params['session_id'] || 'None');
}
}

```

The problem here is that `params['session_id']` could be `string` or `string[]`
but the error is not caught at build time because of the `any` type.

Fixing the type as describe above would break the build because `sessionId`
would becomes an `Observable<string | string[]>`.

However the client code knows if it expects a single or multiple values. By
using the new `ParamMap` interface the user code can decide when it needs a
single value (calling `ParamMap.get(): string`) or multiple values (calling
`ParamMap.getAll(): string[]`).

The above exemple should be rewritten as:

```
class MyComponent {
sessionId: Observable<string>;

constructor(private route: ActivatedRoute) {}

ngOnInit() {
    this.sessionId = this.route
      .queryParamMap
      .map(paramMap => paramMap.get('session_id') || 'None');
}
}

```

Added APIs:
- `interface ParamMap`,
- `ActivatedRoute.paramMap: ParamMap`,
- `ActivatedRoute.queryParamMap: ParamMap`,
- `ActivatedRouteSnapshot.paramMap: ParamMap`,
- `ActivatedRouteSnapshot.queryParamMap: ParamMap`,
- `UrlSegment.parameterMap: ParamMap`
This commit is contained in:
Victor Berchet 2017-03-17 10:09:42 -07:00
parent a9d5de0e56
commit a755b715ed
12 changed files with 200 additions and 15 deletions

View File

@ -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); }); });
}
}

View File

@ -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';

View File

@ -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<ParamMap>;
/** @internal */
_queryParamMap: Observable<ParamMap>;
/** @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<ParamMap> {
if (!this._paramMap) {
this._paramMap = map.call(this.params, (p: Params): ParamMap => convertToParamMap(p));
}
return this._paramMap;
}
get queryParamMap(): Observable<ParamMap> {
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 : '';

View File

@ -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) {

View File

@ -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); }
}

View File

@ -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');
});

View File

@ -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, [], <any>{a: 1});
expect(t.queryParams).toEqual({a: '1'});
expect(t.queryParamMap.get('a')).toEqual('1');
});
});

View File

@ -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 = (<any>route.snapshot.params).exact === 'true'; }
constructor(route: ActivatedRoute) {
this.exact = route.snapshot.paramMap.get('exact') === 'true';
}
}
@Component({selector: 'link-cmp', template: `<a [routerLink]="['../simple']">link</a>`})
@ -3326,7 +3328,7 @@ class QueryParamsAndFragmentCmp {
fragment: Observable<string>;
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;
}
}

View File

@ -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');
});
});

View File

@ -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([]);
});
});

View File

@ -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);
});

View File

@ -6,9 +6,11 @@ export declare class ActivatedRoute {
readonly firstChild: ActivatedRoute;
fragment: Observable<string>;
outlet: string;
readonly paramMap: Observable<ParamMap>;
params: Observable<Params>;
readonly parent: ActivatedRoute;
readonly pathFromRoot: ActivatedRoute[];
readonly queryParamMap: Observable<ParamMap>;
queryParams: Observable<Params>;
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<any>): Observable<any>;
}
/** @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;
};