feat(router): implement redirectTo
This commit is contained in:
parent
25c6a3715d
commit
66caabca0c
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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[];
|
||||
}
|
|
@ -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;
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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 {}
|
|
@ -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", () => {
|
||||
|
|
|
@ -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",
|
||||
|
|
Loading…
Reference in New Issue