From 602522beb29208626a4593b7cf49d1bf3adbe3a1 Mon Sep 17 00:00:00 2001 From: vsavkin Date: Wed, 16 Nov 2016 17:43:04 -0800 Subject: [PATCH] fix(router): support redirects to named outlets Closes #12740, #9921 --- .../@angular/router/src/apply_redirects.ts | 193 ++++++++++++------ modules/@angular/router/src/router.ts | 2 +- .../router/test/apply_redirects.spec.ts | 106 +++++++--- 3 files changed, 198 insertions(+), 103 deletions(-) diff --git a/modules/@angular/router/src/apply_redirects.ts b/modules/@angular/router/src/apply_redirects.ts index 0662f8402f..bce789e881 100644 --- a/modules/@angular/router/src/apply_redirects.ts +++ b/modules/@angular/router/src/apply_redirects.ts @@ -20,8 +20,8 @@ import {EmptyError} from 'rxjs/util/EmptyError'; import {Route, Routes, UrlMatchResult} from './config'; import {LoadedRouterConfig, RouterConfigLoader} from './router_config_loader'; -import {NavigationCancelingError, PRIMARY_OUTLET, defaultUrlMatcher} from './shared'; -import {UrlSegment, UrlSegmentGroup, UrlTree} from './url_tree'; +import {NavigationCancelingError, PRIMARY_OUTLET, Params, defaultUrlMatcher} from './shared'; +import {UrlSegment, UrlSegmentGroup, UrlSerializer, UrlTree} from './url_tree'; import {andObservables, forEach, merge, waitForMap, wrapIntoObservable} from './utils/collection'; class NoMatch { @@ -29,7 +29,7 @@ class NoMatch { } class AbsoluteRedirect { - constructor(public segments: UrlSegment[]) {} + constructor(public urlTree: UrlTree) {} } function noMatch(segmentGroup: UrlSegmentGroup): Observable { @@ -37,9 +37,15 @@ function noMatch(segmentGroup: UrlSegmentGroup): Observable { (obs: Observer) => obs.error(new NoMatch(segmentGroup))); } -function absoluteRedirect(segments: UrlSegment[]): Observable { +function absoluteRedirect(newTree: UrlTree): Observable { return new Observable( - (obs: Observer) => obs.error(new AbsoluteRedirect(segments))); + (obs: Observer) => obs.error(new AbsoluteRedirect(newTree))); +} + +function namedOutletsRedirect(redirectTo: string): Observable { + return new Observable( + (obs: Observer) => obs.error(new Error( + `Only absolute redirects can have named outlets. redirectTo: '${redirectTo}'`))); } function canLoadFails(route: Route): Observable { @@ -50,9 +56,9 @@ function canLoadFails(route: Route): Observable { export function applyRedirects( - injector: Injector, configLoader: RouterConfigLoader, urlTree: UrlTree, - config: Routes): Observable { - return new ApplyRedirects(injector, configLoader, urlTree, config).apply(); + injector: Injector, configLoader: RouterConfigLoader, urlSerializer: UrlSerializer, + urlTree: UrlTree, config: Routes): Observable { + return new ApplyRedirects(injector, configLoader, urlSerializer, urlTree, config).apply(); } class ApplyRedirects { @@ -60,21 +66,20 @@ class ApplyRedirects { constructor( private injector: Injector, private configLoader: RouterConfigLoader, - private urlTree: UrlTree, private config: Routes) {} + private urlSerializer: UrlSerializer, private urlTree: UrlTree, private config: Routes) {} apply(): Observable { const expanded$ = this.expandSegmentGroup(this.injector, this.config, this.urlTree.root, PRIMARY_OUTLET); const urlTrees$ = map.call( - expanded$, (rootSegmentGroup: UrlSegmentGroup) => this.createUrlTree(rootSegmentGroup)); + expanded$, (rootSegmentGroup: UrlSegmentGroup) => this.createUrlTree( + rootSegmentGroup, this.urlTree.queryParams, this.urlTree.fragment)); return _catch.call(urlTrees$, (e: any) => { if (e instanceof AbsoluteRedirect) { // after an absolute redirect we do not apply any more redirects! this.allowRedirects = false; - const group = - new UrlSegmentGroup([], {[PRIMARY_OUTLET]: new UrlSegmentGroup(e.segments, {})}); // we need to run matching, so we can fetch all lazy-loaded modules - return this.match(group); + return this.match(e.urlTree); } else if (e instanceof NoMatch) { throw this.noMatchError(e); } else { @@ -83,11 +88,12 @@ class ApplyRedirects { }); } - private match(segmentGroup: UrlSegmentGroup): Observable { + private match(tree: UrlTree): Observable { const expanded$ = - this.expandSegmentGroup(this.injector, this.config, segmentGroup, PRIMARY_OUTLET); + this.expandSegmentGroup(this.injector, this.config, tree.root, PRIMARY_OUTLET); const mapped$ = map.call( - expanded$, (rootSegmentGroup: UrlSegmentGroup) => this.createUrlTree(rootSegmentGroup)); + expanded$, (rootSegmentGroup: UrlSegmentGroup) => + this.createUrlTree(rootSegmentGroup, tree.queryParams, tree.fragment)); return _catch.call(mapped$, (e: any): Observable => { if (e instanceof NoMatch) { throw this.noMatchError(e); @@ -101,11 +107,12 @@ class ApplyRedirects { return new Error(`Cannot match any routes. URL Segment: '${e.segmentGroup}'`); } - private createUrlTree(rootCandidate: UrlSegmentGroup): UrlTree { + private createUrlTree(rootCandidate: UrlSegmentGroup, queryParams: Params, fragment: string): + UrlTree { const root = rootCandidate.segments.length > 0 ? new UrlSegmentGroup([], {[PRIMARY_OUTLET]: rootCandidate}) : rootCandidate; - return new UrlTree(root, this.urlTree.queryParams, this.urlTree.fragment); + return new UrlTree(root, queryParams, fragment); } private expandSegmentGroup( @@ -191,12 +198,14 @@ class ApplyRedirects { private expandWildCardWithParamsAgainstRouteUsingRedirect( injector: Injector, routes: Route[], route: Route, outlet: string): Observable { - const newSegments = applyRedirectCommands([], route.redirectTo, {}); + const newTree = this.applyRedirectCommands([], route.redirectTo, {}); if (route.redirectTo.startsWith('/')) { - return absoluteRedirect(newSegments); + return absoluteRedirect(newTree); } else { - const group = new UrlSegmentGroup(newSegments, {}); - return this.expandSegment(injector, group, routes, newSegments, outlet, false); + return mergeMap.call(this.lineralizeSegments(route, newTree), (newSegments: UrlSegment[]) => { + const group = new UrlSegmentGroup(newSegments, {}); + return this.expandSegment(injector, group, routes, newSegments, outlet, false); + }); } } @@ -207,14 +216,16 @@ class ApplyRedirects { match(segmentGroup, route, segments); if (!matched) return noMatch(segmentGroup); - const newSegments = - applyRedirectCommands(consumedSegments, route.redirectTo, positionalParamSegments); + const newTree = this.applyRedirectCommands( + consumedSegments, route.redirectTo, positionalParamSegments); if (route.redirectTo.startsWith('/')) { - return absoluteRedirect(newSegments); + return absoluteRedirect(newTree); } else { - return this.expandSegment( - injector, segmentGroup, routes, newSegments.concat(segments.slice(lastChild)), outlet, - false); + return mergeMap.call(this.lineralizeSegments(route, newTree), (newSegments: UrlSegment[]) => { + return this.expandSegment( + injector, segmentGroup, routes, newSegments.concat(segments.slice(lastChild)), outlet, + false); + }); } } @@ -284,6 +295,92 @@ class ApplyRedirects { return of (new LoadedRouterConfig([], injector, null, null)); } } + + private lineralizeSegments(route: Route, urlTree: UrlTree): Observable { + let res: UrlSegment[] = []; + let c = urlTree.root; + while (true) { + res = res.concat(c.segments); + if (c.numberOfChildren === 0) { + return of (res); + } else if (c.numberOfChildren > 1 || !c.children[PRIMARY_OUTLET]) { + return namedOutletsRedirect(route.redirectTo); + } else { + c = c.children[PRIMARY_OUTLET]; + } + } + } + + private applyRedirectCommands( + segments: UrlSegment[], redirectTo: string, posParams: {[k: string]: UrlSegment}): UrlTree { + const t = this.urlSerializer.parse(redirectTo); + return this.applyRedirectCreatreUrlTree( + redirectTo, this.urlSerializer.parse(redirectTo), segments, posParams); + } + + private applyRedirectCreatreUrlTree( + redirectTo: string, urlTree: UrlTree, segments: UrlSegment[], + posParams: {[k: string]: UrlSegment}): UrlTree { + const newRoot = this.createSegmentGroup(redirectTo, urlTree.root, segments, posParams); + return new UrlTree( + newRoot, this.createQueryParams(urlTree.queryParams, this.urlTree.queryParams), + urlTree.fragment); + } + + private createQueryParams(redirectToParams: Params, actualParams: Params): Params { + const res: Params = {}; + forEach(redirectToParams, (v: any, k: string) => { + if (v.startsWith(':')) { + res[k] = actualParams[v.substring(1)]; + } else { + res[k] = v; + } + }); + return res; + } + + private createSegmentGroup( + redirectTo: string, group: UrlSegmentGroup, segments: UrlSegment[], + posParams: {[k: string]: UrlSegment}): UrlSegmentGroup { + const updatedSegments = this.createSegments(redirectTo, group.segments, segments, posParams); + + let children: {[n: string]: UrlSegmentGroup} = {}; + forEach(group.children, (child: UrlSegmentGroup, name: string) => { + children[name] = this.createSegmentGroup(redirectTo, child, segments, posParams); + }); + + return new UrlSegmentGroup(updatedSegments, children); + } + + private createSegments( + redirectTo: string, redirectToSegments: UrlSegment[], actualSegments: UrlSegment[], + posParams: {[k: string]: UrlSegment}): UrlSegment[] { + return redirectToSegments.map( + s => s.path.startsWith(':') ? this.findPosParam(redirectTo, s, posParams) : + this.findOrReturn(s, actualSegments)); + } + + private findPosParam( + redirectTo: string, redirectToUrlSegment: UrlSegment, + posParams: {[k: string]: UrlSegment}): UrlSegment { + const pos = posParams[redirectToUrlSegment.path.substring(1)]; + if (!pos) + throw new Error( + `Cannot redirect to '${redirectTo}'. Cannot find '${redirectToUrlSegment.path}'.`); + return pos; + } + + private findOrReturn(redirectToUrlSegment: UrlSegment, actualSegments: UrlSegment[]): UrlSegment { + let idx = 0; + for (const s of actualSegments) { + if (s.path === redirectToUrlSegment.path) { + actualSegments.splice(idx); + return s; + } + idx++; + } + return redirectToUrlSegment; + } } function runGuards(injector: Injector, route: Route): Observable { @@ -328,46 +425,6 @@ function match(segmentGroup: UrlSegmentGroup, route: Route, segments: UrlSegment }; } -function applyRedirectCommands( - segments: UrlSegment[], redirectTo: string, - posParams: {[k: string]: UrlSegment}): UrlSegment[] { - const r = redirectTo.startsWith('/') ? redirectTo.substring(1) : redirectTo; - if (r === '') { - return []; - } else { - return createSegments(redirectTo, r.split('/'), segments, posParams); - } -} - -function createSegments( - redirectTo: string, parts: string[], segments: UrlSegment[], - posParams: {[k: string]: UrlSegment}): UrlSegment[] { - return parts.map( - p => p.startsWith(':') ? findPosParam(p, posParams, redirectTo) : - findOrCreateSegment(p, segments)); -} - -function findPosParam( - part: string, posParams: {[k: string]: UrlSegment}, redirectTo: string): UrlSegment { - const paramName = part.substring(1); - const pos = posParams[paramName]; - if (!pos) throw new Error(`Cannot redirect to '${redirectTo}'. Cannot find '${part}'.`); - return pos; -} - -function findOrCreateSegment(part: string, segments: UrlSegment[]): UrlSegment { - let idx = 0; - for (const s of segments) { - if (s.path === part) { - segments.splice(idx); - return s; - } - idx++; - } - return new UrlSegment(part, {}); -} - - function split( segmentGroup: UrlSegmentGroup, consumedSegments: UrlSegment[], slicedSegments: UrlSegment[], config: Route[]) { diff --git a/modules/@angular/router/src/router.ts b/modules/@angular/router/src/router.ts index f24a95e993..5aea6eeef5 100644 --- a/modules/@angular/router/src/router.ts +++ b/modules/@angular/router/src/router.ts @@ -650,7 +650,7 @@ export class Router { let urlAndSnapshot$: Observable<{appliedUrl: UrlTree, snapshot: RouterStateSnapshot}>; if (!precreatedState) { const redirectsApplied$ = - applyRedirects(this.injector, this.configLoader, url, this.config); + applyRedirects(this.injector, this.configLoader, this.urlSerializer, url, this.config); urlAndSnapshot$ = mergeMap.call(redirectsApplied$, (appliedUrl: UrlTree) => { return map.call( diff --git a/modules/@angular/router/test/apply_redirects.spec.ts b/modules/@angular/router/test/apply_redirects.spec.ts index 4068de3504..87c60cef91 100644 --- a/modules/@angular/router/test/apply_redirects.spec.ts +++ b/modules/@angular/router/test/apply_redirects.spec.ts @@ -15,6 +15,7 @@ import {LoadedRouterConfig} from '../src/router_config_loader'; import {DefaultUrlSerializer, UrlSegmentGroup, UrlTree, equalSegments} from '../src/url_tree'; describe('applyRedirects', () => { + const serializer = new DefaultUrlSerializer(); it('should return the same url tree when no redirects', () => { checkRedirect( @@ -38,7 +39,7 @@ describe('applyRedirects', () => { }); it('should throw when cannot handle a positional parameter', () => { - applyRedirects(null, null, tree('/a/1'), [ + applyRedirects(null, null, serializer, tree('/a/1'), [ {path: 'a/:id', redirectTo: 'a/:other'} ]).subscribe(() => {}, (e) => { expect(e.message).toEqual('Cannot redirect to \'a/:other\'. Cannot find \':other\'.'); @@ -133,11 +134,11 @@ describe('applyRedirects', () => { { path: 'a', component: ComponentA, - children: [{path: 'b/:id', redirectTo: '/absolute/:id'}] + children: [{path: 'b/:id', redirectTo: '/absolute/:id?a=1&b=:b#f1'}] }, {path: '**', component: ComponentC} ], - '/a/b/1', (t: UrlTree) => { compareTrees(t, tree('/absolute/1')); }); + '/a/b/1?b=2', (t: UrlTree) => { compareTrees(t, tree('/absolute/1?a=1&b=2#f1')); }); }); describe('lazy loading', () => { @@ -153,10 +154,11 @@ describe('applyRedirects', () => { }; const config = [{path: 'a', component: ComponentA, loadChildren: 'children'}]; - applyRedirects('providedInjector', loader, tree('a/b'), config).forEach(r => { - compareTrees(r, tree('/a/b')); - expect((config[0])._loadedConfig).toBe(loadedConfig); - }); + applyRedirects('providedInjector', loader, serializer, tree('a/b'), config) + .forEach(r => { + compareTrees(r, tree('/a/b')); + expect((config[0])._loadedConfig).toBe(loadedConfig); + }); }); it('should handle the case when the loader errors', () => { @@ -165,9 +167,8 @@ describe('applyRedirects', () => { }; const config = [{path: 'a', component: ComponentA, loadChildren: 'children'}]; - applyRedirects(null, loader, tree('a/b'), config).subscribe(() => {}, (e) => { - expect(e.message).toEqual('Loading Error'); - }); + applyRedirects(null, loader, serializer, tree('a/b'), config) + .subscribe(() => {}, (e) => { expect(e.message).toEqual('Loading Error'); }); }); it('should load when all canLoad guards return true', () => { @@ -186,7 +187,7 @@ describe('applyRedirects', () => { loadChildren: 'children' }]; - applyRedirects(injector, loader, tree('a/b'), config).forEach(r => { + applyRedirects(injector, loader, serializer, tree('a/b'), config).forEach(r => { compareTrees(r, tree('/a/b')); }); }); @@ -208,7 +209,7 @@ describe('applyRedirects', () => { loadChildren: 'children' }]; - applyRedirects(injector, loader, tree('a/b'), config) + applyRedirects(injector, loader, serializer, tree('a/b'), config) .subscribe( () => { throw 'Should not reach'; }, (e) => { @@ -234,7 +235,7 @@ describe('applyRedirects', () => { loadChildren: 'children' }]; - applyRedirects(injector, loader, tree('a/b'), config) + applyRedirects(injector, loader, serializer, tree('a/b'), config) .subscribe( () => { throw 'Should not reach'; }, (e) => { expect(e).toEqual('someError'); }); }); @@ -251,7 +252,7 @@ describe('applyRedirects', () => { const config = [{path: 'a', component: ComponentA, canLoad: ['guard'], loadChildren: 'children'}]; - applyRedirects(injector, loader, tree('a/b'), config) + applyRedirects(injector, loader, serializer, tree('a/b'), config) .subscribe( (r) => { compareTrees(r, tree('/a/b')); }, (e) => { throw 'Should not reach'; }); @@ -267,10 +268,11 @@ describe('applyRedirects', () => { const config = [{path: '', pathMatch: 'full', redirectTo: '/a'}, {path: 'a', loadChildren: 'children'}]; - applyRedirects('providedInjector', loader, tree(''), config).forEach(r => { - compareTrees(r, tree('a')); - expect((config[1])._loadedConfig).toBe(loadedConfig); - }); + applyRedirects('providedInjector', loader, serializer, tree(''), config) + .forEach(r => { + compareTrees(r, tree('a')); + expect((config[1])._loadedConfig).toBe(loadedConfig); + }); }); it('should load the configuration only once', () => { @@ -289,12 +291,13 @@ describe('applyRedirects', () => { const config = [{path: 'a', loadChildren: 'children'}]; - applyRedirects('providedInjector', loader, tree('a?k1'), config).subscribe(r => {}); + applyRedirects('providedInjector', loader, serializer, tree('a?k1'), config) + .subscribe(r => {}); - applyRedirects('providedInjector', loader, tree('a?k2'), config) + applyRedirects('providedInjector', loader, serializer, tree('a?k2'), config) .subscribe( r => { - compareTrees(r, tree('a')); + compareTrees(r, tree('a?k2')); expect((config[0])._loadedConfig).toBe(loadedConfig); }, (e) => { throw 'Should not reach'; }); @@ -309,9 +312,8 @@ describe('applyRedirects', () => { const config = [{path: '**', loadChildren: 'children'}]; - applyRedirects('providedInjector', loader, tree('xyz'), config).forEach(r => { - expect((config[0])._loadedConfig).toBe(loadedConfig); - }); + applyRedirects('providedInjector', loader, serializer, tree('xyz'), config) + .forEach(r => { expect((config[0])._loadedConfig).toBe(loadedConfig); }); }); it('should load the configuration after a local redirect from a wildcard route', () => { @@ -324,9 +326,8 @@ describe('applyRedirects', () => { const config = [{path: 'not-found', loadChildren: 'children'}, {path: '**', redirectTo: 'not-found'}]; - applyRedirects('providedInjector', loader, tree('xyz'), config).forEach(r => { - expect((config[0])._loadedConfig).toBe(loadedConfig); - }); + applyRedirects('providedInjector', loader, serializer, tree('xyz'), config) + .forEach(r => { expect((config[0])._loadedConfig).toBe(loadedConfig); }); }); it('should load the configuration after an absolute redirect from a wildcard route', () => { @@ -339,9 +340,8 @@ describe('applyRedirects', () => { const config = [{path: 'not-found', loadChildren: 'children'}, {path: '**', redirectTo: '/not-found'}]; - applyRedirects('providedInjector', loader, tree('xyz'), config).forEach(r => { - expect((config[0])._loadedConfig).toBe(loadedConfig); - }); + applyRedirects('providedInjector', loader, serializer, tree('xyz'), config) + .forEach(r => { expect((config[0])._loadedConfig).toBe(loadedConfig); }); }); }); @@ -388,7 +388,7 @@ describe('applyRedirects', () => { {path: '', redirectTo: 'a', pathMatch: 'full'} ]; - applyRedirects(null, null, tree('b'), config) + applyRedirects(null, null, serializer, tree('b'), config) .subscribe( (_) => { throw 'Should not be reached'; }, e => { expect(e.message).toEqual('Cannot match any routes. URL Segment: \'b\''); }); @@ -518,7 +518,7 @@ describe('applyRedirects', () => { ] }]; - applyRedirects(null, null, tree('a/(d//aux:e)'), config) + applyRedirects(null, null, serializer, tree('a/(d//aux:e)'), config) .subscribe( (_) => { throw 'Should not be reached'; }, e => { expect(e.message).toEqual('Cannot match any routes. URL Segment: \'a\''); }); @@ -549,7 +549,7 @@ describe('applyRedirects', () => { it('should error when no children matching and some url is left', () => { applyRedirects( - null, null, tree('/a/c'), + null, null, serializer, tree('/a/c'), [{path: 'a', component: ComponentA, children: [{path: 'b', component: ComponentB}]}]) .subscribe( (_) => { throw 'Should not be reached'; }, @@ -576,10 +576,46 @@ describe('applyRedirects', () => { '/a/1/b', (t: UrlTree) => { compareTrees(t, tree('a/1/b')); }); }); }); + + describe('redirecting to named outlets', () => { + it('should work when using absolute redirects', () => { + checkRedirect( + [ + {path: 'a/:id', redirectTo: '/b/:id(aux:c/:id)'}, + {path: 'b/:id', component: ComponentB}, + {path: 'c/:id', component: ComponentC, outlet: 'aux'} + ], + 'a/1;p=99', (t: UrlTree) => { compareTrees(t, tree('/b/1;p=99(aux:c/1;p=99)')); }); + }); + + it('should work when using absolute redirects (wildcard)', () => { + checkRedirect( + [ + {path: '**', redirectTo: '/b(aux:c)'}, {path: 'b', component: ComponentB}, + {path: 'c', component: ComponentC, outlet: 'aux'} + ], + 'a/1', (t: UrlTree) => { compareTrees(t, tree('/b(aux:c)')); }); + }); + + it('should throw when using non-absolute redirects', () => { + applyRedirects( + null, null, serializer, tree('a'), + [ + {path: 'a', redirectTo: 'b(aux:c)'}, + ]) + .subscribe( + () => { throw new Error('should not be reached'); }, + (e) => { + expect(e.message).toEqual( + 'Only absolute redirects can have named outlets. redirectTo: \'b(aux:c)\''); + }); + }); + }); }); function checkRedirect(config: Routes, url: string, callback: any): void { - applyRedirects(null, null, tree(url), config).subscribe(callback, e => { throw e; }); + applyRedirects(null, null, new DefaultUrlSerializer(), tree(url), config) + .subscribe(callback, e => { throw e; }); } function tree(url: string): UrlTree { @@ -591,6 +627,8 @@ function compareTrees(actual: UrlTree, expected: UrlTree): void { const error = `"${serializer.serialize(actual)}" is not equal to "${serializer.serialize(expected)}"`; compareSegments(actual.root, expected.root, error); + expect(actual.queryParams).toEqual(expected.queryParams); + expect(actual.fragment).toEqual(expected.fragment); } function compareSegments(actual: UrlSegmentGroup, expected: UrlSegmentGroup, error: string): void {