From 6ddfff5cd59aac9099aa6da5118c5598eea7ea11 Mon Sep 17 00:00:00 2001 From: Brian Ford Date: Mon, 23 Nov 2015 18:07:37 -0800 Subject: [PATCH] refactor(router): improve recognition and generation pipeline This is a big change. @matsko also deserves much of the credit for the implementation. Previously, `ComponentInstruction`s held all the state for async components. Now, we introduce several subclasses for `Instruction` to describe each type of navigation. BREAKING CHANGE: Redirects now use the Link DSL syntax. Before: ``` @RouteConfig([ { path: '/foo', redirectTo: '/bar' }, { path: '/bar', component: BarCmp } ]) ``` After: ``` @RouteConfig([ { path: '/foo', redirectTo: ['Bar'] }, { path: '/bar', component: BarCmp, name: 'Bar' } ]) ``` BREAKING CHANGE: This also introduces `useAsDefault` in the RouteConfig, which makes cases like lazy-loading and encapsulating large routes with sub-routes easier. Previously, you could use `redirectTo` like this to expand a URL like `/tab` to `/tab/posts`: @RouteConfig([ { path: '/tab', redirectTo: '/tab/users' } { path: '/tab', component: TabsCmp, name: 'Tab' } ]) AppCmp { ... } Now the recommended way to handle this is case is to use `useAsDefault` like so: ``` @RouteConfig([ { path: '/tab', component: TabsCmp, name: 'Tab' } ]) AppCmp { ... } @RouteConfig([ { path: '/posts', component: PostsCmp, useAsDefault: true, name: 'Posts' }, { path: '/users', component: UsersCmp, name: 'Users' } ]) TabsCmp { ... } ``` In the above example, you can write just `['/Tab']` and the route `Users` is automatically selected as a child route. Closes #4728 Closes #4228 Closes #4170 Closes #4490 Closes #4694 Closes #5200 Closes #5475 --- modules/angular1_router/build.js | 8 +- modules/angular1_router/lib/facades.es5 | 4 + .../angular1_router/src/module_template.js | 21 +- modules/angular1_router/src/ng_route_shim.js | 4 +- .../test/integration/navigation_spec.js | 18 +- .../test/integration/shim_spec.js | 7 +- modules/angular2/router.ts | 33 +- .../src/router/async_route_handler.ts | 10 +- .../src/router/component_recognizer.ts | 157 +++++ modules/angular2/src/router/instruction.ts | 286 +++++--- .../angular2/src/router/path_recognizer.ts | 43 +- .../angular2/src/router/route_config_impl.ts | 42 +- .../src/router/route_config_nomalizer.dart | 15 +- .../src/router/route_config_nomalizer.ts | 55 +- .../angular2/src/router/route_definition.dart | 3 +- .../angular2/src/router/route_definition.ts | 3 +- modules/angular2/src/router/route_handler.ts | 3 +- .../angular2/src/router/route_recognizer.ts | 261 +++---- modules/angular2/src/router/route_registry.ts | 397 +++++++---- modules/angular2/src/router/router.ts | 143 ++-- modules/angular2/src/router/router_link.ts | 4 +- .../angular2/src/router/sync_route_handler.ts | 12 +- .../test/router/component_recognizer_spec.ts | 216 ++++++ .../test/router/integration/README.md | 9 + .../router/integration/async_route_spec.ts | 28 + .../integration/auxiliary_route_spec.ts | 145 ++++ ..._integration_spec.ts => bootstrap_spec.ts} | 119 ++-- .../integration/impl/async_route_spec_impl.ts | 655 ++++++++++++++++++ .../integration/impl/fixture_components.ts | 131 ++++ .../integration/impl/sync_route_spec_impl.ts | 431 ++++++++++++ .../router/integration/lifecycle_hook_spec.ts | 160 +++-- .../router/integration/navigation_spec.ts | 150 +--- .../router/integration/redirect_route_spec.ts | 121 ++++ .../router/integration/router_link_spec.ts | 37 +- .../router/integration/sync_route_spec.ts | 24 + .../angular2/test/router/integration/util.ts | 133 ++++ .../test/router/path_recognizer_spec.ts | 60 +- .../angular2/test/router/route_config_spec.ts | 5 +- .../test/router/route_recognizer_spec.ts | 185 ----- .../test/router/route_registry_spec.ts | 133 ++-- .../angular2/test/router/router_link_spec.ts | 17 +- modules/angular2/test/router/router_spec.ts | 20 +- tools/broccoli/trees/node_tree.ts | 2 +- 43 files changed, 3113 insertions(+), 1197 deletions(-) create mode 100644 modules/angular2/src/router/component_recognizer.ts create mode 100644 modules/angular2/test/router/component_recognizer_spec.ts create mode 100644 modules/angular2/test/router/integration/README.md create mode 100644 modules/angular2/test/router/integration/async_route_spec.ts create mode 100644 modules/angular2/test/router/integration/auxiliary_route_spec.ts rename modules/angular2/test/router/integration/{router_integration_spec.ts => bootstrap_spec.ts} (78%) create mode 100644 modules/angular2/test/router/integration/impl/async_route_spec_impl.ts create mode 100644 modules/angular2/test/router/integration/impl/fixture_components.ts create mode 100644 modules/angular2/test/router/integration/impl/sync_route_spec_impl.ts create mode 100644 modules/angular2/test/router/integration/redirect_route_spec.ts create mode 100644 modules/angular2/test/router/integration/sync_route_spec.ts create mode 100644 modules/angular2/test/router/integration/util.ts delete mode 100644 modules/angular2/test/router/route_recognizer_spec.ts diff --git a/modules/angular1_router/build.js b/modules/angular1_router/build.js index ddaacbaa67..5d5065bd86 100644 --- a/modules/angular1_router/build.js +++ b/modules/angular1_router/build.js @@ -6,12 +6,13 @@ var ts = require('typescript'); var files = [ 'lifecycle_annotations_impl.ts', 'url_parser.ts', - 'path_recognizer.ts', + 'route_recognizer.ts', 'route_config_impl.ts', 'async_route_handler.ts', 'sync_route_handler.ts', - 'route_recognizer.ts', + 'component_recognizer.ts', 'instruction.ts', + 'path_recognizer.ts', 'route_config_nomalizer.ts', 'route_lifecycle_reflector.ts', 'route_registry.ts', @@ -39,7 +40,10 @@ function main() { * sourcemap, and exported variable identifier name for the content. */ var IMPORT_RE = new RegExp("import \\{?([\\w\\n_, ]+)\\}? from '(.+)';?", 'g'); +var INJECT_RE = new RegExp("@Inject\\(ROUTER_PRIMARY_COMPONENT\\)", 'g'); +var IMJECTABLE_RE = new RegExp("@Injectable\\(\\)", 'g'); function transform(contents) { + contents = contents.replace(INJECT_RE, '').replace(IMJECTABLE_RE, ''); contents = contents.replace(IMPORT_RE, function (match, imports, includePath) { //TODO: remove special-case if (isFacadeModule(includePath) || includePath === './router_outlet') { diff --git a/modules/angular1_router/lib/facades.es5 b/modules/angular1_router/lib/facades.es5 index f60f3b986d..b82d198056 100644 --- a/modules/angular1_router/lib/facades.es5 +++ b/modules/angular1_router/lib/facades.es5 @@ -173,6 +173,10 @@ var StringMapWrapper = { var List = Array; var ListWrapper = { + clear: function (l) { + l.length = 0; + }, + create: function () { return []; }, diff --git a/modules/angular1_router/src/module_template.js b/modules/angular1_router/src/module_template.js index aeed65ea3e..6712ca77c8 100644 --- a/modules/angular1_router/src/module_template.js +++ b/modules/angular1_router/src/module_template.js @@ -9,7 +9,11 @@ function routerFactory($q, $location, $$directiveIntrospector, $browser, $rootSc // the contents of `../lib/facades.es5`. //{{FACADES}} - var exports = {Injectable: function () {}}; + var exports = { + Injectable: function () {}, + OpaqueToken: function () {}, + Inject: function () {} + }; var require = function () {return exports;}; // When this file is processed, the line below is replaced with @@ -31,12 +35,19 @@ function routerFactory($q, $location, $$directiveIntrospector, $browser, $rootSc // property in a route config exports.assertComponentExists = function () {}; - angular.stringifyInstruction = exports.stringifyInstruction; + angular.stringifyInstruction = function (instruction) { + return instruction.toRootUrl(); + }; var RouteRegistry = exports.RouteRegistry; var RootRouter = exports.RootRouter; - var registry = new RouteRegistry(); + + // Because Angular 1 has no notion of a root component, we use an object with unique identity + // to represent this. + var ROOT_COMPONENT_OBJECT = new Object(); + + var registry = new RouteRegistry(ROOT_COMPONENT_OBJECT); var location = new Location(); $$directiveIntrospector(function (name, factory) { @@ -47,10 +58,6 @@ function routerFactory($q, $location, $$directiveIntrospector, $browser, $rootSc } }); - // Because Angular 1 has no notion of a root component, we use an object with unique identity - // to represent this. - var ROOT_COMPONENT_OBJECT = new Object(); - var router = new RootRouter(registry, location, ROOT_COMPONENT_OBJECT); $rootScope.$watch(function () { return $location.path(); }, function (path) { if (router.lastNavigationAttempt !== path) { diff --git a/modules/angular1_router/src/ng_route_shim.js b/modules/angular1_router/src/ng_route_shim.js index 0d3f245b28..5ba7aa4fe7 100644 --- a/modules/angular1_router/src/ng_route_shim.js +++ b/modules/angular1_router/src/ng_route_shim.js @@ -110,7 +110,7 @@ routeMap[path] = routeCopy; if (route.redirectTo) { - routeDefinition.redirectTo = route.redirectTo; + routeDefinition.redirectTo = [routeMap[route.redirectTo].name]; } else { if (routeCopy.controller && !routeCopy.controllerAs) { console.warn('Route for "' + path + '" should use "controllerAs".'); @@ -123,7 +123,7 @@ } routeDefinition.component = directiveName; - routeDefinition.as = upperCase(directiveName); + routeDefinition.name = route.name || upperCase(directiveName); var directiveController = routeCopy.controller; diff --git a/modules/angular1_router/test/integration/navigation_spec.js b/modules/angular1_router/test/integration/navigation_spec.js index 1a82f197c6..76b9490bab 100644 --- a/modules/angular1_router/test/integration/navigation_spec.js +++ b/modules/angular1_router/test/integration/navigation_spec.js @@ -113,8 +113,7 @@ describe('navigation', function () { }); - // TODO: fix this - xit('should work with recursive nested outlets', function () { + it('should work with recursive nested outlets', function () { registerComponent('recurCmp', { template: '
recur {
}
', $routeConfig: [ @@ -152,8 +151,8 @@ describe('navigation', function () { compile('
'); $router.config([ - { path: '/', redirectTo: '/user' }, - { path: '/user', component: 'userCmp' } + { path: '/', redirectTo: ['/User'] }, + { path: '/user', component: 'userCmp', name: 'User' } ]); $router.navigateByUrl('/'); @@ -167,16 +166,15 @@ describe('navigation', function () { registerComponent('childRouter', { template: '
inner {
}
', $routeConfig: [ - { path: '/old-child', redirectTo: '/new-child' }, - { path: '/new-child', component: 'oneCmp'}, - { path: '/old-child-two', redirectTo: '/new-child-two' }, - { path: '/new-child-two', component: 'twoCmp'} + { path: '/new-child', component: 'oneCmp', name: 'NewChild'}, + { path: '/new-child-two', component: 'twoCmp', name: 'NewChildTwo'} ] }); $router.config([ - { path: '/old-parent', redirectTo: '/new-parent' }, - { path: '/new-parent/...', component: 'childRouter' } + { path: '/old-parent/old-child', redirectTo: ['/NewParent', 'NewChild'] }, + { path: '/old-parent/old-child-two', redirectTo: ['/NewParent', 'NewChildTwo'] }, + { path: '/new-parent/...', component: 'childRouter', name: 'NewParent' } ]); compile('
'); diff --git a/modules/angular1_router/test/integration/shim_spec.js b/modules/angular1_router/test/integration/shim_spec.js index f074bd50fd..e2edffe552 100644 --- a/modules/angular1_router/test/integration/shim_spec.js +++ b/modules/angular1_router/test/integration/shim_spec.js @@ -139,11 +139,12 @@ describe('ngRoute shim', function () { it('should adapt routes with redirects', inject(function ($location) { $routeProvider + .when('/home', { + template: 'welcome home!', + name: 'Home' + }) .when('/', { redirectTo: '/home' - }) - .when('/home', { - template: 'welcome home!' }); $rootScope.$digest(); diff --git a/modules/angular2/router.ts b/modules/angular2/router.ts index 3bc054c9b7..b329aa8a0d 100644 --- a/modules/angular2/router.ts +++ b/modules/angular2/router.ts @@ -8,8 +8,8 @@ export {Router} from './src/router/router'; export {RouterOutlet} from './src/router/router_outlet'; export {RouterLink} from './src/router/router_link'; export {RouteParams, RouteData} from './src/router/instruction'; -export {RouteRegistry} from './src/router/route_registry'; export {PlatformLocation} from './src/router/platform_location'; +export {RouteRegistry, ROUTER_PRIMARY_COMPONENT} from './src/router/route_registry'; export {LocationStrategy, APP_BASE_HREF} from './src/router/location_strategy'; export {HashLocationStrategy} from './src/router/hash_location_strategy'; export {PathLocationStrategy} from './src/router/path_location_strategy'; @@ -27,41 +27,12 @@ import {PathLocationStrategy} from './src/router/path_location_strategy'; import {Router, RootRouter} from './src/router/router'; import {RouterOutlet} from './src/router/router_outlet'; import {RouterLink} from './src/router/router_link'; -import {RouteRegistry} from './src/router/route_registry'; +import {RouteRegistry, ROUTER_PRIMARY_COMPONENT} from './src/router/route_registry'; import {Location} from './src/router/location'; import {ApplicationRef, provide, OpaqueToken, Provider} from 'angular2/core'; import {CONST_EXPR} from './src/facade/lang'; import {BaseException} from 'angular2/src/facade/exceptions'; - -/** - * Token used to bind the component with the top-level {@link RouteConfig}s for the - * application. - * - * ### Example ([live demo](http://plnkr.co/edit/iRUP8B5OUbxCWQ3AcIDm)) - * - * ``` - * import {Component} from 'angular2/angular2'; - * import { - * ROUTER_DIRECTIVES, - * ROUTER_PROVIDERS, - * RouteConfig - * } from 'angular2/router'; - * - * @Component({directives: [ROUTER_DIRECTIVES]}) - * @RouteConfig([ - * {...}, - * ]) - * class AppCmp { - * // ... - * } - * - * bootstrap(AppCmp, [ROUTER_PROVIDERS]); - * ``` - */ -export const ROUTER_PRIMARY_COMPONENT: OpaqueToken = - CONST_EXPR(new OpaqueToken('RouterPrimaryComponent')); - /** * A list of directives. To use the router directives like {@link RouterOutlet} and * {@link RouterLink}, add this to your `directives` array in the {@link View} decorator of your diff --git a/modules/angular2/src/router/async_route_handler.ts b/modules/angular2/src/router/async_route_handler.ts index 6e22218344..a739352151 100644 --- a/modules/angular2/src/router/async_route_handler.ts +++ b/modules/angular2/src/router/async_route_handler.ts @@ -1,13 +1,19 @@ -import {RouteHandler} from './route_handler'; import {Promise, PromiseWrapper} from 'angular2/src/facade/async'; import {isPresent, Type} from 'angular2/src/facade/lang'; +import {RouteHandler} from './route_handler'; +import {RouteData, BLANK_ROUTE_DATA} from './instruction'; + + export class AsyncRouteHandler implements RouteHandler { /** @internal */ _resolvedComponent: Promise = null; componentType: Type; + public data: RouteData; - constructor(private _loader: Function, public data?: {[key: string]: any}) {} + constructor(private _loader: Function, data: {[key: string]: any} = null) { + this.data = isPresent(data) ? new RouteData(data) : BLANK_ROUTE_DATA; + } resolveComponentType(): Promise { if (isPresent(this._resolvedComponent)) { diff --git a/modules/angular2/src/router/component_recognizer.ts b/modules/angular2/src/router/component_recognizer.ts new file mode 100644 index 0000000000..56730566fd --- /dev/null +++ b/modules/angular2/src/router/component_recognizer.ts @@ -0,0 +1,157 @@ +import {isBlank, isPresent} from 'angular2/src/facade/lang'; +import {BaseException, WrappedException} from 'angular2/src/facade/exceptions'; +import {Map, MapWrapper, ListWrapper, StringMapWrapper} from 'angular2/src/facade/collection'; +import {Promise, PromiseWrapper} from 'angular2/src/facade/async'; + +import { + AbstractRecognizer, + RouteRecognizer, + RedirectRecognizer, + RouteMatch +} from './route_recognizer'; +import {Route, AsyncRoute, AuxRoute, Redirect, RouteDefinition} from './route_config_impl'; +import {AsyncRouteHandler} from './async_route_handler'; +import {SyncRouteHandler} from './sync_route_handler'; +import {Url} from './url_parser'; +import {ComponentInstruction} from './instruction'; + + +/** + * `ComponentRecognizer` is responsible for recognizing routes for a single component. + * It is consumed by `RouteRegistry`, which knows how to recognize an entire hierarchy of + * components. + */ +export class ComponentRecognizer { + names = new Map(); + + // map from name to recognizer + auxNames = new Map(); + + // map from starting path to recognizer + auxRoutes = new Map(); + + // TODO: optimize this into a trie + matchers: AbstractRecognizer[] = []; + + defaultRoute: RouteRecognizer = null; + + /** + * returns whether or not the config is terminal + */ + config(config: RouteDefinition): boolean { + var handler; + + if (isPresent(config.name) && config.name[0].toUpperCase() != config.name[0]) { + var suggestedName = config.name[0].toUpperCase() + config.name.substring(1); + throw new BaseException( + `Route "${config.path}" with name "${config.name}" does not begin with an uppercase letter. Route names should be CamelCase like "${suggestedName}".`); + } + + if (config instanceof AuxRoute) { + handler = new SyncRouteHandler(config.component, config.data); + let path = config.path.startsWith('/') ? config.path.substring(1) : config.path; + var recognizer = new RouteRecognizer(config.path, handler); + this.auxRoutes.set(path, recognizer); + if (isPresent(config.name)) { + this.auxNames.set(config.name, recognizer); + } + return recognizer.terminal; + } + + var useAsDefault = false; + + if (config instanceof Redirect) { + let redirector = new RedirectRecognizer(config.path, config.redirectTo); + this._assertNoHashCollision(redirector.hash, config.path); + this.matchers.push(redirector); + return true; + } + + if (config instanceof Route) { + handler = new SyncRouteHandler(config.component, config.data); + useAsDefault = isPresent(config.useAsDefault) && config.useAsDefault; + } else if (config instanceof AsyncRoute) { + handler = new AsyncRouteHandler(config.loader, config.data); + useAsDefault = isPresent(config.useAsDefault) && config.useAsDefault; + } + var recognizer = new RouteRecognizer(config.path, handler); + + this._assertNoHashCollision(recognizer.hash, config.path); + + if (useAsDefault) { + if (isPresent(this.defaultRoute)) { + throw new BaseException(`Only one route can be default`); + } + this.defaultRoute = recognizer; + } + + this.matchers.push(recognizer); + if (isPresent(config.name)) { + this.names.set(config.name, recognizer); + } + return recognizer.terminal; + } + + + private _assertNoHashCollision(hash: string, path) { + this.matchers.forEach((matcher) => { + if (hash == matcher.hash) { + throw new BaseException( + `Configuration '${path}' conflicts with existing route '${matcher.path}'`); + } + }); + } + + + /** + * Given a URL, returns a list of `RouteMatch`es, which are partial recognitions for some route. + */ + recognize(urlParse: Url): Promise[] { + var solutions = []; + + this.matchers.forEach((routeRecognizer: AbstractRecognizer) => { + var pathMatch = routeRecognizer.recognize(urlParse); + + if (isPresent(pathMatch)) { + solutions.push(pathMatch); + } + }); + + return solutions; + } + + recognizeAuxiliary(urlParse: Url): Promise[] { + var routeRecognizer: RouteRecognizer = this.auxRoutes.get(urlParse.path); + if (isPresent(routeRecognizer)) { + return [routeRecognizer.recognize(urlParse)]; + } + + return [PromiseWrapper.resolve(null)]; + } + + hasRoute(name: string): boolean { return this.names.has(name); } + + componentLoaded(name: string): boolean { + return this.hasRoute(name) && isPresent(this.names.get(name).handler.componentType); + } + + loadComponent(name: string): Promise { + return this.names.get(name).handler.resolveComponentType(); + } + + generate(name: string, params: any): ComponentInstruction { + var pathRecognizer: RouteRecognizer = this.names.get(name); + if (isBlank(pathRecognizer)) { + return null; + } + return pathRecognizer.generate(params); + } + + generateAuxiliary(name: string, params: any): ComponentInstruction { + var pathRecognizer: RouteRecognizer = this.auxNames.get(name); + if (isBlank(pathRecognizer)) { + return null; + } + return pathRecognizer.generate(params); + } +} diff --git a/modules/angular2/src/router/instruction.ts b/modules/angular2/src/router/instruction.ts index 760394c65e..13e4e9f192 100644 --- a/modules/angular2/src/router/instruction.ts +++ b/modules/angular2/src/router/instruction.ts @@ -1,10 +1,7 @@ import {Map, MapWrapper, StringMapWrapper, ListWrapper} from 'angular2/src/facade/collection'; -import {unimplemented} from 'angular2/src/facade/exceptions'; import {isPresent, isBlank, normalizeBlank, Type, CONST_EXPR} from 'angular2/src/facade/lang'; -import {Promise} from 'angular2/src/facade/async'; +import {Promise, PromiseWrapper} from 'angular2/src/facade/async'; -import {PathRecognizer} from './path_recognizer'; -import {Url} from './url_parser'; /** * `RouteParams` is an immutable map of parameters for the given route @@ -77,7 +74,7 @@ export class RouteData { get(key: string): any { return normalizeBlank(StringMapWrapper.get(this.data, key)); } } -var BLANK_ROUTE_DATA = new RouteData(); +export var BLANK_ROUTE_DATA = new RouteData(); /** * `Instruction` is a tree of {@link ComponentInstruction}s with all the information needed @@ -106,74 +103,184 @@ var BLANK_ROUTE_DATA = new RouteData(); * bootstrap(AppCmp, ROUTER_PROVIDERS); * ``` */ -export class Instruction { - constructor(public component: ComponentInstruction, public child: Instruction, - public auxInstruction: {[key: string]: Instruction}) {} +export abstract class Instruction { + public component: ComponentInstruction; + public child: Instruction; + public auxInstruction: {[key: string]: Instruction} = {}; + + get urlPath(): string { return this.component.urlPath; } + + get urlParams(): string[] { return this.component.urlParams; } + + get specificity(): number { + var total = 0; + if (isPresent(this.component)) { + total += this.component.specificity; + } + if (isPresent(this.child)) { + total += this.child.specificity; + } + return total; + } + + abstract resolveComponent(): Promise; + + /** + * converts the instruction into a URL string + */ + toRootUrl(): string { return this.toUrlPath() + this.toUrlQuery(); } + + /** @internal */ + _toNonRootUrl(): string { + return this._stringifyPathMatrixAuxPrefixed() + + (isPresent(this.child) ? this.child._toNonRootUrl() : ''); + } + + toUrlQuery(): string { return this.urlParams.length > 0 ? ('?' + this.urlParams.join('&')) : ''; } /** * Returns a new instruction that shares the state of the existing instruction, but with * the given child {@link Instruction} replacing the existing child. */ replaceChild(child: Instruction): Instruction { - return new Instruction(this.component, child, this.auxInstruction); + return new ResolvedInstruction(this.component, child, this.auxInstruction); } -} -/** - * Represents a partially completed instruction during recognition that only has the - * primary (non-aux) route instructions matched. - * - * `PrimaryInstruction` is an internal class used by `RouteRecognizer` while it's - * figuring out where to navigate. - */ -export class PrimaryInstruction { - constructor(public component: ComponentInstruction, public child: PrimaryInstruction, - public auxUrls: Url[]) {} -} - -export function stringifyInstruction(instruction: Instruction): string { - return stringifyInstructionPath(instruction) + stringifyInstructionQuery(instruction); -} - -export function stringifyInstructionPath(instruction: Instruction): string { - return instruction.component.urlPath + stringifyAux(instruction) + - stringifyPrimaryPrefixed(instruction.child); -} - -export function stringifyInstructionQuery(instruction: Instruction): string { - return instruction.component.urlParams.length > 0 ? - ('?' + instruction.component.urlParams.join('&')) : - ''; -} - -function stringifyPrimaryPrefixed(instruction: Instruction): string { - var primary = stringifyPrimary(instruction); - if (primary.length > 0) { - primary = '/' + primary; + /** + * If the final URL for the instruction is `` + */ + toUrlPath(): string { + return this.urlPath + this._stringifyAux() + + (isPresent(this.child) ? this.child._toNonRootUrl() : ''); } - return primary; -} -function stringifyPrimary(instruction: Instruction): string { - if (isBlank(instruction)) { + // default instructions override these + toLinkUrl(): string { + return this.urlPath + this._stringifyAux() + + (isPresent(this.child) ? this.child._toLinkUrl() : ''); + } + + // this is the non-root version (called recursively) + /** @internal */ + _toLinkUrl(): string { + return this._stringifyPathMatrixAuxPrefixed() + + (isPresent(this.child) ? this.child._toLinkUrl() : ''); + } + + /** @internal */ + _stringifyPathMatrixAuxPrefixed(): string { + var primary = this._stringifyPathMatrixAux(); + if (primary.length > 0) { + primary = '/' + primary; + } + return primary; + } + + /** @internal */ + _stringifyMatrixParams(): string { + return this.urlParams.length > 0 ? (';' + this.component.urlParams.join(';')) : ''; + } + + /** @internal */ + _stringifyPathMatrixAux(): string { + if (isBlank(this.component)) { + return ''; + } + return this.urlPath + this._stringifyMatrixParams() + this._stringifyAux(); + } + + /** @internal */ + _stringifyAux(): string { + var routes = []; + StringMapWrapper.forEach(this.auxInstruction, (auxInstruction, _) => { + routes.push(auxInstruction._stringifyPathMatrixAux()); + }); + if (routes.length > 0) { + return '(' + routes.join('//') + ')'; + } return ''; } - var params = instruction.component.urlParams.length > 0 ? - (';' + instruction.component.urlParams.join(';')) : - ''; - return instruction.component.urlPath + params + stringifyAux(instruction) + - stringifyPrimaryPrefixed(instruction.child); } -function stringifyAux(instruction: Instruction): string { - var routes = []; - StringMapWrapper.forEach(instruction.auxInstruction, (auxInstruction, _) => { - routes.push(stringifyPrimary(auxInstruction)); - }); - if (routes.length > 0) { - return '(' + routes.join('//') + ')'; + +/** + * a resolved instruction has an outlet instruction for itself, but maybe not for... + */ +export class ResolvedInstruction extends Instruction { + constructor(public component: ComponentInstruction, public child: Instruction, + public auxInstruction: {[key: string]: Instruction}) { + super(); + } + + resolveComponent(): Promise { + return PromiseWrapper.resolve(this.component); + } +} + + +/** + * Represents a resolved default route + */ +export class DefaultInstruction extends Instruction { + constructor(public component: ComponentInstruction, public child: DefaultInstruction) { super(); } + + resolveComponent(): Promise { + return PromiseWrapper.resolve(this.component); + } + + toLinkUrl(): string { return ''; } + + /** @internal */ + _toLinkUrl(): string { return ''; } +} + + +/** + * Represents a component that may need to do some redirection or lazy loading at a later time. + */ +export class UnresolvedInstruction extends Instruction { + constructor(private _resolver: () => Promise, private _urlPath: string = '', + private _urlParams: string[] = CONST_EXPR([])) { + super(); + } + + get urlPath(): string { + if (isPresent(this.component)) { + return this.component.urlPath; + } + if (isPresent(this._urlPath)) { + return this._urlPath; + } + return ''; + } + + get urlParams(): string[] { + if (isPresent(this.component)) { + return this.component.urlParams; + } + if (isPresent(this._urlParams)) { + return this._urlParams; + } + return []; + } + + resolveComponent(): Promise { + if (isPresent(this.component)) { + return PromiseWrapper.resolve(this.component); + } + return this._resolver().then((resolution: Instruction) => { + this.child = resolution.child; + return this.component = resolution.component; + }); + } +} + + +export class RedirectInstruction extends ResolvedInstruction { + constructor(component: ComponentInstruction, child: Instruction, + auxInstruction: {[key: string]: Instruction}) { + super(component, child, auxInstruction); } - return ''; } @@ -185,67 +292,18 @@ function stringifyAux(instruction: Instruction): string { * to route lifecycle hooks, like {@link CanActivate}. * * `ComponentInstruction`s are [https://en.wikipedia.org/wiki/Hash_consing](hash consed). You should - * never construct one yourself with "new." Instead, rely on {@link Router/PathRecognizer} to + * never construct one yourself with "new." Instead, rely on {@link Router/RouteRecognizer} to * construct `ComponentInstruction`s. * * You should not modify this object. It should be treated as immutable. */ -export abstract class ComponentInstruction { +export class ComponentInstruction { reuse: boolean = false; - public urlPath: string; - public urlParams: string[]; - public params: {[key: string]: any}; + public routeData: RouteData; - /** - * Returns the component type of the represented route, or `null` if this instruction - * hasn't been resolved. - */ - get componentType() { return unimplemented(); }; - - /** - * Returns a promise that will resolve to component type of the represented route. - * If this instruction references an {@link AsyncRoute}, the `loader` function of that route - * will run. - */ - abstract resolveComponentType(): Promise; - - /** - * Returns the specificity of the route associated with this `Instruction`. - */ - get specificity() { return unimplemented(); }; - - /** - * Returns `true` if the component type of this instruction has no child {@link RouteConfig}, - * or `false` if it does. - */ - get terminal() { return unimplemented(); }; - - /** - * Returns the route data of the given route that was specified in the {@link RouteDefinition}, - * or an empty object if no route data was specified. - */ - get routeData(): RouteData { return unimplemented(); }; -} - -export class ComponentInstruction_ extends ComponentInstruction { - private _routeData: RouteData; - - constructor(urlPath: string, urlParams: string[], private _recognizer: PathRecognizer, - params: {[key: string]: any} = null) { - super(); - this.urlPath = urlPath; - this.urlParams = urlParams; - this.params = params; - if (isPresent(this._recognizer.handler.data)) { - this._routeData = new RouteData(this._recognizer.handler.data); - } else { - this._routeData = BLANK_ROUTE_DATA; - } + constructor(public urlPath: string, public urlParams: string[], data: RouteData, + public componentType, public terminal: boolean, public specificity: number, + public params: {[key: string]: any} = null) { + this.routeData = isPresent(data) ? data : BLANK_ROUTE_DATA; } - - get componentType() { return this._recognizer.handler.componentType; } - resolveComponentType(): Promise { return this._recognizer.handler.resolveComponentType(); } - get specificity() { return this._recognizer.specificity; } - get terminal() { return this._recognizer.terminal; } - get routeData(): RouteData { return this._routeData; } } diff --git a/modules/angular2/src/router/path_recognizer.ts b/modules/angular2/src/router/path_recognizer.ts index 01e84e0cc0..1e03e1271d 100644 --- a/modules/angular2/src/router/path_recognizer.ts +++ b/modules/angular2/src/router/path_recognizer.ts @@ -7,12 +7,9 @@ import { isBlank } from 'angular2/src/facade/lang'; import {BaseException, WrappedException} from 'angular2/src/facade/exceptions'; - import {Map, MapWrapper, StringMapWrapper} from 'angular2/src/facade/collection'; -import {RouteHandler} from './route_handler'; import {Url, RootUrl, serializeParams} from './url_parser'; -import {ComponentInstruction, ComponentInstruction_} from './instruction'; class TouchMap { map: {[key: string]: string} = {}; @@ -33,7 +30,7 @@ class TouchMap { } getUnused(): {[key: string]: any} { - var unused: {[key: string]: any} = StringMapWrapper.create(); + var unused: {[key: string]: any} = {}; var keys = StringMapWrapper.keys(this.keys); keys.forEach(key => unused[key] = StringMapWrapper.get(this.map, key)); return unused; @@ -126,7 +123,6 @@ function parsePathString(route: string): {[key: string]: any} { results.push(new StarSegment(match[1])); } else if (segment == '...') { if (i < limit) { - // TODO (matsko): setup a proper error here ` throw new BaseException(`Unexpected "..." before the end of the path for "${route}".`); } results.push(new ContinuationSegment()); @@ -175,23 +171,17 @@ function assertPath(path: string) { } } -export class PathMatch { - constructor(public instruction: ComponentInstruction, public remaining: Url, - public remainingAux: Url[]) {} -} -// represents something like '/foo/:bar' +/** + * Parses a URL string using a given matcher DSL, and generates URLs from param maps + */ export class PathRecognizer { private _segments: Segment[]; specificity: number; terminal: boolean = true; hash: string; - private _cache: Map = new Map(); - - // TODO: cache component instruction instances by params and by ParsedUrl instance - - constructor(public path: string, public handler: RouteHandler) { + constructor(public path: string) { assertPath(path); var parsed = parsePathString(path); @@ -203,8 +193,7 @@ export class PathRecognizer { this.terminal = !(lastSegment instanceof ContinuationSegment); } - - recognize(beginningSegment: Url): PathMatch { + recognize(beginningSegment: Url): {[key: string]: any} { var nextSegment = beginningSegment; var currentSegment: Url; var positionalParams = {}; @@ -247,7 +236,6 @@ export class PathRecognizer { var urlPath = captured.join('/'); var auxiliary; - var instruction: ComponentInstruction; var urlParams; var allParams; if (isPresent(currentSegment)) { @@ -267,12 +255,11 @@ export class PathRecognizer { auxiliary = []; urlParams = []; } - instruction = this._getInstruction(urlPath, urlParams, this, allParams); - return new PathMatch(instruction, nextSegment, auxiliary); + return {urlPath, urlParams, allParams, auxiliary, nextSegment}; } - generate(params: {[key: string]: any}): ComponentInstruction { + generate(params: {[key: string]: any}): {[key: string]: any} { var paramTokens = new TouchMap(params); var path = []; @@ -288,18 +275,6 @@ export class PathRecognizer { var nonPositionalParams = paramTokens.getUnused(); var urlParams = serializeParams(nonPositionalParams); - return this._getInstruction(urlPath, urlParams, this, params); - } - - private _getInstruction(urlPath: string, urlParams: string[], _recognizer: PathRecognizer, - params: {[key: string]: any}): ComponentInstruction { - var hashKey = urlPath + '?' + urlParams.join('?'); - if (this._cache.has(hashKey)) { - return this._cache.get(hashKey); - } - var instruction = new ComponentInstruction_(urlPath, urlParams, _recognizer, params); - this._cache.set(hashKey, instruction); - - return instruction; + return {urlPath, urlParams}; } } diff --git a/modules/angular2/src/router/route_config_impl.ts b/modules/angular2/src/router/route_config_impl.ts index b56fb0103f..b52de354df 100644 --- a/modules/angular2/src/router/route_config_impl.ts +++ b/modules/angular2/src/router/route_config_impl.ts @@ -21,6 +21,8 @@ export class RouteConfig { * - `name` is an optional `CamelCase` string representing the name of the route. * - `data` is an optional property of any type representing arbitrary route metadata for the given * route. It is injectable via {@link RouteData}. + * - `useAsDefault` is a boolean value. If `true`, the child route will be navigated to if no child + * route is specified during the navigation. * * ### Example * ``` @@ -38,16 +40,20 @@ export class Route implements RouteDefinition { path: string; component: Type; name: string; + useAsDefault: boolean; // added next three properties to work around https://github.com/Microsoft/TypeScript/issues/4107 aux: string = null; loader: Function = null; - redirectTo: string = null; - constructor({path, component, name, - data}: {path: string, component: Type, name?: string, data?: {[key: string]: any}}) { + redirectTo: any[] = null; + constructor({path, component, name, data, useAsDefault}: { + path: string, + component: Type, name?: string, data?: {[key: string]: any}, useAsDefault?: boolean + }) { this.path = path; this.component = component; this.name = name; this.data = data; + this.useAsDefault = useAsDefault; } } @@ -80,7 +86,8 @@ export class AuxRoute implements RouteDefinition { // added next three properties to work around https://github.com/Microsoft/TypeScript/issues/4107 aux: string = null; loader: Function = null; - redirectTo: string = null; + redirectTo: any[] = null; + useAsDefault: boolean = false; constructor({path, component, name}: {path: string, component: Type, name?: string}) { this.path = path; this.component = component; @@ -98,6 +105,8 @@ export class AuxRoute implements RouteDefinition { * - `name` is an optional `CamelCase` string representing the name of the route. * - `data` is an optional property of any type representing arbitrary route metadata for the given * route. It is injectable via {@link RouteData}. + * - `useAsDefault` is a boolean value. If `true`, the child route will be navigated to if no child + * route is specified during the navigation. * * ### Example * ``` @@ -115,31 +124,37 @@ export class AsyncRoute implements RouteDefinition { path: string; loader: Function; name: string; + useAsDefault: boolean; aux: string = null; - constructor({path, loader, name, data}: - {path: string, loader: Function, name?: string, data?: {[key: string]: any}}) { + constructor({path, loader, name, data, useAsDefault}: { + path: string, + loader: Function, name?: string, data?: {[key: string]: any}, useAsDefault?: boolean + }) { this.path = path; this.loader = loader; this.name = name; this.data = data; + this.useAsDefault = useAsDefault; } } /** - * `Redirect` is a type of {@link RouteDefinition} used to route a path to an asynchronously loaded - * component. + * `Redirect` is a type of {@link RouteDefinition} used to route a path to a canonical route. * * It has the following properties: * - `path` is a string that uses the route matcher DSL. - * - `redirectTo` is a string representing the new URL to be matched against. + * - `redirectTo` is an array representing the link DSL. + * + * Note that redirects **do not** affect how links are generated. For that, see the `useAsDefault` + * option. * * ### Example * ``` * import {RouteConfig} from 'angular2/router'; * * @RouteConfig([ - * {path: '/', redirectTo: '/home'}, - * {path: '/home', component: HomeCmp} + * {path: '/', redirectTo: ['/Home'] }, + * {path: '/home', component: HomeCmp, name: 'Home'} * ]) * class MyApp {} * ``` @@ -147,13 +162,14 @@ export class AsyncRoute implements RouteDefinition { @CONST() export class Redirect implements RouteDefinition { path: string; - redirectTo: string; + redirectTo: any[]; name: string = null; // added next three properties to work around https://github.com/Microsoft/TypeScript/issues/4107 loader: Function = null; data: any = null; aux: string = null; - constructor({path, redirectTo}: {path: string, redirectTo: string}) { + useAsDefault: boolean = false; + constructor({path, redirectTo}: {path: string, redirectTo: any[]}) { this.path = path; this.redirectTo = redirectTo; } diff --git a/modules/angular2/src/router/route_config_nomalizer.dart b/modules/angular2/src/router/route_config_nomalizer.dart index 2d3005295b..6fe053e62d 100644 --- a/modules/angular2/src/router/route_config_nomalizer.dart +++ b/modules/angular2/src/router/route_config_nomalizer.dart @@ -1,9 +1,22 @@ library angular2.src.router.route_config_normalizer; import "route_config_decorator.dart"; +import "route_registry.dart"; import "package:angular2/src/facade/exceptions.dart" show BaseException; -RouteDefinition normalizeRouteConfig(RouteDefinition config) { +RouteDefinition normalizeRouteConfig(RouteDefinition config, RouteRegistry registry) { + if (config is AsyncRoute) { + + configRegistryAndReturnType(componentType) { + registry.configFromComponent(componentType); + return componentType; + } + + loader() { + return config.loader().then(configRegistryAndReturnType); + } + return new AsyncRoute(path: config.path, loader: loader, name: config.name, data: config.data, useAsDefault: config.useAsDefault); + } return config; } diff --git a/modules/angular2/src/router/route_config_nomalizer.ts b/modules/angular2/src/router/route_config_nomalizer.ts index 8d0725dbab..16518fa79f 100644 --- a/modules/angular2/src/router/route_config_nomalizer.ts +++ b/modules/angular2/src/router/route_config_nomalizer.ts @@ -2,14 +2,29 @@ import {AsyncRoute, AuxRoute, Route, Redirect, RouteDefinition} from './route_co import {ComponentDefinition} from './route_definition'; import {isType, Type} from 'angular2/src/facade/lang'; import {BaseException, WrappedException} from 'angular2/src/facade/exceptions'; +import {RouteRegistry} from './route_registry'; /** - * Given a JS Object that represents... returns a corresponding Route, AsyncRoute, or Redirect + * Given a JS Object that represents a route config, returns a corresponding Route, AsyncRoute, + * AuxRoute or Redirect object. + * + * Also wraps an AsyncRoute's loader function to add the loaded component's route config to the + * `RouteRegistry`. */ -export function normalizeRouteConfig(config: RouteDefinition): RouteDefinition { - if (config instanceof Route || config instanceof Redirect || config instanceof AsyncRoute || - config instanceof AuxRoute) { +export function normalizeRouteConfig(config: RouteDefinition, + registry: RouteRegistry): RouteDefinition { + if (config instanceof AsyncRoute) { + var wrappedLoader = wrapLoaderToReconfigureRegistry(config.loader, registry); + return new AsyncRoute({ + path: config.path, + loader: wrappedLoader, + name: config.name, + data: config.data, + useAsDefault: config.useAsDefault + }); + } + if (config instanceof Route || config instanceof Redirect || config instanceof AuxRoute) { return config; } @@ -24,7 +39,13 @@ export function normalizeRouteConfig(config: RouteDefinition): RouteDefinition { config.name = config.as; } if (config.loader) { - return new AsyncRoute({path: config.path, loader: config.loader, name: config.name}); + var wrappedLoader = wrapLoaderToReconfigureRegistry(config.loader, registry); + return new AsyncRoute({ + path: config.path, + loader: wrappedLoader, + name: config.name, + useAsDefault: config.useAsDefault + }); } if (config.aux) { return new AuxRoute({path: config.aux, component:config.component, name: config.name}); @@ -36,11 +57,17 @@ export function normalizeRouteConfig(config: RouteDefinition): RouteDefinition { return new Route({ path: config.path, component:componentDefinitionObject.constructor, - name: config.name + name: config.name, + data: config.data, + useAsDefault: config.useAsDefault }); } else if (componentDefinitionObject.type == 'loader') { - return new AsyncRoute( - {path: config.path, loader: componentDefinitionObject.loader, name: config.name}); + return new AsyncRoute({ + path: config.path, + loader: componentDefinitionObject.loader, + name: config.name, + useAsDefault: config.useAsDefault + }); } else { throw new BaseException( `Invalid component type "${componentDefinitionObject.type}". Valid types are "constructor" and "loader".`); @@ -50,6 +77,8 @@ export function normalizeRouteConfig(config: RouteDefinition): RouteDefinition { path: string; component: Type; name?: string; + data?: {[key: string]: any}; + useAsDefault?: boolean; }>config); } @@ -60,6 +89,16 @@ export function normalizeRouteConfig(config: RouteDefinition): RouteDefinition { return config; } + +function wrapLoaderToReconfigureRegistry(loader: Function, registry: RouteRegistry): Function { + return () => { + return loader().then((componentType) => { + registry.configFromComponent(componentType); + return componentType; + }); + }; +} + export function assertComponentExists(component: Type, path: string): void { if (!isType(component)) { throw new BaseException(`Component for route "${path}" is not defined, or is not a class.`); diff --git a/modules/angular2/src/router/route_definition.dart b/modules/angular2/src/router/route_definition.dart index e5d0a22539..79c8b5672b 100644 --- a/modules/angular2/src/router/route_definition.dart +++ b/modules/angular2/src/router/route_definition.dart @@ -3,5 +3,6 @@ library angular2.src.router.route_definition; abstract class RouteDefinition { final String path; final String name; - const RouteDefinition({this.path, this.name}); + final bool useAsDefault; + const RouteDefinition({this.path, this.name, this.useAsDefault : false}); } diff --git a/modules/angular2/src/router/route_definition.ts b/modules/angular2/src/router/route_definition.ts index ee1266143d..7d38690ee1 100644 --- a/modules/angular2/src/router/route_definition.ts +++ b/modules/angular2/src/router/route_definition.ts @@ -16,10 +16,11 @@ export interface RouteDefinition { aux?: string; component?: Type | ComponentDefinition; loader?: Function; - redirectTo?: string; + redirectTo?: any[]; as?: string; name?: string; data?: any; + useAsDefault?: boolean; } export interface ComponentDefinition { diff --git a/modules/angular2/src/router/route_handler.ts b/modules/angular2/src/router/route_handler.ts index 54ca3b2ef0..5971267ee8 100644 --- a/modules/angular2/src/router/route_handler.ts +++ b/modules/angular2/src/router/route_handler.ts @@ -1,8 +1,9 @@ import {Promise, PromiseWrapper} from 'angular2/src/facade/async'; import {Type} from 'angular2/src/facade/lang'; +import {RouteData} from './instruction'; export interface RouteHandler { componentType: Type; resolveComponentType(): Promise; - data?: {[key: string]: any}; + data: RouteData; } diff --git a/modules/angular2/src/router/route_recognizer.ts b/modules/angular2/src/router/route_recognizer.ts index 6511fadda7..fb0b8973e9 100644 --- a/modules/angular2/src/router/route_recognizer.ts +++ b/modules/angular2/src/router/route_recognizer.ts @@ -1,184 +1,119 @@ -import { - RegExp, - RegExpWrapper, - isBlank, - isPresent, - isType, - isStringMap, - Type -} from 'angular2/src/facade/lang'; -import {BaseException, WrappedException} from 'angular2/src/facade/exceptions'; -import {Map, MapWrapper, ListWrapper, StringMapWrapper} from 'angular2/src/facade/collection'; +import {isPresent, isBlank} from 'angular2/src/facade/lang'; +import {BaseException} from 'angular2/src/facade/exceptions'; +import {PromiseWrapper, Promise} from 'angular2/src/facade/promise'; +import {Map} from 'angular2/src/facade/collection'; -import {PathRecognizer, PathMatch} from './path_recognizer'; -import {Route, AsyncRoute, AuxRoute, Redirect, RouteDefinition} from './route_config_impl'; -import {AsyncRouteHandler} from './async_route_handler'; -import {SyncRouteHandler} from './sync_route_handler'; +import {RouteHandler} from './route_handler'; import {Url} from './url_parser'; import {ComponentInstruction} from './instruction'; +import {PathRecognizer} from './path_recognizer'; -/** - * `RouteRecognizer` is responsible for recognizing routes for a single component. - * It is consumed by `RouteRegistry`, which knows how to recognize an entire hierarchy of - * components. - */ -export class RouteRecognizer { - names = new Map(); +export abstract class RouteMatch {} - // map from name to recognizer - auxNames = new Map(); - - // map from starting path to recognizer - auxRoutes = new Map(); - - // TODO: optimize this into a trie - matchers: PathRecognizer[] = []; - - // TODO: optimize this into a trie - redirects: Redirector[] = []; - - config(config: RouteDefinition): boolean { - var handler; - - if (isPresent(config.name) && config.name[0].toUpperCase() != config.name[0]) { - var suggestedName = config.name[0].toUpperCase() + config.name.substring(1); - throw new BaseException( - `Route "${config.path}" with name "${config.name}" does not begin with an uppercase letter. Route names should be CamelCase like "${suggestedName}".`); - } - - if (config instanceof AuxRoute) { - handler = new SyncRouteHandler(config.component, config.data); - let path = config.path.startsWith('/') ? config.path.substring(1) : config.path; - var recognizer = new PathRecognizer(config.path, handler); - this.auxRoutes.set(path, recognizer); - if (isPresent(config.name)) { - this.auxNames.set(config.name, recognizer); - } - return recognizer.terminal; - } - - if (config instanceof Redirect) { - this.redirects.push(new Redirector(config.path, config.redirectTo)); - return true; - } - - if (config instanceof Route) { - handler = new SyncRouteHandler(config.component, config.data); - } else if (config instanceof AsyncRoute) { - handler = new AsyncRouteHandler(config.loader, config.data); - } - var recognizer = new PathRecognizer(config.path, handler); - - this.matchers.forEach((matcher) => { - if (recognizer.hash == matcher.hash) { - throw new BaseException( - `Configuration '${config.path}' conflicts with existing route '${matcher.path}'`); - } - }); - - this.matchers.push(recognizer); - if (isPresent(config.name)) { - this.names.set(config.name, recognizer); - } - return recognizer.terminal; - } +export interface AbstractRecognizer { + hash: string; + path: string; + recognize(beginningSegment: Url): Promise; + generate(params: {[key: string]: any}): ComponentInstruction; +} - /** - * Given a URL, returns a list of `RouteMatch`es, which are partial recognitions for some route. - * - */ - recognize(urlParse: Url): PathMatch[] { - var solutions = []; - - urlParse = this._redirect(urlParse); - - this.matchers.forEach((pathRecognizer: PathRecognizer) => { - var pathMatch = pathRecognizer.recognize(urlParse); - - if (isPresent(pathMatch)) { - solutions.push(pathMatch); - } - }); - - return solutions; - } - - /** @internal */ - _redirect(urlParse: Url): Url { - for (var i = 0; i < this.redirects.length; i += 1) { - let redirector = this.redirects[i]; - var redirectedUrl = redirector.redirect(urlParse); - if (isPresent(redirectedUrl)) { - return redirectedUrl; - } - } - - return urlParse; - } - - recognizeAuxiliary(urlParse: Url): PathMatch { - var pathRecognizer = this.auxRoutes.get(urlParse.path); - if (isBlank(pathRecognizer)) { - return null; - } - return pathRecognizer.recognize(urlParse); - } - - hasRoute(name: string): boolean { return this.names.has(name); } - - generate(name: string, params: any): ComponentInstruction { - var pathRecognizer: PathRecognizer = this.names.get(name); - if (isBlank(pathRecognizer)) { - return null; - } - return pathRecognizer.generate(params); - } - - generateAuxiliary(name: string, params: any): ComponentInstruction { - var pathRecognizer: PathRecognizer = this.auxNames.get(name); - if (isBlank(pathRecognizer)) { - return null; - } - return pathRecognizer.generate(params); +export class PathMatch extends RouteMatch { + constructor(public instruction: ComponentInstruction, public remaining: Url, + public remainingAux: Url[]) { + super(); } } -export class Redirector { - segments: string[] = []; - toSegments: string[] = []; - constructor(path: string, redirectTo: string) { - if (path.startsWith('/')) { - path = path.substring(1); - } - this.segments = path.split('/'); - if (redirectTo.startsWith('/')) { - redirectTo = redirectTo.substring(1); - } - this.toSegments = redirectTo.split('/'); +export class RedirectMatch extends RouteMatch { + constructor(public redirectTo: any[], public specificity) { super(); } +} + +export class RedirectRecognizer implements AbstractRecognizer { + private _pathRecognizer: PathRecognizer; + public hash: string; + + constructor(public path: string, public redirectTo: any[]) { + this._pathRecognizer = new PathRecognizer(path); + this.hash = this._pathRecognizer.hash; } /** * Returns `null` or a `ParsedUrl` representing the new path to match */ - redirect(urlParse: Url): Url { - for (var i = 0; i < this.segments.length; i += 1) { - if (isBlank(urlParse)) { - return null; - } - let segment = this.segments[i]; - if (segment != urlParse.path) { - return null; - } - urlParse = urlParse.child; + recognize(beginningSegment: Url): Promise { + var match = null; + if (isPresent(this._pathRecognizer.recognize(beginningSegment))) { + match = new RedirectMatch(this.redirectTo, this._pathRecognizer.specificity); } + return PromiseWrapper.resolve(match); + } - for (var i = this.toSegments.length - 1; i >= 0; i -= 1) { - let segment = this.toSegments[i]; - urlParse = new Url(segment, urlParse); - } - return urlParse; + generate(params: {[key: string]: any}): ComponentInstruction { + throw new BaseException(`Tried to generate a redirect.`); + } +} + + +// represents something like '/foo/:bar' +export class RouteRecognizer implements AbstractRecognizer { + specificity: number; + terminal: boolean = true; + hash: string; + + private _cache: Map = new Map(); + private _pathRecognizer: PathRecognizer; + + // TODO: cache component instruction instances by params and by ParsedUrl instance + + constructor(public path: string, public handler: RouteHandler) { + this._pathRecognizer = new PathRecognizer(path); + this.specificity = this._pathRecognizer.specificity; + this.hash = this._pathRecognizer.hash; + this.terminal = this._pathRecognizer.terminal; + } + + recognize(beginningSegment: Url): Promise { + var res = this._pathRecognizer.recognize(beginningSegment); + if (isBlank(res)) { + return null; + } + + return this.handler.resolveComponentType().then((_) => { + var componentInstruction = + this._getInstruction(res['urlPath'], res['urlParams'], res['allParams']); + return new PathMatch(componentInstruction, res['nextSegment'], res['auxiliary']); + }); + } + + generate(params: {[key: string]: any}): ComponentInstruction { + var generated = this._pathRecognizer.generate(params); + var urlPath = generated['urlPath']; + var urlParams = generated['urlParams']; + return this._getInstruction(urlPath, urlParams, params); + } + + generateComponentPathValues(params: {[key: string]: any}): {[key: string]: any} { + return this._pathRecognizer.generate(params); + } + + private _getInstruction(urlPath: string, urlParams: string[], + params: {[key: string]: any}): ComponentInstruction { + if (isBlank(this.handler.componentType)) { + throw new BaseException(`Tried to get instruction before the type was loaded.`); + } + + var hashKey = urlPath + '?' + urlParams.join('?'); + if (this._cache.has(hashKey)) { + return this._cache.get(hashKey); + } + var instruction = + new ComponentInstruction(urlPath, urlParams, this.handler.data, this.handler.componentType, + this.terminal, this.specificity, params); + this._cache.set(hashKey, instruction); + + return instruction; } } diff --git a/modules/angular2/src/router/route_registry.ts b/modules/angular2/src/router/route_registry.ts index cb57d6408b..7ea7c41daa 100644 --- a/modules/angular2/src/router/route_registry.ts +++ b/modules/angular2/src/router/route_registry.ts @@ -1,6 +1,3 @@ -import {PathMatch} from './path_recognizer'; -import {RouteRecognizer} from './route_recognizer'; -import {Instruction, ComponentInstruction, PrimaryInstruction} from './instruction'; import {ListWrapper, Map, MapWrapper, StringMapWrapper} from 'angular2/src/facade/collection'; import {Promise, PromiseWrapper} from 'angular2/src/facade/async'; import { @@ -10,12 +7,14 @@ import { isType, isString, isStringMap, - isFunction, - StringWrapper, Type, - getTypeNameForDebugging + getTypeNameForDebugging, + CONST_EXPR } from 'angular2/src/facade/lang'; import {BaseException, WrappedException} from 'angular2/src/facade/exceptions'; +import {reflector} from 'angular2/src/core/reflection/reflection'; +import {Injectable, Inject, OpaqueToken} from 'angular2/core'; + import { RouteConfig, AsyncRoute, @@ -24,13 +23,52 @@ import { Redirect, RouteDefinition } from './route_config_impl'; -import {reflector} from 'angular2/src/core/reflection/reflection'; -import {Injectable} from 'angular2/core'; +import {PathMatch, RedirectMatch, RouteMatch} from './route_recognizer'; +import {ComponentRecognizer} from './component_recognizer'; +import { + Instruction, + ResolvedInstruction, + RedirectInstruction, + UnresolvedInstruction, + DefaultInstruction +} from './instruction'; + import {normalizeRouteConfig, assertComponentExists} from './route_config_nomalizer'; import {parser, Url, pathSegmentsToUrl} from './url_parser'; var _resolveToNull = PromiseWrapper.resolve(null); + + +/** + * Token used to bind the component with the top-level {@link RouteConfig}s for the + * application. + * + * ### Example ([live demo](http://plnkr.co/edit/iRUP8B5OUbxCWQ3AcIDm)) + * + * ``` + * import {Component} from 'angular2/angular2'; + * import { + * ROUTER_DIRECTIVES, + * ROUTER_PROVIDERS, + * RouteConfig + * } from 'angular2/router'; + * + * @Component({directives: [ROUTER_DIRECTIVES]}) + * @RouteConfig([ + * {...}, + * ]) + * class AppCmp { + * // ... + * } + * + * bootstrap(AppCmp, [ROUTER_PROVIDERS]); + * ``` + */ +export const ROUTER_PRIMARY_COMPONENT: OpaqueToken = + CONST_EXPR(new OpaqueToken('RouterPrimaryComponent')); + + /** * The RouteRegistry holds route configurations for each component in an Angular app. * It is responsible for creating Instructions from URLs, and generating URLs based on route and @@ -38,13 +76,15 @@ var _resolveToNull = PromiseWrapper.resolve(null); */ @Injectable() export class RouteRegistry { - private _rules = new Map(); + private _rules = new Map(); + + constructor(@Inject(ROUTER_PRIMARY_COMPONENT) private _rootComponent: Type) {} /** * Given a component and a configuration object, add the route to this registry */ config(parentComponent: any, config: RouteDefinition): void { - config = normalizeRouteConfig(config); + config = normalizeRouteConfig(config, this); // this is here because Dart type guard reasons if (config instanceof Route) { @@ -53,10 +93,10 @@ export class RouteRegistry { assertComponentExists(config.component, config.path); } - var recognizer: RouteRecognizer = this._rules.get(parentComponent); + var recognizer: ComponentRecognizer = this._rules.get(parentComponent); if (isBlank(recognizer)) { - recognizer = new RouteRecognizer(); + recognizer = new ComponentRecognizer(); this._rules.set(parentComponent, recognizer); } @@ -102,102 +142,188 @@ export class RouteRegistry { * Given a URL and a parent component, return the most specific instruction for navigating * the application into the state specified by the url */ - recognize(url: string, parentComponent: any): Promise { + recognize(url: string, ancestorInstructions: Instruction[]): Promise { var parsedUrl = parser.parse(url); - return this._recognize(parsedUrl, parentComponent); + return this._recognize(parsedUrl, ancestorInstructions); } - private _recognize(parsedUrl: Url, parentComponent): Promise { - return this._recognizePrimaryRoute(parsedUrl, parentComponent) - .then((instruction: PrimaryInstruction) => - this._completeAuxiliaryRouteMatches(instruction, parentComponent)); - } - private _recognizePrimaryRoute(parsedUrl: Url, parentComponent): Promise { + /** + * Recognizes all parent-child routes, but creates unresolved auxiliary routes + */ + + private _recognize(parsedUrl: Url, ancestorInstructions: Instruction[], + _aux = false): Promise { + var parentComponent = + ancestorInstructions.length > 0 ? + ancestorInstructions[ancestorInstructions.length - 1].component.componentType : + this._rootComponent; + var componentRecognizer = this._rules.get(parentComponent); if (isBlank(componentRecognizer)) { return _resolveToNull; } // Matches some beginning part of the given URL - var possibleMatches = componentRecognizer.recognize(parsedUrl); + var possibleMatches: Promise[] = + _aux ? componentRecognizer.recognizeAuxiliary(parsedUrl) : + componentRecognizer.recognize(parsedUrl); - var matchPromises = - possibleMatches.map(candidate => this._completePrimaryRouteMatch(candidate)); + var matchPromises: Promise[] = possibleMatches.map( + (candidate: Promise) => candidate.then((candidate: RouteMatch) => { + + if (candidate instanceof PathMatch) { + var auxParentInstructions = + ancestorInstructions.length > 0 ? + [ancestorInstructions[ancestorInstructions.length - 1]] : + []; + var auxInstructions = + this._auxRoutesToUnresolved(candidate.remainingAux, auxParentInstructions); + var instruction = new ResolvedInstruction(candidate.instruction, null, auxInstructions); + + if (candidate.instruction.terminal) { + return instruction; + } + + var newAncestorComponents = ancestorInstructions.concat([instruction]); + + return this._recognize(candidate.remaining, newAncestorComponents) + .then((childInstruction) => { + if (isBlank(childInstruction)) { + return null; + } + + // redirect instructions are already absolute + if (childInstruction instanceof RedirectInstruction) { + return childInstruction; + } + instruction.child = childInstruction; + return instruction; + }); + } + + if (candidate instanceof RedirectMatch) { + var instruction = this.generate(candidate.redirectTo, ancestorInstructions); + return new RedirectInstruction(instruction.component, instruction.child, + instruction.auxInstruction); + } + })); + + if ((isBlank(parsedUrl) || parsedUrl.path == '') && possibleMatches.length == 0) { + return PromiseWrapper.resolve(this.generateDefault(parentComponent)); + } return PromiseWrapper.all(matchPromises).then(mostSpecific); } - private _completePrimaryRouteMatch(partialMatch: PathMatch): Promise { - var instruction = partialMatch.instruction; - return instruction.resolveComponentType().then((componentType) => { - this.configFromComponent(componentType); + private _auxRoutesToUnresolved(auxRoutes: Url[], + parentInstructions: Instruction[]): {[key: string]: Instruction} { + var unresolvedAuxInstructions: {[key: string]: Instruction} = {}; - if (instruction.terminal) { - return new PrimaryInstruction(instruction, null, partialMatch.remainingAux); - } - - return this._recognizePrimaryRoute(partialMatch.remaining, componentType) - .then((childInstruction) => { - if (isBlank(childInstruction)) { - return null; - } else { - return new PrimaryInstruction(instruction, childInstruction, - partialMatch.remainingAux); - } - }); + auxRoutes.forEach((auxUrl: Url) => { + unresolvedAuxInstructions[auxUrl.path] = new UnresolvedInstruction( + () => { return this._recognize(auxUrl, parentInstructions, true); }); }); + + return unresolvedAuxInstructions; } - private _completeAuxiliaryRouteMatches(instruction: PrimaryInstruction, - parentComponent: any): Promise { - if (isBlank(instruction)) { - return _resolveToNull; - } - - var componentRecognizer = this._rules.get(parentComponent); - var auxInstructions: {[key: string]: Instruction} = {}; - - var promises = instruction.auxUrls.map((auxSegment: Url) => { - var match = componentRecognizer.recognizeAuxiliary(auxSegment); - if (isBlank(match)) { - return _resolveToNull; - } - return this._completePrimaryRouteMatch(match).then((auxInstruction: PrimaryInstruction) => { - if (isPresent(auxInstruction)) { - return this._completeAuxiliaryRouteMatches(auxInstruction, parentComponent) - .then((finishedAuxRoute: Instruction) => { - auxInstructions[auxSegment.path] = finishedAuxRoute; - }); - } - }); - }); - return PromiseWrapper.all(promises).then((_) => { - if (isBlank(instruction.child)) { - return new Instruction(instruction.component, null, auxInstructions); - } - return this._completeAuxiliaryRouteMatches(instruction.child, - instruction.component.componentType) - .then((completeChild) => { - return new Instruction(instruction.component, completeChild, auxInstructions); - }); - }); - } - /** * Given a normalized list with component names and params like: `['user', {id: 3 }]` * generates a url with a leading slash relative to the provided `parentComponent`. + * + * If the optional param `_aux` is `true`, then we generate starting at an auxiliary + * route boundary. */ - generate(linkParams: any[], parentComponent: any, _aux = false): Instruction { + generate(linkParams: any[], ancestorInstructions: Instruction[], _aux = false): Instruction { + let normalizedLinkParams = splitAndFlattenLinkParams(linkParams); + + var first = ListWrapper.first(normalizedLinkParams); + var rest = ListWrapper.slice(normalizedLinkParams, 1); + + // The first segment should be either '.' (generate from parent) or '' (generate from root). + // When we normalize above, we strip all the slashes, './' becomes '.' and '/' becomes ''. + if (first == '') { + ancestorInstructions = []; + } else if (first == '..') { + // we already captured the first instance of "..", so we need to pop off an ancestor + ancestorInstructions.pop(); + while (ListWrapper.first(rest) == '..') { + rest = ListWrapper.slice(rest, 1); + ancestorInstructions.pop(); + if (ancestorInstructions.length <= 0) { + throw new BaseException( + `Link "${ListWrapper.toJSON(linkParams)}" has too many "../" segments.`); + } + } + } else if (first != '.') { + let parentComponent = this._rootComponent; + let grandparentComponent = null; + if (ancestorInstructions.length > 1) { + parentComponent = + ancestorInstructions[ancestorInstructions.length - 1].component.componentType; + grandparentComponent = + ancestorInstructions[ancestorInstructions.length - 2].component.componentType; + } else if (ancestorInstructions.length == 1) { + parentComponent = ancestorInstructions[0].component.componentType; + grandparentComponent = this._rootComponent; + } + + // For a link with no leading `./`, `/`, or `../`, we look for a sibling and child. + // If both exist, we throw. Otherwise, we prefer whichever exists. + var childRouteExists = this.hasRoute(first, parentComponent); + var parentRouteExists = + isPresent(grandparentComponent) && this.hasRoute(first, grandparentComponent); + + if (parentRouteExists && childRouteExists) { + let msg = + `Link "${ListWrapper.toJSON(linkParams)}" is ambiguous, use "./" or "../" to disambiguate.`; + throw new BaseException(msg); + } + if (parentRouteExists) { + ancestorInstructions.pop(); + } + rest = linkParams; + } + + if (rest[rest.length - 1] == '') { + rest.pop(); + } + + if (rest.length < 1) { + let msg = `Link "${ListWrapper.toJSON(linkParams)}" must include a route name.`; + throw new BaseException(msg); + } + + var generatedInstruction = this._generate(rest, ancestorInstructions, _aux); + + for (var i = ancestorInstructions.length - 1; i >= 0; i--) { + let ancestorInstruction = ancestorInstructions[i]; + generatedInstruction = ancestorInstruction.replaceChild(generatedInstruction); + } + + return generatedInstruction; + } + + + /* + * Internal helper that does not make any assertions about the beginning of the link DSL + */ + private _generate(linkParams: any[], ancestorInstructions: Instruction[], + _aux = false): Instruction { + let parentComponent = + ancestorInstructions.length > 0 ? + ancestorInstructions[ancestorInstructions.length - 1].component.componentType : + this._rootComponent; + + + if (linkParams.length == 0) { + return this.generateDefault(parentComponent); + } let linkIndex = 0; let routeName = linkParams[linkIndex]; - // TODO: this is kind of odd but it makes existing assertions pass - if (isBlank(parentComponent)) { - throw new BaseException(`Could not find route named "${routeName}".`); - } - if (!isString(routeName)) { throw new BaseException(`Unexpected segment "${routeName}" in link DSL. Expected a string.`); } else if (routeName == '' || routeName == '.' || routeName == '..') { @@ -216,7 +342,13 @@ export class RouteRegistry { let auxInstructions: {[key: string]: Instruction} = {}; var nextSegment; while (linkIndex + 1 < linkParams.length && isArray(nextSegment = linkParams[linkIndex + 1])) { - auxInstructions[nextSegment[0]] = this.generate(nextSegment, parentComponent, true); + let auxParentInstruction = ancestorInstructions.length > 0 ? + [ancestorInstructions[ancestorInstructions.length - 1]] : + []; + let auxInstruction = this._generate(nextSegment, auxParentInstruction, true); + + // TODO: this will not work for aux routes with parameters or multiple segments + auxInstructions[auxInstruction.component.urlPath] = auxInstruction; linkIndex += 1; } @@ -226,74 +358,107 @@ export class RouteRegistry { `Component "${getTypeNameForDebugging(parentComponent)}" has no route config.`); } - var componentInstruction = _aux ? componentRecognizer.generateAuxiliary(routeName, params) : - componentRecognizer.generate(routeName, params); + var routeRecognizer = + (_aux ? componentRecognizer.auxNames : componentRecognizer.names).get(routeName); - if (isBlank(componentInstruction)) { + if (!isPresent(routeRecognizer)) { throw new BaseException( `Component "${getTypeNameForDebugging(parentComponent)}" has no route named "${routeName}".`); } - var childInstruction = null; - if (linkIndex + 1 < linkParams.length) { - var remaining = linkParams.slice(linkIndex + 1); - childInstruction = this.generate(remaining, componentInstruction.componentType); - } else if (!componentInstruction.terminal) { - throw new BaseException( - `Link "${ListWrapper.toJSON(linkParams)}" does not resolve to a terminal or async instruction.`); + if (!isPresent(routeRecognizer.handler.componentType)) { + var compInstruction = routeRecognizer.generateComponentPathValues(params); + return new UnresolvedInstruction(() => { + return routeRecognizer.handler.resolveComponentType().then( + (_) => { return this._generate(linkParams, ancestorInstructions, _aux); }); + }, compInstruction['urlPath'], compInstruction['urlParams']); } - return new Instruction(componentInstruction, childInstruction, auxInstructions); + var componentInstruction = _aux ? componentRecognizer.generateAuxiliary(routeName, params) : + componentRecognizer.generate(routeName, params); + + + + var remaining = linkParams.slice(linkIndex + 1); + + var instruction = new ResolvedInstruction(componentInstruction, null, auxInstructions); + + // the component is sync + if (isPresent(componentInstruction.componentType)) { + let childInstruction: Instruction = null; + if (linkIndex + 1 < linkParams.length) { + let childAncestorComponents = ancestorInstructions.concat([instruction]); + childInstruction = this._generate(remaining, childAncestorComponents); + } else if (!componentInstruction.terminal) { + // ... look for defaults + childInstruction = this.generateDefault(componentInstruction.componentType); + + if (isBlank(childInstruction)) { + throw new BaseException( + `Link "${ListWrapper.toJSON(linkParams)}" does not resolve to a terminal instruction.`); + } + } + instruction.child = childInstruction; + } + + return instruction; } public hasRoute(name: string, parentComponent: any): boolean { - var componentRecognizer: RouteRecognizer = this._rules.get(parentComponent); + var componentRecognizer: ComponentRecognizer = this._rules.get(parentComponent); if (isBlank(componentRecognizer)) { return false; } return componentRecognizer.hasRoute(name); } - // if the child includes a redirect like : "/" -> "/something", - // we want to honor that redirection when creating the link - private _generateRedirects(componentCursor: Type): Instruction { + public generateDefault(componentCursor: Type): Instruction { if (isBlank(componentCursor)) { return null; } + var componentRecognizer = this._rules.get(componentCursor); - if (isBlank(componentRecognizer)) { + if (isBlank(componentRecognizer) || isBlank(componentRecognizer.defaultRoute)) { return null; } - for (let i = 0; i < componentRecognizer.redirects.length; i += 1) { - let redirect = componentRecognizer.redirects[i]; - // we only handle redirecting from an empty segment - if (redirect.segments.length == 1 && redirect.segments[0] == '') { - var toSegments = pathSegmentsToUrl(redirect.toSegments); - var matches = componentRecognizer.recognize(toSegments); - var primaryInstruction = - ListWrapper.maximum(matches, (match: PathMatch) => match.instruction.specificity); - - if (isPresent(primaryInstruction)) { - var child = this._generateRedirects(primaryInstruction.instruction.componentType); - return new Instruction(primaryInstruction.instruction, child, {}); - } - return null; + var defaultChild = null; + if (isPresent(componentRecognizer.defaultRoute.handler.componentType)) { + var componentInstruction = componentRecognizer.defaultRoute.generate({}); + if (!componentRecognizer.defaultRoute.terminal) { + defaultChild = this.generateDefault(componentRecognizer.defaultRoute.handler.componentType); } + return new DefaultInstruction(componentInstruction, defaultChild); } - return null; + return new UnresolvedInstruction(() => { + return componentRecognizer.defaultRoute.handler.resolveComponentType().then( + () => this.generateDefault(componentCursor)); + }); } } +/* + * Given: ['/a/b', {c: 2}] + * Returns: ['', 'a', 'b', {c: 2}] + */ +function splitAndFlattenLinkParams(linkParams: any[]): any[] { + return linkParams.reduce((accumulation: any[], item) => { + if (isString(item)) { + let strItem: string = item; + return accumulation.concat(strItem.split('/')); + } + accumulation.push(item); + return accumulation; + }, []); +} /* * Given a list of instructions, returns the most specific instruction */ -function mostSpecific(instructions: PrimaryInstruction[]): PrimaryInstruction { - return ListWrapper.maximum( - instructions, (instruction: PrimaryInstruction) => instruction.component.specificity); +function mostSpecific(instructions: Instruction[]): Instruction { + return ListWrapper.maximum(instructions, (instruction: Instruction) => instruction.specificity); } function assertTerminalComponent(component, path) { diff --git a/modules/angular2/src/router/router.ts b/modules/angular2/src/router/router.ts index 5eb4a31ec9..137ac7db36 100644 --- a/modules/angular2/src/router/router.ts +++ b/modules/angular2/src/router/router.ts @@ -2,13 +2,12 @@ import {Promise, PromiseWrapper, EventEmitter, ObservableWrapper} from 'angular2 import {Map, StringMapWrapper, MapWrapper, ListWrapper} from 'angular2/src/facade/collection'; import {isBlank, isString, isPresent, Type, isArray} from 'angular2/src/facade/lang'; import {BaseException, WrappedException} from 'angular2/src/facade/exceptions'; -import {RouteRegistry} from './route_registry'; +import {Inject, Injectable} from 'angular2/core'; + +import {RouteRegistry, ROUTER_PRIMARY_COMPONENT} from './route_registry'; import { ComponentInstruction, Instruction, - stringifyInstruction, - stringifyInstructionPath, - stringifyInstructionQuery } from './instruction'; import {RouterOutlet} from './router_outlet'; import {Location} from './location'; @@ -126,6 +125,7 @@ export class Router { this._currentInstruction.component == instruction.component; } + /** * Dynamically update the routing configuration and trigger a navigation. * @@ -144,6 +144,7 @@ export class Router { return this.renavigate(); } + /** * Navigate based on the provided Route Link DSL. It's preferred to navigate with this method * over `navigateByUrl`. @@ -212,7 +213,7 @@ export class Router { if (result) { return this.commit(instruction, _skipLocationChange) .then((_) => { - this._emitNavigationFinish(stringifyInstruction(instruction)); + this._emitNavigationFinish(instruction.toRootUrl()); return true; }); } @@ -220,25 +221,22 @@ export class Router { }); } - // TODO(btford): it'd be nice to remove this method as part of cleaning up the traversal logic - // Since refactoring `Router.generate` to return an instruction rather than a string, it's not - // guaranteed that the `componentType`s for the terminal async routes have been loaded by the time - // we begin navigation. The method below simply traverses instructions and resolves any components - // for which `componentType` is not present /** @internal */ _settleInstruction(instruction: Instruction): Promise { - var unsettledInstructions: Array> = []; - if (isBlank(instruction.component.componentType)) { - unsettledInstructions.push(instruction.component.resolveComponentType().then( - (type: Type) => { this.registry.configFromComponent(type); })); - } - if (isPresent(instruction.child)) { - unsettledInstructions.push(this._settleInstruction(instruction.child)); - } - StringMapWrapper.forEach(instruction.auxInstruction, (instruction, _) => { - unsettledInstructions.push(this._settleInstruction(instruction)); + return instruction.resolveComponent().then((_) => { + instruction.component.reuse = false; + + var unsettledInstructions: Array> = []; + + if (isPresent(instruction.child)) { + unsettledInstructions.push(this._settleInstruction(instruction.child)); + } + + StringMapWrapper.forEach(instruction.auxInstruction, (instruction, _) => { + unsettledInstructions.push(this._settleInstruction(instruction)); + }); + return PromiseWrapper.all(unsettledInstructions); }); - return PromiseWrapper.all(unsettledInstructions); } private _emitNavigationFinish(url): void { ObservableWrapper.callEmit(this._subject, url); } @@ -378,7 +376,20 @@ export class Router { * Given a URL, returns an instruction representing the component graph */ recognize(url: string): Promise { - return this.registry.recognize(url, this.hostComponent); + var ancestorComponents = this._getAncestorInstructions(); + return this.registry.recognize(url, ancestorComponents); + } + + private _getAncestorInstructions(): Instruction[] { + var ancestorComponents = []; + var ancestorRouter = this; + while (isPresent(ancestorRouter.parent) && + isPresent(ancestorRouter.parent._currentInstruction)) { + ancestorRouter = ancestorRouter.parent; + ancestorComponents.unshift(ancestorRouter._currentInstruction); + } + + return ancestorComponents; } @@ -399,80 +410,20 @@ export class Router { * app's base href. */ generate(linkParams: any[]): Instruction { - let normalizedLinkParams = splitAndFlattenLinkParams(linkParams); - - var first = ListWrapper.first(normalizedLinkParams); - var rest = ListWrapper.slice(normalizedLinkParams, 1); - - var router = this; - - // The first segment should be either '.' (generate from parent) or '' (generate from root). - // When we normalize above, we strip all the slashes, './' becomes '.' and '/' becomes ''. - if (first == '') { - while (isPresent(router.parent)) { - router = router.parent; - } - } else if (first == '..') { - router = router.parent; - while (ListWrapper.first(rest) == '..') { - rest = ListWrapper.slice(rest, 1); - router = router.parent; - if (isBlank(router)) { - throw new BaseException( - `Link "${ListWrapper.toJSON(linkParams)}" has too many "../" segments.`); - } - } - } else if (first != '.') { - // For a link with no leading `./`, `/`, or `../`, we look for a sibling and child. - // If both exist, we throw. Otherwise, we prefer whichever exists. - var childRouteExists = this.registry.hasRoute(first, this.hostComponent); - var parentRouteExists = - isPresent(this.parent) && this.registry.hasRoute(first, this.parent.hostComponent); - - if (parentRouteExists && childRouteExists) { - let msg = - `Link "${ListWrapper.toJSON(linkParams)}" is ambiguous, use "./" or "../" to disambiguate.`; - throw new BaseException(msg); - } - if (parentRouteExists) { - router = this.parent; - } - rest = linkParams; - } - - if (rest[rest.length - 1] == '') { - rest.pop(); - } - - if (rest.length < 1) { - let msg = `Link "${ListWrapper.toJSON(linkParams)}" must include a route name.`; - throw new BaseException(msg); - } - - var nextInstruction = this.registry.generate(rest, router.hostComponent); - - var url = []; - var parent = router.parent; - while (isPresent(parent)) { - url.unshift(parent._currentInstruction); - parent = parent.parent; - } - - while (url.length > 0) { - nextInstruction = url.pop().replaceChild(nextInstruction); - } - - return nextInstruction; + var ancestorInstructions = this._getAncestorInstructions(); + return this.registry.generate(linkParams, ancestorInstructions); } } +@Injectable() export class RootRouter extends Router { /** @internal */ _location: Location; /** @internal */ _locationSub: Object; - constructor(registry: RouteRegistry, location: Location, primaryComponent: Type) { + constructor(registry: RouteRegistry, location: Location, + @Inject(ROUTER_PRIMARY_COMPONENT) primaryComponent: Type) { super(registry, null, primaryComponent); this._location = location; this._locationSub = this._location.subscribe( @@ -482,8 +433,8 @@ export class RootRouter extends Router { } commit(instruction: Instruction, _skipLocationChange: boolean = false): Promise { - var emitPath = stringifyInstructionPath(instruction); - var emitQuery = stringifyInstructionQuery(instruction); + var emitPath = instruction.toUrlPath(); + var emitQuery = instruction.toUrlQuery(); if (emitPath.length > 0) { emitPath = '/' + emitPath; } @@ -521,20 +472,6 @@ class ChildRouter extends Router { } } -/* - * Given: ['/a/b', {c: 2}] - * Returns: ['', 'a', 'b', {c: 2}] - */ -function splitAndFlattenLinkParams(linkParams: any[]): any[] { - return linkParams.reduce((accumulation: any[], item) => { - if (isString(item)) { - let strItem: string = item; - return accumulation.concat(strItem.split('/')); - } - accumulation.push(item); - return accumulation; - }, []); -} function canActivateOne(nextInstruction: Instruction, prevInstruction: Instruction): Promise { diff --git a/modules/angular2/src/router/router_link.ts b/modules/angular2/src/router/router_link.ts index 6ea1e5d6bb..6d380f4ffd 100644 --- a/modules/angular2/src/router/router_link.ts +++ b/modules/angular2/src/router/router_link.ts @@ -3,7 +3,7 @@ import {isString} from 'angular2/src/facade/lang'; import {Router} from './router'; import {Location} from './location'; -import {Instruction, stringifyInstruction} from './instruction'; +import {Instruction} from './instruction'; /** * The RouterLink directive lets you link to specific parts of your app. @@ -61,7 +61,7 @@ export class RouterLink { this._routeParams = changes; this._navigationInstruction = this._router.generate(this._routeParams); - var navigationHref = stringifyInstruction(this._navigationInstruction); + var navigationHref = this._navigationInstruction.toLinkUrl(); this.visibleHref = this._location.prepareExternalUrl(navigationHref); } diff --git a/modules/angular2/src/router/sync_route_handler.ts b/modules/angular2/src/router/sync_route_handler.ts index 5ad7f0aa8d..1b951a098a 100644 --- a/modules/angular2/src/router/sync_route_handler.ts +++ b/modules/angular2/src/router/sync_route_handler.ts @@ -1,13 +1,19 @@ -import {RouteHandler} from './route_handler'; import {Promise, PromiseWrapper} from 'angular2/src/facade/async'; -import {Type} from 'angular2/src/facade/lang'; +import {isPresent, Type} from 'angular2/src/facade/lang'; + +import {RouteHandler} from './route_handler'; +import {RouteData, BLANK_ROUTE_DATA} from './instruction'; + export class SyncRouteHandler implements RouteHandler { + public data: RouteData; + /** @internal */ _resolvedComponent: Promise = null; - constructor(public componentType: Type, public data?: {[key: string]: any}) { + constructor(public componentType: Type, data?: {[key: string]: any}) { this._resolvedComponent = PromiseWrapper.resolve(componentType); + this.data = isPresent(data) ? new RouteData(data) : BLANK_ROUTE_DATA; } resolveComponentType(): Promise { return this._resolvedComponent; } diff --git a/modules/angular2/test/router/component_recognizer_spec.ts b/modules/angular2/test/router/component_recognizer_spec.ts new file mode 100644 index 0000000000..5924bf5b73 --- /dev/null +++ b/modules/angular2/test/router/component_recognizer_spec.ts @@ -0,0 +1,216 @@ +import { + AsyncTestCompleter, + describe, + it, + iit, + ddescribe, + expect, + inject, + beforeEach, + SpyObject +} from 'angular2/testing_internal'; + +import {Map, StringMapWrapper} from 'angular2/src/facade/collection'; + +import {RouteMatch, PathMatch, RedirectMatch} from 'angular2/src/router/route_recognizer'; +import {ComponentRecognizer} from 'angular2/src/router/component_recognizer'; + +import {Route, Redirect} from 'angular2/src/router/route_config_decorator'; +import {parser} from 'angular2/src/router/url_parser'; +import {Promise, PromiseWrapper} from 'angular2/src/facade/promise'; + + +export function main() { + describe('ComponentRecognizer', () => { + var recognizer: ComponentRecognizer; + + beforeEach(() => { recognizer = new ComponentRecognizer(); }); + + + it('should recognize a static segment', inject([AsyncTestCompleter], (async) => { + recognizer.config(new Route({path: '/test', component: DummyCmpA})); + recognize(recognizer, '/test') + .then((solutions: RouteMatch[]) => { + expect(solutions.length).toBe(1); + expect(getComponentType(solutions[0])).toEqual(DummyCmpA); + async.done(); + }); + })); + + + it('should recognize a single slash', inject([AsyncTestCompleter], (async) => { + recognizer.config(new Route({path: '/', component: DummyCmpA})); + recognize(recognizer, '/') + .then((solutions: RouteMatch[]) => { + expect(solutions.length).toBe(1); + expect(getComponentType(solutions[0])).toEqual(DummyCmpA); + async.done(); + }); + })); + + + it('should recognize a dynamic segment', inject([AsyncTestCompleter], (async) => { + recognizer.config(new Route({path: '/user/:name', component: DummyCmpA})); + recognize(recognizer, '/user/brian') + .then((solutions: RouteMatch[]) => { + expect(solutions.length).toBe(1); + expect(getComponentType(solutions[0])).toEqual(DummyCmpA); + expect(getParams(solutions[0])).toEqual({'name': 'brian'}); + async.done(); + }); + })); + + + it('should recognize a star segment', inject([AsyncTestCompleter], (async) => { + recognizer.config(new Route({path: '/first/*rest', component: DummyCmpA})); + recognize(recognizer, '/first/second/third') + .then((solutions: RouteMatch[]) => { + expect(solutions.length).toBe(1); + expect(getComponentType(solutions[0])).toEqual(DummyCmpA); + expect(getParams(solutions[0])).toEqual({'rest': 'second/third'}); + async.done(); + }); + })); + + + it('should throw when given two routes that start with the same static segment', () => { + recognizer.config(new Route({path: '/hello', component: DummyCmpA})); + expect(() => recognizer.config(new Route({path: '/hello', component: DummyCmpB}))) + .toThrowError('Configuration \'/hello\' conflicts with existing route \'/hello\''); + }); + + + it('should throw when given two routes that have dynamic segments in the same order', () => { + recognizer.config(new Route({path: '/hello/:person/how/:doyoudou', component: DummyCmpA})); + expect(() => recognizer.config( + new Route({path: '/hello/:friend/how/:areyou', component: DummyCmpA}))) + .toThrowError( + 'Configuration \'/hello/:friend/how/:areyou\' conflicts with existing route \'/hello/:person/how/:doyoudou\''); + + expect(() => recognizer.config( + new Redirect({path: '/hello/:pal/how/:goesit', redirectTo: ['/Foo']}))) + .toThrowError( + 'Configuration \'/hello/:pal/how/:goesit\' conflicts with existing route \'/hello/:person/how/:doyoudou\''); + }); + + + it('should recognize redirects', inject([AsyncTestCompleter], (async) => { + recognizer.config(new Route({path: '/b', component: DummyCmpA})); + recognizer.config(new Redirect({path: '/a', redirectTo: ['B']})); + recognize(recognizer, '/a') + .then((solutions: RouteMatch[]) => { + expect(solutions.length).toBe(1); + var solution = solutions[0]; + expect(solution).toBeAnInstanceOf(RedirectMatch); + if (solution instanceof RedirectMatch) { + expect(solution.redirectTo).toEqual(['B']); + } + async.done(); + }); + })); + + + it('should generate URLs with params', () => { + recognizer.config(new Route({path: '/app/user/:name', component: DummyCmpA, name: 'User'})); + var instruction = recognizer.generate('User', {'name': 'misko'}); + expect(instruction.urlPath).toEqual('app/user/misko'); + }); + + + it('should generate URLs with numeric params', () => { + recognizer.config(new Route({path: '/app/page/:number', component: DummyCmpA, name: 'Page'})); + expect(recognizer.generate('Page', {'number': 42}).urlPath).toEqual('app/page/42'); + }); + + + it('should throw in the absence of required params URLs', () => { + recognizer.config(new Route({path: 'app/user/:name', component: DummyCmpA, name: 'User'})); + expect(() => recognizer.generate('User', {})) + .toThrowError('Route generator for \'name\' was not included in parameters passed.'); + }); + + + it('should throw if the route alias is not TitleCase', () => { + expect(() => recognizer.config( + new Route({path: 'app/user/:name', component: DummyCmpA, name: 'user'}))) + .toThrowError( + `Route "app/user/:name" with name "user" does not begin with an uppercase letter. Route names should be CamelCase like "User".`); + }); + + + describe('params', () => { + it('should recognize parameters within the URL path', + inject([AsyncTestCompleter], (async) => { + recognizer.config( + new Route({path: 'profile/:name', component: DummyCmpA, name: 'User'})); + recognize(recognizer, '/profile/matsko?comments=all') + .then((solutions: RouteMatch[]) => { + expect(solutions.length).toBe(1); + expect(getParams(solutions[0])).toEqual({'name': 'matsko', 'comments': 'all'}); + async.done(); + }); + })); + + + it('should generate and populate the given static-based route with querystring params', + () => { + recognizer.config( + new Route({path: 'forum/featured', component: DummyCmpA, name: 'ForumPage'})); + + var params = {'start': 10, 'end': 100}; + + var result = recognizer.generate('ForumPage', params); + expect(result.urlPath).toEqual('forum/featured'); + expect(result.urlParams).toEqual(['start=10', 'end=100']); + }); + + + it('should prefer positional params over query params', + inject([AsyncTestCompleter], (async) => { + recognizer.config( + new Route({path: 'profile/:name', component: DummyCmpA, name: 'User'})); + recognize(recognizer, '/profile/yegor?name=igor') + .then((solutions: RouteMatch[]) => { + expect(solutions.length).toBe(1); + expect(getParams(solutions[0])).toEqual({'name': 'yegor'}); + async.done(); + }); + })); + + + it('should ignore matrix params for the top-level component', + inject([AsyncTestCompleter], (async) => { + recognizer.config( + new Route({path: '/home/:subject', component: DummyCmpA, name: 'User'})); + recognize(recognizer, '/home;sort=asc/zero;one=1?two=2') + .then((solutions: RouteMatch[]) => { + expect(solutions.length).toBe(1); + expect(getParams(solutions[0])).toEqual({'subject': 'zero', 'two': '2'}); + async.done(); + }); + })); + }); + }); +} + +function recognize(recognizer: ComponentRecognizer, url: string): Promise { + var parsedUrl = parser.parse(url); + return PromiseWrapper.all(recognizer.recognize(parsedUrl)); +} + +function getComponentType(routeMatch: RouteMatch): any { + if (routeMatch instanceof PathMatch) { + return routeMatch.instruction.componentType; + } + return null; +} + +function getParams(routeMatch: RouteMatch): any { + if (routeMatch instanceof PathMatch) { + return routeMatch.instruction.params; + } + return null; +} + +class DummyCmpA {} +class DummyCmpB {} diff --git a/modules/angular2/test/router/integration/README.md b/modules/angular2/test/router/integration/README.md new file mode 100644 index 0000000000..157d7423d2 --- /dev/null +++ b/modules/angular2/test/router/integration/README.md @@ -0,0 +1,9 @@ +# Router integration tests + +These tests only mock out `Location`, and otherwise use all the real parts of routing to ensure that +various routing scenarios work as expected. + +The Component Router in Angular 2 exposes only a handful of different options, but because they can +be combined and nested in so many ways, it's difficult to rigorously test all the cases. + +The address this problem, we introduce `describeRouter`, `describeWith`, and `describeWithout`. \ No newline at end of file diff --git a/modules/angular2/test/router/integration/async_route_spec.ts b/modules/angular2/test/router/integration/async_route_spec.ts new file mode 100644 index 0000000000..40b182bfec --- /dev/null +++ b/modules/angular2/test/router/integration/async_route_spec.ts @@ -0,0 +1,28 @@ +import { + describeRouter, + ddescribeRouter, + describeWith, + describeWithout, + describeWithAndWithout, + itShouldRoute +} from './util'; + +import {registerSpecs} from './impl/async_route_spec_impl'; + +export function main() { + registerSpecs(); + + ddescribeRouter('async routes', () => { + describeWithout('children', () => { + describeWith('route data', itShouldRoute); + describeWithAndWithout('params', itShouldRoute); + }); + + describeWith('sync children', + () => { describeWithAndWithout('default routes', itShouldRoute); }); + + describeWith('async children', () => { + describeWithAndWithout('params', () => { describeWithout('default routes', itShouldRoute); }); + }); + }); +} diff --git a/modules/angular2/test/router/integration/auxiliary_route_spec.ts b/modules/angular2/test/router/integration/auxiliary_route_spec.ts new file mode 100644 index 0000000000..14615bc466 --- /dev/null +++ b/modules/angular2/test/router/integration/auxiliary_route_spec.ts @@ -0,0 +1,145 @@ +import { + RootTestComponent, + AsyncTestCompleter, + TestComponentBuilder, + beforeEach, + ddescribe, + xdescribe, + describe, + el, + expect, + iit, + inject, + beforeEachProviders, + it, + xit +} from 'angular2/testing_internal'; + +import {provide, Component, Injector, Inject} from 'angular2/core'; + +import {Router, ROUTER_DIRECTIVES, RouteParams, RouteData, Location} from 'angular2/router'; +import {RouteConfig, Route, AuxRoute, Redirect} from 'angular2/src/router/route_config_decorator'; + +import {TEST_ROUTER_PROVIDERS, RootCmp, compile, clickOnElement, getHref} from './util'; + +function getLinkElement(rtc: RootTestComponent) { + return rtc.debugElement.componentViewChildren[0].nativeElement; +} + +var cmpInstanceCount; +var childCmpInstanceCount; + +export function main() { + describe('auxiliary routes', () => { + + var tcb: TestComponentBuilder; + var fixture: RootTestComponent; + var rtr; + + beforeEachProviders(() => TEST_ROUTER_PROVIDERS); + + beforeEach(inject([TestComponentBuilder, Router], (tcBuilder, router) => { + tcb = tcBuilder; + rtr = router; + childCmpInstanceCount = 0; + cmpInstanceCount = 0; + })); + + it('should recognize and navigate from the URL', inject([AsyncTestCompleter], (async) => { + compile(tcb, `main {} | aux {}`) + .then((rtc) => {fixture = rtc}) + .then((_) => rtr.config([ + new Route({path: '/hello', component: HelloCmp, name: 'Hello'}), + new AuxRoute({path: '/modal', component: ModalCmp, name: 'Aux'}) + ])) + .then((_) => rtr.navigateByUrl('/hello(modal)')) + .then((_) => { + fixture.detectChanges(); + expect(fixture.debugElement.nativeElement).toHaveText('main {hello} | aux {modal}'); + async.done(); + }); + })); + + it('should navigate via the link DSL', inject([AsyncTestCompleter], (async) => { + compile(tcb, `main {} | aux {}`) + .then((rtc) => {fixture = rtc}) + .then((_) => rtr.config([ + new Route({path: '/hello', component: HelloCmp, name: 'Hello'}), + new AuxRoute({path: '/modal', component: ModalCmp, name: 'Modal'}) + ])) + .then((_) => rtr.navigate(['/Hello', ['Modal']])) + .then((_) => { + fixture.detectChanges(); + expect(fixture.debugElement.nativeElement).toHaveText('main {hello} | aux {modal}'); + async.done(); + }); + })); + + it('should generate a link URL', inject([AsyncTestCompleter], (async) => { + compile( + tcb, + `open modal | main {} | aux {}`) + .then((rtc) => {fixture = rtc}) + .then((_) => rtr.config([ + new Route({path: '/hello', component: HelloCmp, name: 'Hello'}), + new AuxRoute({path: '/modal', component: ModalCmp, name: 'Modal'}) + ])) + .then((_) => { + fixture.detectChanges(); + expect(getHref(getLinkElement(fixture))).toEqual('/hello(modal)'); + async.done(); + }); + })); + + it('should navigate from a link click', + inject([AsyncTestCompleter, Location], (async, location) => { + compile( + tcb, + `open modal | main {} | aux {}`) + .then((rtc) => {fixture = rtc}) + .then((_) => rtr.config([ + new Route({path: '/hello', component: HelloCmp, name: 'Hello'}), + new AuxRoute({path: '/modal', component: ModalCmp, name: 'Modal'}) + ])) + .then((_) => { + fixture.detectChanges(); + expect(fixture.debugElement.nativeElement) + .toHaveText('open modal | main {} | aux {}'); + + rtr.subscribe((_) => { + fixture.detectChanges(); + expect(fixture.debugElement.nativeElement) + .toHaveText('open modal | main {hello} | aux {modal}'); + expect(location.urlChanges).toEqual(['/hello(modal)']); + async.done(); + }); + + clickOnElement(getLinkElement(fixture)); + }); + })); + }); +} + + +@Component({selector: 'hello-cmp', template: `{{greeting}}`}) +class HelloCmp { + greeting: string; + constructor() { this.greeting = 'hello'; } +} + +@Component({selector: 'modal-cmp', template: `modal`}) +class ModalCmp { +} + +@Component({ + selector: 'aux-cmp', + template: 'main {} | ' + + 'aux {}', + directives: [ROUTER_DIRECTIVES], +}) +@RouteConfig([ + new Route({path: '/hello', component: HelloCmp, name: 'Hello'}), + new AuxRoute({path: '/modal', component: ModalCmp, name: 'Aux'}) +]) +class AuxCmp { +} diff --git a/modules/angular2/test/router/integration/router_integration_spec.ts b/modules/angular2/test/router/integration/bootstrap_spec.ts similarity index 78% rename from modules/angular2/test/router/integration/router_integration_spec.ts rename to modules/angular2/test/router/integration/bootstrap_spec.ts index 1f0c0c04bb..22fac7e942 100644 --- a/modules/angular2/test/router/integration/router_integration_spec.ts +++ b/modules/angular2/test/router/integration/bootstrap_spec.ts @@ -29,53 +29,47 @@ import { Router, APP_BASE_HREF, ROUTER_DIRECTIVES, - HashLocationStrategy + LocationStrategy } from 'angular2/router'; -import {LocationStrategy} from 'angular2/src/router/location_strategy'; import {MockLocationStrategy} from 'angular2/src/mock/mock_location_strategy'; import {ApplicationRef} from 'angular2/src/core/application_ref'; import {MockApplicationRef} from 'angular2/src/mock/mock_application_ref'; export function main() { - describe('router injectables', () => { - beforeEachProviders(() => { - return [ - ROUTER_PROVIDERS, - provide(LocationStrategy, {useClass: MockLocationStrategy}), - provide(ApplicationRef, {useClass: MockApplicationRef}) - ]; - }); + describe('router bootstrap', () => { + beforeEachProviders(() => [ + ROUTER_PROVIDERS, + provide(LocationStrategy, {useClass: MockLocationStrategy}), + provide(ApplicationRef, {useClass: MockApplicationRef}) + ]); // do not refactor out the `bootstrap` functionality. We still want to // keep this test around so we can ensure that bootstrapping a router works - describe('bootstrap functionality', () => { - it('should bootstrap a simple app', inject([AsyncTestCompleter], (async) => { - var fakeDoc = DOM.createHtmlDocument(); - var el = DOM.createElement('app-cmp', fakeDoc); - DOM.appendChild(fakeDoc.body, el); + it('should bootstrap a simple app', inject([AsyncTestCompleter], (async) => { + var fakeDoc = DOM.createHtmlDocument(); + var el = DOM.createElement('app-cmp', fakeDoc); + DOM.appendChild(fakeDoc.body, el); - bootstrap(AppCmp, - [ - ROUTER_PROVIDERS, - provide(ROUTER_PRIMARY_COMPONENT, {useValue: AppCmp}), - provide(LocationStrategy, {useClass: MockLocationStrategy}), - provide(DOCUMENT, {useValue: fakeDoc}) - ]) - .then((applicationRef) => { - var router = applicationRef.hostComponent.router; - router.subscribe((_) => { - expect(el).toHaveText('outer { hello }'); - expect(applicationRef.hostComponent.location.path()).toEqual(''); - async.done(); - }); + bootstrap(AppCmp, + [ + ROUTER_PROVIDERS, + provide(ROUTER_PRIMARY_COMPONENT, {useValue: AppCmp}), + provide(LocationStrategy, {useClass: MockLocationStrategy}), + provide(DOCUMENT, {useValue: fakeDoc}) + ]) + .then((applicationRef) => { + var router = applicationRef.hostComponent.router; + router.subscribe((_) => { + expect(el).toHaveText('outer { hello }'); + expect(applicationRef.hostComponent.location.path()).toEqual(''); + async.done(); }); - })); - }); + }); + })); describe('broken app', () => { - beforeEachProviders( - () => { return [provide(ROUTER_PRIMARY_COMPONENT, {useValue: BrokenAppCmp})]; }); + beforeEachProviders(() => [provide(ROUTER_PRIMARY_COMPONENT, {useValue: BrokenAppCmp})]); it('should rethrow exceptions from component constructors', inject([AsyncTestCompleter, TestComponentBuilder], (async, tcb: TestComponentBuilder) => { @@ -91,8 +85,7 @@ export function main() { }); describe('back button app', () => { - beforeEachProviders( - () => { return [provide(ROUTER_PRIMARY_COMPONENT, {useValue: HierarchyAppCmp})]; }); + beforeEachProviders(() => [provide(ROUTER_PRIMARY_COMPONENT, {useValue: HierarchyAppCmp})]); it('should change the url without pushing a new history state for back navigations', inject([AsyncTestCompleter, TestComponentBuilder], (async, tcb: TestComponentBuilder) => { @@ -184,7 +177,7 @@ export function main() { })); }); }); - // TODO: add a test in which the child component has bindings + describe('querystring params app', () => { beforeEachProviders( @@ -243,20 +236,21 @@ export function main() { } -@Component({selector: 'hello-cmp'}) -@View({template: 'hello'}) +@Component({selector: 'hello-cmp', template: 'hello'}) class HelloCmp { public message: string; } -@Component({selector: 'hello2-cmp'}) -@View({template: 'hello2'}) +@Component({selector: 'hello2-cmp', template: 'hello2'}) class Hello2Cmp { public greeting: string; } -@Component({selector: 'app-cmp'}) -@View({template: "outer { }", directives: ROUTER_DIRECTIVES}) +@Component({ + selector: 'app-cmp', + template: `outer { }`, + directives: ROUTER_DIRECTIVES +}) @RouteConfig([new Route({path: '/', component: HelloCmp})]) class AppCmp { constructor(public router: Router, public location: LocationStrategy) {} @@ -283,20 +277,29 @@ class AppWithViewChildren implements AfterViewInit { afterViewInit() { this.helloCmp.message = 'Ahoy'; } } -@Component({selector: 'parent-cmp'}) -@View({template: `parent { }`, directives: ROUTER_DIRECTIVES}) +@Component({ + selector: 'parent-cmp', + template: `parent { }`, + directives: ROUTER_DIRECTIVES +}) @RouteConfig([new Route({path: '/child', component: HelloCmp})]) class ParentCmp { } -@Component({selector: 'super-parent-cmp'}) -@View({template: `super-parent { }`, directives: ROUTER_DIRECTIVES}) +@Component({ + selector: 'super-parent-cmp', + template: `super-parent { }`, + directives: ROUTER_DIRECTIVES +}) @RouteConfig([new Route({path: '/child', component: Hello2Cmp})]) class SuperParentCmp { } -@Component({selector: 'app-cmp'}) -@View({template: `root { }`, directives: ROUTER_DIRECTIVES}) +@Component({ + selector: 'app-cmp', + template: `root { }`, + directives: ROUTER_DIRECTIVES +}) @RouteConfig([ new Route({path: '/parent/...', component: ParentCmp}), new Route({path: '/super-parent/...', component: SuperParentCmp}) @@ -305,28 +308,32 @@ class HierarchyAppCmp { constructor(public router: Router, public location: LocationStrategy) {} } -@Component({selector: 'qs-cmp'}) -@View({template: "qParam = {{q}}"}) +@Component({selector: 'qs-cmp', template: `qParam = {{q}}`}) class QSCmp { q: string; constructor(params: RouteParams) { this.q = params.get('q'); } } -@Component({selector: 'app-cmp'}) -@View({template: ``, directives: ROUTER_DIRECTIVES}) +@Component({ + selector: 'app-cmp', + template: ``, + directives: ROUTER_DIRECTIVES +}) @RouteConfig([new Route({path: '/qs', component: QSCmp})]) class QueryStringAppCmp { constructor(public router: Router, public location: LocationStrategy) {} } -@Component({selector: 'oops-cmp'}) -@View({template: "oh no"}) +@Component({selector: 'oops-cmp', template: "oh no"}) class BrokenCmp { constructor() { throw new BaseException('oops!'); } } -@Component({selector: 'app-cmp'}) -@View({template: `outer { }`, directives: ROUTER_DIRECTIVES}) +@Component({ + selector: 'app-cmp', + template: `outer { }`, + directives: ROUTER_DIRECTIVES +}) @RouteConfig([new Route({path: '/cause-error', component: BrokenCmp})]) class BrokenAppCmp { constructor(public router: Router, public location: LocationStrategy) {} diff --git a/modules/angular2/test/router/integration/impl/async_route_spec_impl.ts b/modules/angular2/test/router/integration/impl/async_route_spec_impl.ts new file mode 100644 index 0000000000..583e4ecef8 --- /dev/null +++ b/modules/angular2/test/router/integration/impl/async_route_spec_impl.ts @@ -0,0 +1,655 @@ +import { + AsyncTestCompleter, + beforeEach, + beforeEachProviders, + expect, + iit, + flushMicrotasks, + inject, + it, + TestComponentBuilder, + RootTestComponent, + xit, +} from 'angular2/testing_internal'; + +import {specs, compile, TEST_ROUTER_PROVIDERS, clickOnElement, getHref} from '../util'; + +import {Router, AsyncRoute, Route, Location} from 'angular2/router'; + +import { + HelloCmp, + helloCmpLoader, + UserCmp, + userCmpLoader, + TeamCmp, + asyncTeamLoader, + ParentCmp, + parentCmpLoader, + asyncParentCmpLoader, + asyncDefaultParentCmpLoader, + ParentWithDefaultCmp, + parentWithDefaultCmpLoader, + asyncRouteDataCmp +} from './fixture_components'; + +function getLinkElement(rtc: RootTestComponent) { + return rtc.debugElement.componentViewChildren[0].nativeElement; +} + +function asyncRoutesWithoutChildrenWithRouteData() { + var fixture; + var tcb; + var rtr; + + beforeEachProviders(() => TEST_ROUTER_PROVIDERS); + + beforeEach(inject([TestComponentBuilder, Router], (tcBuilder, router) => { + tcb = tcBuilder; + rtr = router; + })); + + it('should inject route data into the component', inject([AsyncTestCompleter], (async) => { + compile(tcb) + .then((rtc) => {fixture = rtc}) + .then((_) => rtr.config([ + new AsyncRoute( + {path: '/route-data', loader: asyncRouteDataCmp, data: {isAdmin: true}}) + ])) + .then((_) => rtr.navigateByUrl('/route-data')) + .then((_) => { + fixture.detectChanges(); + expect(fixture.debugElement.nativeElement).toHaveText('true'); + async.done(); + }); + })); + + it('should inject empty object if the route has no data property', + inject([AsyncTestCompleter], (async) => { + compile(tcb) + .then((rtc) => {fixture = rtc}) + .then((_) => rtr.config( + [new AsyncRoute({path: '/route-data-default', loader: asyncRouteDataCmp})])) + .then((_) => rtr.navigateByUrl('/route-data-default')) + .then((_) => { + fixture.detectChanges(); + expect(fixture.debugElement.nativeElement).toHaveText(''); + async.done(); + }); + })); +} + +function asyncRoutesWithoutChildrenWithoutParams() { + var fixture; + var tcb; + var rtr; + + beforeEachProviders(() => TEST_ROUTER_PROVIDERS); + + beforeEach(inject([TestComponentBuilder, Router], (tcBuilder, router) => { + tcb = tcBuilder; + rtr = router; + })); + + it('should navigate by URL', inject([AsyncTestCompleter], (async) => { + compile(tcb) + .then((rtc) => {fixture = rtc}) + .then((_) => rtr.config( + [new AsyncRoute({path: '/test', loader: helloCmpLoader, name: 'Hello'})])) + .then((_) => rtr.navigateByUrl('/test')) + .then((_) => { + fixture.detectChanges(); + expect(fixture.debugElement.nativeElement).toHaveText('hello'); + async.done(); + }); + })); + + it('should navigate by link DSL', inject([AsyncTestCompleter], (async) => { + compile(tcb) + .then((rtc) => {fixture = rtc}) + .then((_) => rtr.config( + [new AsyncRoute({path: '/test', loader: helloCmpLoader, name: 'Hello'})])) + .then((_) => rtr.navigate(['/Hello'])) + .then((_) => { + fixture.detectChanges(); + expect(fixture.debugElement.nativeElement).toHaveText('hello'); + async.done(); + }); + })); + + it('should generate a link URL', inject([AsyncTestCompleter], (async) => { + compile(tcb, `go to hello | `) + .then((rtc) => {fixture = rtc}) + .then((_) => rtr.config( + [new AsyncRoute({path: '/test', loader: helloCmpLoader, name: 'Hello'})])) + .then((_) => { + fixture.detectChanges(); + expect(getHref(getLinkElement(fixture))).toEqual('/test'); + async.done(); + }); + })); + + it('should navigate from a link click', + inject([AsyncTestCompleter, Location], (async, location) => { + compile(tcb, `go to hello | `) + .then((rtc) => {fixture = rtc}) + .then((_) => rtr.config( + [new AsyncRoute({path: '/test', loader: helloCmpLoader, name: 'Hello'})])) + .then((_) => { + fixture.detectChanges(); + expect(fixture.debugElement.nativeElement).toHaveText('go to hello | '); + + rtr.subscribe((_) => { + fixture.detectChanges(); + expect(fixture.debugElement.nativeElement).toHaveText('go to hello | hello'); + expect(location.urlChanges).toEqual(['/test']); + async.done(); + }); + + clickOnElement(getLinkElement(fixture)); + }); + })); +} + + +function asyncRoutesWithoutChildrenWithParams() { + var fixture; + var tcb; + var rtr; + + beforeEachProviders(() => TEST_ROUTER_PROVIDERS); + + beforeEach(inject([TestComponentBuilder, Router], (tcBuilder, router) => { + tcb = tcBuilder; + rtr = router; + })); + + it('should navigate by URL', inject([AsyncTestCompleter], (async) => { + compile(tcb) + .then((rtc) => {fixture = rtc}) + .then((_) => rtr.config( + [new AsyncRoute({path: '/user/:name', loader: userCmpLoader, name: 'User'})])) + .then((_) => rtr.navigateByUrl('/user/igor')) + .then((_) => { + fixture.detectChanges(); + expect(fixture.debugElement.nativeElement).toHaveText('hello igor'); + async.done(); + }); + })); + + it('should navigate by link DSL', inject([AsyncTestCompleter], (async) => { + compile(tcb) + .then((rtc) => {fixture = rtc}) + .then((_) => rtr.config( + [new Route({path: '/user/:name', component: UserCmp, name: 'User'})])) + .then((_) => rtr.navigate(['/User', {name: 'brian'}])) + .then((_) => { + fixture.detectChanges(); + expect(fixture.debugElement.nativeElement).toHaveText('hello brian'); + async.done(); + }); + })); + + it('should generate a link URL', inject([AsyncTestCompleter], (async) => { + compile(tcb, `greet naomi | `) + .then((rtc) => {fixture = rtc}) + .then((_) => rtr.config( + [new AsyncRoute({path: '/user/:name', loader: userCmpLoader, name: 'User'})])) + .then((_) => { + fixture.detectChanges(); + expect(getHref(getLinkElement(fixture))).toEqual('/user/naomi'); + async.done(); + }); + })); + + it('should navigate from a link click', + inject([AsyncTestCompleter, Location], (async, location) => { + compile(tcb, `greet naomi | `) + .then((rtc) => {fixture = rtc}) + .then((_) => rtr.config( + [new AsyncRoute({path: '/user/:name', loader: userCmpLoader, name: 'User'})])) + .then((_) => { + fixture.detectChanges(); + expect(fixture.debugElement.nativeElement).toHaveText('greet naomi | '); + + rtr.subscribe((_) => { + fixture.detectChanges(); + expect(fixture.debugElement.nativeElement).toHaveText('greet naomi | hello naomi'); + expect(location.urlChanges).toEqual(['/user/naomi']); + async.done(); + }); + + clickOnElement(getLinkElement(fixture)); + }); + })); + + it('should navigate between components with different parameters', + inject([AsyncTestCompleter], (async) => { + compile(tcb) + .then((rtc) => {fixture = rtc}) + .then((_) => rtr.config( + [new AsyncRoute({path: '/user/:name', loader: userCmpLoader, name: 'User'})])) + .then((_) => rtr.navigateByUrl('/user/brian')) + .then((_) => { + fixture.detectChanges(); + expect(fixture.debugElement.nativeElement).toHaveText('hello brian'); + }) + .then((_) => rtr.navigateByUrl('/user/igor')) + .then((_) => { + fixture.detectChanges(); + expect(fixture.debugElement.nativeElement).toHaveText('hello igor'); + async.done(); + }); + })); +} + + +function asyncRoutesWithSyncChildrenWithoutDefaultRoutes() { + var fixture; + var tcb; + var rtr; + + beforeEachProviders(() => TEST_ROUTER_PROVIDERS); + + beforeEach(inject([TestComponentBuilder, Router], (tcBuilder, router) => { + tcb = tcBuilder; + rtr = router; + })); + + it('should navigate by URL', inject([AsyncTestCompleter], (async) => { + compile(tcb, `outer { }`) + .then((rtc) => {fixture = rtc}) + .then((_) => rtr.config( + [new AsyncRoute({path: '/a/...', loader: parentCmpLoader, name: 'Parent'})])) + .then((_) => rtr.navigateByUrl('/a/b')) + .then((_) => { + fixture.detectChanges(); + expect(fixture.debugElement.nativeElement).toHaveText('outer { inner { hello } }'); + async.done(); + }); + })); + + it('should navigate by link DSL', inject([AsyncTestCompleter], (async) => { + compile(tcb, `outer { }`) + .then((rtc) => {fixture = rtc}) + .then((_) => rtr.config( + [new AsyncRoute({path: '/a/...', loader: parentCmpLoader, name: 'Parent'})])) + .then((_) => rtr.navigate(['/Parent', 'Child'])) + .then((_) => { + fixture.detectChanges(); + expect(fixture.debugElement.nativeElement).toHaveText('outer { inner { hello } }'); + async.done(); + }); + })); + + it('should generate a link URL', inject([AsyncTestCompleter], (async) => { + compile(tcb, `nav to child | outer { }`) + .then((rtc) => {fixture = rtc}) + .then((_) => rtr.config( + [new AsyncRoute({path: '/a/...', loader: parentCmpLoader, name: 'Parent'})])) + .then((_) => { + fixture.detectChanges(); + expect(getHref(getLinkElement(fixture))).toEqual('/a'); + async.done(); + }); + })); + + it('should navigate from a link click', + inject([AsyncTestCompleter, Location], (async, location) => { + compile(tcb, `nav to child | outer { }`) + .then((rtc) => {fixture = rtc}) + .then((_) => rtr.config( + [new AsyncRoute({path: '/a/...', loader: parentCmpLoader, name: 'Parent'})])) + .then((_) => { + fixture.detectChanges(); + expect(fixture.debugElement.nativeElement).toHaveText('nav to child | outer { }'); + + rtr.subscribe((_) => { + fixture.detectChanges(); + expect(fixture.debugElement.nativeElement) + .toHaveText('nav to child | outer { inner { hello } }'); + expect(location.urlChanges).toEqual(['/a/b']); + async.done(); + }); + + clickOnElement(getLinkElement(fixture)); + }); + })); +} + + +function asyncRoutesWithSyncChildrenWithDefaultRoutes() { + var fixture; + var tcb; + var rtr; + + beforeEachProviders(() => TEST_ROUTER_PROVIDERS); + + beforeEach(inject([TestComponentBuilder, Router], (tcBuilder, router) => { + tcb = tcBuilder; + rtr = router; + })); + + it('should navigate by URL', inject([AsyncTestCompleter], (async) => { + compile(tcb, `outer { }`) + .then((rtc) => {fixture = rtc}) + .then((_) => rtr.config([ + new AsyncRoute({path: '/a/...', loader: parentWithDefaultCmpLoader, name: 'Parent'}) + ])) + .then((_) => rtr.navigateByUrl('/a')) + .then((_) => { + fixture.detectChanges(); + expect(fixture.debugElement.nativeElement).toHaveText('outer { inner { hello } }'); + async.done(); + }); + })); + + it('should navigate by link DSL', inject([AsyncTestCompleter], (async) => { + compile(tcb, `outer { }`) + .then((rtc) => {fixture = rtc}) + .then((_) => rtr.config([ + new AsyncRoute({path: '/a/...', loader: parentWithDefaultCmpLoader, name: 'Parent'}) + ])) + .then((_) => rtr.navigate(['/Parent'])) + .then((_) => { + fixture.detectChanges(); + expect(fixture.debugElement.nativeElement).toHaveText('outer { inner { hello } }'); + async.done(); + }); + })); + + it('should generate a link URL', inject([AsyncTestCompleter], (async) => { + compile(tcb, `link to inner | outer { }`) + .then((rtc) => {fixture = rtc}) + .then((_) => rtr.config([ + new AsyncRoute({path: '/a/...', loader: parentWithDefaultCmpLoader, name: 'Parent'}) + ])) + .then((_) => { + fixture.detectChanges(); + expect(getHref(getLinkElement(fixture))).toEqual('/a'); + async.done(); + }); + })); + + it('should navigate from a link click', + inject([AsyncTestCompleter, Location], (async, location) => { + compile(tcb, `link to inner | outer { }`) + .then((rtc) => {fixture = rtc}) + .then((_) => rtr.config([ + new AsyncRoute({path: '/a/...', loader: parentWithDefaultCmpLoader, name: 'Parent'}) + ])) + .then((_) => { + fixture.detectChanges(); + expect(fixture.debugElement.nativeElement).toHaveText('link to inner | outer { }'); + + rtr.subscribe((_) => { + fixture.detectChanges(); + expect(fixture.debugElement.nativeElement) + .toHaveText('link to inner | outer { inner { hello } }'); + expect(location.urlChanges).toEqual(['/a/b']); + async.done(); + }); + + clickOnElement(getLinkElement(fixture)); + }); + })); +} + + +function asyncRoutesWithAsyncChildrenWithoutParamsWithoutDefaultRoutes() { + var rootTC; + var tcb; + var rtr; + + beforeEachProviders(() => TEST_ROUTER_PROVIDERS); + + beforeEach(inject([TestComponentBuilder, Router], (tcBuilder, router) => { + tcb = tcBuilder; + rtr = router; + })); + + it('should navigate by URL', inject([AsyncTestCompleter], (async) => { + compile(tcb, `outer { }`) + .then((rtc) => {rootTC = rtc}) + .then((_) => rtr.config([ + new AsyncRoute({path: '/a/...', loader: asyncParentCmpLoader, name: 'Parent'}) + ])) + .then((_) => rtr.navigateByUrl('/a/b')) + .then((_) => { + rootTC.detectChanges(); + expect(rootTC.debugElement.nativeElement).toHaveText('outer { inner { hello } }'); + async.done(); + }); + })); + + it('should navigate by link DSL', inject([AsyncTestCompleter], (async) => { + compile(tcb, `outer { }`) + .then((rtc) => {rootTC = rtc}) + .then((_) => rtr.config([ + new AsyncRoute({path: '/a/...', loader: asyncParentCmpLoader, name: 'Parent'}) + ])) + .then((_) => rtr.navigate(['/Parent', 'Child'])) + .then((_) => { + rootTC.detectChanges(); + expect(rootTC.debugElement.nativeElement).toHaveText('outer { inner { hello } }'); + async.done(); + }); + })); + + it('should generate a link URL', inject([AsyncTestCompleter], (async) => { + compile(tcb, `nav to child | outer { }`) + .then((rtc) => {rootTC = rtc}) + .then((_) => rtr.config([ + new AsyncRoute({path: '/a/...', loader: asyncParentCmpLoader, name: 'Parent'}) + ])) + .then((_) => { + rootTC.detectChanges(); + expect(getHref(getLinkElement(rootTC))).toEqual('/a'); + async.done(); + }); + })); + + it('should navigate from a link click', + inject([AsyncTestCompleter, Location], (async, location) => { + compile(tcb, `nav to child | outer { }`) + .then((rtc) => {rootTC = rtc}) + .then((_) => rtr.config([ + new AsyncRoute({path: '/a/...', loader: asyncParentCmpLoader, name: 'Parent'}) + ])) + .then((_) => { + rootTC.detectChanges(); + expect(rootTC.debugElement.nativeElement).toHaveText('nav to child | outer { }'); + + rtr.subscribe((_) => { + rootTC.detectChanges(); + expect(rootTC.debugElement.nativeElement) + .toHaveText('nav to child | outer { inner { hello } }'); + expect(location.urlChanges).toEqual(['/a/b']); + async.done(); + }); + + clickOnElement(getLinkElement(rootTC)); + }); + })); +} + + +function asyncRoutesWithAsyncChildrenWithoutParamsWithDefaultRoutes() { + var rootTC; + var tcb; + var rtr; + + beforeEachProviders(() => TEST_ROUTER_PROVIDERS); + + beforeEach(inject([TestComponentBuilder, Router], (tcBuilder, router) => { + tcb = tcBuilder; + rtr = router; + })); + + it('should navigate by URL', inject([AsyncTestCompleter], (async) => { + compile(tcb, `outer { }`) + .then((rtc) => {rootTC = rtc}) + .then((_) => rtr.config([ + new AsyncRoute( + {path: '/a/...', loader: asyncDefaultParentCmpLoader, name: 'Parent'}) + ])) + .then((_) => rtr.navigateByUrl('/a')) + .then((_) => { + rootTC.detectChanges(); + expect(rootTC.debugElement.nativeElement).toHaveText('outer { inner { hello } }'); + async.done(); + }); + })); + + it('should navigate by link DSL', inject([AsyncTestCompleter], (async) => { + compile(tcb, `outer { }`) + .then((rtc) => {rootTC = rtc}) + .then((_) => rtr.config([ + new AsyncRoute( + {path: '/a/...', loader: asyncDefaultParentCmpLoader, name: 'Parent'}) + ])) + .then((_) => rtr.navigate(['/Parent'])) + .then((_) => { + rootTC.detectChanges(); + expect(rootTC.debugElement.nativeElement).toHaveText('outer { inner { hello } }'); + async.done(); + }); + })); + + it('should generate a link URL', inject([AsyncTestCompleter], (async) => { + compile(tcb, `nav to child | outer { }`) + .then((rtc) => {rootTC = rtc}) + .then((_) => rtr.config([ + new AsyncRoute( + {path: '/a/...', loader: asyncDefaultParentCmpLoader, name: 'Parent'}) + ])) + .then((_) => { + rootTC.detectChanges(); + expect(getHref(getLinkElement(rootTC))).toEqual('/a'); + async.done(); + }); + })); + + it('should navigate from a link click', + inject([AsyncTestCompleter, Location], (async, location) => { + compile(tcb, `nav to child | outer { }`) + .then((rtc) => {rootTC = rtc}) + .then((_) => rtr.config([ + new AsyncRoute( + {path: '/a/...', loader: asyncDefaultParentCmpLoader, name: 'Parent'}) + ])) + .then((_) => { + rootTC.detectChanges(); + expect(rootTC.debugElement.nativeElement).toHaveText('nav to child | outer { }'); + + rtr.subscribe((_) => { + rootTC.detectChanges(); + expect(rootTC.debugElement.nativeElement) + .toHaveText('nav to child | outer { inner { hello } }'); + expect(location.urlChanges).toEqual(['/a/b']); + async.done(); + }); + + clickOnElement(getLinkElement(rootTC)); + }); + })); +} + + +function asyncRoutesWithAsyncChildrenWithParamsWithoutDefaultRoutes() { + var fixture; + var tcb; + var rtr; + + beforeEachProviders(() => TEST_ROUTER_PROVIDERS); + + beforeEach(inject([TestComponentBuilder, Router], (tcBuilder, router) => { + tcb = tcBuilder; + rtr = router; + })); + + it('should navigate by URL', inject([AsyncTestCompleter], (async) => { + compile(tcb, `{ }`) + .then((rtc) => {fixture = rtc}) + .then((_) => rtr.config([ + new AsyncRoute({path: '/team/:id/...', loader: asyncTeamLoader, name: 'Team'}) + ])) + .then((_) => rtr.navigateByUrl('/team/angular/user/matias')) + .then((_) => { + fixture.detectChanges(); + expect(fixture.debugElement.nativeElement) + .toHaveText('{ team angular | user { hello matias } }'); + async.done(); + }); + })); + + it('should navigate by link DSL', inject([AsyncTestCompleter], (async) => { + compile(tcb, `{ }`) + .then((rtc) => {fixture = rtc}) + .then((_) => rtr.config([ + new AsyncRoute({path: '/team/:id/...', loader: asyncTeamLoader, name: 'Team'}) + ])) + .then((_) => rtr.navigate(['/Team', {id: 'angular'}, 'User', {name: 'matias'}])) + .then((_) => { + fixture.detectChanges(); + expect(fixture.debugElement.nativeElement) + .toHaveText('{ team angular | user { hello matias } }'); + async.done(); + }); + })); + + it('should generate a link URL', inject([AsyncTestCompleter], (async) => { + compile( + tcb, + `nav to matias { }`) + .then((rtc) => {fixture = rtc}) + .then((_) => rtr.config([ + new AsyncRoute({path: '/team/:id/...', loader: asyncTeamLoader, name: 'Team'}) + ])) + .then((_) => { + fixture.detectChanges(); + expect(getHref(getLinkElement(fixture))).toEqual('/team/angular'); + async.done(); + }); + })); + + it('should navigate from a link click', + inject([AsyncTestCompleter, Location], (async, location) => { + compile( + tcb, + `nav to matias { }`) + .then((rtc) => {fixture = rtc}) + .then((_) => rtr.config([ + new AsyncRoute({path: '/team/:id/...', loader: asyncTeamLoader, name: 'Team'}) + ])) + .then((_) => { + fixture.detectChanges(); + expect(fixture.debugElement.nativeElement).toHaveText('nav to matias { }'); + + rtr.subscribe((_) => { + fixture.detectChanges(); + expect(fixture.debugElement.nativeElement) + .toHaveText('nav to matias { team angular | user { hello matias } }'); + expect(location.urlChanges).toEqual(['/team/angular/user/matias']); + async.done(); + }); + + clickOnElement(getLinkElement(fixture)); + }); + })); +} + +export function registerSpecs() { + specs['asyncRoutesWithoutChildrenWithRouteData'] = asyncRoutesWithoutChildrenWithRouteData; + specs['asyncRoutesWithoutChildrenWithoutParams'] = asyncRoutesWithoutChildrenWithoutParams; + specs['asyncRoutesWithoutChildrenWithParams'] = asyncRoutesWithoutChildrenWithParams; + specs['asyncRoutesWithSyncChildrenWithoutDefaultRoutes'] = + asyncRoutesWithSyncChildrenWithoutDefaultRoutes; + specs['asyncRoutesWithSyncChildrenWithDefaultRoutes'] = + asyncRoutesWithSyncChildrenWithDefaultRoutes; + specs['asyncRoutesWithAsyncChildrenWithoutParamsWithoutDefaultRoutes'] = + asyncRoutesWithAsyncChildrenWithoutParamsWithoutDefaultRoutes; + specs['asyncRoutesWithAsyncChildrenWithoutParamsWithDefaultRoutes'] = + asyncRoutesWithAsyncChildrenWithoutParamsWithDefaultRoutes; + specs['asyncRoutesWithAsyncChildrenWithParamsWithoutDefaultRoutes'] = + asyncRoutesWithAsyncChildrenWithParamsWithoutDefaultRoutes; +} diff --git a/modules/angular2/test/router/integration/impl/fixture_components.ts b/modules/angular2/test/router/integration/impl/fixture_components.ts new file mode 100644 index 0000000000..075213bdf6 --- /dev/null +++ b/modules/angular2/test/router/integration/impl/fixture_components.ts @@ -0,0 +1,131 @@ +import {Component} from 'angular2/core'; +import { + AsyncRoute, + Route, + Redirect, + RouteConfig, + RouteParams, + RouteData, + ROUTER_DIRECTIVES +} from 'angular2/router'; +import {PromiseWrapper} from 'angular2/src/facade/async'; + +@Component({selector: 'hello-cmp', template: `{{greeting}}`}) +export class HelloCmp { + greeting: string; + constructor() { this.greeting = 'hello'; } +} + +export function helloCmpLoader() { + return PromiseWrapper.resolve(HelloCmp); +} + + +@Component({selector: 'user-cmp', template: `hello {{user}}`}) +export class UserCmp { + user: string; + constructor(params: RouteParams) { this.user = params.get('name'); } +} + +export function userCmpLoader() { + return PromiseWrapper.resolve(UserCmp); +} + + +@Component({ + selector: 'parent-cmp', + template: `inner { }`, + directives: [ROUTER_DIRECTIVES], +}) +@RouteConfig([new Route({path: '/b', component: HelloCmp, name: 'Child'})]) +export class ParentCmp { +} + +export function parentCmpLoader() { + return PromiseWrapper.resolve(ParentCmp); +} + + +@Component({ + selector: 'parent-cmp', + template: `inner { }`, + directives: [ROUTER_DIRECTIVES], +}) +@RouteConfig([new AsyncRoute({path: '/b', loader: helloCmpLoader, name: 'Child'})]) +export class AsyncParentCmp { +} + +export function asyncParentCmpLoader() { + return PromiseWrapper.resolve(AsyncParentCmp); +} + +@Component({ + selector: 'parent-cmp', + template: `inner { }`, + directives: [ROUTER_DIRECTIVES], +}) +@RouteConfig( + [new AsyncRoute({path: '/b', loader: helloCmpLoader, name: 'Child', useAsDefault: true})]) +export class AsyncDefaultParentCmp { +} + +export function asyncDefaultParentCmpLoader() { + return PromiseWrapper.resolve(AsyncDefaultParentCmp); +} + + +@Component({ + selector: 'parent-cmp', + template: `inner { }`, + directives: [ROUTER_DIRECTIVES], +}) +@RouteConfig([new Route({path: '/b', component: HelloCmp, name: 'Child', useAsDefault: true})]) +export class ParentWithDefaultCmp { +} + +export function parentWithDefaultCmpLoader() { + return PromiseWrapper.resolve(ParentWithDefaultCmp); +} + + +@Component({ + selector: 'team-cmp', + template: `team {{id}} | user { }`, + directives: [ROUTER_DIRECTIVES], +}) +@RouteConfig([new Route({path: '/user/:name', component: UserCmp, name: 'User'})]) +export class TeamCmp { + id: string; + constructor(params: RouteParams) { this.id = params.get('id'); } +} + +@Component({ + selector: 'team-cmp', + template: `team {{id}} | user { }`, + directives: [ROUTER_DIRECTIVES], +}) +@RouteConfig([new AsyncRoute({path: '/user/:name', loader: userCmpLoader, name: 'User'})]) +export class AsyncTeamCmp { + id: string; + constructor(params: RouteParams) { this.id = params.get('id'); } +} + +export function asyncTeamLoader() { + return PromiseWrapper.resolve(AsyncTeamCmp); +} + + +@Component({selector: 'data-cmp', template: `{{myData}}`}) +export class RouteDataCmp { + myData: boolean; + constructor(data: RouteData) { this.myData = data.get('isAdmin'); } +} + +export function asyncRouteDataCmp() { + return PromiseWrapper.resolve(RouteDataCmp); +} + +@Component({selector: 'redirect-to-parent-cmp', template: 'redirect-to-parent'}) +@RouteConfig([new Redirect({path: '/child-redirect', redirectTo: ['../HelloSib']})]) +export class RedirectToParentCmp { +} diff --git a/modules/angular2/test/router/integration/impl/sync_route_spec_impl.ts b/modules/angular2/test/router/integration/impl/sync_route_spec_impl.ts new file mode 100644 index 0000000000..15fbc3514c --- /dev/null +++ b/modules/angular2/test/router/integration/impl/sync_route_spec_impl.ts @@ -0,0 +1,431 @@ +import { + AsyncTestCompleter, + beforeEach, + beforeEachProviders, + expect, + iit, + flushMicrotasks, + inject, + it, + TestComponentBuilder, + RootTestComponent, + xit, +} from 'angular2/testing_internal'; + +import {specs, compile, TEST_ROUTER_PROVIDERS, clickOnElement, getHref} from '../util'; + +import {Router, Route, Location} from 'angular2/router'; + +import {HelloCmp, UserCmp, TeamCmp, ParentCmp, ParentWithDefaultCmp} from './fixture_components'; + + +function getLinkElement(rtc: RootTestComponent) { + return rtc.debugElement.componentViewChildren[0].nativeElement; +} + +function syncRoutesWithoutChildrenWithoutParams() { + var fixture; + var tcb; + var rtr; + + beforeEachProviders(() => TEST_ROUTER_PROVIDERS); + + beforeEach(inject([TestComponentBuilder, Router], (tcBuilder, router) => { + tcb = tcBuilder; + rtr = router; + })); + + it('should navigate by URL', inject([AsyncTestCompleter], (async) => { + compile(tcb) + .then((rtc) => {fixture = rtc}) + .then((_) => + rtr.config([new Route({path: '/test', component: HelloCmp, name: 'Hello'})])) + .then((_) => rtr.navigateByUrl('/test')) + .then((_) => { + fixture.detectChanges(); + expect(fixture.debugElement.nativeElement).toHaveText('hello'); + async.done(); + }); + })); + + it('should navigate by link DSL', inject([AsyncTestCompleter], (async) => { + compile(tcb) + .then((rtc) => {fixture = rtc}) + .then((_) => + rtr.config([new Route({path: '/test', component: HelloCmp, name: 'Hello'})])) + .then((_) => rtr.navigate(['/Hello'])) + .then((_) => { + fixture.detectChanges(); + expect(fixture.debugElement.nativeElement).toHaveText('hello'); + async.done(); + }); + })); + + it('should generate a link URL', inject([AsyncTestCompleter], (async) => { + compile(tcb, `go to hello | `) + .then((rtc) => {fixture = rtc}) + .then((_) => + rtr.config([new Route({path: '/test', component: HelloCmp, name: 'Hello'})])) + .then((_) => { + fixture.detectChanges(); + expect(getHref(getLinkElement(fixture))).toEqual('/test'); + async.done(); + }); + })); + + it('should navigate from a link click', + inject([AsyncTestCompleter, Location], (async, location) => { + compile(tcb, `go to hello | `) + .then((rtc) => {fixture = rtc}) + .then((_) => + rtr.config([new Route({path: '/test', component: HelloCmp, name: 'Hello'})])) + .then((_) => { + fixture.detectChanges(); + expect(fixture.debugElement.nativeElement).toHaveText('go to hello | '); + + rtr.subscribe((_) => { + fixture.detectChanges(); + expect(fixture.debugElement.nativeElement).toHaveText('go to hello | hello'); + expect(location.urlChanges).toEqual(['/test']); + async.done(); + }); + + clickOnElement(getLinkElement(fixture)); + }); + })); +} + + +function syncRoutesWithoutChildrenWithParams() { + var fixture; + var tcb; + var rtr; + + beforeEachProviders(() => TEST_ROUTER_PROVIDERS); + + beforeEach(inject([TestComponentBuilder, Router], (tcBuilder, router) => { + tcb = tcBuilder; + rtr = router; + })); + + it('should navigate by URL', inject([AsyncTestCompleter], (async) => { + compile(tcb) + .then((rtc) => {fixture = rtc}) + .then((_) => rtr.config( + [new Route({path: '/user/:name', component: UserCmp, name: 'User'})])) + .then((_) => rtr.navigateByUrl('/user/igor')) + .then((_) => { + fixture.detectChanges(); + expect(fixture.debugElement.nativeElement).toHaveText('hello igor'); + async.done(); + }); + })); + + it('should navigate by link DSL', inject([AsyncTestCompleter], (async) => { + compile(tcb) + .then((rtc) => {fixture = rtc}) + .then((_) => rtr.config( + [new Route({path: '/user/:name', component: UserCmp, name: 'User'})])) + .then((_) => rtr.navigate(['/User', {name: 'brian'}])) + .then((_) => { + fixture.detectChanges(); + expect(fixture.debugElement.nativeElement).toHaveText('hello brian'); + async.done(); + }); + })); + + it('should generate a link URL', inject([AsyncTestCompleter], (async) => { + compile(tcb, `greet naomi | `) + .then((rtc) => {fixture = rtc}) + .then((_) => rtr.config( + [new Route({path: '/user/:name', component: UserCmp, name: 'User'})])) + .then((_) => { + fixture.detectChanges(); + expect(getHref(getLinkElement(fixture))).toEqual('/user/naomi'); + async.done(); + }); + })); + + it('should navigate from a link click', + inject([AsyncTestCompleter, Location], (async, location) => { + compile(tcb, `greet naomi | `) + .then((rtc) => {fixture = rtc}) + .then((_) => rtr.config( + [new Route({path: '/user/:name', component: UserCmp, name: 'User'})])) + .then((_) => { + fixture.detectChanges(); + expect(fixture.debugElement.nativeElement).toHaveText('greet naomi | '); + + rtr.subscribe((_) => { + fixture.detectChanges(); + expect(fixture.debugElement.nativeElement).toHaveText('greet naomi | hello naomi'); + expect(location.urlChanges).toEqual(['/user/naomi']); + async.done(); + }); + + clickOnElement(getLinkElement(fixture)); + }); + })); + + it('should navigate between components with different parameters', + inject([AsyncTestCompleter], (async) => { + compile(tcb) + .then((rtc) => {fixture = rtc}) + .then((_) => rtr.config( + [new Route({path: '/user/:name', component: UserCmp, name: 'User'})])) + .then((_) => rtr.navigateByUrl('/user/brian')) + .then((_) => { + fixture.detectChanges(); + expect(fixture.debugElement.nativeElement).toHaveText('hello brian'); + }) + .then((_) => rtr.navigateByUrl('/user/igor')) + .then((_) => { + fixture.detectChanges(); + expect(fixture.debugElement.nativeElement).toHaveText('hello igor'); + async.done(); + }); + })); +} + + +function syncRoutesWithSyncChildrenWithoutDefaultRoutesWithoutParams() { + var fixture; + var tcb; + var rtr; + + beforeEachProviders(() => TEST_ROUTER_PROVIDERS); + + beforeEach(inject([TestComponentBuilder, Router], (tcBuilder, router) => { + tcb = tcBuilder; + rtr = router; + })); + + it('should navigate by URL', inject([AsyncTestCompleter], (async) => { + compile(tcb, `outer { }`) + .then((rtc) => {fixture = rtc}) + .then((_) => rtr.config( + [new Route({path: '/a/...', component: ParentCmp, name: 'Parent'})])) + .then((_) => rtr.navigateByUrl('/a/b')) + .then((_) => { + fixture.detectChanges(); + expect(fixture.debugElement.nativeElement).toHaveText('outer { inner { hello } }'); + async.done(); + }); + })); + + it('should navigate by link DSL', inject([AsyncTestCompleter], (async) => { + compile(tcb, `outer { }`) + .then((rtc) => {fixture = rtc}) + .then((_) => rtr.config( + [new Route({path: '/a/...', component: ParentCmp, name: 'Parent'})])) + .then((_) => rtr.navigate(['/Parent', 'Child'])) + .then((_) => { + fixture.detectChanges(); + expect(fixture.debugElement.nativeElement).toHaveText('outer { inner { hello } }'); + async.done(); + }); + })); + + it('should generate a link URL', inject([AsyncTestCompleter], (async) => { + compile(tcb, `nav to child | outer { }`) + .then((rtc) => {fixture = rtc}) + .then((_) => rtr.config( + [new Route({path: '/a/...', component: ParentCmp, name: 'Parent'})])) + .then((_) => { + fixture.detectChanges(); + expect(getHref(getLinkElement(fixture))).toEqual('/a/b'); + async.done(); + }); + })); + + it('should navigate from a link click', + inject([AsyncTestCompleter, Location], (async, location) => { + compile(tcb, `nav to child | outer { }`) + .then((rtc) => {fixture = rtc}) + .then((_) => rtr.config( + [new Route({path: '/a/...', component: ParentCmp, name: 'Parent'})])) + .then((_) => { + fixture.detectChanges(); + expect(fixture.debugElement.nativeElement).toHaveText('nav to child | outer { }'); + + rtr.subscribe((_) => { + fixture.detectChanges(); + expect(fixture.debugElement.nativeElement) + .toHaveText('nav to child | outer { inner { hello } }'); + expect(location.urlChanges).toEqual(['/a/b']); + async.done(); + }); + + clickOnElement(getLinkElement(fixture)); + }); + })); +} + + +function syncRoutesWithSyncChildrenWithoutDefaultRoutesWithParams() { + var fixture; + var tcb; + var rtr; + + beforeEachProviders(() => TEST_ROUTER_PROVIDERS); + + beforeEach(inject([TestComponentBuilder, Router], (tcBuilder, router) => { + tcb = tcBuilder; + rtr = router; + })); + + it('should navigate by URL', inject([AsyncTestCompleter], (async) => { + compile(tcb, `{ }`) + .then((rtc) => {fixture = rtc}) + .then((_) => rtr.config( + [new Route({path: '/team/:id/...', component: TeamCmp, name: 'Team'})])) + .then((_) => rtr.navigateByUrl('/team/angular/user/matias')) + .then((_) => { + fixture.detectChanges(); + expect(fixture.debugElement.nativeElement) + .toHaveText('{ team angular | user { hello matias } }'); + async.done(); + }); + })); + + it('should navigate by link DSL', inject([AsyncTestCompleter], (async) => { + compile(tcb, `{ }`) + .then((rtc) => {fixture = rtc}) + .then((_) => rtr.config( + [new Route({path: '/team/:id/...', component: TeamCmp, name: 'Team'})])) + .then((_) => rtr.navigate(['/Team', {id: 'angular'}, 'User', {name: 'matias'}])) + .then((_) => { + fixture.detectChanges(); + expect(fixture.debugElement.nativeElement) + .toHaveText('{ team angular | user { hello matias } }'); + async.done(); + }); + })); + + it('should generate a link URL', inject([AsyncTestCompleter], (async) => { + compile( + tcb, + `nav to matias { }`) + .then((rtc) => {fixture = rtc}) + .then((_) => rtr.config( + [new Route({path: '/team/:id/...', component: TeamCmp, name: 'Team'})])) + .then((_) => { + fixture.detectChanges(); + expect(getHref(getLinkElement(fixture))).toEqual('/team/angular/user/matias'); + async.done(); + }); + })); + + it('should navigate from a link click', + inject([AsyncTestCompleter, Location], (async, location) => { + compile( + tcb, + `nav to matias { }`) + .then((rtc) => {fixture = rtc}) + .then((_) => rtr.config( + [new Route({path: '/team/:id/...', component: TeamCmp, name: 'Team'})])) + .then((_) => { + fixture.detectChanges(); + expect(fixture.debugElement.nativeElement).toHaveText('nav to matias { }'); + + rtr.subscribe((_) => { + fixture.detectChanges(); + expect(fixture.debugElement.nativeElement) + .toHaveText('nav to matias { team angular | user { hello matias } }'); + expect(location.urlChanges).toEqual(['/team/angular/user/matias']); + async.done(); + }); + + clickOnElement(getLinkElement(fixture)); + }); + })); +} + + +function syncRoutesWithSyncChildrenWithDefaultRoutesWithoutParams() { + var fixture; + var tcb; + var rtr; + + beforeEachProviders(() => TEST_ROUTER_PROVIDERS); + + beforeEach(inject([TestComponentBuilder, Router], (tcBuilder, router) => { + tcb = tcBuilder; + rtr = router; + })); + + it('should navigate by URL', inject([AsyncTestCompleter], (async) => { + compile(tcb, `outer { }`) + .then((rtc) => {fixture = rtc}) + .then( + (_) => rtr.config( + [new Route({path: '/a/...', component: ParentWithDefaultCmp, name: 'Parent'})])) + .then((_) => rtr.navigateByUrl('/a')) + .then((_) => { + fixture.detectChanges(); + expect(fixture.debugElement.nativeElement).toHaveText('outer { inner { hello } }'); + async.done(); + }); + })); + + it('should navigate by link DSL', inject([AsyncTestCompleter], (async) => { + compile(tcb, `outer { }`) + .then((rtc) => {fixture = rtc}) + .then( + (_) => rtr.config( + [new Route({path: '/a/...', component: ParentWithDefaultCmp, name: 'Parent'})])) + .then((_) => rtr.navigate(['/Parent'])) + .then((_) => { + fixture.detectChanges(); + expect(fixture.debugElement.nativeElement).toHaveText('outer { inner { hello } }'); + async.done(); + }); + })); + + it('should generate a link URL', inject([AsyncTestCompleter], (async) => { + compile(tcb, `link to inner | outer { }`) + .then((rtc) => {fixture = rtc}) + .then( + (_) => rtr.config( + [new Route({path: '/a/...', component: ParentWithDefaultCmp, name: 'Parent'})])) + .then((_) => { + fixture.detectChanges(); + expect(getHref(getLinkElement(fixture))).toEqual('/a'); + async.done(); + }); + })); + + it('should navigate from a link click', + inject([AsyncTestCompleter, Location], (async, location) => { + compile(tcb, `link to inner | outer { }`) + .then((rtc) => {fixture = rtc}) + .then( + (_) => rtr.config( + [new Route({path: '/a/...', component: ParentWithDefaultCmp, name: 'Parent'})])) + .then((_) => { + fixture.detectChanges(); + expect(fixture.debugElement.nativeElement).toHaveText('link to inner | outer { }'); + + rtr.subscribe((_) => { + fixture.detectChanges(); + expect(fixture.debugElement.nativeElement) + .toHaveText('link to inner | outer { inner { hello } }'); + expect(location.urlChanges).toEqual(['/a/b']); + async.done(); + }); + + clickOnElement(getLinkElement(fixture)); + }); + })); +} + +export function registerSpecs() { + specs['syncRoutesWithoutChildrenWithoutParams'] = syncRoutesWithoutChildrenWithoutParams; + specs['syncRoutesWithoutChildrenWithParams'] = syncRoutesWithoutChildrenWithParams; + specs['syncRoutesWithSyncChildrenWithoutDefaultRoutesWithoutParams'] = + syncRoutesWithSyncChildrenWithoutDefaultRoutesWithoutParams; + specs['syncRoutesWithSyncChildrenWithoutDefaultRoutesWithParams'] = + syncRoutesWithSyncChildrenWithoutDefaultRoutesWithParams; + specs['syncRoutesWithSyncChildrenWithDefaultRoutesWithoutParams'] = + syncRoutesWithSyncChildrenWithDefaultRoutesWithoutParams; +} diff --git a/modules/angular2/test/router/integration/lifecycle_hook_spec.ts b/modules/angular2/test/router/integration/lifecycle_hook_spec.ts index 57e5d4f533..5130f2c090 100644 --- a/modules/angular2/test/router/integration/lifecycle_hook_spec.ts +++ b/modules/angular2/test/router/integration/lifecycle_hook_spec.ts @@ -10,7 +10,7 @@ import { expect, iit, inject, - beforeEachBindings, + beforeEachProviders, it, xit } from 'angular2/testing_internal'; @@ -25,7 +25,6 @@ import { ObservableWrapper } from 'angular2/src/facade/async'; -import {RootRouter} from 'angular2/src/router/router'; import {Router, RouterOutlet, RouterLink, RouteParams} from 'angular2/router'; import { RouteConfig, @@ -35,9 +34,6 @@ import { Redirect } from 'angular2/src/router/route_config_decorator'; -import {SpyLocation} from 'angular2/src/mock/location_mock'; -import {Location} from 'angular2/src/router/location'; -import {RouteRegistry} from 'angular2/src/router/route_registry'; import { OnActivate, OnDeactivate, @@ -47,7 +43,9 @@ import { } from 'angular2/src/router/interfaces'; import {CanActivate} from 'angular2/src/router/lifecycle_annotations'; import {ComponentInstruction} from 'angular2/src/router/instruction'; -import {DirectiveResolver} from 'angular2/src/core/linker/directive_resolver'; + + +import {TEST_ROUTER_PROVIDERS, RootCmp, compile} from './util'; var cmpInstanceCount; var log: string[]; @@ -61,17 +59,7 @@ export function main() { var fixture: ComponentFixture; var rtr; - beforeEachBindings(() => [ - RouteRegistry, - DirectiveResolver, - provide(Location, {useClass: SpyLocation}), - provide(Router, - { - useFactory: - (registry, location) => { return new RootRouter(registry, location, MyComp); }, - deps: [RouteRegistry, Location] - }) - ]); + beforeEachProviders(() => TEST_ROUTER_PROVIDERS); beforeEach(inject([TestComponentBuilder, Router], (tcBuilder, router) => { tcb = tcBuilder; @@ -81,17 +69,9 @@ export function main() { eventBus = new EventEmitter(); })); - function compile(template: string = "") { - return tcb.overrideView(MyComp, new View({ - template: ('
' + template + '
'), - directives: [RouterOutlet, RouterLink] - })) - .createAsync(MyComp) - .then((tc) => { fixture = tc; }); - } - it('should call the onActivate hook', inject([AsyncTestCompleter], (async) => { - compile() + compile(tcb) + .then((rtc) => {fixture = rtc}) .then((_) => rtr.config([new Route({path: '/...', component: LifecycleCmp})])) .then((_) => rtr.navigateByUrl('/on-activate')) .then((_) => { @@ -104,7 +84,8 @@ export function main() { it('should wait for a parent component\'s onActivate hook to resolve before calling its child\'s', inject([AsyncTestCompleter], (async) => { - compile() + compile(tcb) + .then((rtc) => {fixture = rtc}) .then((_) => rtr.config([new Route({path: '/...', component: LifecycleCmp})])) .then((_) => { ObservableWrapper.subscribe(eventBus, (ev) => { @@ -126,7 +107,8 @@ export function main() { })); it('should call the onDeactivate hook', inject([AsyncTestCompleter], (async) => { - compile() + compile(tcb) + .then((rtc) => {fixture = rtc}) .then((_) => rtr.config([new Route({path: '/...', component: LifecycleCmp})])) .then((_) => rtr.navigateByUrl('/on-deactivate')) .then((_) => rtr.navigateByUrl('/a')) @@ -140,7 +122,8 @@ export function main() { it('should wait for a child component\'s onDeactivate hook to resolve before calling its parent\'s', inject([AsyncTestCompleter], (async) => { - compile() + compile(tcb) + .then((rtc) => {fixture = rtc}) .then((_) => rtr.config([new Route({path: '/...', component: LifecycleCmp})])) .then((_) => rtr.navigateByUrl('/parent-deactivate/child-deactivate')) .then((_) => { @@ -165,7 +148,8 @@ export function main() { it('should reuse a component when the canReuse hook returns true', inject([AsyncTestCompleter], (async) => { - compile() + compile(tcb) + .then((rtc) => {fixture = rtc}) .then((_) => rtr.config([new Route({path: '/...', component: LifecycleCmp})])) .then((_) => rtr.navigateByUrl('/on-reuse/1/a')) .then((_) => { @@ -187,7 +171,8 @@ export function main() { it('should not reuse a component when the canReuse hook returns false', inject([AsyncTestCompleter], (async) => { - compile() + compile(tcb) + .then((rtc) => {fixture = rtc}) .then((_) => rtr.config([new Route({path: '/...', component: LifecycleCmp})])) .then((_) => rtr.navigateByUrl('/never-reuse/1/a')) .then((_) => { @@ -208,7 +193,8 @@ export function main() { it('should navigate when canActivate returns true', inject([AsyncTestCompleter], (async) => { - compile() + compile(tcb) + .then((rtc) => {fixture = rtc}) .then((_) => rtr.config([new Route({path: '/...', component: LifecycleCmp})])) .then((_) => { ObservableWrapper.subscribe(eventBus, (ev) => { @@ -228,7 +214,8 @@ export function main() { it('should not navigate when canActivate returns false', inject([AsyncTestCompleter], (async) => { - compile() + compile(tcb) + .then((rtc) => {fixture = rtc}) .then((_) => rtr.config([new Route({path: '/...', component: LifecycleCmp})])) .then((_) => { ObservableWrapper.subscribe(eventBus, (ev) => { @@ -248,7 +235,8 @@ export function main() { it('should navigate away when canDeactivate returns true', inject([AsyncTestCompleter], (async) => { - compile() + compile(tcb) + .then((rtc) => {fixture = rtc}) .then((_) => rtr.config([new Route({path: '/...', component: LifecycleCmp})])) .then((_) => rtr.navigateByUrl('/can-deactivate/a')) .then((_) => { @@ -273,7 +261,8 @@ export function main() { it('should not navigate away when canDeactivate returns false', inject([AsyncTestCompleter], (async) => { - compile() + compile(tcb) + .then((rtc) => {fixture = rtc}) .then((_) => rtr.config([new Route({path: '/...', component: LifecycleCmp})])) .then((_) => rtr.navigateByUrl('/can-deactivate/a')) .then((_) => { @@ -299,7 +288,8 @@ export function main() { it('should run activation and deactivation hooks in the correct order', inject([AsyncTestCompleter], (async) => { - compile() + compile(tcb) + .then((rtc) => {fixture = rtc}) .then((_) => rtr.config([new Route({path: '/...', component: LifecycleCmp})])) .then((_) => rtr.navigateByUrl('/activation-hooks/child')) .then((_) => { @@ -325,7 +315,8 @@ export function main() { })); it('should only run reuse hooks when reusing', inject([AsyncTestCompleter], (async) => { - compile() + compile(tcb) + .then((rtc) => {fixture = rtc}) .then((_) => rtr.config([new Route({path: '/...', component: LifecycleCmp})])) .then((_) => rtr.navigateByUrl('/reuse-hooks/1')) .then((_) => { @@ -352,7 +343,7 @@ export function main() { })); it('should not run reuse hooks when not reusing', inject([AsyncTestCompleter], (async) => { - compile() + compile(tcb) .then((_) => rtr.config([new Route({path: '/...', component: LifecycleCmp})])) .then((_) => rtr.navigateByUrl('/reuse-hooks/1')) .then((_) => { @@ -383,23 +374,16 @@ export function main() { } -@Component({selector: 'a-cmp'}) -@View({template: "A"}) +@Component({selector: 'a-cmp', template: "A"}) class A { } -@Component({selector: 'b-cmp'}) -@View({template: "B"}) +@Component({selector: 'b-cmp', template: "B"}) class B { } -@Component({selector: 'my-comp'}) -class MyComp { - name; -} - function logHook(name: string, next: ComponentInstruction, prev: ComponentInstruction) { var message = name + ': ' + (isPresent(prev) ? ('/' + prev.urlPath) : 'null') + ' -> ' + (isPresent(next) ? ('/' + next.urlPath) : 'null'); @@ -407,16 +391,18 @@ function logHook(name: string, next: ComponentInstruction, prev: ComponentInstru ObservableWrapper.callEmit(eventBus, message); } -@Component({selector: 'activate-cmp'}) -@View({template: 'activate cmp'}) +@Component({selector: 'activate-cmp', template: 'activate cmp'}) class ActivateCmp implements OnActivate { onActivate(next: ComponentInstruction, prev: ComponentInstruction) { logHook('activate', next, prev); } } -@Component({selector: 'parent-activate-cmp'}) -@View({template: `parent {}`, directives: [RouterOutlet]}) +@Component({ + selector: 'parent-activate-cmp', + template: `parent {}`, + directives: [RouterOutlet] +}) @RouteConfig([new Route({path: '/child-activate', component: ActivateCmp})]) class ParentActivateCmp implements OnActivate { onActivate(next: ComponentInstruction, prev: ComponentInstruction): Promise { @@ -426,16 +412,14 @@ class ParentActivateCmp implements OnActivate { } } -@Component({selector: 'deactivate-cmp'}) -@View({template: 'deactivate cmp'}) +@Component({selector: 'deactivate-cmp', template: 'deactivate cmp'}) class DeactivateCmp implements OnDeactivate { onDeactivate(next: ComponentInstruction, prev: ComponentInstruction) { logHook('deactivate', next, prev); } } -@Component({selector: 'deactivate-cmp'}) -@View({template: 'deactivate cmp'}) +@Component({selector: 'deactivate-cmp', template: 'deactivate cmp'}) class WaitDeactivateCmp implements OnDeactivate { onDeactivate(next: ComponentInstruction, prev: ComponentInstruction): Promise { completer = PromiseWrapper.completer(); @@ -444,8 +428,11 @@ class WaitDeactivateCmp implements OnDeactivate { } } -@Component({selector: 'parent-deactivate-cmp'}) -@View({template: `parent {}`, directives: [RouterOutlet]}) +@Component({ + selector: 'parent-deactivate-cmp', + template: `parent {}`, + directives: [RouterOutlet] +}) @RouteConfig([new Route({path: '/child-deactivate', component: WaitDeactivateCmp})]) class ParentDeactivateCmp implements OnDeactivate { onDeactivate(next: ComponentInstruction, prev: ComponentInstruction) { @@ -453,26 +440,37 @@ class ParentDeactivateCmp implements OnDeactivate { } } -@Component({selector: 'reuse-cmp'}) -@View({template: `reuse {}`, directives: [RouterOutlet]}) +@Component({ + selector: 'reuse-cmp', + template: `reuse {}`, + directives: [RouterOutlet] +}) @RouteConfig([new Route({path: '/a', component: A}), new Route({path: '/b', component: B})]) -class ReuseCmp implements OnReuse, CanReuse { +class ReuseCmp implements OnReuse, + CanReuse { constructor() { cmpInstanceCount += 1; } canReuse(next: ComponentInstruction, prev: ComponentInstruction) { return true; } onReuse(next: ComponentInstruction, prev: ComponentInstruction) { logHook('reuse', next, prev); } } -@Component({selector: 'never-reuse-cmp'}) -@View({template: `reuse {}`, directives: [RouterOutlet]}) +@Component({ + selector: 'never-reuse-cmp', + template: `reuse {}`, + directives: [RouterOutlet] +}) @RouteConfig([new Route({path: '/a', component: A}), new Route({path: '/b', component: B})]) -class NeverReuseCmp implements OnReuse, CanReuse { +class NeverReuseCmp implements OnReuse, + CanReuse { constructor() { cmpInstanceCount += 1; } canReuse(next: ComponentInstruction, prev: ComponentInstruction) { return false; } onReuse(next: ComponentInstruction, prev: ComponentInstruction) { logHook('reuse', next, prev); } } -@Component({selector: 'can-activate-cmp'}) -@View({template: `canActivate {}`, directives: [RouterOutlet]}) +@Component({ + selector: 'can-activate-cmp', + template: `canActivate {}`, + directives: [RouterOutlet] +}) @RouteConfig([new Route({path: '/a', component: A}), new Route({path: '/b', component: B})]) @CanActivate(CanActivateCmp.canActivate) class CanActivateCmp { @@ -483,8 +481,11 @@ class CanActivateCmp { } } -@Component({selector: 'can-deactivate-cmp'}) -@View({template: `canDeactivate {}`, directives: [RouterOutlet]}) +@Component({ + selector: 'can-deactivate-cmp', + template: `canDeactivate {}`, + directives: [RouterOutlet] +}) @RouteConfig([new Route({path: '/a', component: A}), new Route({path: '/b', component: B})]) class CanDeactivateCmp implements CanDeactivate { canDeactivate(next: ComponentInstruction, prev: ComponentInstruction): Promise { @@ -494,8 +495,7 @@ class CanDeactivateCmp implements CanDeactivate { } } -@Component({selector: 'all-hooks-child-cmp'}) -@View({template: `child`}) +@Component({selector: 'all-hooks-child-cmp', template: `child`}) @CanActivate(AllHooksChildCmp.canActivate) class AllHooksChildCmp implements CanDeactivate, OnDeactivate, OnActivate { canDeactivate(next: ComponentInstruction, prev: ComponentInstruction) { @@ -517,11 +517,15 @@ class AllHooksChildCmp implements CanDeactivate, OnDeactivate, OnActivate { } } -@Component({selector: 'all-hooks-parent-cmp'}) -@View({template: ``, directives: [RouterOutlet]}) +@Component({ + selector: 'all-hooks-parent-cmp', + template: ``, + directives: [RouterOutlet] +}) @RouteConfig([new Route({path: '/child', component: AllHooksChildCmp})]) @CanActivate(AllHooksParentCmp.canActivate) -class AllHooksParentCmp implements CanDeactivate, OnDeactivate, OnActivate { +class AllHooksParentCmp implements CanDeactivate, + OnDeactivate, OnActivate { canDeactivate(next: ComponentInstruction, prev: ComponentInstruction) { logHook('canDeactivate parent', next, prev); return true; @@ -541,8 +545,7 @@ class AllHooksParentCmp implements CanDeactivate, OnDeactivate, OnActivate { } } -@Component({selector: 'reuse-hooks-cmp'}) -@View({template: 'reuse hooks cmp'}) +@Component({selector: 'reuse-hooks-cmp', template: 'reuse hooks cmp'}) @CanActivate(ReuseHooksCmp.canActivate) class ReuseHooksCmp implements OnActivate, OnReuse, OnDeactivate, CanReuse, CanDeactivate { canReuse(next: ComponentInstruction, prev: ComponentInstruction): Promise { @@ -574,8 +577,11 @@ class ReuseHooksCmp implements OnActivate, OnReuse, OnDeactivate, CanReuse, CanD } } -@Component({selector: 'lifecycle-cmp'}) -@View({template: ``, directives: [RouterOutlet]}) +@Component({ + selector: 'lifecycle-cmp', + template: ``, + directives: [RouterOutlet] +}) @RouteConfig([ new Route({path: '/a', component: A}), new Route({path: '/on-activate', component: ActivateCmp}), diff --git a/modules/angular2/test/router/integration/navigation_spec.ts b/modules/angular2/test/router/integration/navigation_spec.ts index cb420974e6..8e649ec9c2 100644 --- a/modules/angular2/test/router/integration/navigation_spec.ts +++ b/modules/angular2/test/router/integration/navigation_spec.ts @@ -10,7 +10,7 @@ import { expect, iit, inject, - beforeEachBindings, + beforeEachProviders, it, xit } from 'angular2/testing_internal'; @@ -18,8 +18,7 @@ import { import {provide, Component, View, Injector, Inject} from 'angular2/core'; import {Promise, PromiseWrapper} from 'angular2/src/facade/async'; -import {RootRouter} from 'angular2/src/router/router'; -import {Router, RouterOutlet, RouterLink, RouteParams, RouteData} from 'angular2/router'; +import {Router, RouterOutlet, RouterLink, RouteParams, RouteData, Location} from 'angular2/router'; import { RouteConfig, Route, @@ -28,14 +27,10 @@ import { Redirect } from 'angular2/src/router/route_config_decorator'; -import {SpyLocation} from 'angular2/src/mock/location_mock'; -import {Location} from 'angular2/src/router/location'; -import {RouteRegistry} from 'angular2/src/router/route_registry'; -import {DirectiveResolver} from 'angular2/src/core/linker/directive_resolver'; +import {TEST_ROUTER_PROVIDERS, RootCmp, compile} from './util'; var cmpInstanceCount; var childCmpInstanceCount; -var log: string[]; export function main() { describe('navigation', () => { @@ -44,37 +39,18 @@ export function main() { var fixture: ComponentFixture; var rtr; - beforeEachBindings(() => [ - RouteRegistry, - DirectiveResolver, - provide(Location, {useClass: SpyLocation}), - provide(Router, - { - useFactory: - (registry, location) => { return new RootRouter(registry, location, MyComp); }, - deps: [RouteRegistry, Location] - }) - ]); + beforeEachProviders(() => TEST_ROUTER_PROVIDERS); beforeEach(inject([TestComponentBuilder, Router], (tcBuilder, router) => { tcb = tcBuilder; rtr = router; childCmpInstanceCount = 0; cmpInstanceCount = 0; - log = []; })); - function compile(template: string = "") { - return tcb.overrideView(MyComp, new View({ - template: ('
' + template + '
'), - directives: [RouterOutlet, RouterLink] - })) - .createAsync(MyComp) - .then((tc) => { fixture = tc; }); - } - it('should work in a simple case', inject([AsyncTestCompleter], (async) => { - compile() + compile(tcb) + .then((rtc) => {fixture = rtc}) .then((_) => rtr.config([new Route({path: '/test', component: HelloCmp})])) .then((_) => rtr.navigateByUrl('/test')) .then((_) => { @@ -87,7 +63,8 @@ export function main() { it('should navigate between components with different parameters', inject([AsyncTestCompleter], (async) => { - compile() + compile(tcb) + .then((rtc) => {fixture = rtc}) .then((_) => rtr.config([new Route({path: '/user/:name', component: UserCmp})])) .then((_) => rtr.navigateByUrl('/user/brian')) .then((_) => { @@ -102,9 +79,9 @@ export function main() { }); })); - it('should navigate to child routes', inject([AsyncTestCompleter], (async) => { - compile('outer { }') + compile(tcb, 'outer { }') + .then((rtc) => {fixture = rtc}) .then((_) => rtr.config([new Route({path: '/a/...', component: ParentCmp})])) .then((_) => rtr.navigateByUrl('/a/b')) .then((_) => { @@ -116,7 +93,9 @@ export function main() { it('should navigate to child routes that capture an empty path', inject([AsyncTestCompleter], (async) => { - compile('outer { }') + + compile(tcb, 'outer { }') + .then((rtc) => {fixture = rtc}) .then((_) => rtr.config([new Route({path: '/a/...', component: ParentCmp})])) .then((_) => rtr.navigateByUrl('/a')) .then((_) => { @@ -126,9 +105,9 @@ export function main() { }); })); - it('should navigate to child routes of async routes', inject([AsyncTestCompleter], (async) => { - compile('outer { }') + compile(tcb, 'outer { }') + .then((rtc) => {fixture = rtc}) .then((_) => rtr.config([new AsyncRoute({path: '/a/...', loader: parentLoader})])) .then((_) => rtr.navigateByUrl('/a/b')) .then((_) => { @@ -138,26 +117,9 @@ export function main() { }); })); - - it('should recognize and apply redirects', - inject([AsyncTestCompleter, Location], (async, location) => { - compile() - .then((_) => rtr.config([ - new Redirect({path: '/original', redirectTo: '/redirected'}), - new Route({path: '/redirected', component: HelloCmp}) - ])) - .then((_) => rtr.navigateByUrl('/original')) - .then((_) => { - fixture.detectChanges(); - expect(fixture.debugElement.nativeElement).toHaveText('hello'); - expect(location.urlChanges).toEqual(['/redirected']); - async.done(); - }); - })); - - it('should reuse common parent components', inject([AsyncTestCompleter], (async) => { - compile() + compile(tcb) + .then((rtc) => {fixture = rtc}) .then((_) => rtr.config([new Route({path: '/team/:id/...', component: TeamCmp})])) .then((_) => rtr.navigateByUrl('/team/angular/user/rado')) .then((_) => { @@ -177,7 +139,8 @@ export function main() { it('should not reuse children when parent components change', inject([AsyncTestCompleter], (async) => { - compile() + compile(tcb) + .then((rtc) => {fixture = rtc}) .then((_) => rtr.config([new Route({path: '/team/:id/...', component: TeamCmp})])) .then((_) => rtr.navigateByUrl('/team/angular/user/rado')) .then((_) => { @@ -197,7 +160,8 @@ export function main() { })); it('should inject route data into component', inject([AsyncTestCompleter], (async) => { - compile() + compile(tcb) + .then((rtc) => {fixture = rtc}) .then((_) => rtr.config([ new Route({path: '/route-data', component: RouteDataCmp, data: {isAdmin: true}}) ])) @@ -211,10 +175,11 @@ export function main() { it('should inject route data into component with AsyncRoute', inject([AsyncTestCompleter], (async) => { - compile() + compile(tcb) + .then((rtc) => {fixture = rtc}) .then((_) => rtr.config([ new AsyncRoute( - {path: '/route-data', loader: AsyncRouteDataCmp, data: {isAdmin: true}}) + {path: '/route-data', loader: asyncRouteDataCmp, data: {isAdmin: true}}) ])) .then((_) => rtr.navigateByUrl('/route-data')) .then((_) => { @@ -226,7 +191,8 @@ export function main() { it('should inject empty object if the route has no data property', inject([AsyncTestCompleter], (async) => { - compile() + compile(tcb) + .then((rtc) => {fixture = rtc}) .then((_) => rtr.config( [new Route({path: '/route-data-default', component: RouteDataCmp})])) .then((_) => rtr.navigateByUrl('/route-data-default')) @@ -236,45 +202,28 @@ export function main() { async.done(); }); })); - - describe('auxiliary routes', () => { - it('should recognize a simple case', inject([AsyncTestCompleter], (async) => { - compile() - .then((_) => rtr.config([new Route({path: '/...', component: AuxCmp})])) - .then((_) => rtr.navigateByUrl('/hello(modal)')) - .then((_) => { - fixture.detectChanges(); - expect(fixture.debugElement.nativeElement) - .toHaveText('main {hello} | aux {modal}'); - async.done(); - }); - })); - }); }); } -@Component({selector: 'hello-cmp'}) -@View({template: "{{greeting}}"}) +@Component({selector: 'hello-cmp', template: `{{greeting}}`}) class HelloCmp { greeting: string; - constructor() { this.greeting = "hello"; } + constructor() { this.greeting = 'hello'; } } -function AsyncRouteDataCmp() { +function asyncRouteDataCmp() { return PromiseWrapper.resolve(RouteDataCmp); } -@Component({selector: 'data-cmp'}) -@View({template: "{{myData}}"}) +@Component({selector: 'data-cmp', template: `{{myData}}`}) class RouteDataCmp { myData: boolean; constructor(data: RouteData) { this.myData = data.get('isAdmin'); } } -@Component({selector: 'user-cmp'}) -@View({template: "hello {{user}}"}) +@Component({selector: 'user-cmp', template: `hello {{user}}`}) class UserCmp { user: string; constructor(params: RouteParams) { @@ -288,9 +237,9 @@ function parentLoader() { return PromiseWrapper.resolve(ParentCmp); } -@Component({selector: 'parent-cmp'}) -@View({ - template: "inner { }", +@Component({ + selector: 'parent-cmp', + template: `inner { }`, directives: [RouterOutlet], }) @RouteConfig([ @@ -298,13 +247,12 @@ function parentLoader() { new Route({path: '/', component: HelloCmp}), ]) class ParentCmp { - constructor() {} } -@Component({selector: 'team-cmp'}) -@View({ - template: "team {{id}} { }", +@Component({ + selector: 'team-cmp', + template: `team {{id}} { }`, directives: [RouterOutlet], }) @RouteConfig([new Route({path: '/user/:name', component: UserCmp})]) @@ -315,27 +263,3 @@ class TeamCmp { cmpInstanceCount += 1; } } - - -@Component({selector: 'my-comp'}) -class MyComp { - name; -} - -@Component({selector: 'modal-cmp'}) -@View({template: "modal"}) -class ModalCmp { -} - -@Component({selector: 'aux-cmp'}) -@View({ - template: 'main {} | ' + - 'aux {}', - directives: [RouterOutlet], -}) -@RouteConfig([ - new Route({path: '/hello', component: HelloCmp}), - new AuxRoute({path: '/modal', component: ModalCmp}), -]) -class AuxCmp { -} diff --git a/modules/angular2/test/router/integration/redirect_route_spec.ts b/modules/angular2/test/router/integration/redirect_route_spec.ts new file mode 100644 index 0000000000..7f8bb28a92 --- /dev/null +++ b/modules/angular2/test/router/integration/redirect_route_spec.ts @@ -0,0 +1,121 @@ +import { + RootTestComponent, + AsyncTestCompleter, + TestComponentBuilder, + beforeEach, + ddescribe, + xdescribe, + describe, + el, + expect, + iit, + inject, + beforeEachProviders, + it, + xit +} from 'angular2/testing_internal'; + +import {Router, RouterOutlet, RouterLink, RouteParams, RouteData, Location} from 'angular2/router'; +import { + RouteConfig, + Route, + AuxRoute, + AsyncRoute, + Redirect +} from 'angular2/src/router/route_config_decorator'; + +import {TEST_ROUTER_PROVIDERS, RootCmp, compile} from './util'; +import {HelloCmp, RedirectToParentCmp} from './impl/fixture_components'; + +var cmpInstanceCount; +var childCmpInstanceCount; + +export function main() { + describe('redirects', () => { + + var tcb: TestComponentBuilder; + var rootTC: RootTestComponent; + var rtr; + + beforeEachProviders(() => TEST_ROUTER_PROVIDERS); + + beforeEach(inject([TestComponentBuilder, Router], (tcBuilder, router) => { + tcb = tcBuilder; + rtr = router; + childCmpInstanceCount = 0; + cmpInstanceCount = 0; + })); + + + it('should apply when navigating by URL', + inject([AsyncTestCompleter, Location], (async, location) => { + compile(tcb) + .then((rtc) => {rootTC = rtc}) + .then((_) => rtr.config([ + new Redirect({path: '/original', redirectTo: ['Hello']}), + new Route({path: '/redirected', component: HelloCmp, name: 'Hello'}) + ])) + .then((_) => rtr.navigateByUrl('/original')) + .then((_) => { + rootTC.detectChanges(); + expect(rootTC.debugElement.nativeElement).toHaveText('hello'); + expect(location.urlChanges).toEqual(['/redirected']); + async.done(); + }); + })); + + + it('should recognize and apply absolute redirects', + inject([AsyncTestCompleter, Location], (async, location) => { + compile(tcb) + .then((rtc) => {rootTC = rtc}) + .then((_) => rtr.config([ + new Redirect({path: '/original', redirectTo: ['/Hello']}), + new Route({path: '/redirected', component: HelloCmp, name: 'Hello'}) + ])) + .then((_) => rtr.navigateByUrl('/original')) + .then((_) => { + rootTC.detectChanges(); + expect(rootTC.debugElement.nativeElement).toHaveText('hello'); + expect(location.urlChanges).toEqual(['/redirected']); + async.done(); + }); + })); + + + it('should recognize and apply relative child redirects', + inject([AsyncTestCompleter, Location], (async, location) => { + compile(tcb) + .then((rtc) => {rootTC = rtc}) + .then((_) => rtr.config([ + new Redirect({path: '/original', redirectTo: ['./Hello']}), + new Route({path: '/redirected', component: HelloCmp, name: 'Hello'}) + ])) + .then((_) => rtr.navigateByUrl('/original')) + .then((_) => { + rootTC.detectChanges(); + expect(rootTC.debugElement.nativeElement).toHaveText('hello'); + expect(location.urlChanges).toEqual(['/redirected']); + async.done(); + }); + })); + + + it('should recognize and apply relative parent redirects', + inject([AsyncTestCompleter, Location], (async, location) => { + compile(tcb) + .then((rtc) => {rootTC = rtc}) + .then((_) => rtr.config([ + new Route({path: '/original/...', component: RedirectToParentCmp}), + new Route({path: '/redirected', component: HelloCmp, name: 'HelloSib'}) + ])) + .then((_) => rtr.navigateByUrl('/original/child-redirect')) + .then((_) => { + rootTC.detectChanges(); + expect(rootTC.debugElement.nativeElement).toHaveText('hello'); + expect(location.urlChanges).toEqual(['/redirected']); + async.done(); + }); + })); + }); +} diff --git a/modules/angular2/test/router/integration/router_link_spec.ts b/modules/angular2/test/router/integration/router_link_spec.ts index 5b76fd538b..6a074c06b6 100644 --- a/modules/angular2/test/router/integration/router_link_spec.ts +++ b/modules/angular2/test/router/integration/router_link_spec.ts @@ -9,7 +9,7 @@ import { expect, iit, inject, - beforeEachBindings, + beforeEachProviders, it, xit, TestComponentBuilder, @@ -21,7 +21,7 @@ import {NumberWrapper} from 'angular2/src/facade/lang'; import {PromiseWrapper} from 'angular2/src/facade/async'; import {ListWrapper} from 'angular2/src/facade/collection'; -import {provide, Component, DirectiveResolver} from 'angular2/core'; +import {provide, Component, View, DirectiveResolver} from 'angular2/core'; import {SpyLocation} from 'angular2/src/mock/location_mock'; import { @@ -35,7 +35,8 @@ import { Route, RouteParams, RouteConfig, - ROUTER_DIRECTIVES + ROUTER_DIRECTIVES, + ROUTER_PRIMARY_COMPONENT } from 'angular2/router'; import {RootRouter} from 'angular2/src/router/router'; @@ -47,16 +48,12 @@ export function main() { var fixture: ComponentFixture; var router, location; - beforeEachBindings(() => [ + beforeEachProviders(() => [ RouteRegistry, DirectiveResolver, provide(Location, {useClass: SpyLocation}), - provide(Router, - { - useFactory: - (registry, location) => { return new RootRouter(registry, location, MyComp); }, - deps: [RouteRegistry, Location] - }) + provide(ROUTER_PRIMARY_COMPONENT, {useValue: MyComp}), + provide(Router, {useClass: RootRouter}) ]); beforeEach(inject([TestComponentBuilder, Router, Location], (tcBuilder, rtr, loc) => { @@ -240,8 +237,8 @@ export function main() { .then((_) => router.config([new Route({path: '/...', component: AuxLinkCmp})])) .then((_) => router.navigateByUrl('/')) .then((_) => { - rootTC.detectChanges(); - expect(DOM.getAttribute(rootTC.debugElement.componentViewChildren[1] + fixture.detectChanges(); + expect(DOM.getAttribute(fixture.debugElement.componentViewChildren[1] .componentViewChildren[0] .nativeElement, 'href')) @@ -386,10 +383,7 @@ class MyComp { name; } -@Component({ - selector: 'user-cmp', - template: "hello {{user}}" -}) +@Component({selector: 'user-cmp', template: "hello {{user}}"}) class UserCmp { user: string; constructor(params: RouteParams) { this.user = params.get('name'); } @@ -425,17 +419,11 @@ class NoPrefixSiblingPageCmp { } } -@Component({ - selector: 'hello-cmp', - template: 'hello' -}) +@Component({selector: 'hello-cmp', template: 'hello'}) class HelloCmp { } -@Component({ - selector: 'hello2-cmp', - template: 'hello2' -}) +@Component({selector: 'hello2-cmp', template: 'hello2'}) class Hello2Cmp { } @@ -455,7 +443,6 @@ function parentCmpLoader() { new Route({path: '/better-grandchild', component: Hello2Cmp, name: 'BetterGrandchild'}) ]) class ParentCmp { - constructor(public router: Router) {} } @Component({ diff --git a/modules/angular2/test/router/integration/sync_route_spec.ts b/modules/angular2/test/router/integration/sync_route_spec.ts new file mode 100644 index 0000000000..12a4d339fe --- /dev/null +++ b/modules/angular2/test/router/integration/sync_route_spec.ts @@ -0,0 +1,24 @@ +import { + describeRouter, + ddescribeRouter, + describeWith, + describeWithout, + describeWithAndWithout, + itShouldRoute +} from './util'; + +import {registerSpecs} from './impl/sync_route_spec_impl'; + +export function main() { + registerSpecs(); + + describeRouter('sync routes', () => { + describeWithout('children', () => { describeWithAndWithout('params', itShouldRoute); }); + + describeWith('sync children', () => { + describeWithout('default routes', () => { describeWithAndWithout('params', itShouldRoute); }); + describeWith('default routes', () => { describeWithout('params', itShouldRoute); }); + + }); + }); +} diff --git a/modules/angular2/test/router/integration/util.ts b/modules/angular2/test/router/integration/util.ts new file mode 100644 index 0000000000..08abbf3438 --- /dev/null +++ b/modules/angular2/test/router/integration/util.ts @@ -0,0 +1,133 @@ +import {provide, Provider, Component, View} from 'angular2/core'; +import {Type, isBlank} from 'angular2/src/facade/lang'; +import {BaseException} from 'angular2/src/facade/exceptions'; + +import { + RootTestComponent, + AsyncTestCompleter, + TestComponentBuilder, + beforeEach, + ddescribe, + xdescribe, + describe, + el, + inject, + beforeEachProviders, + it, + xit +} from 'angular2/testing_internal'; + +import {RootRouter} from 'angular2/src/router/router'; +import {Router, ROUTER_DIRECTIVES, ROUTER_PRIMARY_COMPONENT} from 'angular2/router'; + +import {SpyLocation} from 'angular2/src/mock/location_mock'; +import {Location} from 'angular2/src/router/location'; +import {RouteRegistry} from 'angular2/src/router/route_registry'; +import {DirectiveResolver} from 'angular2/src/core/linker/directive_resolver'; +import {DOM} from 'angular2/src/platform/dom/dom_adapter'; +export {ComponentFixture} from 'angular2/testing_internal'; + + +/** + * Router test helpers and fixtures + */ + +@Component({ + selector: 'root-comp', + template: ``, + directives: [ROUTER_DIRECTIVES] +}) +export class RootCmp { + name: string; +} + +export function compile(tcb: TestComponentBuilder, + template: string = "") { + return tcb.overrideTemplate(RootCmp, ('
' + template + '
')).createAsync(RootCmp); +} + +export var TEST_ROUTER_PROVIDERS = [ + RouteRegistry, + DirectiveResolver, + provide(Location, {useClass: SpyLocation}), + provide(ROUTER_PRIMARY_COMPONENT, {useValue: RootCmp}), + provide(Router, {useClass: RootRouter}) +]; + +export function clickOnElement(anchorEl) { + var dispatchedEvent = DOM.createMouseEvent('click'); + DOM.dispatchEvent(anchorEl, dispatchedEvent); + return dispatchedEvent; +} + +export function getHref(elt) { + return DOM.getAttribute(elt, 'href'); +} + + +/** + * Router integration suite DSL + */ + +var specNameBuilder = []; + +// we add the specs themselves onto this map +export var specs = {}; + +export function describeRouter(description: string, fn: Function, exclusive = false): void { + var specName = descriptionToSpecName(description); + specNameBuilder.push(specName); + describe(description, fn); + specNameBuilder.pop(); +} + +export function ddescribeRouter(description: string, fn: Function, exclusive = false): void { + describeRouter(description, fn, true); +} + +export function describeWithAndWithout(description: string, fn: Function): void { + // the "without" case is usually simpler, so we opt to run this spec first + describeWithout(description, fn); + describeWith(description, fn); +} + +export function describeWith(description: string, fn: Function): void { + var specName = 'with ' + description; + specNameBuilder.push(specName); + describe(specName, fn); + specNameBuilder.pop(); +} + +export function describeWithout(description: string, fn: Function): void { + var specName = 'without ' + description; + specNameBuilder.push(specName); + describe(specName, fn); + specNameBuilder.pop(); +} + +function descriptionToSpecName(description: string): string { + return spaceCaseToCamelCase(description); +} + +// this helper looks up the suite registered from the "impl" folder in this directory +export function itShouldRoute() { + var specSuiteName = spaceCaseToCamelCase(specNameBuilder.join(' ')); + + var spec = specs[specSuiteName]; + if (isBlank(spec)) { + throw new BaseException(`Router integration spec suite "${specSuiteName}" was not found.`); + } else { + // todo: remove spec from map, throw if there are extra left over?? + spec(); + } +} + +function spaceCaseToCamelCase(str: string): string { + var words = str.split(' '); + var first = words.shift(); + return first + words.map(title).join(''); +} + +function title(str: string): string { + return str[0].toUpperCase() + str.substring(1); +} diff --git a/modules/angular2/test/router/path_recognizer_spec.ts b/modules/angular2/test/router/path_recognizer_spec.ts index 979c21a7d0..08fddc1044 100644 --- a/modules/angular2/test/router/path_recognizer_spec.ts +++ b/modules/angular2/test/router/path_recognizer_spec.ts @@ -12,100 +12,82 @@ import { import {PathRecognizer} from 'angular2/src/router/path_recognizer'; import {parser, Url, RootUrl} from 'angular2/src/router/url_parser'; -import {SyncRouteHandler} from 'angular2/src/router/sync_route_handler'; - -class DummyClass { - constructor() {} -} - -var mockRouteHandler = new SyncRouteHandler(DummyClass); export function main() { describe('PathRecognizer', () => { it('should throw when given an invalid path', () => { - expect(() => new PathRecognizer('/hi#', mockRouteHandler)) + expect(() => new PathRecognizer('/hi#')) .toThrowError(`Path "/hi#" should not include "#". Use "HashLocationStrategy" instead.`); - expect(() => new PathRecognizer('hi?', mockRouteHandler)) + expect(() => new PathRecognizer('hi?')) .toThrowError(`Path "hi?" contains "?" which is not allowed in a route config.`); - expect(() => new PathRecognizer('hi;', mockRouteHandler)) + expect(() => new PathRecognizer('hi;')) .toThrowError(`Path "hi;" contains ";" which is not allowed in a route config.`); - expect(() => new PathRecognizer('hi=', mockRouteHandler)) + expect(() => new PathRecognizer('hi=')) .toThrowError(`Path "hi=" contains "=" which is not allowed in a route config.`); - expect(() => new PathRecognizer('hi(', mockRouteHandler)) + expect(() => new PathRecognizer('hi(')) .toThrowError(`Path "hi(" contains "(" which is not allowed in a route config.`); - expect(() => new PathRecognizer('hi)', mockRouteHandler)) + expect(() => new PathRecognizer('hi)')) .toThrowError(`Path "hi)" contains ")" which is not allowed in a route config.`); - expect(() => new PathRecognizer('hi//there', mockRouteHandler)) + expect(() => new PathRecognizer('hi//there')) .toThrowError(`Path "hi//there" contains "//" which is not allowed in a route config.`); }); - it('should return the same instruction instance when recognizing the same path', () => { - var rec = new PathRecognizer('/one', mockRouteHandler); - - var one = new Url('one', null, null, {}); - - var firstMatch = rec.recognize(one); - var secondMatch = rec.recognize(one); - - expect(firstMatch.instruction).toBe(secondMatch.instruction); - }); - describe('querystring params', () => { it('should parse querystring params so long as the recognizer is a root', () => { - var rec = new PathRecognizer('/hello/there', mockRouteHandler); + var rec = new PathRecognizer('/hello/there'); var url = parser.parse('/hello/there?name=igor'); var match = rec.recognize(url); - expect(match.instruction.params).toEqual({'name': 'igor'}); + expect(match['allParams']).toEqual({'name': 'igor'}); }); it('should return a combined map of parameters with the param expected in the URL path', () => { - var rec = new PathRecognizer('/hello/:name', mockRouteHandler); + var rec = new PathRecognizer('/hello/:name'); var url = parser.parse('/hello/paul?topic=success'); var match = rec.recognize(url); - expect(match.instruction.params).toEqual({'name': 'paul', 'topic': 'success'}); + expect(match['allParams']).toEqual({'name': 'paul', 'topic': 'success'}); }); }); describe('matrix params', () => { it('should be parsed along with dynamic paths', () => { - var rec = new PathRecognizer('/hello/:id', mockRouteHandler); + var rec = new PathRecognizer('/hello/:id'); var url = new Url('hello', new Url('matias', null, null, {'key': 'value'})); var match = rec.recognize(url); - expect(match.instruction.params).toEqual({'id': 'matias', 'key': 'value'}); + expect(match['allParams']).toEqual({'id': 'matias', 'key': 'value'}); }); it('should be parsed on a static path', () => { - var rec = new PathRecognizer('/person', mockRouteHandler); + var rec = new PathRecognizer('/person'); var url = new Url('person', null, null, {'name': 'dave'}); var match = rec.recognize(url); - expect(match.instruction.params).toEqual({'name': 'dave'}); + expect(match['allParams']).toEqual({'name': 'dave'}); }); it('should be ignored on a wildcard segment', () => { - var rec = new PathRecognizer('/wild/*everything', mockRouteHandler); + var rec = new PathRecognizer('/wild/*everything'); var url = parser.parse('/wild/super;variable=value'); var match = rec.recognize(url); - expect(match.instruction.params).toEqual({'everything': 'super;variable=value'}); + expect(match['allParams']).toEqual({'everything': 'super;variable=value'}); }); it('should set matrix param values to true when no value is present', () => { - var rec = new PathRecognizer('/path', mockRouteHandler); + var rec = new PathRecognizer('/path'); var url = new Url('path', null, null, {'one': true, 'two': true, 'three': '3'}); var match = rec.recognize(url); - expect(match.instruction.params).toEqual({'one': true, 'two': true, 'three': '3'}); + expect(match['allParams']).toEqual({'one': true, 'two': true, 'three': '3'}); }); it('should be parsed on the final segment of the path', () => { - var rec = new PathRecognizer('/one/two/three', mockRouteHandler); + var rec = new PathRecognizer('/one/two/three'); var three = new Url('three', null, null, {'c': '3'}); var two = new Url('two', three, null, {'b': '2'}); var one = new Url('one', two, null, {'a': '1'}); var match = rec.recognize(one); - expect(match.instruction.params).toEqual({'c': '3'}); + expect(match['allParams']).toEqual({'c': '3'}); }); }); }); diff --git a/modules/angular2/test/router/route_config_spec.ts b/modules/angular2/test/router/route_config_spec.ts index ac64143273..6094178ac4 100644 --- a/modules/angular2/test/router/route_config_spec.ts +++ b/modules/angular2/test/router/route_config_spec.ts @@ -214,7 +214,10 @@ class HelloCmp { @Component({selector: 'app-cmp'}) @View({template: `root { }`, directives: ROUTER_DIRECTIVES}) -@RouteConfig([{path: '/before', redirectTo: '/after'}, {path: '/after', component: HelloCmp}]) +@RouteConfig([ + {path: '/before', redirectTo: ['Hello']}, + {path: '/after', component: HelloCmp, name: 'Hello'} +]) class RedirectAppCmp { constructor(public router: Router, public location: LocationStrategy) {} } diff --git a/modules/angular2/test/router/route_recognizer_spec.ts b/modules/angular2/test/router/route_recognizer_spec.ts deleted file mode 100644 index 99426fd461..0000000000 --- a/modules/angular2/test/router/route_recognizer_spec.ts +++ /dev/null @@ -1,185 +0,0 @@ -import { - AsyncTestCompleter, - describe, - it, - iit, - ddescribe, - expect, - inject, - beforeEach, - SpyObject -} from 'angular2/testing_internal'; - -import {Map, StringMapWrapper} from 'angular2/src/facade/collection'; - -import {RouteRecognizer} from 'angular2/src/router/route_recognizer'; -import {ComponentInstruction} from 'angular2/src/router/instruction'; - -import {Route, Redirect} from 'angular2/src/router/route_config_decorator'; -import {parser} from 'angular2/src/router/url_parser'; - -export function main() { - describe('RouteRecognizer', () => { - var recognizer; - - beforeEach(() => { recognizer = new RouteRecognizer(); }); - - - it('should recognize a static segment', () => { - recognizer.config(new Route({path: '/test', component: DummyCmpA})); - var solution = recognize(recognizer, '/test'); - expect(getComponentType(solution)).toEqual(DummyCmpA); - }); - - - it('should recognize a single slash', () => { - recognizer.config(new Route({path: '/', component: DummyCmpA})); - var solution = recognize(recognizer, '/'); - expect(getComponentType(solution)).toEqual(DummyCmpA); - }); - - - it('should recognize a dynamic segment', () => { - recognizer.config(new Route({path: '/user/:name', component: DummyCmpA})); - var solution = recognize(recognizer, '/user/brian'); - expect(getComponentType(solution)).toEqual(DummyCmpA); - expect(solution.params).toEqual({'name': 'brian'}); - }); - - - it('should recognize a star segment', () => { - recognizer.config(new Route({path: '/first/*rest', component: DummyCmpA})); - var solution = recognize(recognizer, '/first/second/third'); - expect(getComponentType(solution)).toEqual(DummyCmpA); - expect(solution.params).toEqual({'rest': 'second/third'}); - }); - - - it('should throw when given two routes that start with the same static segment', () => { - recognizer.config(new Route({path: '/hello', component: DummyCmpA})); - expect(() => recognizer.config(new Route({path: '/hello', component: DummyCmpB}))) - .toThrowError('Configuration \'/hello\' conflicts with existing route \'/hello\''); - }); - - - it('should throw when given two routes that have dynamic segments in the same order', () => { - recognizer.config(new Route({path: '/hello/:person/how/:doyoudou', component: DummyCmpA})); - expect(() => recognizer.config( - new Route({path: '/hello/:friend/how/:areyou', component: DummyCmpA}))) - .toThrowError( - 'Configuration \'/hello/:friend/how/:areyou\' conflicts with existing route \'/hello/:person/how/:doyoudou\''); - }); - - - it('should recognize redirects', () => { - recognizer.config(new Route({path: '/b', component: DummyCmpA})); - recognizer.config(new Redirect({path: '/a', redirectTo: 'b'})); - var solution = recognize(recognizer, '/a'); - expect(getComponentType(solution)).toEqual(DummyCmpA); - expect(solution.urlPath).toEqual('b'); - }); - - - it('should not perform root URL redirect on a non-root route', () => { - recognizer.config(new Redirect({path: '/', redirectTo: '/foo'})); - recognizer.config(new Route({path: '/bar', component: DummyCmpA})); - var solution = recognize(recognizer, '/bar'); - expect(solution.componentType).toEqual(DummyCmpA); - expect(solution.urlPath).toEqual('bar'); - }); - - - it('should perform a root URL redirect only for root routes', () => { - recognizer.config(new Redirect({path: '/', redirectTo: '/matias'})); - recognizer.config(new Route({path: '/matias', component: DummyCmpA})); - recognizer.config(new Route({path: '/fatias', component: DummyCmpA})); - - var solution; - - solution = recognize(recognizer, '/'); - expect(solution.urlPath).toEqual('matias'); - - solution = recognize(recognizer, '/fatias'); - expect(solution.urlPath).toEqual('fatias'); - - solution = recognize(recognizer, ''); - expect(solution.urlPath).toEqual('matias'); - }); - - - it('should generate URLs with params', () => { - recognizer.config(new Route({path: '/app/user/:name', component: DummyCmpA, name: 'User'})); - var instruction = recognizer.generate('User', {'name': 'misko'}); - expect(instruction.urlPath).toEqual('app/user/misko'); - }); - - - it('should generate URLs with numeric params', () => { - recognizer.config(new Route({path: '/app/page/:number', component: DummyCmpA, name: 'Page'})); - expect(recognizer.generate('Page', {'number': 42}).urlPath).toEqual('app/page/42'); - }); - - - it('should throw in the absence of required params URLs', () => { - recognizer.config(new Route({path: 'app/user/:name', component: DummyCmpA, name: 'User'})); - expect(() => recognizer.generate('User', {})) - .toThrowError('Route generator for \'name\' was not included in parameters passed.'); - }); - - - it('should throw if the route alias is not CamelCase', () => { - expect(() => recognizer.config( - new Route({path: 'app/user/:name', component: DummyCmpA, name: 'user'}))) - .toThrowError( - `Route "app/user/:name" with name "user" does not begin with an uppercase letter. Route names should be CamelCase like "User".`); - }); - - - describe('params', () => { - it('should recognize parameters within the URL path', () => { - recognizer.config(new Route({path: 'profile/:name', component: DummyCmpA, name: 'User'})); - var solution = recognize(recognizer, '/profile/matsko?comments=all'); - expect(solution.params).toEqual({'name': 'matsko', 'comments': 'all'}); - }); - - - it('should generate and populate the given static-based route with querystring params', - () => { - recognizer.config( - new Route({path: 'forum/featured', component: DummyCmpA, name: 'ForumPage'})); - - var params = {'start': 10, 'end': 100}; - - var result = recognizer.generate('ForumPage', params); - expect(result.urlPath).toEqual('forum/featured'); - expect(result.urlParams).toEqual(['start=10', 'end=100']); - }); - - - it('should prefer positional params over query params', () => { - recognizer.config(new Route({path: 'profile/:name', component: DummyCmpA, name: 'User'})); - - var solution = recognize(recognizer, '/profile/yegor?name=igor'); - expect(solution.params).toEqual({'name': 'yegor'}); - }); - - - it('should ignore matrix params for the top-level component', () => { - recognizer.config(new Route({path: '/home/:subject', component: DummyCmpA, name: 'User'})); - var solution = recognize(recognizer, '/home;sort=asc/zero;one=1?two=2'); - expect(solution.params).toEqual({'subject': 'zero', 'two': '2'}); - }); - }); - }); -} - -function recognize(recognizer: RouteRecognizer, url: string): ComponentInstruction { - return recognizer.recognize(parser.parse(url))[0].instruction; -} - -function getComponentType(routeMatch: ComponentInstruction): any { - return routeMatch.componentType; -} - -class DummyCmpA {} -class DummyCmpB {} diff --git a/modules/angular2/test/router/route_registry_spec.ts b/modules/angular2/test/router/route_registry_spec.ts index 3b906bb749..423a88d264 100644 --- a/modules/angular2/test/router/route_registry_spec.ts +++ b/modules/angular2/test/router/route_registry_spec.ts @@ -11,7 +11,7 @@ import { } from 'angular2/testing_internal'; import {Promise, PromiseWrapper} from 'angular2/src/facade/async'; -import {Type} from 'angular2/src/facade/lang'; +import {Type, IS_DART} from 'angular2/src/facade/lang'; import {RouteRegistry} from 'angular2/src/router/route_registry'; import { @@ -21,20 +21,19 @@ import { AuxRoute, AsyncRoute } from 'angular2/src/router/route_config_decorator'; -import {stringifyInstruction} from 'angular2/src/router/instruction'; -import {IS_DART} from 'angular2/src/facade/lang'; + export function main() { describe('RouteRegistry', () => { var registry; - beforeEach(() => { registry = new RouteRegistry(); }); + beforeEach(() => { registry = new RouteRegistry(RootHostCmp); }); it('should match the full URL', inject([AsyncTestCompleter], (async) => { registry.config(RootHostCmp, new Route({path: '/', component: DummyCmpA})); registry.config(RootHostCmp, new Route({path: '/test', component: DummyCmpB})); - registry.recognize('/test', RootHostCmp) + registry.recognize('/test', []) .then((instruction) => { expect(instruction.component.componentType).toBe(DummyCmpB); async.done(); @@ -45,28 +44,35 @@ export function main() { registry.config(RootHostCmp, new Route({path: '/first/...', component: DummyParentCmp, name: 'FirstCmp'})); - expect(stringifyInstruction(registry.generate(['FirstCmp', 'SecondCmp'], RootHostCmp))) + var instr = registry.generate(['FirstCmp', 'SecondCmp'], []); + expect(stringifyInstruction(instr)).toEqual('first/second'); + + expect(stringifyInstruction(registry.generate(['SecondCmp'], [instr]))) .toEqual('first/second'); - expect(stringifyInstruction(registry.generate(['SecondCmp'], DummyParentCmp))) - .toEqual('second'); - }); - - xit('should generate URLs that account for redirects', () => { - registry.config( - RootHostCmp, - new Route({path: '/first/...', component: DummyParentRedirectCmp, name: 'FirstCmp'})); - - expect(stringifyInstruction(registry.generate(['FirstCmp'], RootHostCmp))) + expect(stringifyInstruction(registry.generate(['./SecondCmp'], [instr]))) .toEqual('first/second'); }); - xit('should generate URLs in a hierarchy of redirects', () => { + it('should generate URLs that account for default routes', () => { registry.config( RootHostCmp, - new Route({path: '/first/...', component: DummyMultipleRedirectCmp, name: 'FirstCmp'})); + new Route({path: '/first/...', component: ParentWithDefaultRouteCmp, name: 'FirstCmp'})); - expect(stringifyInstruction(registry.generate(['FirstCmp'], RootHostCmp))) - .toEqual('first/second/third'); + var instruction = registry.generate(['FirstCmp'], []); + + expect(instruction.toLinkUrl()).toEqual('first'); + expect(instruction.toRootUrl()).toEqual('first/second'); + }); + + it('should generate URLs in a hierarchy of default routes', () => { + registry.config( + RootHostCmp, + new Route({path: '/first/...', component: MultipleDefaultCmp, name: 'FirstCmp'})); + + var instruction = registry.generate(['FirstCmp'], []); + + expect(instruction.toLinkUrl()).toEqual('first'); + expect(instruction.toRootUrl()).toEqual('first/second/third'); }); it('should generate URLs with params', () => { @@ -74,14 +80,14 @@ export function main() { RootHostCmp, new Route({path: '/first/:param/...', component: DummyParentParamCmp, name: 'FirstCmp'})); - var url = stringifyInstruction(registry.generate( - ['FirstCmp', {param: 'one'}, 'SecondCmp', {param: 'two'}], RootHostCmp)); + var url = stringifyInstruction( + registry.generate(['FirstCmp', {param: 'one'}, 'SecondCmp', {param: 'two'}], [])); expect(url).toEqual('first/one/second/two'); }); it('should generate params as an empty StringMap when no params are given', () => { registry.config(RootHostCmp, new Route({path: '/test', component: DummyCmpA, name: 'Test'})); - var instruction = registry.generate(['Test'], RootHostCmp); + var instruction = registry.generate(['Test'], []); expect(instruction.component.params).toEqual({}); }); @@ -91,20 +97,20 @@ export function main() { RootHostCmp, new AsyncRoute({path: '/first/...', loader: asyncParentLoader, name: 'FirstCmp'})); - expect(() => registry.generate(['FirstCmp', 'SecondCmp'], RootHostCmp)) - .toThrowError('Could not find route named "SecondCmp".'); + var instruction = registry.generate(['FirstCmp', 'SecondCmp'], []); - registry.recognize('/first/second', RootHostCmp) + expect(stringifyInstruction(instruction)).toEqual('first'); + + registry.recognize('/first/second', []) .then((_) => { - expect( - stringifyInstruction(registry.generate(['FirstCmp', 'SecondCmp'], RootHostCmp))) - .toEqual('first/second'); + var instruction = registry.generate(['FirstCmp', 'SecondCmp'], []); + expect(stringifyInstruction(instruction)).toEqual('first/second'); async.done(); }); })); it('should throw when generating a url and a parent has no config', () => { - expect(() => registry.generate(['FirstCmp', 'SecondCmp'], RootHostCmp)) + expect(() => registry.generate(['FirstCmp', 'SecondCmp'], [])) .toThrowError('Component "RootHostCmp" has no route config.'); }); @@ -113,7 +119,7 @@ export function main() { new Route({path: '/primary', component: DummyCmpA, name: 'Primary'})); registry.config(RootHostCmp, new AuxRoute({path: '/aux', component: DummyCmpB, name: 'Aux'})); - expect(stringifyInstruction(registry.generate(['Primary', ['Aux']], RootHostCmp))) + expect(stringifyInstruction(registry.generate(['Primary', ['Aux']], []))) .toEqual('primary(aux)'); }); @@ -121,7 +127,7 @@ export function main() { registry.config(RootHostCmp, new Route({path: '/:site', component: DummyCmpB})); registry.config(RootHostCmp, new Route({path: '/home', component: DummyCmpA})); - registry.recognize('/home', RootHostCmp) + registry.recognize('/home', []) .then((instruction) => { expect(instruction.component.componentType).toBe(DummyCmpA); async.done(); @@ -132,7 +138,7 @@ export function main() { registry.config(RootHostCmp, new Route({path: '/:site', component: DummyCmpA})); registry.config(RootHostCmp, new Route({path: '/*site', component: DummyCmpB})); - registry.recognize('/home', RootHostCmp) + registry.recognize('/home', []) .then((instruction) => { expect(instruction.component.componentType).toBe(DummyCmpA); async.done(); @@ -143,7 +149,7 @@ export function main() { registry.config(RootHostCmp, new Route({path: '/:first/*rest', component: DummyCmpA})); registry.config(RootHostCmp, new Route({path: '/*all', component: DummyCmpB})); - registry.recognize('/some/path', RootHostCmp) + registry.recognize('/some/path', []) .then((instruction) => { expect(instruction.component.componentType).toBe(DummyCmpA); async.done(); @@ -154,7 +160,7 @@ export function main() { registry.config(RootHostCmp, new Route({path: '/first/:second', component: DummyCmpA})); registry.config(RootHostCmp, new Route({path: '/:first/:second', component: DummyCmpB})); - registry.recognize('/first/second', RootHostCmp) + registry.recognize('/first/second', []) .then((instruction) => { expect(instruction.component.componentType).toBe(DummyCmpA); async.done(); @@ -168,7 +174,7 @@ export function main() { registry.config(RootHostCmp, new Route({path: '/first/:second/third', component: DummyCmpA})); - registry.recognize('/first/second/third', RootHostCmp) + registry.recognize('/first/second/third', []) .then((instruction) => { expect(instruction.component.componentType).toBe(DummyCmpB); async.done(); @@ -178,7 +184,7 @@ export function main() { it('should match the full URL using child components', inject([AsyncTestCompleter], (async) => { registry.config(RootHostCmp, new Route({path: '/first/...', component: DummyParentCmp})); - registry.recognize('/first/second', RootHostCmp) + registry.recognize('/first/second', []) .then((instruction) => { expect(instruction.component.componentType).toBe(DummyParentCmp); expect(instruction.child.component.componentType).toBe(DummyCmpB); @@ -190,11 +196,14 @@ export function main() { inject([AsyncTestCompleter], (async) => { registry.config(RootHostCmp, new Route({path: '/first/...', component: DummyAsyncCmp})); - registry.recognize('/first/second', RootHostCmp) + registry.recognize('/first/second', []) .then((instruction) => { expect(instruction.component.componentType).toBe(DummyAsyncCmp); - expect(instruction.child.component.componentType).toBe(DummyCmpB); - async.done(); + + instruction.child.resolveComponent().then((childComponentInstruction) => { + expect(childComponentInstruction.componentType).toBe(DummyCmpB); + async.done(); + }); }); })); @@ -203,11 +212,14 @@ export function main() { registry.config(RootHostCmp, new AsyncRoute({path: '/first/...', loader: asyncParentLoader})); - registry.recognize('/first/second', RootHostCmp) + registry.recognize('/first/second', []) .then((instruction) => { expect(instruction.component.componentType).toBe(DummyParentCmp); - expect(instruction.child.component.componentType).toBe(DummyCmpB); - async.done(); + + instruction.child.resolveComponent().then((childType) => { + expect(childType.componentType).toBe(DummyCmpB); + async.done(); + }); }); })); @@ -242,15 +254,15 @@ export function main() { it('should throw when linkParams are not terminal', () => { registry.config(RootHostCmp, new Route({path: '/first/...', component: DummyParentCmp, name: 'First'})); - expect(() => { registry.generate(['First'], RootHostCmp); }) - .toThrowError('Link "["First"]" does not resolve to a terminal or async instruction.'); + expect(() => { registry.generate(['First'], []); }) + .toThrowError('Link "["First"]" does not resolve to a terminal instruction.'); }); it('should match matrix params on child components and query params on the root component', inject([AsyncTestCompleter], (async) => { registry.config(RootHostCmp, new Route({path: '/first/...', component: DummyParentCmp})); - registry.recognize('/first/second;filter=odd?comments=all', RootHostCmp) + registry.recognize('/first/second;filter=odd?comments=all', []) .then((instruction) => { expect(instruction.component.componentType).toBe(DummyParentCmp); expect(instruction.component.params).toEqual({'comments': 'all'}); @@ -276,13 +288,18 @@ export function main() { sort: 'asc', } ], - RootHostCmp)); + [])); expect(url).toEqual('first/one/second/two;sort=asc?query=cats'); }); }); } +function stringifyInstruction(instruction): string { + return instruction.toRootUrl(); +} + + function asyncParentLoader() { return PromiseWrapper.resolve(DummyParentCmp); } @@ -300,26 +317,22 @@ class DummyAsyncCmp { class DummyCmpA {} class DummyCmpB {} -@RouteConfig([ - new Redirect({path: '/', redirectTo: '/third'}), - new Route({path: '/third', component: DummyCmpB, name: 'ThirdCmp'}) -]) -class DummyRedirectCmp { +@RouteConfig( + [new Route({path: '/third', component: DummyCmpB, name: 'ThirdCmp', useAsDefault: true})]) +class DefaultRouteCmp { } @RouteConfig([ - new Redirect({path: '/', redirectTo: '/second'}), - new Route({path: '/second/...', component: DummyRedirectCmp, name: 'SecondCmp'}) + new Route( + {path: '/second/...', component: DefaultRouteCmp, name: 'SecondCmp', useAsDefault: true}) ]) -class DummyMultipleRedirectCmp { +class MultipleDefaultCmp { } -@RouteConfig([ - new Redirect({path: '/', redirectTo: '/second'}), - new Route({path: '/second', component: DummyCmpB, name: 'SecondCmp'}) -]) -class DummyParentRedirectCmp { +@RouteConfig( + [new Route({path: '/second', component: DummyCmpB, name: 'SecondCmp', useAsDefault: true})]) +class ParentWithDefaultRouteCmp { } @RouteConfig([new Route({path: '/second', component: DummyCmpB, name: 'SecondCmp'})]) diff --git a/modules/angular2/test/router/router_link_spec.ts b/modules/angular2/test/router/router_link_spec.ts index 49c90696fc..46af69f37a 100644 --- a/modules/angular2/test/router/router_link_spec.ts +++ b/modules/angular2/test/router/router_link_spec.ts @@ -8,7 +8,7 @@ import { expect, iit, inject, - beforeEachBindings, + beforeEachProviders, it, xit, TestComponentBuilder @@ -27,24 +27,20 @@ import { RouterOutlet, Route, RouteParams, - Instruction, ComponentInstruction } from 'angular2/router'; import {DOM} from 'angular2/src/platform/dom/dom_adapter'; -import {ComponentInstruction_} from 'angular2/src/router/instruction'; -import {PathRecognizer} from 'angular2/src/router/path_recognizer'; -import {SyncRouteHandler} from 'angular2/src/router/sync_route_handler'; +import {ResolvedInstruction} from 'angular2/src/router/instruction'; -let dummyPathRecognizer = new PathRecognizer('', new SyncRouteHandler(null)); let dummyInstruction = - new Instruction(new ComponentInstruction_('detail', [], dummyPathRecognizer), null, {}); + new ResolvedInstruction(new ComponentInstruction('detail', [], null, null, true, 0), null, {}); export function main() { describe('router-link directive', function() { var tcb: TestComponentBuilder; - beforeEachBindings(() => [ + beforeEachProviders(() => [ provide(Location, {useValue: makeDummyLocation()}), provide(Router, {useValue: makeDummyRouter()}) ]); @@ -106,11 +102,6 @@ export function main() { }); } -@Component({selector: 'my-comp'}) -class MyComp { - name; -} - @Component({selector: 'user-cmp'}) @View({template: "hello {{user}}"}) class UserCmp { diff --git a/modules/angular2/test/router/router_spec.ts b/modules/angular2/test/router/router_spec.ts index 1fb2a3fb11..af3190e623 100644 --- a/modules/angular2/test/router/router_spec.ts +++ b/modules/angular2/test/router/router_spec.ts @@ -8,7 +8,7 @@ import { expect, inject, beforeEach, - beforeEachBindings + beforeEachProviders } from 'angular2/testing_internal'; import {SpyRouterOutlet} from './spies'; import {Type} from 'angular2/src/facade/lang'; @@ -18,9 +18,8 @@ import {ListWrapper} from 'angular2/src/facade/collection'; import {Router, RootRouter} from 'angular2/src/router/router'; import {SpyLocation} from 'angular2/src/mock/location_mock'; import {Location} from 'angular2/src/router/location'; -import {stringifyInstruction} from 'angular2/src/router/instruction'; -import {RouteRegistry} from 'angular2/src/router/route_registry'; +import {RouteRegistry, ROUTER_PRIMARY_COMPONENT} from 'angular2/src/router/route_registry'; import {RouteConfig, AsyncRoute, Route} from 'angular2/src/router/route_config_decorator'; import {DirectiveResolver} from 'angular2/src/core/linker/directive_resolver'; @@ -30,16 +29,12 @@ export function main() { describe('Router', () => { var router, location; - beforeEachBindings(() => [ + beforeEachProviders(() => [ RouteRegistry, DirectiveResolver, provide(Location, {useClass: SpyLocation}), - provide(Router, - { - useFactory: - (registry, location) => { return new RootRouter(registry, location, AppCmp); }, - deps: [RouteRegistry, Location] - }) + provide(ROUTER_PRIMARY_COMPONENT, {useValue: AppCmp}), + provide(Router, {useClass: RootRouter}) ]); @@ -225,6 +220,11 @@ export function main() { }); } + +function stringifyInstruction(instruction): string { + return instruction.toRootUrl(); +} + function loader(): Promise { return PromiseWrapper.resolve(DummyComponent); } diff --git a/tools/broccoli/trees/node_tree.ts b/tools/broccoli/trees/node_tree.ts index da6e6647eb..29d911ddf2 100644 --- a/tools/broccoli/trees/node_tree.ts +++ b/tools/broccoli/trees/node_tree.ts @@ -30,7 +30,7 @@ module.exports = function makeNodeTree(projects, destinationPath) { // we call browser's bootstrap 'angular2/test/router/route_config_spec.ts', - 'angular2/test/router/integration/router_integration_spec.ts', + 'angular2/test/router/integration/bootstrap_spec.ts', // we check the public api by importing angular2/angular2 'angular2/test/symbol_inspector/**/*.ts',