X Tutup
Skip to content

Commit 286a249

Browse files
committed
feat(router): support deep-linking to siblings
Closes #2807
1 parent d828664 commit 286a249

File tree

7 files changed

+198
-92
lines changed

7 files changed

+198
-92
lines changed

modules/angular2/router.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,7 @@ import {List} from './src/facade/collection';
3434
export const routerDirectives: List<any> = CONST_EXPR([RouterOutlet, RouterLink]);
3535

3636
export var routerInjectables: List<any> = [
37-
bind(RouteRegistry)
38-
.toFactory((appRoot) => new RouteRegistry(appRoot), [appComponentTypeToken]),
37+
RouteRegistry,
3938
Pipeline,
4039
bind(LocationStrategy).toClass(HTML5LocationStrategy),
4140
Location,

modules/angular2/src/router/route_registry.ts

Lines changed: 8 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,6 @@ import {Injectable} from 'angular2/di';
3232
export class RouteRegistry {
3333
private _rules: Map<any, RouteRecognizer> = new Map();
3434

35-
constructor(private _rootHostComponent: any) {}
36-
3735
/**
3836
* Given a component and a configuration object, add the route to this registry
3937
*/
@@ -144,41 +142,22 @@ export class RouteRegistry {
144142
}
145143

146144
/**
147-
* Given a list with component names and params like: `['./user', {id: 3 }]`
145+
* Given a normalized list with component names and params like: `['user', {id: 3 }]`
148146
* generates a url with a leading slash relative to the provided `parentComponent`.
149147
*/
150148
generate(linkParams: List<any>, parentComponent): string {
151-
let normalizedLinkParams = splitAndFlattenLinkParams(linkParams);
152-
let url = '/';
153-
149+
let url = '';
154150
let componentCursor = parentComponent;
155-
156-
// The first segment should be either '.' (generate from parent) or '' (generate from root).
157-
// When we normalize above, we strip all the slashes, './' becomes '.' and '/' becomes ''.
158-
if (normalizedLinkParams[0] == '') {
159-
componentCursor = this._rootHostComponent;
160-
} else if (normalizedLinkParams[0] != '.') {
161-
throw new BaseException(
162-
`Link "${ListWrapper.toJSON(linkParams)}" must start with "/" or "./"`);
163-
}
164-
165-
if (normalizedLinkParams[normalizedLinkParams.length - 1] == '') {
166-
ListWrapper.removeLast(normalizedLinkParams);
167-
}
168-
169-
if (normalizedLinkParams.length < 2) {
170-
let msg = `Link "${ListWrapper.toJSON(linkParams)}" must include a route name.`;
171-
throw new BaseException(msg);
172-
}
173-
174-
for (let i = 1; i < normalizedLinkParams.length; i += 1) {
175-
let segment = normalizedLinkParams[i];
151+
for (let i = 0; i < linkParams.length; i += 1) {
152+
let segment = linkParams[i];
176153
if (!isString(segment)) {
177154
throw new BaseException(`Unexpected segment "${segment}" in link DSL. Expected a string.`);
155+
} else if (segment == '' || segment == '.' || segment == '..') {
156+
throw new BaseException(`"${segment}/" is only allowed at the beginning of a link DSL.`);
178157
}
179158
let params = null;
180-
if (i + 1 < normalizedLinkParams.length) {
181-
let nextSegment = normalizedLinkParams[i + 1];
159+
if (i + 1 < linkParams.length) {
160+
let nextSegment = linkParams[i + 1];
182161
if (isStringMap(nextSegment)) {
183162
params = nextSegment;
184163
i += 1;
@@ -274,18 +253,3 @@ function assertTerminalComponent(component, path) {
274253
}
275254
}
276255
}
277-
278-
/*
279-
* Given: ['/a/b', {c: 2}]
280-
* Returns: ['', 'a', 'b', {c: 2}]
281-
*/
282-
var SLASH = new RegExp('/');
283-
function splitAndFlattenLinkParams(linkParams: List<any>): List<any> {
284-
return ListWrapper.reduce(linkParams, (accumulation, item) => {
285-
if (isString(item)) {
286-
return ListWrapper.concat(accumulation, StringWrapper.split(item, SLASH));
287-
}
288-
accumulation.push(item);
289-
return accumulation;
290-
}, []);
291-
}

modules/angular2/src/router/router.ts

Lines changed: 72 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,14 @@
11
import {Promise, PromiseWrapper, EventEmitter, ObservableWrapper} from 'angular2/src/facade/async';
22
import {Map, MapWrapper, List, ListWrapper} from 'angular2/src/facade/collection';
3-
import {isBlank, isPresent, Type, isArray} from 'angular2/src/facade/lang';
3+
import {
4+
isBlank,
5+
isString,
6+
StringWrapper,
7+
isPresent,
8+
Type,
9+
isArray,
10+
BaseException
11+
} from 'angular2/src/facade/lang';
412

513
import {RouteRegistry} from './route_registry';
614
import {Pipeline} from './pipeline';
@@ -42,7 +50,7 @@ export class Router {
4250

4351
// todo(jeffbcross): rename _registry to registry since it is accessed from subclasses
4452
// todo(jeffbcross): rename _pipeline to pipeline since it is accessed from subclasses
45-
constructor(public _registry: RouteRegistry, public _pipeline: Pipeline, public parent: Router,
53+
constructor(public registry: RouteRegistry, public _pipeline: Pipeline, public parent: Router,
4654
public hostComponent: any) {}
4755

4856

@@ -88,9 +96,9 @@ export class Router {
8896
config(config: StringMap<string, any>| List<StringMap<string, any>>): Promise<any> {
8997
if (isArray(config)) {
9098
(<List<any>>config)
91-
.forEach((configObject) => { this._registry.config(this.hostComponent, configObject); });
99+
.forEach((configObject) => { this.registry.config(this.hostComponent, configObject); });
92100
} else {
93-
this._registry.config(this.hostComponent, config);
101+
this.registry.config(this.hostComponent, config);
94102
}
95103
return this.renavigate();
96104
}
@@ -170,7 +178,7 @@ export class Router {
170178
* Given a URL, returns an instruction representing the component graph
171179
*/
172180
recognize(url: string): Promise<Instruction> {
173-
return this._registry.recognize(url, this.hostComponent);
181+
return this.registry.recognize(url, this.hostComponent);
174182
}
175183

176184

@@ -192,7 +200,48 @@ export class Router {
192200
* app's base href.
193201
*/
194202
generate(linkParams: List<any>): string {
195-
return this._registry.generate(linkParams, this.hostComponent);
203+
let normalizedLinkParams = splitAndFlattenLinkParams(linkParams);
204+
205+
var first = ListWrapper.first(normalizedLinkParams);
206+
var rest = ListWrapper.slice(normalizedLinkParams, 1);
207+
208+
var router = this;
209+
210+
// The first segment should be either '.' (generate from parent) or '' (generate from root).
211+
// When we normalize above, we strip all the slashes, './' becomes '.' and '/' becomes ''.
212+
if (first == '') {
213+
while (isPresent(router.parent)) {
214+
router = router.parent;
215+
}
216+
} else if (first == '..') {
217+
router = router.parent;
218+
while (ListWrapper.first(rest) == '..') {
219+
rest = ListWrapper.slice(rest, 1);
220+
router = router.parent;
221+
if (isBlank(router)) {
222+
throw new BaseException(
223+
`Link "${ListWrapper.toJSON(linkParams)}" has too many "../" segments.`);
224+
}
225+
}
226+
} else if (first != '.') {
227+
throw new BaseException(
228+
`Link "${ListWrapper.toJSON(linkParams)}" must start with "/", "./", or "../"`);
229+
}
230+
231+
if (rest[rest.length - 1] == '') {
232+
ListWrapper.removeLast(rest);
233+
}
234+
235+
if (rest.length < 1) {
236+
let msg = `Link "${ListWrapper.toJSON(linkParams)}" must include a route name.`;
237+
throw new BaseException(msg);
238+
}
239+
240+
let url = '';
241+
if (isPresent(router.parent) && isPresent(router.parent._currentInstruction)) {
242+
url = router.parent._currentInstruction.capturedUrl;
243+
}
244+
return url + '/' + this.registry.generate(rest, router.hostComponent);
196245
}
197246
}
198247

@@ -204,7 +253,7 @@ export class RootRouter extends Router {
204253
super(registry, pipeline, null, hostComponent);
205254
this._location = location;
206255
this._location.subscribe((change) => this.navigate(change['url']));
207-
this._registry.configFromComponent(hostComponent);
256+
this.registry.configFromComponent(hostComponent);
208257
this.navigate(location.path());
209258
}
210259

@@ -216,7 +265,7 @@ export class RootRouter extends Router {
216265

217266
class ChildRouter extends Router {
218267
constructor(parent: Router, hostComponent) {
219-
super(parent._registry, parent._pipeline, parent, hostComponent);
268+
super(parent.registry, parent._pipeline, parent, hostComponent);
220269
this.parent = parent;
221270
}
222271

@@ -226,3 +275,18 @@ class ChildRouter extends Router {
226275
return this.parent.navigate(url);
227276
}
228277
}
278+
279+
/*
280+
* Given: ['/a/b', {c: 2}]
281+
* Returns: ['', 'a', 'b', {c: 2}]
282+
*/
283+
var SLASH = new RegExp('/');
284+
function splitAndFlattenLinkParams(linkParams: List<any>): List<any> {
285+
return ListWrapper.reduce(linkParams, (accumulation, item) => {
286+
if (isString(item)) {
287+
return ListWrapper.concat(accumulation, StringWrapper.split(item, SLASH));
288+
}
289+
accumulation.push(item);
290+
return accumulation;
291+
}, []);
292+
}

modules/angular2/src/router/router_link.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,10 +27,11 @@ import {Location} from './location';
2727
* means that we want to generate a link for the `team` route with params `{teamId: 1}`,
2828
* and with a child route `user` with params `{userId: 2}`.
2929
*
30-
* The first route name should be prepended with either `./` or `/`.
30+
* The first route name should be prepended with `/`, `./`, or `../`.
3131
* If the route begins with `/`, the router will look up the route from the root of the app.
3232
* If the route begins with `./`, the router will instead look in the current component's
33-
* children for the route.
33+
* children for the route. And if the route begins with `../`, the router will look at the
34+
* current component's parent.
3435
*
3536
* @exportedAs angular2/router
3637
*/

modules/angular2/test/router/outlet_spec.ts

Lines changed: 73 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ import {
1818
import {Injector, bind} from 'angular2/di';
1919
import {Component, View} from 'angular2/src/core/annotations/decorators';
2020
import * as annotations from 'angular2/src/core/annotations_impl/view';
21-
import {CONST} from 'angular2/src/facade/lang';
21+
import {CONST, NumberWrapper} from 'angular2/src/facade/lang';
2222

2323
import {RootRouter} from 'angular2/src/router/router';
2424
import {Pipeline} from 'angular2/src/router/pipeline';
@@ -42,7 +42,7 @@ export function main() {
4242

4343
beforeEachBindings(() => [
4444
Pipeline,
45-
bind(RouteRegistry).toFactory(() => new RouteRegistry(MyComp)),
45+
RouteRegistry,
4646
DirectiveResolver,
4747
bind(Location).toClass(SpyLocation),
4848
bind(Router)
@@ -185,6 +185,49 @@ export function main() {
185185
});
186186
}));
187187

188+
189+
it('should generate link hrefs from a child to its sibling',
190+
inject([AsyncTestCompleter], (async) => {
191+
compile()
192+
.then((_) => rtr.config(
193+
{'path': '/page/:number', 'component': SiblingPageCmp, 'as': 'page'}))
194+
.then((_) => rtr.navigate('/page/1'))
195+
.then((_) => {
196+
rootTC.detectChanges();
197+
expect(DOM.getAttribute(rootTC.componentViewChildren[1]
198+
.componentViewChildren[0]
199+
.children[0]
200+
.nativeElement,
201+
'href'))
202+
.toEqual('/page/2');
203+
async.done();
204+
});
205+
}));
206+
207+
it('should generate relative links preserving the existing parent route',
208+
inject([AsyncTestCompleter], (async) => {
209+
compile()
210+
.then((_) =>
211+
rtr.config({'path': '/book/:title/...', 'component': BookCmp, 'as': 'book'}))
212+
.then((_) => rtr.navigate('/book/1984/page/1'))
213+
.then((_) => {
214+
rootTC.detectChanges();
215+
expect(DOM.getAttribute(
216+
rootTC.componentViewChildren[1].componentViewChildren[0].nativeElement,
217+
'href'))
218+
.toEqual('/book/1984/page/100');
219+
220+
expect(DOM.getAttribute(rootTC.componentViewChildren[1]
221+
.componentViewChildren[2]
222+
.componentViewChildren[0]
223+
.children[0]
224+
.nativeElement,
225+
'href'))
226+
.toEqual('/book/1984/page/2');
227+
async.done();
228+
});
229+
}));
230+
188231
describe('when clicked', () => {
189232

190233
var clickOnElement = function(view) {
@@ -266,6 +309,34 @@ class UserCmp {
266309
}
267310

268311

312+
@Component({selector: 'page-cmp'})
313+
@View({
314+
template:
315+
`page #{{pageNumber}} | <a href="hello" [router-link]="[\'../page\', {number: nextPage}]">next</a>`,
316+
directives: [RouterLink]
317+
})
318+
class SiblingPageCmp {
319+
pageNumber: number;
320+
nextPage: number;
321+
constructor(params: RouteParams) {
322+
this.pageNumber = NumberWrapper.parseInt(params.get('number'), 10);
323+
this.nextPage = this.pageNumber + 1;
324+
}
325+
}
326+
327+
@Component({selector: 'book-cmp'})
328+
@View({
329+
template: `<a href="hello" [router-link]="[\'./page\', {number: 100}]">{{title}}</a> |
330+
<router-outlet></router-outlet>`,
331+
directives: [RouterLink, RouterOutlet]
332+
})
333+
@RouteConfig([{path: '/page/:number', component: SiblingPageCmp, 'as': 'page'}])
334+
class BookCmp {
335+
title: string;
336+
constructor(params: RouteParams) { this.title = params.get('title'); }
337+
}
338+
339+
269340
@Component({selector: 'parent-cmp'})
270341
@View({template: "inner { <router-outlet></router-outlet> }", directives: [RouterOutlet]})
271342
@RouteConfig([{path: '/b', component: HelloCmp}])

0 commit comments

Comments
 (0)
X Tutup