ui-router
Version:
State-based routing for Javascript
478 lines (440 loc) • 21.2 kB
text/typescript
/** @module state */ /** */
import {extend, defaults } from "../common/common";
import {isDefined, isObject, isString} from "../common/predicates";
import {Queue} from "../common/queue";
import {services} from "../common/coreservices";
import {PathFactory} from "../path/pathFactory";
import {Node} from "../path/node";
import {ViewService} from "../view/view";
import {StateParams} from "../params/stateParams";
import {UrlRouter} from "../url/urlRouter";
import {TransitionOptions} from "../transition/interface";
import {TransitionService, defaultTransOpts} from "../transition/transitionService";
import {RejectFactory} from "../transition/rejectFactory";
import {Transition} from "../transition/transition";
import {StateOrName, StateDeclaration} from "./interface";
import {StateRegistry} from "./stateRegistry";
import {State} from "./stateObject";
import {TargetState} from "./targetState";
import {RawParams} from "../params/interface";
import {ParamsOrArray} from "../params/interface";
import {TransitionManager} from "./hooks/transitionManager";
import {Param} from "../params/param";
import {Glob} from "../common/glob";
import {equalForKeys} from "../common/common";
import {HrefOptions} from "./interface";
import {StateProvider} from "./state";
import {bindFunctions} from "../common/common";
import {UIRouterGlobals} from "../globals";
export class StateService {
get transition() { return this.globals.transition; }
get params() { return this.globals.params; }
get current() { return this.globals.current; }
get $current() { return this.globals.$current; }
private rejectFactory = new RejectFactory();
constructor(private $view: ViewService,
private $urlRouter: UrlRouter,
private $transitions: TransitionService,
private stateRegistry: StateRegistry,
private stateProvider: StateProvider,
private globals: UIRouterGlobals) {
let getters = ['current', '$current', 'params', 'transition'];
let boundFns = Object.keys(StateService.prototype).filter(key => getters.indexOf(key) === -1);
bindFunctions(StateService.prototype, this, this, boundFns);
}
/**
* Invokes the onInvalid callbacks, in natural order. Each callback's return value is checked in sequence
* until one of them returns an instance of TargetState. The results of the callbacks are wrapped
* in $q.when(), so the callbacks may return promises.
*
* If a callback returns an TargetState, then it is used as arguments to $state.transitionTo() and
* the result returned.
*/
private _handleInvalidTargetState(fromPath: Node[], $to$: TargetState) {
const latestThing = () => this.globals.transitionHistory.peekTail();
let latest = latestThing();
let $from$ = PathFactory.makeTargetState(fromPath);
let callbackQueue = new Queue<Function>([].concat(this.stateProvider.invalidCallbacks));
let rejectFactory = this.rejectFactory;
let {$q, $injector} = services;
const invokeCallback = (callback: Function) => $q.when($injector.invoke(callback, null, { $to$, $from$ }));
const checkForRedirect = (result) => {
if (!(result instanceof TargetState)) {
return;
}
let target = <TargetState> result;
// Recreate the TargetState, in case the state is now defined.
target = this.target(target.identifier(), target.params(), target.options());
if (!target.valid()) return rejectFactory.invalid(target.error());
if (latestThing() !== latest) return rejectFactory.superseded();
return this.transitionTo(target.identifier(), target.params(), target.options());
};
function invokeNextCallback() {
let nextCallback = callbackQueue.dequeue();
if (nextCallback === undefined) return rejectFactory.invalid($to$.error());
return invokeCallback(nextCallback).then(checkForRedirect).then(result => result || invokeNextCallback());
}
return invokeNextCallback();
}
/**
* @ngdoc function
* @name ui.router.state.$state#reload
* @methodOf ui.router.state.$state
*
* @description
* A method that force reloads the current state, or a partial state hierarchy. All resolves are re-resolved,
* controllers reinstantiated, and events re-fired.
*
* @example
* <pre>
* let app angular.module('app', ['ui.router']);
*
* app.controller('ctrl', function ($scope, $state) {
* $scope.reload = function(){
* $state.reload();
* }
* });
* </pre>
*
* `reload()` is just an alias for:
* <pre>
* $state.transitionTo($state.current, $stateParams, {
* reload: true, inherit: false, notify: true
* });
* </pre>
*
* @param {string=|object=} reloadState - A state name or a state object, which is the root of the resolves to be re-resolved.
* @example
* <pre>
* //assuming app application consists of 3 states: 'contacts', 'contacts.detail', 'contacts.detail.item'
* //and current state is 'contacts.detail.item'
* let app angular.module('app', ['ui.router']);
*
* app.controller('ctrl', function ($scope, $state) {
* $scope.reload = function(){
* //will reload 'contact.detail' and nested 'contact.detail.item' states
* $state.reload('contact.detail');
* }
* });
* </pre>
*
* @returns {promise} A promise representing the state of the new transition. See
* {@link ui.router.state.$state#methods_go $state.go}.
*/
reload(reloadState: StateOrName): Promise<State> {
return this.transitionTo(this.current, this.params, {
reload: isDefined(reloadState) ? reloadState : true,
inherit: false,
notify: false
});
};
/**
* @ngdoc function
* @name ui.router.state.$state#go
* @methodOf ui.router.state.$state
*
* @description
* Convenience method for transitioning to a new state. `$state.go` calls
* `$state.transitionTo` internally but automatically sets options to
* `{ location: true, inherit: true, relative: $state.$current, notify: true }`.
* This allows you to easily use an absolute or relative to path and specify
* only the parameters you'd like to update (while letting unspecified parameters
* inherit from the currently active ancestor states).
*
* @example
* <pre>
* let app = angular.module('app', ['ui.router']);
*
* app.controller('ctrl', function ($scope, $state) {
* $scope.changeState = function () {
* $state.go('contact.detail');
* };
* });
* </pre>
* <img src='../ngdoc_assets/StateGoExamples.png'/>
*
* @param {string} to Absolute state name or relative state path. Some examples:
*
* - `$state.go('contact.detail')` - will go to the `contact.detail` state
* - `$state.go('^')` - will go to a parent state
* - `$state.go('^.sibling')` - will go to a sibling state
* - `$state.go('.child.grandchild')` - will go to grandchild state
*
* @param {object=} params A map of the parameters that will be sent to the state,
* will populate $stateParams. Any parameters that are not specified will be inherited from currently
* defined parameters. This allows, for example, going to a sibling state that shares parameters
* specified in a parent state. Parameter inheritance only works between common ancestor states, I.e.
* transitioning to a sibling will get you the parameters for all parents, transitioning to a child
* will get you all current parameters, etc.
* @param {object=} options Options object. The options are:
*
* - **`location`** - {boolean=true|string=} - If `true` will update the url in the location bar, if `false`
* will not. If string, must be `"replace"`, which will update url and also replace last history record.
* - **`inherit`** - {boolean=true}, If `true` will inherit url parameters from current url.
* - **`relative`** - {object=$state.$current}, When transitioning with relative path (e.g '^'),
* defines which state to be relative from.
* - **`notify`** - {boolean=true}, If `true` will broadcast $stateChangeStart and $stateChangeSuccess events.
* - **`reload`** (v0.2.5) - {boolean=false}, If `true` will force transition even if the state or params
* have not changed, aka a reload of the same state. It differs from reloadOnSearch because you'd
* use this when you want to force a reload when *everything* is the same, including search params.
*
* @returns {promise} A promise representing the state of the new transition.
*
* Possible success values:
*
* - $state.current
*
* <br/>Possible rejection values:
*
* - 'transition superseded' - when a newer transition has been started after this one
* - 'transition prevented' - when `event.preventDefault()` has been called in a `$stateChangeStart` listener
* - 'transition aborted' - when `event.preventDefault()` has been called in a `$stateNotFound` listener or
* when a `$stateNotFound` `event.retry` promise errors.
* - 'transition failed' - when a state has been unsuccessfully found after 2 tries.
* - *resolve error* - when an error has occurred with a `resolve`
*
*/
go(to: StateOrName, params: RawParams, options: TransitionOptions): Promise<State> {
let defautGoOpts = { relative: this.$current, inherit: true };
let transOpts = defaults(options, defautGoOpts, defaultTransOpts);
return this.transitionTo(to, params, transOpts);
};
/** Factory method for creating a TargetState */
target(identifier: StateOrName, params: ParamsOrArray, options: TransitionOptions = {}): TargetState {
// If we're reloading, find the state object to reload from
if (isObject(options.reload) && !(<any>options.reload).name)
throw new Error('Invalid reload state object');
options.reloadState = options.reload === true ? this.stateRegistry.root() : this.stateRegistry.matcher.find(<any> options.reload, options.relative);
if (options.reload && !options.reloadState)
throw new Error(`No such reload state '${(isString(options.reload) ? options.reload : (<any>options.reload).name)}'`);
let stateDefinition = this.stateRegistry.matcher.find(identifier, options.relative);
return new TargetState(identifier, stateDefinition, params, options);
};
/**
* @ngdoc function
* @name ui.router.state.$state#transitionTo
* @methodOf ui.router.state.$state
*
* @description
* Low-level method for transitioning to a new state. {@link ui.router.state.$state#methods_go $state.go}
* uses `transitionTo` internally. `$state.go` is recommended in most situations.
*
* @example
* <pre>
* let app = angular.module('app', ['ui.router']);
*
* app.controller('ctrl', function ($scope, $state) {
* $scope.changeState = function () {
* $state.transitionTo('contact.detail');
* };
* });
* </pre>
*
* @param {string} to State name.
* @param {object=} toParams A map of the parameters that will be sent to the state,
* will populate $stateParams.
* @param {object=} options Options object. The options are:
*
* - **`location`** - {boolean=true|string=} - If `true` will update the url in the location bar, if `false`
* will not. If string, must be `"replace"`, which will update url and also replace last history record.
* - **`inherit`** - {boolean=false}, If `true` will inherit url parameters from current url.
* - **`relative`** - {object=}, When transitioning with relative path (e.g '^'),
* defines which state to be relative from.
* - **`notify`** - {boolean=true}, If `true` will broadcast $stateChangeStart and $stateChangeSuccess events.
* - **`reload`** (v0.2.5) - {boolean=false}, If `true` will force transition even if the state or params
* have not changed, aka a reload of the same state. It differs from reloadOnSearch because you'd
* use this when you want to force a reload when *everything* is the same, including search params.
*
* @returns {promise} A promise representing the state of the new transition. See
* {@link ui.router.state.$state#methods_go $state.go}.
*/
transitionTo(to: StateOrName, toParams: RawParams = {}, options: TransitionOptions = {}): Promise<State> {
let transHistory = this.globals.transitionHistory;
options = defaults(options, defaultTransOpts);
options = extend(options, { current: transHistory.peekTail.bind(transHistory)});
let ref: TargetState = this.target(to, toParams, options);
let latestSuccess: Transition = this.globals.successfulTransitions.peekTail();
const rootPath = () => PathFactory.bindTransNodesToPath([new Node(this.stateRegistry.root())]);
let currentPath: Node[] = latestSuccess ? latestSuccess.treeChanges().to : rootPath();
if (!ref.exists())
return this._handleInvalidTargetState(currentPath, ref);
if (!ref.valid())
return services.$q.reject(ref.error());
let transition = this.$transitions.create(currentPath, ref);
let tMgr = new TransitionManager(transition, this.$transitions, this.$urlRouter, this.$view, <StateService> this, this.globals);
let transitionPromise = tMgr.runTransition();
// Return a promise for the transition, which also has the transition object on it.
return extend(transitionPromise, { transition });
};
/**
* @ngdoc function
* @name ui.router.state.$state#is
* @methodOf ui.router.state.$state
*
* @description
* Similar to {@link ui.router.state.$state#methods_includes $state.includes},
* but only checks for the full state name. If params is supplied then it will be
* tested for strict equality against the current active params object, so all params
* must match with none missing and no extras.
*
* @example
* <pre>
* $state.$current.name = 'contacts.details.item';
*
* // absolute name
* $state.is('contact.details.item'); // returns true
* $state.is(contactDetailItemStateObject); // returns true
*
* // relative name (. and ^), typically from a template
* // E.g. from the 'contacts.details' template
* <div ng-class="{highlighted: $state.is('.item')}">Item</div>
* </pre>
*
* @param {string|object} stateOrName The state name (absolute or relative) or state object you'd like to check.
* @param {object=} params A param object, e.g. `{sectionId: section.id}`, that you'd like
* to test against the current active state.
* @param {object=} options An options object. The options are:
*
* - **`relative`** - {string|object} - If `stateOrName` is a relative state name and `options.relative` is set, .is will
* test relative to `options.relative` state (or name).
*
* @returns {boolean} Returns true if it is the state.
*/
is(stateOrName: StateOrName, params?: RawParams, options?: TransitionOptions): boolean {
options = defaults(options, { relative: this.$current });
let state = this.stateRegistry.matcher.find(stateOrName, options.relative);
if (!isDefined(state)) return undefined;
if (this.$current !== state) return false;
return isDefined(params) && params !== null ? Param.equals(state.parameters(), this.params, params) : true;
};
/**
* @ngdoc function
* @name ui.router.state.$state#includes
* @methodOf ui.router.state.$state
*
* @description
* A method to determine if the current active state is equal to or is the child of the
* state stateName. If any params are passed then they will be tested for a match as well.
* Not all the parameters need to be passed, just the ones you'd like to test for equality.
*
* @example
* Partial and relative names
* <pre>
* $state.$current.name = 'contacts.details.item';
*
* // Using partial names
* $state.includes("contacts"); // returns true
* $state.includes("contacts.details"); // returns true
* $state.includes("contacts.details.item"); // returns true
* $state.includes("contacts.list"); // returns false
* $state.includes("about"); // returns false
*
* // Using relative names (. and ^), typically from a template
* // E.g. from the 'contacts.details' template
* <div ng-class="{highlighted: $state.includes('.item')}">Item</div>
* </pre>
*
* Basic globbing patterns
* <pre>
* $state.$current.name = 'contacts.details.item.url';
*
* $state.includes("*.details.*.*"); // returns true
* $state.includes("*.details.**"); // returns true
* $state.includes("**.item.**"); // returns true
* $state.includes("*.details.item.url"); // returns true
* $state.includes("*.details.*.url"); // returns true
* $state.includes("*.details.*"); // returns false
* $state.includes("item.**"); // returns false
* </pre>
*
* @param {string} stateOrName A partial name, relative name, or glob pattern
* to be searched for within the current state name.
* @param {object=} params A param object, e.g. `{sectionId: section.id}`,
* that you'd like to test against the current active state.
* @param {object=} options An options object. The options are:
*
* - **`relative`** - {string|object=} - If `stateOrName` is a relative state reference and `options.relative` is set,
* .includes will test relative to `options.relative` state (or name).
*
* @returns {boolean} Returns true if it does include the state
*/
includes(stateOrName: StateOrName, params?: RawParams, options?: TransitionOptions): boolean {
options = defaults(options, { relative: this.$current });
let glob = isString(stateOrName) && Glob.fromString(<string> stateOrName);
if (glob) {
if (!glob.matches(this.$current.name)) return false;
stateOrName = this.$current.name;
}
let state = this.stateRegistry.matcher.find(stateOrName, options.relative), include = this.$current.includes;
if (!isDefined(state)) return undefined;
if (!isDefined(include[state.name])) return false;
// @TODO Replace with Param.equals() ?
return params ? equalForKeys(Param.values(state.parameters(), params), this.params, Object.keys(params)) : true;
};
/**
* @ngdoc function
* @name ui.router.state.$state#href
* @methodOf ui.router.state.$state
*
* @description
* A url generation method that returns the compiled url for the given state populated with the given params.
*
* @example
* <pre>
* expect($state.href("about.person", { person: "bob" })).toEqual("/about/bob");
* </pre>
*
* @param {string|object} stateOrName The state name or state object you'd like to generate a url from.
* @param {object=} params An object of parameter values to fill the state's required parameters.
* @param {object=} options Options object. The options are:
*
* - **`lossy`** - {boolean=true} - If true, and if there is no url associated with the state provided in the
* first parameter, then the constructed href url will be built from the first navigable ancestor (aka
* ancestor with a valid url).
* - **`inherit`** - {boolean=true}, If `true` will inherit url parameters from current url.
* - **`relative`** - {object=$state.$current}, When transitioning with relative path (e.g '^'),
* defines which state to be relative from.
* - **`absolute`** - {boolean=false}, If true will generate an absolute url, e.g. "http://www.example.com/fullurl".
*
* @returns {string} compiled state url
*/
href(stateOrName: StateOrName, params?: RawParams, options?: HrefOptions): string {
let defaultHrefOpts = {
lossy: true,
inherit: true,
absolute: false,
relative: this.$current
};
options = defaults(options, defaultHrefOpts);
let state = this.stateRegistry.matcher.find(stateOrName, options.relative);
if (!isDefined(state)) return null;
if (options.inherit) params = <any> this.params.$inherit(params || {}, this.$current, state);
let nav = (state && options.lossy) ? state.navigable : state;
if (!nav || nav.url === undefined || nav.url === null) {
return null;
}
return this.$urlRouter.href(nav.url, Param.values(state.parameters(), params), {
absolute: options.absolute
});
};
/**
* @ngdoc function
* @name ui.router.state.$state#get
* @methodOf ui.router.state.$state
*
* @description
* Returns the state configuration object for any specific state or all states.
*
* @param {string|Object=} stateOrName (absolute or relative) If provided, will only get the config for
* the requested state. If not provided, returns an array of ALL state configs.
* @param {string|object=} base When stateOrName is a relative state reference, the state will be retrieved relative to context.
* @returns {Object|Array} State configuration object or array of all objects.
*/
get(): StateDeclaration[];
get(stateOrName: StateOrName): StateDeclaration;
get(stateOrName: StateOrName, base: StateOrName): StateDeclaration;
get(stateOrName?: StateOrName, base?: StateOrName): any {
if (arguments.length === 0) return this.stateRegistry.get();
return this.stateRegistry.get(stateOrName, base || this.$current);
}
}