feat(router): implement redirectTo

This commit is contained in:
vsavkin 2016-06-08 16:14:26 -07:00
parent 25c6a3715d
commit 66caabca0c
8 changed files with 315 additions and 46 deletions

View File

@ -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<UrlTree> {
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<UrlTree>(obs => obs.error(new Error('Cannot match any routes')));
} else {
return new Observable<UrlTree>(obs => obs.error(e));
}
}
}
function createUrlTree(urlTree: UrlTree, children: TreeNode<UrlSegment>[]): Observable<UrlTree> {
const transformedRoot = new TreeNode<UrlSegment>(urlTree.root, children);
return of (new UrlTree(transformedRoot, urlTree.queryParams, urlTree.fragment));
}
function applyNode(config: Route[], url: TreeNode<UrlSegment>): TreeNode<UrlSegment> {
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<UrlSegment>): TreeNode<UrlSegment> {
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<UrlSegment>) {
const path = route.path.startsWith('/') ? route.path.substring(1) : route.path;
const parts = path.split('/');
const positionalParamSegments = {};
const consumedUrlSegments = [];
let lastParent: TreeNode<UrlSegment>|null = null;
let lastSegment: TreeNode<UrlSegment>|null = null;
let current: TreeNode<UrlSegment>|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<UrlSegment>[],
secondary: TreeNode<UrlSegment>[]): TreeNode<UrlSegment> {
let prevChildren = children;
for (let i = segments.length - 1; i >= 0; --i) {
if (i === segments.length - 2) {
prevChildren = [new TreeNode<UrlSegment>(segments[i], prevChildren.concat(secondary))];
} else {
prevChildren = [new TreeNode<UrlSegment>(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);
}
}

View File

@ -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[];
}

View File

@ -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<RouterStateSnapshot>(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<RouterStateSnapshot>(
@ -66,7 +64,7 @@ function createActivatedRouteSnapshot(match: MatchResult): ActivatedRouteSnapsho
function recognizeOne(
config: Route[], url: TreeNode<UrlSegment>): TreeNode<ActivatedRouteSnapshot>[] {
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<ActivatedRouteSnapshot>[]):
return nodes;
}
function match(config: Route[], url: TreeNode<UrlSegment>): MatchResult[] {
function matchNode(config: Route[], url: TreeNode<UrlSegment>): MatchResult[] {
const res = [];
for (let r of config) {
if (r.index) {
@ -148,43 +146,14 @@ function matchWithParts(route: Route, url: TreeNode<UrlSegment>): MatchResult|nu
route.component, [], consumedUrl, last.parameters, [], [], PRIMARY_OUTLET, route, last);
}
const parts = path.split('/');
const positionalParams = {};
const consumedUrlSegments = [];
let lastParent: TreeNode<UrlSegment>|null = null;
let lastSegment: TreeNode<UrlSegment>|null = null;
let current: TreeNode<UrlSegment>|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;

View File

@ -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(

View File

@ -28,6 +28,10 @@ export function first<T>(a: T[]): T|null {
return a.length > 0 ? a[0] : null;
}
export function last<T>(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);
}

View File

@ -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<UrlSegment>, expected: TreeNode<UrlSegment>, 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 {}

View File

@ -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", () => {

View File

@ -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",