UNPKG

ui-router

Version:

State-based routing for Javascript

466 lines (412 loc) 16.6 kB
/** @module transition */ /** for typedoc */ import {trace} from "../common/trace"; import {services} from "../common/coreservices"; import { map, find, extend, filter, mergeR, unnest, tail, omit, toJson, abstractKey, arrayTuples, allTrueR, unnestR, identity, anyTrueR } from "../common/common"; import { isObject } from "../common/predicates"; import { not, prop, propEq, val } from "../common/hof"; import {StateDeclaration, StateOrName} from "../state/interface"; import {TransitionOptions, TransitionHookOptions, TreeChanges, IHookRegistry, IHookRegistration, IHookGetter} from "./interface"; import {TransitionHook, HookRegistry, matchState, HookBuilder, RejectFactory} from "./module"; import {Node} from "../path/node"; import {PathFactory} from "../path/pathFactory"; import {State, TargetState} from "../state/module"; import {Param} from "../params/module"; import {Resolvable} from "../resolve/module"; import {TransitionService} from "./transitionService"; import {ViewConfig} from "../view/interface"; let transitionCount = 0, REJECT = new RejectFactory(); const stateSelf: (_state: State) => StateDeclaration = prop("self"); /** * The representation of a transition between two states. * * Contains all contextual information about the to/from states, parameters, resolves, as well as the * list of states being entered and exited as a result of this transition. */ export class Transition implements IHookRegistry { $id: number; success: boolean; private _deferred = services.$q.defer(); /** * This promise is resolved or rejected based on the outcome of the Transition. * * When the transition is successful, the promise is resolved * When the transition is unsuccessful, the promise is rejected with the [[TransitionRejection]] or javascript error */ promise: Promise<any> = this._deferred.promise; private _options: TransitionOptions; private _treeChanges: TreeChanges; /** * Registers a callback function as an `onBefore` Transition Hook * * The hook is only registered for this specific `Transition`. For global hooks, use [[TransitionService.onBefore]] * * See [[IHookRegistry.onBefore]] */ onBefore: IHookRegistration; /** * Registers a callback function as an `onStart` Transition Hook * * The hook is only registered for this specific `Transition`. For global hooks, use [[TransitionService.onStart]] * * See [[IHookRegistry.onStart]] */ onStart: IHookRegistration; /** * Registers a callback function as an `onEnter` State Hook * * The hook is only registered for this specific `Transition`. For global hooks, use [[TransitionService.onEnter]] * * See [[IHookRegistry.onEnter]] */ onEnter: IHookRegistration; /** * Registers a callback function as an `onRetain` State Hook * * The hook is only registered for this specific `Transition`. For global hooks, use [[TransitionService.onRetain]] * * See [[IHookRegistry.onRetain]] */ onRetain: IHookRegistration; /** * Registers a callback function as an `onExit` State Hook * * The hook is only registered for this specific `Transition`. For global hooks, use [[TransitionService.onExit]] * * See [[IHookRegistry.onExit]] */ onExit: IHookRegistration; /** * Registers a callback function as an `onFinish` Transition Hook * * The hook is only registered for this specific `Transition`. For global hooks, use [[TransitionService.onFinish]] * * See [[IHookRegistry.onFinish]] */ onFinish: IHookRegistration; /** * Registers a callback function as an `onSuccess` Transition Hook * * The hook is only registered for this specific `Transition`. For global hooks, use [[TransitionService.onSuccess]] * * See [[IHookRegistry.onSuccess]] */ onSuccess: IHookRegistration; /** * Registers a callback function as an `onError` Transition Hook * * The hook is only registered for this specific `Transition`. For global hooks, use [[TransitionService.onError]] * * See [[IHookRegistry.onError]] */ onError: IHookRegistration; getHooks: IHookGetter; /** * Creates a new Transition object. * * If the target state is not valid, an error is thrown. * * @param fromPath The path of [[Node]]s from which the transition is leaving. The last node in the `fromPath` * encapsulates the "from state". * @param targetState The target state and parameters being transitioned to (also, the transition options) * @param _transitionService The Transition Service instance */ constructor(fromPath: Node[], targetState: TargetState, private _transitionService: TransitionService) { if (!targetState.valid()) { throw new Error(targetState.error()); } // Makes the Transition instance a hook registry (onStart, etc) HookRegistry.mixin(new HookRegistry(), this); // current() is assumed to come from targetState.options, but provide a naive implementation otherwise. this._options = extend({ current: val(this) }, targetState.options()); this.$id = transitionCount++; let toPath = PathFactory.buildToPath(fromPath, targetState); toPath = PathFactory.applyViewConfigs(_transitionService.$view, toPath); this._treeChanges = PathFactory.treeChanges(fromPath, toPath, this._options.reloadState); PathFactory.bindTransitionResolve(this._treeChanges, this); } $from() { return tail(this._treeChanges.from).state; } $to() { return tail(this._treeChanges.to).state; } /** * Returns the "from state" * * @returns The state object for the Transition's "from state". */ from(): StateDeclaration { return this.$from().self; } /** * Returns the "to state" * * @returns The state object for the Transition's target state ("to state"). */ to() { return this.$to().self; } /** * Determines whether two transitions are equivalent. */ is(compare: (Transition|{to: any, from: any})): boolean { if (compare instanceof Transition) { // TODO: Also compare parameters return this.is({ to: compare.$to().name, from: compare.$from().name }); } return !( (compare.to && !matchState(this.$to(), compare.to)) || (compare.from && !matchState(this.$from(), compare.from)) ); } /** * Gets transition parameter values * * @param pathname Pick which treeChanges path to get parameters for: * (`'to'`, `'from'`, `'entering'`, `'exiting'`, `'retained'`) * @returns transition parameter values for the desired path. */ params(pathname: string = "to"): { [key: string]: any } { return this._treeChanges[pathname].map(prop("paramValues")).reduce(mergeR, {}); } /** * Get resolved data * * @returns an object (key/value pairs) where keys are resolve names and values are any settled resolve data, * or `undefined` for pending resolve data */ resolves(): { [resolveName: string]: any } { return map(tail(this._treeChanges.to).resolveContext.getResolvables(), res => res.data); } /** * Adds new resolves to this transition. * * @param resolves an [[ResolveDeclarations]] object which describes the new resolves * @param state the state in the "to path" which should receive the new resolves (otherwise, the root state) */ addResolves(resolves: { [key: string]: Function }, state: StateOrName = ""): void { let stateName: string = (typeof state === "string") ? state : state.name; let topath = this._treeChanges.to; let targetNode = find(topath, node => node.state.name === stateName); tail(topath).resolveContext.addResolvables(Resolvable.makeResolvables(resolves), targetNode.state); } /** * Gets the previous transition, from which this transition was redirected. * * @returns The previous Transition, or null if this Transition is not the result of a redirection */ previous(): Transition { return this._options.previous || null; } /** * Get the transition options * * @returns the options for this Transition. */ options(): TransitionOptions { return this._options; } /** * Gets the states being entered. * * @returns an array of states that will be entered during this transition. */ entering(): StateDeclaration[] { return map(this._treeChanges.entering, prop('state')).map(stateSelf); } /** * Gets the states being exited. * * @returns an array of states that will be exited during this transition. */ exiting(): StateDeclaration[] { return map(this._treeChanges.exiting, prop('state')).map(stateSelf).reverse(); } /** * Gets the states being retained. * * @returns an array of states that are already entered from a previous Transition, that will not be * exited during this Transition */ retained(): StateDeclaration[] { return map(this._treeChanges.retained, prop('state')).map(stateSelf); } /** * Get the [[ViewConfig]]s associated with this Transition * * Each state can define one or more views (template/controller), which are encapsulated as `ViewConfig` objects. * This method fetches the `ViewConfigs` for a given path in the Transition (e.g., "to" or "entering"). * * @param pathname the name of the path to fetch views for: * (`'to'`, `'from'`, `'entering'`, `'exiting'`, `'retained'`) * @param state If provided, only returns the `ViewConfig`s for a single state in the path * * @returns a list of ViewConfig objects for the given path. */ views(pathname: string = "entering", state?: State): ViewConfig[] { let path = this._treeChanges[pathname]; path = !state ? path : path.filter(propEq('state', state)); return path.map(prop("views")).filter(identity).reduce(unnestR, []); } treeChanges = () => this._treeChanges; /** * @ngdoc function * @name ui.router.state.type:Transition#redirect * @methodOf ui.router.state.type:Transition * * @description * Creates a new transition that is a redirection of the current one. This transition can * be returned from a `$transitionsProvider` hook, `$state` event, or other method, to * redirect a transition to a new state and/or set of parameters. * * @returns {Transition} Returns a new `Transition` instance. */ redirect(targetState: TargetState): Transition { let newOptions = extend({}, this.options(), targetState.options(), { previous: this }); targetState = new TargetState(targetState.identifier(), targetState.$state(), targetState.params(), newOptions); let redirectTo = new Transition(this._treeChanges.from, targetState, this._transitionService); let reloadState = targetState.options().reloadState; // If the current transition has already resolved any resolvables which are also in the redirected "to path", then // add those resolvables to the redirected transition. Allows you to define a resolve at a parent level, wait for // the resolve, then redirect to a child state based on the result, and not have to re-fetch the resolve. let redirectedPath = this.treeChanges().to; let copyResolvesFor: Node[] = Node.matching(redirectTo.treeChanges().to, redirectedPath) .filter(node => !reloadState || !reloadState.includes[node.state.name]); const includeResolve = (resolve, key) => ['$stateParams', '$transition$'].indexOf(key) === -1; copyResolvesFor.forEach((node, idx) => extend(node.resolves, filter(redirectedPath[idx].resolves, includeResolve))); return redirectTo; } /** @hidden If a transition doesn't exit/enter any states, returns any [[Param]] whose value changed */ private _changedParams(): Param[] { let {to, from} = this._treeChanges; if (this._options.reload || tail(to).state !== tail(from).state) return undefined; let nodeSchemas: Param[][] = to.map((node: Node) => node.paramSchema); let [toValues, fromValues] = [to, from].map(path => path.map(x => x.paramValues)); let tuples = arrayTuples(nodeSchemas, toValues, fromValues); return tuples.map(([schema, toVals, fromVals]) => Param.changed(schema, toVals, fromVals)).reduce(unnestR, []); } /** * Returns true if the transition is dynamic. * * A transition is dynamic if no states are entered nor exited, but at least one dynamic parameter has changed. * * @returns true if the Transition is dynamic */ dynamic(): boolean { let changes = this._changedParams(); return !changes ? false : changes.map(x => x.dynamic).reduce(anyTrueR, false); } /** * Returns true if the transition is ignored. * * A transition is ignored if no states are entered nor exited, and no parameter values have changed. * * @returns true if the Transition is ignored. */ ignored(): boolean { let changes = this._changedParams(); return !changes ? false : changes.length === 0; } /** * @hidden */ hookBuilder(): HookBuilder { return new HookBuilder(this._transitionService, this, <TransitionHookOptions> { transition: this, current: this._options.current }); } /** * Runs the transition * * This method is generally called from the [[StateService.transitionTo]] * * @returns a promise for a successful transition. */ run (): Promise<any> { let hookBuilder = this.hookBuilder(); let runSynchronousHooks = TransitionHook.runSynchronousHooks; // TODO: nuke these in favor of chaining off the promise, i.e., // $transitions.onBefore({}, $transition$ => {$transition$.promise.then()} const runSuccessHooks = () => runSynchronousHooks(hookBuilder.getOnSuccessHooks(), {}, true); const runErrorHooks = ($error$) => runSynchronousHooks(hookBuilder.getOnErrorHooks(), { $error$ }, true); // Run the success/error hooks *after* the Transition promise is settled. this.promise.then(runSuccessHooks, runErrorHooks); let syncResult = runSynchronousHooks(hookBuilder.getOnBeforeHooks()); if (TransitionHook.isRejection(syncResult)) { let rejectReason = (<any> syncResult).reason; this._deferred.reject(rejectReason); return this.promise; } if (!this.valid()) { let error = new Error(this.error()); this._deferred.reject(error); return this.promise; } if (this.ignored()) { trace.traceTransitionIgnored(this); let ignored = REJECT.ignored(); this._deferred.reject(ignored.reason); return this.promise; } // When the chain is complete, then resolve or reject the deferred const resolve = () => { this.success = true; this._deferred.resolve(this); trace.traceSuccess(this.$to(), this); }; const reject = (error) => { this.success = false; this._deferred.reject(error); trace.traceError(error, this); return services.$q.reject(error); }; trace.traceTransitionStart(this); let chain = hookBuilder.asyncHooks().reduce((_chain, step) => _chain.then(step.invokeStep), syncResult); chain.then(resolve, reject); return this.promise; } isActive = () => this === this._options.current(); /** * Checks if the Transition is valid * * @returns true if the Transition is valid */ valid() { return !this.error(); } /** * The reason the Transition is invalid * * @returns an error message explaining why the transition is invalid */ error() { let state = this.$to(); if (state.self[abstractKey]) return `Cannot transition to abstract state '${state.name}'`; if (!Param.validates(state.parameters(), this.params())) return `Param values not valid for state '${state.name}'`; } /** * A string representation of the Transition * * @returns A string representation of the Transition */ toString () { let fromStateOrName = this.from(); let toStateOrName = this.to(); const avoidEmptyHash = (params) => (params["#"] !== null && params["#"] !== undefined) ? params : omit(params, "#"); // (X) means the to state is invalid. let id = this.$id, from = isObject(fromStateOrName) ? fromStateOrName.name : fromStateOrName, fromParams = toJson(avoidEmptyHash(this._treeChanges.from.map(prop('paramValues')).reduce(mergeR, {}))), toValid = this.valid() ? "" : "(X) ", to = isObject(toStateOrName) ? toStateOrName.name : toStateOrName, toParams = toJson(avoidEmptyHash(this.params())); return `Transition#${id}( '${from}'${fromParams} -> ${toValid}'${to}'${toParams} )`; } }