From 2e1bd46bb1ade091ae902fa55ef40fb5f71edac2 Mon Sep 17 00:00:00 2001 From: vsavkin Date: Thu, 26 May 2016 16:51:19 -0700 Subject: [PATCH] feat(router): add createUrlTree --- .../@angular/router/src/create_url_tree.ts | 213 ++++++++++++++++++ .../router/test/create_url_tree.spec.ts | 180 +++++++++++++++ 2 files changed, 393 insertions(+) create mode 100644 modules/@angular/router/src/create_url_tree.ts create mode 100644 modules/@angular/router/test/create_url_tree.spec.ts diff --git a/modules/@angular/router/src/create_url_tree.ts b/modules/@angular/router/src/create_url_tree.ts new file mode 100644 index 0000000000..d17067cb40 --- /dev/null +++ b/modules/@angular/router/src/create_url_tree.ts @@ -0,0 +1,213 @@ +import { UrlTree, UrlSegment, equalUrlSegments } from './url_tree'; +import { TreeNode, rootNode } from './utils/tree'; +import { forEach, shallowEqual } from './utils/collection'; +import { RouterState, ActivatedRoute } from './router_state'; +import { Params, PRIMARY_OUTLET } from './shared'; + +export function createUrlTree(route: ActivatedRoute, urlTree: UrlTree, commands: any[], + queryParameters: Params | undefined, fragment: string | undefined): UrlTree { + if (commands.length === 0) { + return tree(rootNode(urlTree), urlTree, queryParameters, fragment); + } + + const normalizedCommands = normalizeCommands(commands); + if (navigateToRoot(normalizedCommands)) { + return tree(new TreeNode(urlTree.root, []), urlTree, queryParameters, fragment); + } + + const startingNode = findStartingNode(normalizedCommands, urlTree, route); + const updated = normalizedCommands.commands.length > 0 ? + updateMany(startingNode.children.slice(0), normalizedCommands.commands) : + []; + const newRoot = constructNewTree(rootNode(urlTree), startingNode, updated); + + return tree(newRoot, urlTree, queryParameters, fragment); +} + +function tree(root: TreeNode, urlTree: UrlTree, queryParameters: Params | undefined, fragment: string | undefined): UrlTree { + const q = queryParameters ? stringify(queryParameters) : urlTree.queryParameters; + const f = fragment ? fragment : urlTree.fragment; + return new UrlTree(root, q, f); +} + +function navigateToRoot(normalizedChange: NormalizedNavigationCommands): boolean { + return normalizedChange.isAbsolute && normalizedChange.commands.length === 1 && + normalizedChange.commands[0] == "/"; +} + +class NormalizedNavigationCommands { + constructor(public isAbsolute: boolean, public numberOfDoubleDots: number, + public commands: any[]) {} +} + +function normalizeCommands(commands: any[]): NormalizedNavigationCommands { + if ((typeof commands[0] === "string") && commands.length === 1 && commands[0] == "/") { + return new NormalizedNavigationCommands(true, 0, commands); + } + + let numberOfDoubleDots = 0; + let isAbsolute = false; + const res = []; + + for (let i = 0; i < commands.length; ++i) { + const c = commands[i]; + + if (!(typeof c === "string")) { + res.push(c); + continue; + } + + const parts = c.split('/'); + for (let j = 0; j < parts.length; ++j) { + let cc = parts[j]; + + // first exp is treated in a special way + if (i == 0) { + if (j == 0 && cc == ".") { // './a' + // skip it + } else if (j == 0 && cc == "") { // '/a' + isAbsolute = true; + } else if (cc == "..") { // '../a' + numberOfDoubleDots++; + } else if (cc != '') { + res.push(cc); + } + + } else { + if (cc != '') { + res.push(cc); + } + } + } + } + + return new NormalizedNavigationCommands(isAbsolute, numberOfDoubleDots, res); +} + +function findStartingNode(normalizedChange: NormalizedNavigationCommands, urlTree: UrlTree, + route: ActivatedRoute): TreeNode { + if (normalizedChange.isAbsolute) { + return rootNode(urlTree); + } else { + const urlSegment = + findUrlSegment(route, urlTree, normalizedChange.numberOfDoubleDots); + return findMatchingNode(urlSegment, rootNode(urlTree)); + } +} + +function findUrlSegment(route: ActivatedRoute, urlTree: UrlTree, numberOfDoubleDots: number): UrlSegment { + const segments = (route.urlSegments).value; + const urlSegment = segments[segments.length - 1]; + const path = urlTree.pathFromRoot(urlSegment); + if (path.length <= numberOfDoubleDots) { + throw new Error("Invalid number of '../'"); + } + return path[path.length - 1 - numberOfDoubleDots]; +} + +function findMatchingNode(segment: UrlSegment, node: TreeNode): TreeNode { + if (node.value === segment) return node; + for (let c of node.children) { + const r = findMatchingNode(segment, c); + if (r) return r; + } + throw new Error(`Cannot find url segment '${segment}'`); +} + +function constructNewTree(node: TreeNode, original: TreeNode, + updated: TreeNode[]): TreeNode { + if (node === original) { + return new TreeNode(node.value, updated); + } else { + return new TreeNode( + node.value, node.children.map(c => constructNewTree(c, original, updated))); + } +} + +function updateMany(nodes: TreeNode[], commands: any[]): TreeNode[] { + const outlet = getOutlet(commands); + const nodesInRightOutlet = nodes.filter(c => c.value.outlet === outlet); + if (nodesInRightOutlet.length > 0) { + const nodeRightOutlet = nodesInRightOutlet[0]; // there can be only one + nodes[nodes.indexOf(nodeRightOutlet)] = update(nodeRightOutlet, commands); + } else { + nodes.push(update(null, commands)); + } + return nodes; +} + +function getPath(commands: any[]): any { + if (!(typeof commands[0] === "string")) return commands[0]; + const parts = commands[0].toString().split(":"); + return parts.length > 1 ? parts[1] : commands[0]; +} + +function getOutlet(commands: any[]): string { + if (!(typeof commands[0] === "string")) return PRIMARY_OUTLET; + const parts = commands[0].toString().split(":"); + return parts.length > 1 ? parts[0] : PRIMARY_OUTLET; +} + +function update(node: TreeNode|null, commands: any[]): TreeNode { + const rest = commands.slice(1); + const next = rest.length === 0 ? null : rest[0]; + const outlet = getOutlet(commands); + const path = getPath(commands); + + // reach the end of the tree => create new tree nodes. + if (!node && !(typeof next === 'object')) { + const urlSegment = new UrlSegment(path, {}, outlet); + const children = rest.length === 0 ? [] : [update(null, rest)]; + return new TreeNode(urlSegment, children); + + } else if (!node && typeof next === 'object') { + const urlSegment = new UrlSegment(path, stringify(next), outlet); + return recurse(urlSegment, node, rest.slice(1)); + + // different outlet => preserve the subtree + } else if (node && outlet !== node.value.outlet) { + return node; + + // params command + } else if (node && typeof path === 'object') { + const newSegment = new UrlSegment(node.value.path, stringify(path), node.value.outlet); + return recurse(newSegment, node, rest); + + // next one is a params command && can reuse the node + } else if (node && typeof next === 'object' && compare(path, stringify(next), node.value)) { + return recurse(node.value, node, rest.slice(1)); + + // next one is a params command && cannot reuse the node + } else if (node && typeof next === 'object') { + const urlSegment = new UrlSegment(path, stringify(next), outlet); + return recurse(urlSegment, node, rest.slice(1)); + + // next one is not a params command && can reuse the node + } else if (node && compare(path, {}, node.value)) { + return recurse(node.value, node, rest); + + // next one is not a params command && cannot reuse the node + } else { + const urlSegment = new UrlSegment(path, {}, outlet); + return recurse(urlSegment, node, rest); + } +} + +function stringify(params: {[key: string]: any}): {[key: string]: string} { + const res = {}; + forEach(params, (v, k) => res[k] = v.toString()); + return res; +} + +function compare(path: string, params: {[key: string]: any}, segment: UrlSegment): boolean { + return path == segment.path && shallowEqual(params, segment.parameters); +} + +function recurse(urlSegment: UrlSegment, node: TreeNode | null, + rest: any[]): TreeNode { + if (rest.length === 0) { + return new TreeNode(urlSegment, []); + } + const children = node ? node.children.slice(0) : []; + return new TreeNode(urlSegment, updateMany(children, rest)); +} \ No newline at end of file diff --git a/modules/@angular/router/test/create_url_tree.spec.ts b/modules/@angular/router/test/create_url_tree.spec.ts new file mode 100644 index 0000000000..ae9d96b3a8 --- /dev/null +++ b/modules/@angular/router/test/create_url_tree.spec.ts @@ -0,0 +1,180 @@ +import {DefaultUrlSerializer} from '../src/url_serializer'; +import {UrlTree, UrlSegment} from '../src/url_tree'; +import {ActivatedRoute} from '../src/router_state'; +import {PRIMARY_OUTLET, Params} from '../src/shared'; +import {createUrlTree} from '../src/create_url_tree'; +import {BehaviorSubject} from 'rxjs/BehaviorSubject'; + +describe('createUrlTree', () => { + const serializer = new DefaultUrlSerializer(); + + it("should navigate to the root", () => { + const p = serializer.parse("/"); + const t = create(p.root, p, ["/"]); + expect(serializer.serialize(t)).toEqual(""); + }); + + it("should support nested segments", () => { + const p = serializer.parse("/a/b"); + const t = create(p.root, p, ["/one", 11, "two", 22]); + expect(serializer.serialize(t)).toEqual("/one/11/two/22"); + }); + + it("should preserve secondary segments", () => { + const p = serializer.parse("/a/11/b(right:c)"); + const t = create(p.root, p, ["/a", 11, 'd']); + expect(serializer.serialize(t)).toEqual("/a/11/d(right:c)"); + }); + + it('should update matrix parameters', () => { + const p = serializer.parse("/a;aa=11"); + const t = create(p.root, p, ["/a", {aa: 22, bb: 33}]); + expect(serializer.serialize(t)).toEqual("/a;aa=22;bb=33"); + }); + + it('should create matrix parameters', () => { + const p = serializer.parse("/a"); + const t = create(p.root, p, ["/a", {aa: 22, bb: 33}]); + expect(serializer.serialize(t)).toEqual("/a;aa=22;bb=33"); + }); + + it('should create matrix parameters together with other segments', () => { + const p = serializer.parse("/a"); + const t = create(p.root, p, ["/a", "/b", {aa: 22, bb: 33}]); + expect(serializer.serialize(t)).toEqual("/a/b;aa=22;bb=33"); + }); + + describe("node reuse", () => { + it('should reuse nodes when path is the same', () => { + const p = serializer.parse("/a/b"); + const t = create(p.root, p, ['/a/c']); + + expect(t.root).toBe(p.root); + expect(t.firstChild(t.root)).toBe(p.firstChild(p.root)); + expect(t.firstChild(t.firstChild(t.root))).not.toBe(p.firstChild(p.firstChild(p.root))); + }); + + it("should create new node when params are the same", () => { + const p = serializer.parse("/a;x=1"); + const t = create(p.root, p, ['/a', {'x': 1}]); + + expect(t.firstChild(t.root)).toBe(p.firstChild(p.root)); + }); + + it("should create new node when params are different", () => { + const p = serializer.parse("/a;x=1"); + const t = create(p.root, p, ['/a', {'x': 2}]); + + expect(t.firstChild(t.root)).not.toBe(p.firstChild(p.root)); + }); + }); + + describe("relative navigation", () => { + it("should work", () => { + const p = serializer.parse("/a(left:ap)/c(left:cp)"); + const c = p.firstChild(p.root); + const t = create(c, p, ["c2"]); + expect(serializer.serialize(t)).toEqual("/a(left:ap)/c2(left:cp)"); + }); + + it("should work when the first command starts with a ./", () => { + const p = serializer.parse("/a(left:ap)/c(left:cp)"); + const c = p.firstChild(p.root); + const t = create(c, p, ["./c2"]); + expect(serializer.serialize(t)).toEqual("/a(left:ap)/c2(left:cp)"); + }); + + it("should work when the first command is ./)", () => { + const p = serializer.parse("/a(left:ap)/c(left:cp)"); + const c = p.firstChild(p.root); + const t = create(c, p, ["./", "c2"]); + expect(serializer.serialize(t)).toEqual("/a(left:ap)/c2(left:cp)"); + }); + + it("should work when given params", () => { + const p = serializer.parse("/a(left:ap)/c(left:cp)"); + const c = p.firstChild(p.root); + const t = create(c, p, [{'x': 99}]); + expect(serializer.serialize(t)).toEqual("/a(left:ap)/c;x=99(left:cp)"); + }); + + it("should support going to a parent", () => { + const p = serializer.parse("/a(left:ap)/c(left:cp)"); + const c = p.firstChild(p.root); + const t = create(c, p, ["../a2"]); + expect(serializer.serialize(t)).toEqual("/a2(left:ap)"); + }); + + it("should support going to a parent (nested case)", () => { + const p = serializer.parse("/a/c"); + const c = p.firstChild(p.firstChild(p.root)); + const t = create(c, p, ["../c2"]); + expect(serializer.serialize(t)).toEqual("/a/c2"); + }); + + it("should work when given ../", () => { + const p = serializer.parse("/a/c"); + const c = p.firstChild(p.firstChild(p.root)); + const t = create(c, p, ["../"]); + expect(serializer.serialize(t)).toEqual("/a"); + }); + + it("should navigate to the root", () => { + const p = serializer.parse("/a/c"); + const c = p.firstChild(p.root); + const t = create(c, p, ["../"]); + expect(serializer.serialize(t)).toEqual(""); + }); + + it("should support setting matrix params", () => { + const p = serializer.parse("/a(left:ap)/c(left:cp)"); + const c = p.firstChild(p.root); + const t = create(c, p, ["../", {'x': 5}]); + expect(serializer.serialize(t)).toEqual("/a;x=5(left:ap)"); + }); + + it("should throw when too many ..", () => { + const p = serializer.parse("/a(left:ap)/c(left:cp)"); + const c = p.firstChild(p.root); + expect(() => create(c, p, ["../../"])).toThrowError("Invalid number of '../'"); + }); + }); + + it("should set query params", () => { + const p = serializer.parse("/"); + const t = create(p.root, p, [], {a: 'hey'}); + expect(t.queryParameters).toEqual({a: 'hey'}); + }); + + it("should stringify query params", () => { + const p = serializer.parse("/"); + const t = create(p.root, p, [], {a: 1}); + expect(t.queryParameters).toEqual({a: '1'}); + }); + + it("should reuse old query params when given undefined", () => { + const p = serializer.parse("/?a=1"); + const t = create(p.root, p, [], undefined); + expect(t.queryParameters).toEqual({a: '1'}); + }); + + it("should set fragment", () => { + const p = serializer.parse("/"); + const t = create(p.root, p, [], {}, "fragment"); + expect(t.fragment).toEqual("fragment"); + }); + + it("should reused old fragment when given undefined", () => { + const p = serializer.parse("/#fragment"); + const t = create(p.root, p, [], undefined, undefined); + expect(t.fragment).toEqual("fragment"); + }); +}); + +function create(start: UrlSegment | null, tree: UrlTree, commands: any[], queryParameters?: Params, fragment?: string) { + if (!start) { + expect(start).toBeDefined(); + } + const a = new ActivatedRoute(new BehaviorSubject([start]), null, PRIMARY_OUTLET, "someComponent"); + return createUrlTree(a, tree, commands, queryParameters, fragment); +} \ No newline at end of file