From 62e7c0f464c0b41b0756a92313f11d70db7c1fcf Mon Sep 17 00:00:00 2001 From: vsavkin Date: Tue, 26 Jul 2016 14:39:02 -0700 Subject: [PATCH] feat(router): implement canLoad --- modules/@angular/router/index.ts | 2 +- .../@angular/router/src/apply_redirects.ts | 35 ++++- modules/@angular/router/src/config.ts | 1 + modules/@angular/router/src/interfaces.ts | 53 +++++++- modules/@angular/router/src/router.ts | 17 +-- .../@angular/router/src/utils/collection.ts | 15 +++ .../router/test/apply_redirects.spec.ts | 83 ++++++++++++ modules/@angular/router/test/router.spec.ts | 120 ++++++++++++++---- tools/public_api_guard/router/index.d.ts | 5 + 9 files changed, 282 insertions(+), 49 deletions(-) diff --git a/modules/@angular/router/index.ts b/modules/@angular/router/index.ts index 193864b841..a59d64b7c4 100644 --- a/modules/@angular/router/index.ts +++ b/modules/@angular/router/index.ts @@ -12,7 +12,7 @@ export {Data, ResolveData, Route, RouterConfig, Routes} from './src/config'; export {RouterLink, RouterLinkWithHref} from './src/directives/router_link'; export {RouterLinkActive} from './src/directives/router_link_active'; export {RouterOutlet} from './src/directives/router_outlet'; -export {CanActivate, CanActivateChild, CanDeactivate, Resolve} from './src/interfaces'; +export {CanActivate, CanActivateChild, CanDeactivate, CanLoad, Resolve} from './src/interfaces'; export {Event, NavigationCancel, NavigationEnd, NavigationError, NavigationExtras, NavigationStart, Router, RoutesRecognized} from './src/router'; export {ROUTER_DIRECTIVES, RouterModule, RouterModuleWithoutProviders} from './src/router_module'; export {RouterOutletMap} from './src/router_outlet_map'; diff --git a/modules/@angular/router/src/apply_redirects.ts b/modules/@angular/router/src/apply_redirects.ts index 04ef62e489..d0283e6a7e 100644 --- a/modules/@angular/router/src/apply_redirects.ts +++ b/modules/@angular/router/src/apply_redirects.ts @@ -13,6 +13,7 @@ import 'rxjs/add/operator/concatAll'; import {Injector} from '@angular/core'; import {Observable} from 'rxjs/Observable'; import {Observer} from 'rxjs/Observer'; +import {from} from 'rxjs/observable/from'; import {of } from 'rxjs/observable/of'; import {EmptyError} from 'rxjs/util/EmptyError'; @@ -20,7 +21,7 @@ import {Route, Routes} from './config'; import {LoadedRouterConfig, RouterConfigLoader} from './router_config_loader'; import {PRIMARY_OUTLET} from './shared'; import {UrlSegment, UrlSegmentGroup, UrlTree} from './url_tree'; -import {merge, waitForMap} from './utils/collection'; +import {andObservables, merge, waitForMap, wrapIntoObservable} from './utils/collection'; class NoMatch { constructor(public segmentGroup: UrlSegmentGroup = null) {} @@ -40,6 +41,12 @@ function absoluteRedirect(segments: UrlSegment[]): Observable { (obs: Observer) => obs.error(new AbsoluteRedirect(segments))); } +function canLoadFails(route: Route): Observable { + return new Observable( + (obs: Observer) => obs.error(new Error( + `Cannot load children because the guard of the route "path: '${route.path}'" returned false`))); +} + export function applyRedirects( injector: Injector, configLoader: RouterConfigLoader, urlTree: UrlTree, @@ -209,15 +216,35 @@ function getChildConfig(injector: Injector, configLoader: RouterConfigLoader, ro if (route.children) { return of (new LoadedRouterConfig(route.children, injector, null)); } else if (route.loadChildren) { - return configLoader.load(injector, route.loadChildren).map(r => { - (route)._loadedConfig = r; - return r; + return runGuards(injector, route).mergeMap(shouldLoad => { + if (shouldLoad) { + return configLoader.load(injector, route.loadChildren).map(r => { + (route)._loadedConfig = r; + return r; + }); + } else { + return canLoadFails(route); + } }); } else { return of (new LoadedRouterConfig([], injector, null)); } } +function runGuards(injector: Injector, route: Route): Observable { + const canLoad = route.canLoad; + if (!canLoad || canLoad.length === 0) return of (true); + const obs = from(canLoad).map(c => { + const guard = injector.get(c); + if (guard.canLoad) { + return wrapIntoObservable(guard.canLoad(route)); + } else { + return wrapIntoObservable(guard(route)); + } + }); + return andObservables(obs); +} + function match(segmentGroup: UrlSegmentGroup, route: Route, segments: UrlSegment[]): { matched: boolean, consumedSegments: UrlSegment[], diff --git a/modules/@angular/router/src/config.ts b/modules/@angular/router/src/config.ts index c2e48df9f9..18802622fe 100644 --- a/modules/@angular/router/src/config.ts +++ b/modules/@angular/router/src/config.ts @@ -492,6 +492,7 @@ export interface Route { canActivate?: any[]; canActivateChild?: any[]; canDeactivate?: any[]; + canLoad?: any[]; data?: Data; resolve?: ResolveData; children?: Route[]; diff --git a/modules/@angular/router/src/interfaces.ts b/modules/@angular/router/src/interfaces.ts index 70638625ed..03be15de4a 100644 --- a/modules/@angular/router/src/interfaces.ts +++ b/modules/@angular/router/src/interfaces.ts @@ -7,8 +7,11 @@ */ import {Observable} from 'rxjs/Observable'; + +import {Route} from './config'; import {ActivatedRouteSnapshot, RouterStateSnapshot} from './router_state'; + /** * An interface a class can implement to be a guard deciding if a route can be activated. * @@ -68,7 +71,7 @@ export interface CanActivate { * * canActivateChild(route: ActivatedRouteSnapshot, state: RouterStateSnapshot):Observable * { - * return this.permissions.canActivate(this.currentUser, this.route.params.id); + * return this.permissions.canActivate(this.currentUser, route.params.id); * } * } * @@ -128,7 +131,7 @@ export interface CanActivateChild { * constructor(private permissions: Permissions, private currentUser: UserToken) {} * * canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot):Observable { - * return this.permissions.canDeactivate(this.currentUser, this.route.params.id); + * return this.permissions.canDeactivate(this.currentUser, route.params.id); * } * } * @@ -200,3 +203,49 @@ export interface Resolve { resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable|Promise|any; } + + +/** + * An interface a class can implement to be a guard deciding if a children can be loaded. + * + * ### Example + * + * ``` + * @Injectable() + * class CanLoadTeamSection implements CanActivate { + * constructor(private permissions: Permissions, private currentUser: UserToken) {} + * + * canLoad(route: Route):Observable { + * return this.permissions.canLoadChildren(this.currentUser, route); + * } + * } + * + * bootstrap(AppComponent, [ + * CanLoadTeamSection, + * + * provideRouter([{ + * path: 'team/:id', + * component: Team, + * loadChildren: 'team.js', + * canLoad: [CanLoadTeamSection] + * }]) + * ]); + * ``` + * + * You can also provide a function with the same signature instead of the class: + * + * ``` + * bootstrap(AppComponent, [ + * {provide: 'canLoadTeamSection', useValue: (route: Route) => true}, + * provideRouter([{ + * path: 'team/:id', + * component: Team, + * loadChildren: 'team.js', + * canLoad: ['canLoadTeamSection'] + * }]) + * ]); + * ``` + * + * @stable + */ +export interface CanLoad { canLoad(route: Route): Observable|Promise|boolean; } \ No newline at end of file diff --git a/modules/@angular/router/src/router.ts b/modules/@angular/router/src/router.ts index 1818802dbf..07551acccb 100644 --- a/modules/@angular/router/src/router.ts +++ b/modules/@angular/router/src/router.ts @@ -18,7 +18,6 @@ import {Observable} from 'rxjs/Observable'; import {Subject} from 'rxjs/Subject'; import {Subscription} from 'rxjs/Subscription'; import {from} from 'rxjs/observable/from'; -import {fromPromise} from 'rxjs/observable/fromPromise'; import {of } from 'rxjs/observable/of'; import {applyRedirects} from './apply_redirects'; @@ -33,7 +32,7 @@ import {RouterOutletMap} from './router_outlet_map'; import {ActivatedRoute, ActivatedRouteSnapshot, RouterState, RouterStateSnapshot, advanceActivatedRoute, createEmptyState} from './router_state'; import {PRIMARY_OUTLET, Params} from './shared'; import {UrlSerializer, UrlTree, createEmptyUrlTree} from './url_tree'; -import {forEach, merge, shallowEqual, waitForMap} from './utils/collection'; +import {andObservables, forEach, merge, shallowEqual, waitForMap, wrapIntoObservable} from './utils/collection'; import {TreeNode} from './utils/tree'; declare var Zone: any; @@ -628,16 +627,6 @@ class PreActivation { } } -function wrapIntoObservable(value: T | Observable): Observable { - if (value instanceof Observable) { - return value; - } else if (value instanceof Promise) { - return fromPromise(value); - } else { - return of (value); - } -} - class ActivateRoutes { constructor(private futureState: RouterState, private currState: RouterState) {} @@ -755,10 +744,6 @@ function closestLoadedConfig( return b.length > 0 ? (b[b.length - 1])._routeConfig._loadedConfig : null; } -function andObservables(observables: Observable>): Observable { - return observables.mergeAll().every(result => result === true); -} - function pushQueryParamsAndFragment(state: RouterState): void { if (!shallowEqual(state.snapshot.queryParams, (state.queryParams).value)) { (state.queryParams).next(state.snapshot.queryParams); diff --git a/modules/@angular/router/src/utils/collection.ts b/modules/@angular/router/src/utils/collection.ts index af5f3737fe..551d076140 100644 --- a/modules/@angular/router/src/utils/collection.ts +++ b/modules/@angular/router/src/utils/collection.ts @@ -10,6 +10,7 @@ import 'rxjs/add/operator/concatAll'; import 'rxjs/add/operator/last'; import {Observable} from 'rxjs/Observable'; +import {fromPromise} from 'rxjs/observable/fromPromise'; import {of } from 'rxjs/observable/of'; import {PRIMARY_OUTLET} from '../shared'; @@ -115,4 +116,18 @@ export function waitForMap( } else { return of (res); } +} + +export function andObservables(observables: Observable>): Observable { + return observables.mergeAll().every(result => result === true); +} + +export function wrapIntoObservable(value: T | Observable): Observable { + if (value instanceof Observable) { + return value; + } else if (value instanceof Promise) { + return fromPromise(value); + } else { + return of (value); + } } \ No newline at end of file diff --git a/modules/@angular/router/test/apply_redirects.spec.ts b/modules/@angular/router/test/apply_redirects.spec.ts index c9f635bf88..bc986f10f6 100644 --- a/modules/@angular/router/test/apply_redirects.spec.ts +++ b/modules/@angular/router/test/apply_redirects.spec.ts @@ -168,6 +168,89 @@ describe('applyRedirects', () => { expect(e.message).toEqual('Loading Error'); }); }); + + it('should load when all canLoad guards return true', () => { + const loadedConfig = new LoadedRouterConfig( + [{path: 'b', component: ComponentB}], 'stubInjector', 'stubFactoryResolver'); + const loader = {load: (injector: any, p: any) => of (loadedConfig)}; + + const guard = () => true; + const injector = {get: () => guard}; + + const config = [{ + path: 'a', + component: ComponentA, + canLoad: ['guard1', 'guard2'], + loadChildren: 'children' + }]; + + applyRedirects(injector, loader, tree('a/b'), config).forEach(r => { + compareTrees(r, tree('/a/b')); + }); + }); + + it('should not load when any canLoad guards return false', () => { + const loadedConfig = new LoadedRouterConfig( + [{path: 'b', component: ComponentB}], 'stubInjector', 'stubFactoryResolver'); + const loader = {load: (injector: any, p: any) => of (loadedConfig)}; + + const trueGuard = () => true; + const falseGuard = () => false; + const injector = {get: (guardName: any) => guardName === 'guard1' ? trueGuard : falseGuard}; + + const config = [{ + path: 'a', + component: ComponentA, + canLoad: ['guard1', 'guard2'], + loadChildren: 'children' + }]; + + applyRedirects(injector, loader, tree('a/b'), config) + .subscribe( + () => { throw 'Should not reach'; }, + (e) => { + expect(e.message).toEqual( + `Cannot load children because the guard of the route "path: 'a'" returned false`); + }); + }); + + it('should not load when any canLoad guards is rejected (promises)', () => { + const loadedConfig = new LoadedRouterConfig( + [{path: 'b', component: ComponentB}], 'stubInjector', 'stubFactoryResolver'); + const loader = {load: (injector: any, p: any) => of (loadedConfig)}; + + const trueGuard = () => Promise.resolve(true); + const falseGuard = () => Promise.reject('someError'); + const injector = {get: (guardName: any) => guardName === 'guard1' ? trueGuard : falseGuard}; + + const config = [{ + path: 'a', + component: ComponentA, + canLoad: ['guard1', 'guard2'], + loadChildren: 'children' + }]; + + applyRedirects(injector, loader, tree('a/b'), config) + .subscribe( + () => { throw 'Should not reach'; }, (e) => { expect(e).toEqual('someError'); }); + }); + + it('should work with objects implementing the CanLoad interface', () => { + const loadedConfig = new LoadedRouterConfig( + [{path: 'b', component: ComponentB}], 'stubInjector', 'stubFactoryResolver'); + const loader = {load: (injector: any, p: any) => of (loadedConfig)}; + + const guard = {canLoad: () => Promise.resolve(true)}; + const injector = {get: () => guard}; + + const config = + [{path: 'a', component: ComponentA, canLoad: ['guard'], loadChildren: 'children'}]; + + applyRedirects(injector, loader, tree('a/b'), config) + .subscribe( + (r) => { compareTrees(r, tree('/a/b')); }, (e) => { throw 'Should not reach'; }); + + }); }); describe('empty paths', () => { diff --git a/modules/@angular/router/test/router.spec.ts b/modules/@angular/router/test/router.spec.ts index 3aaed823cc..57b343f5b2 100644 --- a/modules/@angular/router/test/router.spec.ts +++ b/modules/@angular/router/test/router.spec.ts @@ -1116,6 +1116,36 @@ describe('Integration', () => { expect(location.path()).toEqual('/team/33'); }))); }); + + + describe('should work when returns an observable', () => { + beforeEach(() => { + addProviders([{ + provide: 'CanDeactivate', + useValue: (c: TeamCmp, a: ActivatedRouteSnapshot, b: RouterStateSnapshot) => { + return of (false); + } + }]); + }); + + it('works', + fakeAsync(inject( + [Router, TestComponentBuilder, Location], + (router: Router, tcb: TestComponentBuilder, location: Location) => { + const fixture = createRoot(tcb, router, RootCmp); + + router.resetConfig( + [{path: 'team/:id', component: TeamCmp, canDeactivate: ['CanDeactivate']}]); + + router.navigateByUrl('/team/22'); + advance(fixture); + expect(location.path()).toEqual('/team/22'); + + router.navigateByUrl('/team/33'); + advance(fixture); + expect(location.path()).toEqual('/team/22'); + }))); + }); }); describe('CanActivateChild', () => { @@ -1151,33 +1181,71 @@ describe('Integration', () => { }); }); - describe('should work when returns an observable', () => { - beforeEach(() => { - addProviders([{ - provide: 'CanDeactivate', - useValue: (c: TeamCmp, a: ActivatedRouteSnapshot, b: RouterStateSnapshot) => { - return of (false); - } - }]); + describe('CanLoad', () => { + describe('should not load children when CanLoad returns false', () => { + beforeEach(() => { + addProviders([ + {provide: 'alwaysFalse', useValue: (a: any) => false}, + {provide: 'alwaysTrue', useValue: (a: any) => true} + ]); + }); + + it('works', + fakeAsync(inject( + [Router, TestComponentBuilder, Location, NgModuleFactoryLoader], + (router: Router, tcb: TestComponentBuilder, location: Location, + loader: SpyNgModuleFactoryLoader) => { + + @Component({selector: 'lazy', template: 'lazy-loaded'}) + class LazyLoadedComponent { + } + + @NgModule({ + declarations: [LazyLoadedComponent], + providers: [provideRoutes([{path: 'loaded', component: LazyLoadedComponent}])], + imports: [RouterModuleWithoutProviders], + entryComponents: [LazyLoadedComponent] + }) + class LoadedModule { + } + + loader.stubbedModules = {lazyFalse: LoadedModule, lazyTrue: LoadedModule}; + const fixture = createRoot(tcb, router, RootCmp); + + router.resetConfig([ + {path: 'lazyFalse', canLoad: ['alwaysFalse'], loadChildren: 'lazyFalse'}, + {path: 'lazyTrue', canLoad: ['alwaysTrue'], loadChildren: 'lazyTrue'} + ]); + + const recordedEvents: any[] = []; + router.events.forEach(e => recordedEvents.push(e)); + + + // failed navigation + router.navigateByUrl('/lazyFalse/loaded').catch(s => {}); + advance(fixture); + + expect(location.path()).toEqual('/'); + + expectEvents(recordedEvents, [ + [NavigationStart, '/lazyFalse/loaded'], [NavigationError, '/lazyFalse/loaded'] + ]); + + recordedEvents.splice(0); + + + // successful navigation + router.navigateByUrl('/lazyTrue/loaded'); + advance(fixture); + + expect(location.path()).toEqual('/lazyTrue/loaded'); + + expectEvents(recordedEvents, [ + [NavigationStart, '/lazyTrue/loaded'], [RoutesRecognized, '/lazyTrue/loaded'], + [NavigationEnd, '/lazyTrue/loaded'] + ]); + }))); }); - - it('works', - fakeAsync(inject( - [Router, TestComponentBuilder, Location], - (router: Router, tcb: TestComponentBuilder, location: Location) => { - const fixture = createRoot(tcb, router, RootCmp); - - router.resetConfig( - [{path: 'team/:id', component: TeamCmp, canDeactivate: ['CanDeactivate']}]); - - router.navigateByUrl('/team/22'); - advance(fixture); - expect(location.path()).toEqual('/team/22'); - - router.navigateByUrl('/team/33'); - advance(fixture); - expect(location.path()).toEqual('/team/22'); - }))); }); }); diff --git a/tools/public_api_guard/router/index.d.ts b/tools/public_api_guard/router/index.d.ts index 66498cb7a1..98ac22019b 100644 --- a/tools/public_api_guard/router/index.d.ts +++ b/tools/public_api_guard/router/index.d.ts @@ -34,6 +34,11 @@ export interface CanDeactivate { canDeactivate(component: T, route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable | Promise | boolean; } +/** @stable */ +export interface CanLoad { + canLoad(route: Route): Observable | Promise | boolean; +} + /** @stable */ export declare type Data = { [name: string]: any;