diff --git a/modules/@angular/router/src/apply_redirects.ts b/modules/@angular/router/src/apply_redirects.ts new file mode 100644 index 0000000000..6d0a47488e --- /dev/null +++ b/modules/@angular/router/src/apply_redirects.ts @@ -0,0 +1,160 @@ +import {Observable} from 'rxjs/Observable'; +import {of } from 'rxjs/observable/of'; + +import {Route, RouterConfig} from './config'; +import {PRIMARY_OUTLET} from './shared'; +import {UrlSegment, UrlTree} from './url_tree'; +import {first} from './utils/collection'; +import {TreeNode} from './utils/tree'; + +class NoMatch {} +class GlobalRedirect { + constructor(public segments: UrlSegment[]) {} +} + +export function applyRedirects(urlTree: UrlTree, config: RouterConfig): Observable { + try { + const transformedChildren = urlTree._root.children.map(c => applyNode(config, c)); + return createUrlTree(urlTree, transformedChildren); + } catch (e) { + if (e instanceof GlobalRedirect) { + return createUrlTree(urlTree, [constructNodes(e.segments, [], [])]); + } else if (e instanceof NoMatch) { + return new Observable(obs => obs.error(new Error('Cannot match any routes'))); + } else { + return new Observable(obs => obs.error(e)); + } + } +} + +function createUrlTree(urlTree: UrlTree, children: TreeNode[]): Observable { + const transformedRoot = new TreeNode(urlTree.root, children); + return of (new UrlTree(transformedRoot, urlTree.queryParams, urlTree.fragment)); +} + +function applyNode(config: Route[], url: TreeNode): TreeNode { + for (let r of config) { + try { + return matchNode(config, r, url); + } catch (e) { + if (!(e instanceof NoMatch)) throw e; + } + } + throw new NoMatch(); +} + +function matchNode(config: Route[], route: Route, url: TreeNode): TreeNode { + if (!route.path) throw new NoMatch(); + if ((route.outlet ? route.outlet : PRIMARY_OUTLET) !== url.value.outlet) { + throw new NoMatch(); + } + + if (route.path === '**') { + const newSegments = applyRedirectCommands([], route.redirectTo, {}); + return constructNodes(newSegments, [], []); + } + + const m = match(route, url); + if (!m) throw new NoMatch(); + const {consumedUrlSegments, lastSegment, lastParent, positionalParamSegments} = m; + + const newSegments = + applyRedirectCommands(consumedUrlSegments, route.redirectTo, positionalParamSegments); + + const childConfig = route.children ? route.children : []; + const transformedChildren = lastSegment.children.map(c => applyNode(childConfig, c)); + + const secondarySubtrees = lastParent ? lastParent.children.slice(1) : []; + const transformedSecondarySubtrees = secondarySubtrees.map(c => applyNode(config, c)); + + return constructNodes(newSegments, transformedChildren, transformedSecondarySubtrees); +} + +export function match(route: Route, url: TreeNode) { + const path = route.path.startsWith('/') ? route.path.substring(1) : route.path; + const parts = path.split('/'); + const positionalParamSegments = {}; + const consumedUrlSegments = []; + + let lastParent: TreeNode|null = null; + let lastSegment: TreeNode|null = null; + + let current: TreeNode|null = url; + for (let i = 0; i < parts.length; ++i) { + if (!current) return null; + + const p = parts[i]; + const isLastSegment = i === parts.length - 1; + const isLastParent = i === parts.length - 2; + const isPosParam = p.startsWith(':'); + + if (!isPosParam && p != current.value.path) return null; + if (isLastSegment) { + lastSegment = current; + } + if (isLastParent) { + lastParent = current; + } + if (isPosParam) { + positionalParamSegments[p.substring(1)] = current.value; + } + consumedUrlSegments.push(current.value); + current = first(current.children); + } + if (!lastSegment) throw 'Cannot be reached'; + return {consumedUrlSegments, lastSegment, lastParent, positionalParamSegments}; +} + +function constructNodes( + segments: UrlSegment[], children: TreeNode[], + secondary: TreeNode[]): TreeNode { + let prevChildren = children; + for (let i = segments.length - 1; i >= 0; --i) { + if (i === segments.length - 2) { + prevChildren = [new TreeNode(segments[i], prevChildren.concat(secondary))]; + } else { + prevChildren = [new TreeNode(segments[i], prevChildren)]; + } + } + return prevChildren[0]; +} + +function applyRedirectCommands( + segments: UrlSegment[], redirectTo: string, + posParams: {[k: string]: UrlSegment}): UrlSegment[] { + if (!redirectTo) return segments; + + if (redirectTo.startsWith('/')) { + const parts = redirectTo.substring(1).split('/'); + throw new GlobalRedirect(createSegments(redirectTo, parts, segments, posParams)); + } else { + return createSegments(redirectTo, redirectTo.split('/'), segments, posParams); + } +} + +function createSegments( + redirectTo: string, parts: string[], segments: UrlSegment[], + posParams: {[k: string]: UrlSegment}): UrlSegment[] { + return parts.map( + p => p.startsWith(':') ? findPosParamSegment(p, posParams, redirectTo) : + findOrCreateSegment(p, segments)); +} + +function findPosParamSegment( + 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 { + const matchingIndex = segments.findIndex(s => s.path === part); + if (matchingIndex > -1) { + const r = segments[matchingIndex]; + segments.splice(matchingIndex); + return r; + } else { + return new UrlSegment(part, {}, PRIMARY_OUTLET); + } +} diff --git a/modules/@angular/router/src/config.ts b/modules/@angular/router/src/config.ts index d8b959e065..754a22a24f 100644 --- a/modules/@angular/router/src/config.ts +++ b/modules/@angular/router/src/config.ts @@ -5,9 +5,10 @@ export type RouterConfig = Route[]; export interface Route { index?: boolean; path?: string; - component: Type|string; + component?: Type|string; outlet?: string; canActivate?: any[]; canDeactivate?: any[]; + redirectTo?: string; children?: Route[]; } \ No newline at end of file diff --git a/modules/@angular/router/src/recognize.ts b/modules/@angular/router/src/recognize.ts index e78a51f3ce..b2bd8cc598 100644 --- a/modules/@angular/router/src/recognize.ts +++ b/modules/@angular/router/src/recognize.ts @@ -1,11 +1,13 @@ import {Type} from '@angular/core'; import {Observable} from 'rxjs/Observable'; +import {of } from 'rxjs/observable/of'; +import {match} from './apply_redirects'; import {Route, RouterConfig} from './config'; import {ActivatedRouteSnapshot, RouterStateSnapshot} from './router_state'; import {PRIMARY_OUTLET} from './shared'; import {UrlSegment, UrlTree} from './url_tree'; -import {first, flatten, merge} from './utils/collection'; +import {first, flatten, forEach, merge} from './utils/collection'; import {TreeNode} from './utils/tree'; class CannotRecognize {} @@ -17,11 +19,7 @@ export function recognize( rootComponentType, config, [url.root], {}, url._root.children, [], PRIMARY_OUTLET, null, url.root); const roots = constructActivatedRoute(match); - const res = new RouterStateSnapshot(roots[0], url.queryParams, url.fragment); - return new Observable(obs => { - obs.next(res); - obs.complete(); - }); + return of (new RouterStateSnapshot(roots[0], url.queryParams, url.fragment)); } catch (e) { if (e instanceof CannotRecognize) { return new Observable( @@ -66,7 +64,7 @@ function createActivatedRouteSnapshot(match: MatchResult): ActivatedRouteSnapsho function recognizeOne( config: Route[], url: TreeNode): TreeNode[] { - const matches = match(config, url); + const matches = matchNode(config, url); for (let match of matches) { try { const primary = constructActivatedRoute(match); @@ -98,7 +96,7 @@ function checkOutletNameUniqueness(nodes: TreeNode[]): return nodes; } -function match(config: Route[], url: TreeNode): MatchResult[] { +function matchNode(config: Route[], url: TreeNode): MatchResult[] { const res = []; for (let r of config) { if (r.index) { @@ -148,43 +146,14 @@ function matchWithParts(route: Route, url: TreeNode): MatchResult|nu route.component, [], consumedUrl, last.parameters, [], [], PRIMARY_OUTLET, route, last); } - const parts = path.split('/'); - const positionalParams = {}; - const consumedUrlSegments = []; - - let lastParent: TreeNode|null = null; - let lastSegment: TreeNode|null = null; - - let current: TreeNode|null = url; - for (let i = 0; i < parts.length; ++i) { - if (!current) return null; - - const p = parts[i]; - const isLastSegment = i === parts.length - 1; - const isLastParent = i === parts.length - 2; - const isPosParam = p.startsWith(':'); - - if (!isPosParam && p != current.value.path) return null; - if (isLastSegment) { - lastSegment = current; - } - if (isLastParent) { - lastParent = current; - } - - if (isPosParam) { - positionalParams[p.substring(1)] = current.value.path; - } - - consumedUrlSegments.push(current.value); - - current = first(current.children); - } - - if (!lastSegment) throw 'Cannot be reached'; + const m = match(route, url); + if (!m) return null; + const {consumedUrlSegments, lastSegment, lastParent, positionalParamSegments} = m; const p = lastSegment.value.parameters; - const parameters = <{[key: string]: string}>merge(p, positionalParams); + const posParams = {}; + forEach(positionalParamSegments, (v, k) => { posParams[k] = v.path; }); + const parameters = <{[key: string]: string}>merge(p, posParams); const secondarySubtrees = lastParent ? lastParent.children.slice(1) : []; const children = route.children ? route.children : []; const outlet = route.outlet ? route.outlet : PRIMARY_OUTLET; diff --git a/modules/@angular/router/src/router.ts b/modules/@angular/router/src/router.ts index 6bb67bbba0..943a663e61 100644 --- a/modules/@angular/router/src/router.ts +++ b/modules/@angular/router/src/router.ts @@ -14,6 +14,7 @@ import {Subject} from 'rxjs/Subject'; import {Subscription} from 'rxjs/Subscription'; import {of } from 'rxjs/observable/of'; +import {applyRedirects} from './apply_redirects'; import {RouterConfig} from './config'; import {createRouterState} from './create_router_state'; import {createUrlTree} from './create_url_tree'; @@ -235,8 +236,14 @@ export class Router { } return new Promise((resolvePromise, rejectPromise) => { + let updatedUrl; let state; - recognize(this.rootComponentType, this.config, url) + applyRedirects(url, this.config) + .mergeMap(u => { + updatedUrl = u; + return recognize(this.rootComponentType, this.config, updatedUrl); + }) + .mergeMap((newRouterStateSnapshot) => { return resolve(this.resolver, newRouterStateSnapshot); @@ -265,7 +272,7 @@ export class Router { this.currentUrlTree = url; this.currentRouterState = state; if (!pop) { - this.location.go(this.urlSerializer.serialize(url)); + this.location.go(this.urlSerializer.serialize(updatedUrl)); } }) .then( diff --git a/modules/@angular/router/src/utils/collection.ts b/modules/@angular/router/src/utils/collection.ts index 7926aa085e..460ea0646a 100644 --- a/modules/@angular/router/src/utils/collection.ts +++ b/modules/@angular/router/src/utils/collection.ts @@ -28,6 +28,10 @@ export function first(a: T[]): T|null { return a.length > 0 ? a[0] : null; } +export function last(a: T[]): T|null { + return a.length > 0 ? a[a.length - 1] : null; +} + export function and(bools: boolean[]): boolean { return bools.reduce((a, b) => a && b, true); } diff --git a/modules/@angular/router/test/apply_redirects.spec.ts b/modules/@angular/router/test/apply_redirects.spec.ts new file mode 100644 index 0000000000..ba22116ef6 --- /dev/null +++ b/modules/@angular/router/test/apply_redirects.spec.ts @@ -0,0 +1,109 @@ +import {DefaultUrlSerializer} from '../src/url_serializer'; +import {TreeNode} from '../src/utils/tree'; +import {UrlTree, UrlSegment, equalUrlSegments} from '../src/url_tree'; +import {Params, PRIMARY_OUTLET} from '../src/shared'; +import {applyRedirects} from '../src/apply_redirects'; + +describe('applyRedirects', () => { + it("should return the same url tree when no redirects", () => { + applyRedirects(tree("/a/b"), [ + {path: 'a', component: ComponentA, children: [{path: 'b', component: ComponentB}]} + ]).forEach(t => { + compareTrees(t, tree('/a/b')); + }); + }); + + it("should add new segments when needed", () => { + applyRedirects(tree("/a/b"), [ + {path: 'a/b', redirectTo: 'a/b/c'} + ]).forEach(t => { + compareTrees(t, tree('/a/b/c')); + }); + }); + + it("should handle positional parameters", () => { + applyRedirects(tree("/a/1/b/2"), [ + {path: 'a/:aid/b/:bid', redirectTo: 'newa/:aid/newb/:bid'} + ]).forEach(t => { + compareTrees(t, tree('/newa/1/newb/2')); + }); + }); + + it("should throw when cannot handle a positional parameter", () => { + applyRedirects(tree("/a/1"), [ + {path: 'a/:id', redirectTo: 'a/:other'} + ]).subscribe(() => {}, (e) => { + expect(e.message).toEqual("Cannot redirect to 'a/:other'. Cannot find ':other'."); + }); + }); + + it("should pass matrix parameters", () => { + applyRedirects(tree("/a;p1=1/1;p2=2"), [ + {path: 'a/:id', redirectTo: 'd/a/:id/e'} + ]).forEach(t => { + compareTrees(t, tree('/d/a;p1=1/1;p2=2/e')); + }); + }); + + it("should handle preserve secondary routes", () => { + applyRedirects(tree("/a/1(aux:c/d)"), [ + {path: 'a/:id', redirectTo: 'd/a/:id/e'}, + {path: 'c/d', component: ComponentA, outlet: 'aux'} + ]).forEach(t => { + compareTrees(t, tree('/d/a/1/e(aux:c/d)')); + }); + }); + + it("should redirect secondary routes", () => { + applyRedirects(tree("/a/1(aux:c/d)"), [ + {path: 'a/:id', component: ComponentA}, + {path: 'c/d', redirectTo: 'f/c/d/e', outlet: 'aux'} + ]).forEach(t => { + compareTrees(t, tree('/a/1(aux:f/c/d/e)')); + }); + }); + + it("should redirect wild cards", () => { + applyRedirects(tree("/a/1(aux:c/d)"), [ + {path: '**', redirectTo: '/404'}, + ]).forEach(t => { + compareTrees(t, tree('/404')); + }); + }); + + it("should support global redirects", () => { + applyRedirects(tree("/a/b/1"), [ + {path: 'a', component: ComponentA, children: [ + {path: 'b/:id', redirectTo: '/global/:id'} + ]}, + ]).forEach(t => { + compareTrees(t, tree('/global/1')); + }); + }); +}); + +function tree(url: string): UrlTree { + return new DefaultUrlSerializer().parse(url); +} + +function compareTrees(actual: UrlTree, expected: UrlTree): void{ + const serializer = new DefaultUrlSerializer(); + const error = `"${serializer.serialize(actual)}" is not equal to "${serializer.serialize(expected)}"`; + compareNode(actual._root, expected._root, error); +} + +function compareNode(actual: TreeNode, expected: TreeNode, error: string): void{ + expect(equalUrlSegments([actual.value], [expected.value])).toEqual(true, error); + + expect(actual.children.length).toEqual(expected.children.length, error); + + if (actual.children.length === expected.children.length) { + for (let i = 0; i < actual.children.length; ++i) { + compareNode(actual.children[i], expected.children[i], error); + } + } +} + +class ComponentA {} +class ComponentB {} +class ComponentC {} diff --git a/modules/@angular/router/test/router.spec.ts b/modules/@angular/router/test/router.spec.ts index 24c9ecee3e..2dfe268e4c 100644 --- a/modules/@angular/router/test/router.spec.ts +++ b/modules/@angular/router/test/router.spec.ts @@ -420,6 +420,23 @@ describe("Integration", () => { }))); }); + describe("redirects", () => { + it("should work", fakeAsync(inject([Router, TestComponentBuilder, Location], (router, tcb, location) => { + router.resetConfig([ + { path: '/old/team/:id', redirectTo: 'team/:id' }, + { path: '/team/:id', component: TeamCmp } + ]); + + const fixture = tcb.createFakeAsync(RootCmp); + advance(fixture); + + router.navigateByUrl('old/team/22'); + advance(fixture); + + expect(location.path()).toEqual('/team/22'); + }))); + }); + describe("guards", () => { describe("CanActivate", () => { describe("should not activate a route when CanActivate returns false", () => { diff --git a/modules/@angular/router/tsconfig.json b/modules/@angular/router/tsconfig.json index 95fe5988b1..945409c60c 100644 --- a/modules/@angular/router/tsconfig.json +++ b/modules/@angular/router/tsconfig.json @@ -16,6 +16,7 @@ "files": [ "src/index.ts", "src/router.ts", + "src/apply_redirects.ts", "src/recognize.ts", "src/resolve.ts", "src/create_router_state.ts", @@ -35,6 +36,7 @@ "src/utils/collection.ts", "test/utils/tree.spec.ts", "test/url_serializer.spec.ts", + "test/apply_redirects.spec.ts", "test/recognize.spec.ts", "test/create_router_state.spec.ts", "test/create_url_tree.spec.ts",