ui-router
Version:
State-based routing for Javascript
466 lines (412 loc) • 16.6 kB
text/typescript
/** @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} )`;
}
}