From 4f6ec01932ff6c56bbd8c284d1738b18af35b5cc Mon Sep 17 00:00:00 2001 From: vsavkin Date: Tue, 24 May 2016 13:23:27 -0700 Subject: [PATCH] feat: implement a simple version of the router service --- modules/@angular/router/index.ts | 7 + modules/@angular/router/src/router.ts | 125 +++++++++++- modules/@angular/router/src/util.ts | 8 + modules/@angular/router/test/router.spec.ts | 201 +++++++++++++++++++- modules/@angular/router/tsconfig.json | 2 + 5 files changed, 340 insertions(+), 3 deletions(-) create mode 100644 modules/@angular/router/index.ts diff --git a/modules/@angular/router/index.ts b/modules/@angular/router/index.ts new file mode 100644 index 0000000000..07f6108ecf --- /dev/null +++ b/modules/@angular/router/index.ts @@ -0,0 +1,7 @@ +export { Router } from './src/router'; +export { UrlSerializer, DefaultUrlSerializer } from './src/url_serializer'; +export { RouterState, ActivatedRoute } from './src/router_state'; +export { RouterOutletMap } from './src/router_outlet_map'; + +import { RouterOutlet } from './src/directives/router_outlet'; +export const ROUTER_DIRECTIVES = [RouterOutlet]; \ No newline at end of file diff --git a/modules/@angular/router/src/router.ts b/modules/@angular/router/src/router.ts index 4ad8593add..b094931ce5 100644 --- a/modules/@angular/router/src/router.ts +++ b/modules/@angular/router/src/router.ts @@ -1 +1,124 @@ -export const X = 100; \ No newline at end of file +import { ComponentResolver, ReflectiveInjector } from '@angular/core'; +import { Location } from '@angular/common'; +import { UrlSerializer } from './url_serializer'; +import { RouterOutletMap } from './router_outlet_map'; +import { recognize } from './recognize'; +import { rootNode, TreeNode } from './tree'; +import { UrlTree } from './url_tree'; +import { createEmptyState, RouterState, ActivatedRoute, PRIMARY_OUTLET } from './router_state'; +import { RouterConfig } from './config'; +import { RouterOutlet } from './directives/router_outlet'; +import { forEach } from './util'; +import { Subscription } from 'rxjs/Subscription'; + +export class Router { + private currentState: RouterState; + private config: RouterConfig; + private locationSubscription: Subscription; + + constructor(private rootComponent:Object, private resolver: ComponentResolver, private urlSerializer: UrlSerializer, private outletMap: RouterOutletMap, private location: Location) { + this.currentState = createEmptyState(rootComponent.constructor); + this.setUpLocationChangeListener(); + this.navigateByUrl(this.location.path()); + } + + navigateByUrl(url: string): void { + const urlTree = this.urlSerializer.parse(url); + this.navigate(urlTree, false); + } + + resetConfig(config: RouterConfig): void { + this.config = config; + } + + private setUpLocationChangeListener(): void { + this.locationSubscription = this.location.subscribe((change) => { + this.navigate(this.urlSerializer.parse(change['url']), change['pop']) + }); + } + + private navigate(url: UrlTree, pop?: boolean): Promise { + return recognize(this.resolver, this.config, url, this.currentState).then(newState => { + new ActivateRoutes(newState, this.currentState).activate(this.outletMap); + this.currentState = newState; + if (!pop) { + this.location.go(this.urlSerializer.serialize(url)); + } + }).catch(e => console.log("error", e.message)); + } +} + +class ActivateRoutes { + constructor(private futureState: RouterState, private currState: RouterState) {} + + activate(parentOutletMap: RouterOutletMap): void { + const currRoot = this.currState ? rootNode(this.currState) : null; + const futureRoot = rootNode(this.futureState); + this.activateChildRoutes(futureRoot, currRoot, parentOutletMap); + } + + private activateChildRoutes(futureNode: TreeNode, + currNode: TreeNode | null, + outletMap: RouterOutletMap): void { + const prevChildren = nodeChildrenAsMap(currNode); + futureNode.children.forEach(c => { + this.activateRoutes(c, prevChildren[c.value.outlet], outletMap); + delete prevChildren[c.value.outlet]; + }); + forEach(prevChildren, (v, k) => this.deactivateOutletAndItChildren(outletMap._outlets[k])); + } + + activateRoutes(futureNode: TreeNode, currNode: TreeNode, + parentOutletMap: RouterOutletMap): void { + const future = futureNode.value; + const curr = currNode ? currNode.value : null; + const outlet = getOutlet(parentOutletMap, futureNode.value); + + if (future === curr) { + this.activateChildRoutes(futureNode, currNode, outlet.outletMap); + } else { + this.deactivateOutletAndItChildren(outlet); + const outletMap = new RouterOutletMap(); + this.activateNewRoutes(outletMap, future, outlet); + this.activateChildRoutes(futureNode, currNode, outletMap); + } + } + + private activateNewRoutes(outletMap: RouterOutletMap, future: ActivatedRoute, outlet: RouterOutlet): void { + const resolved = ReflectiveInjector.resolve([ + {provide: ActivatedRoute, useValue: future}, + {provide: RouterOutletMap, useValue: outletMap} + ]); + outlet.activate(future.factory, resolved, outletMap); + } + + private deactivateOutletAndItChildren(outlet: RouterOutlet): void { + if (outlet && outlet.isActivated) { + forEach(outlet.outletMap._outlets, (v, k) => this.deactivateOutletAndItChildren(v)); + outlet.deactivate(); + } + } +} + +function nodeChildrenAsMap(node: TreeNode|null) { + return node ? + node.children.reduce( + (m, c) => { + m[c.value.outlet] = c; + return m; + }, + {}) : + {}; +} + +function getOutlet(outletMap: RouterOutletMap, route: ActivatedRoute): RouterOutlet { + let outlet = outletMap._outlets[route.outlet]; + if (!outlet) { + if (route.outlet === PRIMARY_OUTLET) { + throw new Error(`Cannot find primary outlet`); + } else { + throw new Error(`Cannot find the outlet ${route.outlet}`); + } + } + return outlet; +} diff --git a/modules/@angular/router/src/util.ts b/modules/@angular/router/src/util.ts index ca97e8dec2..5816b02db4 100644 --- a/modules/@angular/router/src/util.ts +++ b/modules/@angular/router/src/util.ts @@ -44,4 +44,12 @@ export function merge(m1: {[key: string]: V}, m2: {[key: string]: V}): {[key: } return m; +} + +export function forEach(map: {[key: string]: V}, callback: /*(V, K) => void*/ Function): void { + for (var prop in map) { + if (map.hasOwnProperty(prop)) { + callback(map[prop], prop); + } + } } \ No newline at end of file diff --git a/modules/@angular/router/test/router.spec.ts b/modules/@angular/router/test/router.spec.ts index 7849ac34cf..707f9a9564 100644 --- a/modules/@angular/router/test/router.spec.ts +++ b/modules/@angular/router/test/router.spec.ts @@ -1,4 +1,201 @@ +import {Component} from '@angular/core'; +import { + describe, + it, + iit, + xit, + expect, + beforeEach, + beforeEachProviders, + inject, + fakeAsync, + tick +} from '@angular/core/testing'; + +import {TestComponentBuilder, ComponentFixture} from '@angular/compiler/testing'; +import { ComponentResolver } from '@angular/core'; +import { SpyLocation } from '@angular/common/testing'; +import { UrlSerializer, DefaultUrlSerializer, RouterOutletMap, Router, ActivatedRoute, ROUTER_DIRECTIVES } from '../index'; +import { Observable } from 'rxjs/Observable'; +import 'rxjs/add/operator/map'; + describe("Integration", () => { - it("test", () => { - }); + beforeEachProviders(() => [ + RouterOutletMap, + {provide: UrlSerializer, useClass: DefaultUrlSerializer}, + {provide: Location, useClass: SpyLocation}, + { + provide: Router, + useFactory: (resolver, urlSerializer, outletMap, location) => + new Router(new RootCmp(), resolver, urlSerializer, outletMap, location), + deps: [ComponentResolver, UrlSerializer, RouterOutletMap, Location] + } + ]); + + it('should update location when navigating', + fakeAsync(inject([Router, TestComponentBuilder, Location], (router, tcb, location) => { + router.resetConfig([ + { name: 'team', path: 'team/:id', component: TeamCmp } + ]); + + const fixture = tcb.createFakeAsync(RootCmp); + + router.navigateByUrl('/team/22'); + advance(fixture); + expect(location.path()).toEqual('/team/22'); + + router.navigateByUrl('/team/33'); + advance(fixture); + + expect(location.path()).toEqual('/team/33'); + }))); + + xit('should navigate back and forward', + fakeAsync(inject([Router, TestComponentBuilder, Location], (router, tcb, location) => { + router.resetConfig([ + { name: 'team', path: 'team/:id', component: TeamCmp, children: [ + { name: 'simple', path: 'simple', component: SimpleCmp }, + { name: 'user', path: 'user/:name', component: UserCmp } + ] } + ]); + + const fixture = tcb.createFakeAsync(RootCmp); + + router.navigateByUrl('/team/33/simple'); + advance(fixture); + expect(location.path()).toEqual('/team/33/simple'); + + router.navigateByUrl('/team/22/user/victor'); + advance(fixture); + + location.back(); + advance(fixture); + expect(location.path()).toEqual('/team/33/simple'); + + location.forward(); + advance(fixture); + expect(location.path()).toEqual('/team/22/user/victor'); + }))); + + it('should navigate when locations changes', + fakeAsync(inject([Router, TestComponentBuilder, Location], (router, tcb, location) => { + router.resetConfig([ + { name: 'team', path: 'team/:id', component: TeamCmp, children: [ + { name: 'user', path: 'user/:name', component: UserCmp } + ] } + ]); + + const fixture = tcb.createFakeAsync(RootCmp); + + router.navigateByUrl('/team/22/user/victor'); + advance(fixture); + + location.simulateHashChange("/team/22/user/fedor"); + advance(fixture); + + expect(fixture.debugElement.nativeElement).toHaveText('team 22 { user fedor, right: }'); + }))); + + it('should support secondary routes', + fakeAsync(inject([Router, TestComponentBuilder], (router, tcb) => { + router.resetConfig([ + { name: 'team', path: 'team/:id', component: TeamCmp, children: [ + { name: 'user', path: 'user/:name', component: UserCmp }, + { name: 'simple', path: 'simple', component: SimpleCmp, outlet: 'right' } + ] } + ]); + + const fixture = tcb.createFakeAsync(RootCmp); + + router.navigateByUrl('/team/22/user/victor(simple)'); + advance(fixture); + + expect(fixture.debugElement.nativeElement) + .toHaveText('team 22 { user victor, right: simple }'); + }))); + + it('should deactivate outlets', + fakeAsync(inject([Router, TestComponentBuilder], (router, tcb) => { + router.resetConfig([ + { name: 'team', path: 'team/:id', component: TeamCmp, children: [ + { name: 'user', path: 'user/:name', component: UserCmp }, + { name: 'simple', path: 'simple', component: SimpleCmp, outlet: 'right' } + ] } + ]); + + const fixture = tcb.createFakeAsync(RootCmp); + + router.navigateByUrl('/team/22/user/victor(simple)'); + advance(fixture); + + router.navigateByUrl('/team/22/user/victor'); + advance(fixture); + + expect(fixture.debugElement.nativeElement).toHaveText('team 22 { user victor, right: }'); + }))); + + it('should deactivate nested outlets', + fakeAsync(inject([Router, TestComponentBuilder], (router, tcb) => { + router.resetConfig([ + { name: 'team', path: 'team/:id', component: TeamCmp, children: [ + { name: 'user', path: 'user/:name', component: UserCmp }, + { name: 'simple', path: 'simple', component: SimpleCmp, outlet: 'right' } + ] } + ]); + + const fixture = tcb.createFakeAsync(RootCmp); + + router.navigateByUrl('/team/22/user/victor(simple)'); + advance(fixture); + + router.navigateByUrl('/'); + advance(fixture); + + expect(fixture.debugElement.nativeElement).toHaveText(''); + }))); }); + + +@Component({ + selector: 'simple-cmp', + template: `simple`, + directives: [ROUTER_DIRECTIVES] +}) +class SimpleCmp {} + +@Component({ + selector: 'team-cmp', + template: `team {{id | async}} { , right: }`, + directives: [ROUTER_DIRECTIVES] +}) +class TeamCmp { + id: Observable; + + constructor(route: ActivatedRoute) { + this.id = route.params.map(p => p['id']); + } +} + +@Component({ + selector: 'user-cmp', + template: `user {{name | async}}`, + directives: [ROUTER_DIRECTIVES] +}) +class UserCmp { + name: Observable; + constructor(route: ActivatedRoute) { + this.name = route.params.map(p => p['name']); + } +} + +@Component({ + selector: 'root-cmp', + template: ``, + directives: [ROUTER_DIRECTIVES] +}) +class RootCmp {} + +function advance(fixture: ComponentFixture): void { + tick(); + fixture.detectChanges(); +} diff --git a/modules/@angular/router/tsconfig.json b/modules/@angular/router/tsconfig.json index 673c53eef4..b789a05c6f 100644 --- a/modules/@angular/router/tsconfig.json +++ b/modules/@angular/router/tsconfig.json @@ -21,3 +21,5 @@ "typings/index.d.ts" ] } + +