import {PromiseWrapper, EventEmitter, ObservableWrapper} from '../src/facade/async'; import {Map, StringMapWrapper} from '../src/facade/collection'; import {isBlank, isPresent, Type} from '../src/facade/lang'; import {BaseException} from '../src/facade/exceptions'; import {Location} from '@angular/common'; import {RouteRegistry, ROUTER_PRIMARY_COMPONENT} from './route_registry'; import {ComponentInstruction, Instruction} from './instruction'; import {RouterOutlet} from './directives/router_outlet'; import {getCanActivateHook} from './lifecycle/route_lifecycle_reflector'; import {RouteDefinition} from './route_config/route_config_impl'; import {Injectable, Inject} from '@angular/core'; import {DefaultInstruction} from "./instruction"; let _resolveToTrue = PromiseWrapper.resolve(true); let _resolveToFalse = PromiseWrapper.resolve(false); /** * The `Router` is responsible for mapping URLs to components. * * You can see the state of the router by inspecting the read-only field `router.navigating`. * This may be useful for showing a spinner, for instance. * * ## Concepts * * Routers and component instances have a 1:1 correspondence. * * The router holds reference to a number of {@link RouterOutlet}. * An outlet is a placeholder that the router dynamically fills in depending on the current URL. * * When the router navigates from a URL, it must first recognize it and serialize it into an * `Instruction`. * The router uses the `RouteRegistry` to get an `Instruction`. */ @Injectable() export class Router { navigating: boolean = false; lastNavigationAttempt: string; /** * The current `Instruction` for the router */ public currentInstruction: Instruction = null; private _currentNavigation: Promise = _resolveToTrue; private _outlet: RouterOutlet = null; private _auxRouters = new Map(); private _childRouter: Router; private _subject: EventEmitter = new EventEmitter(); constructor(public registry: RouteRegistry, public parent: Router, public hostComponent: any, public root?: Router) {} /** * Constructs a child router. You probably don't need to use this unless you're writing a reusable * component. */ childRouter(hostComponent: any): Router { return this._childRouter = new ChildRouter(this, hostComponent); } /** * Constructs a child router. You probably don't need to use this unless you're writing a reusable * component. */ auxRouter(hostComponent: any): Router { return new ChildRouter(this, hostComponent); } /** * Register an outlet to be notified of primary route changes. * * You probably don't need to use this unless you're writing a reusable component. */ registerPrimaryOutlet(outlet: RouterOutlet): Promise { if (isPresent(outlet.name)) { throw new BaseException(`registerPrimaryOutlet expects to be called with an unnamed outlet.`); } if (isPresent(this._outlet)) { throw new BaseException(`Primary outlet is already registered.`); } this._outlet = outlet; if (isPresent(this.currentInstruction)) { return this.commit(this.currentInstruction, false); } return _resolveToTrue; } /** * Unregister an outlet (because it was destroyed, etc). * * You probably don't need to use this unless you're writing a custom outlet implementation. */ unregisterPrimaryOutlet(outlet: RouterOutlet): void { if (isPresent(outlet.name)) { throw new BaseException(`registerPrimaryOutlet expects to be called with an unnamed outlet.`); } this._outlet = null; } /** * Register an outlet to notified of auxiliary route changes. * * You probably don't need to use this unless you're writing a reusable component. */ registerAuxOutlet(outlet: RouterOutlet): Promise { var outletName = outlet.name; if (isBlank(outletName)) { throw new BaseException(`registerAuxOutlet expects to be called with an outlet with a name.`); } var router = this.auxRouter(this.hostComponent); this._auxRouters.set(outletName, router); router._outlet = outlet; var auxInstruction; if (isPresent(this.currentInstruction) && isPresent(auxInstruction = this.currentInstruction.auxInstruction[outletName])) { return router.commit(auxInstruction); } return _resolveToTrue; } /** * Given an instruction, returns `true` if the instruction is currently active, * otherwise `false`. */ isRouteActive(instruction: Instruction): boolean { var router: Router = this; var currentInstruction = this.currentInstruction; if (isBlank(currentInstruction)) { return false; } // `instruction` corresponds to the root router while (isPresent(router.parent) && isPresent(instruction.child)) { router = router.parent; instruction = instruction.child; } let reason = true; // check the instructions in depth do { if (isBlank(instruction.component) || isBlank(currentInstruction.component) || currentInstruction.component.routeName != instruction.component.routeName) { return false; } if (isPresent(instruction.component.params)) { StringMapWrapper.forEach(instruction.component.params, (value, key) => { if (currentInstruction.component.params[key] !== value) { reason = false; } }); } currentInstruction = currentInstruction.child; instruction = instruction.child; } while (isPresent(currentInstruction) && isPresent(instruction) && !(instruction instanceof DefaultInstruction) && reason); // ignore DefaultInstruction return reason && (isBlank(instruction) || instruction instanceof DefaultInstruction); } /** * Dynamically update the routing configuration and trigger a navigation. * * ### Usage * * ``` * router.config([ * { 'path': '/', 'component': IndexComp }, * { 'path': '/user/:id', 'component': UserComp }, * ]); * ``` */ config(definitions: RouteDefinition[]): Promise { definitions.forEach( (routeDefinition) => { this.registry.config(this.hostComponent, routeDefinition); }); return this.renavigate(); } /** * Navigate based on the provided Route Link DSL. It's preferred to navigate with this method * over `navigateByUrl`. * * ### Usage * * This method takes an array representing the Route Link DSL: * ``` * ['./MyCmp', {param: 3}] * ``` * See the {@link RouterLink} directive for more. */ navigate(linkParams: any[]): Promise { var instruction = this.generate(linkParams); return this.navigateByInstruction(instruction, false); } /** * Navigate to a URL. Returns a promise that resolves when navigation is complete. * It's preferred to navigate with `navigate` instead of this method, since URLs are more brittle. * * If the given URL begins with a `/`, router will navigate absolutely. * If the given URL does not begin with `/`, the router will navigate relative to this component. */ navigateByUrl(url: string, _skipLocationChange: boolean = false): Promise { return this._currentNavigation = this._currentNavigation.then((_) => { this.lastNavigationAttempt = url; this._startNavigating(); return this._afterPromiseFinishNavigating(this.recognize(url).then((instruction) => { if (isBlank(instruction)) { return false; } return this._navigate(instruction, _skipLocationChange); })); }); } /** * Navigate via the provided instruction. Returns a promise that resolves when navigation is * complete. */ navigateByInstruction(instruction: Instruction, _skipLocationChange: boolean = false): Promise { if (isBlank(instruction)) { return _resolveToFalse; } return this._currentNavigation = this._currentNavigation.then((_) => { this._startNavigating(); return this._afterPromiseFinishNavigating(this._navigate(instruction, _skipLocationChange)); }); } /** @internal */ _settleInstruction(instruction: Instruction): Promise { return instruction.resolveComponent().then((_) => { var unsettledInstructions: Array> = []; if (isPresent(instruction.component)) { instruction.component.reuse = false; } if (isPresent(instruction.child)) { unsettledInstructions.push(this._settleInstruction(instruction.child)); } StringMapWrapper.forEach(instruction.auxInstruction, (instruction: Instruction, _) => { unsettledInstructions.push(this._settleInstruction(instruction)); }); return PromiseWrapper.all(unsettledInstructions); }); } /** @internal */ _navigate(instruction: Instruction, _skipLocationChange: boolean): Promise { return this._settleInstruction(instruction) .then((_) => this._routerCanReuse(instruction)) .then((_) => this._canActivate(instruction)) .then((result: boolean) => { if (!result) { return false; } return this._routerCanDeactivate(instruction) .then((result: boolean) => { if (result) { return this.commit(instruction, _skipLocationChange) .then((_) => { this._emitNavigationFinish(instruction.toRootUrl()); return true; }); } }); }); } private _emitNavigationFinish(url): void { ObservableWrapper.callEmit(this._subject, url); } /** @internal */ _emitNavigationFail(url): void { ObservableWrapper.callError(this._subject, url); } private _afterPromiseFinishNavigating(promise: Promise): Promise { return PromiseWrapper.catchError(promise.then((_) => this._finishNavigating()), (err) => { this._finishNavigating(); throw err; }); } /* * Recursively set reuse flags */ /** @internal */ _routerCanReuse(instruction: Instruction): Promise { if (isBlank(this._outlet)) { return _resolveToFalse; } if (isBlank(instruction.component)) { return _resolveToTrue; } return this._outlet.routerCanReuse(instruction.component) .then((result) => { instruction.component.reuse = result; if (result && isPresent(this._childRouter) && isPresent(instruction.child)) { return this._childRouter._routerCanReuse(instruction.child); } }); } private _canActivate(nextInstruction: Instruction): Promise { return canActivateOne(nextInstruction, this.currentInstruction); } private _routerCanDeactivate(instruction: Instruction): Promise { if (isBlank(this._outlet)) { return _resolveToTrue; } var next: Promise; var childInstruction: Instruction = null; var reuse: boolean = false; var componentInstruction: ComponentInstruction = null; if (isPresent(instruction)) { childInstruction = instruction.child; componentInstruction = instruction.component; reuse = isBlank(instruction.component) || instruction.component.reuse; } if (reuse) { next = _resolveToTrue; } else { next = this._outlet.routerCanDeactivate(componentInstruction); } // TODO: aux route lifecycle hooks return next.then((result): boolean | Promise => { if (result == false) { return false; } if (isPresent(this._childRouter)) { // TODO: ideally, this closure would map to async-await in Dart. // For now, casting to any to suppress an error. return this._childRouter._routerCanDeactivate(childInstruction); } return true; }); } /** * Updates this router and all descendant routers according to the given instruction */ commit(instruction: Instruction, _skipLocationChange: boolean = false): Promise { this.currentInstruction = instruction; var next: Promise = _resolveToTrue; if (isPresent(this._outlet) && isPresent(instruction.component)) { var componentInstruction = instruction.component; if (componentInstruction.reuse) { next = this._outlet.reuse(componentInstruction); } else { next = this.deactivate(instruction).then((_) => this._outlet.activate(componentInstruction)); } if (isPresent(instruction.child)) { next = next.then((_) => { if (isPresent(this._childRouter)) { return this._childRouter.commit(instruction.child); } }); } } var promises: Promise[] = []; this._auxRouters.forEach((router, name) => { if (isPresent(instruction.auxInstruction[name])) { promises.push(router.commit(instruction.auxInstruction[name])); } }); return next.then((_) => PromiseWrapper.all(promises)); } /** @internal */ _startNavigating(): void { this.navigating = true; } /** @internal */ _finishNavigating(): void { this.navigating = false; } /** * Subscribe to URL updates from the router */ subscribe(onNext: (value: any) => void, onError?: (value: any) => void): Object { return ObservableWrapper.subscribe(this._subject, onNext, onError); } /** * Removes the contents of this router's outlet and all descendant outlets */ deactivate(instruction: Instruction): Promise { var childInstruction: Instruction = null; var componentInstruction: ComponentInstruction = null; if (isPresent(instruction)) { childInstruction = instruction.child; componentInstruction = instruction.component; } var next: Promise = _resolveToTrue; if (isPresent(this._childRouter)) { next = this._childRouter.deactivate(childInstruction); } if (isPresent(this._outlet)) { next = next.then((_) => this._outlet.deactivate(componentInstruction)); } // TODO: handle aux routes return next; } /** * Given a URL, returns an instruction representing the component graph */ recognize(url: string): Promise { var ancestorComponents = this._getAncestorInstructions(); return this.registry.recognize(url, ancestorComponents); } private _getAncestorInstructions(): Instruction[] { var ancestorInstructions: Instruction[] = [this.currentInstruction]; var ancestorRouter: Router = this; while (isPresent(ancestorRouter = ancestorRouter.parent)) { ancestorInstructions.unshift(ancestorRouter.currentInstruction); } return ancestorInstructions; } /** * Navigates to either the last URL successfully navigated to, or the last URL requested if the * router has yet to successfully navigate. */ renavigate(): Promise { if (isBlank(this.lastNavigationAttempt)) { return this._currentNavigation; } return this.navigateByUrl(this.lastNavigationAttempt); } /** * Generate an `Instruction` based on the provided Route Link DSL. */ generate(linkParams: any[]): Instruction { 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, @Inject(ROUTER_PRIMARY_COMPONENT) primaryComponent: Type) { super(registry, null, primaryComponent); this.root = this; this._location = location; this._locationSub = this._location.subscribe((change) => { // we call recognize ourselves this.recognize(change['url']) .then((instruction) => { if (isPresent(instruction)) { this.navigateByInstruction(instruction, isPresent(change['pop'])) .then((_) => { // this is a popstate event; no need to change the URL if (isPresent(change['pop']) && change['type'] != 'hashchange') { return; } var emitPath = instruction.toUrlPath(); var emitQuery = instruction.toUrlQuery(); if (emitPath.length > 0 && emitPath[0] != '/') { emitPath = '/' + emitPath; } // We've opted to use pushstate and popState APIs regardless of whether you // an app uses HashLocationStrategy or PathLocationStrategy. // However, apps that are migrating might have hash links that operate outside // angular to which routing must respond. // Therefore we know that all hashchange events occur outside Angular. // To support these cases where we respond to hashchanges and redirect as a // result, we need to replace the top item on the stack. if (change['type'] == 'hashchange') { if (instruction.toRootUrl() != this._location.path()) { this._location.replaceState(emitPath, emitQuery); } } else { this._location.go(emitPath, emitQuery); } }); } else { this._emitNavigationFail(change['url']); } }); }); this.registry.configFromComponent(primaryComponent); this.navigateByUrl(location.path()); } commit(instruction: Instruction, _skipLocationChange: boolean = false): Promise { var emitPath = instruction.toUrlPath(); var emitQuery = instruction.toUrlQuery(); if (emitPath.length > 0 && emitPath[0] != '/') { emitPath = '/' + emitPath; } var promise = super.commit(instruction); if (!_skipLocationChange) { if (this._location.isCurrentPathEqualTo(emitPath, emitQuery)) { promise = promise.then((_) => { this._location.replaceState(emitPath, emitQuery); }); } else { promise = promise.then((_) => { this._location.go(emitPath, emitQuery); }); } } return promise; } dispose(): void { if (isPresent(this._locationSub)) { ObservableWrapper.dispose(this._locationSub); this._locationSub = null; } } } class ChildRouter extends Router { constructor(parent: Router, hostComponent) { super(parent.registry, parent, hostComponent, parent.root); this.parent = parent; } navigateByUrl(url: string, _skipLocationChange: boolean = false): Promise { // Delegate navigation to the root router return this.parent.navigateByUrl(url, _skipLocationChange); } navigateByInstruction(instruction: Instruction, _skipLocationChange: boolean = false): Promise { // Delegate navigation to the root router return this.parent.navigateByInstruction(instruction, _skipLocationChange); } } function canActivateOne(nextInstruction: Instruction, prevInstruction: Instruction): Promise { var next = _resolveToTrue; if (isBlank(nextInstruction.component)) { return next; } if (isPresent(nextInstruction.child)) { next = canActivateOne(nextInstruction.child, isPresent(prevInstruction) ? prevInstruction.child : null); } return next.then((result: boolean): boolean => { if (result == false) { return false; } if (nextInstruction.component.reuse) { return true; } var hook = getCanActivateHook(nextInstruction.component.componentType); if (isPresent(hook)) { return hook(nextInstruction.component, isPresent(prevInstruction) ? prevInstruction.component : null); } return true; }); }