Skip to content

Commit

Permalink
feat(router): introduce ParamMap to access parameters
Browse files Browse the repository at this point in the history
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`
  • Loading branch information
vicb committed Mar 20, 2017
1 parent a9d5de0 commit a755b71
Show file tree
Hide file tree
Showing 12 changed files with 200 additions and 15 deletions.
4 changes: 2 additions & 2 deletions modules/playground/src/routing/app/inbox-detail.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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); }); });
}
}

Expand Down
2 changes: 1 addition & 1 deletion packages/router/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
41 changes: 40 additions & 1 deletion packages/router/src/router_state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down Expand Up @@ -110,6 +112,10 @@ export class ActivatedRoute {
_futureSnapshot: ActivatedRouteSnapshot;
/** @internal */
_routerState: RouterState;
/** @internal */
_paramMap: Observable<ParamMap>;
/** @internal */
_queryParamMap: Observable<ParamMap>;

/** @internal */
constructor(
Expand Down Expand Up @@ -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})`;
}
Expand Down Expand Up @@ -225,6 +246,10 @@ export class ActivatedRouteSnapshot {
_resolvedData: Data;
/** @internal */
_routerState: RouterStateSnapshot;
/** @internal */
_paramMap: ParamMap;
/** @internal */
_queryParamMap: ParamMap;

/** @internal */
constructor(
Expand Down Expand Up @@ -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 : '';
Expand Down
59 changes: 59 additions & 0 deletions packages/router/src/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
22 changes: 21 additions & 1 deletion packages/router/src/url_tree.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -103,6 +103,9 @@ function containsSegmentGroupHelper(
* @stable
*/
export class UrlTree {
/** @internal */
_queryParamMap: ParamMap;

/** @internal */
constructor(
/** The root segment group of the URL tree */
Expand All @@ -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); }
}
Expand Down Expand Up @@ -176,13 +186,23 @@ export class UrlSegmentGroup {
* @stable
*/
export class UrlSegment {
/** @internal */
_parameterMap: ParamMap;

constructor(
/** The path part of a URL segment */
public path: string,

/** 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); }
}
Expand Down
2 changes: 2 additions & 0 deletions packages/router/test/create_router_state.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
Expand Down
2 changes: 2 additions & 0 deletions packages/router/test/create_url_tree.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
});

Expand Down
16 changes: 9 additions & 7 deletions packages/router/test/integration.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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',
}]
});
});
Expand Down Expand Up @@ -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>`})
Expand Down Expand Up @@ -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;
}
}
Expand Down
1 change: 1 addition & 0 deletions packages/router/test/recognize.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
});

Expand Down
40 changes: 40 additions & 0 deletions packages/router/test/shared.spec.ts
Original file line number Diff line number Diff line change
@@ -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([]);
});
});
13 changes: 10 additions & 3 deletions packages/router/test/url_serializer.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});

Expand Down Expand Up @@ -199,16 +201,21 @@ 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);
});

it('should encode/decode query params', () => {
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);
});

Expand Down
Loading

0 comments on commit a755b71

Please sign in to comment.