From 6becb12da895ee0e60050ed4635117d77ce27a96 Mon Sep 17 00:00:00 2001 From: Chris Thielen Date: Sat, 25 Jun 2016 22:03:21 -0500 Subject: [PATCH] feat(redirectTo): Process `redirectTo` property of a state as a redirect string/object/hook function Closes #27 Closes #948 --- src/hooks/redirectTo.ts | 31 +++++++ src/state/interface.ts | 67 ++++++++++++++- src/transition/transitionService.ts | 6 ++ test/core/hooksSpec.ts | 122 ++++++++++++++++++++++++++++ 4 files changed, 224 insertions(+), 2 deletions(-) create mode 100644 test/core/hooksSpec.ts diff --git a/src/hooks/redirectTo.ts b/src/hooks/redirectTo.ts index e69de29bb..4c14c77e7 100644 --- a/src/hooks/redirectTo.ts +++ b/src/hooks/redirectTo.ts @@ -0,0 +1,31 @@ +import {isString, isFunction} from "../common/predicates" +import {UIRInjector} from "../common/interface"; +import {Transition} from "../transition/transition"; +import {UiRouter} from "../router"; +import {services} from "../common/coreservices"; +import {TargetState} from "../state/targetState"; + +/** + * A hook that redirects to a different state or params + * + * See [[StateDeclaration.redirectTo]] + */ +export const redirectToHook = (transition: Transition, $injector: UIRInjector) => { + let redirect = transition.to().redirectTo; + if (!redirect) return; + + let router: UiRouter = $injector.get(UiRouter); + let $state = router.stateService; + + if (isFunction(redirect)) + return services.$q.when(redirect(transition, $injector)).then(handleResult); + + return handleResult(redirect); + + function handleResult(result) { + if (result instanceof TargetState) return result; + if (isString(result)) return $state.target( result, transition.params(), transition.options()); + if (result['state'] || result['params']) + return $state.target(result['state'] || transition.to(), result['params'] || transition.params(), transition.options()); + } +}; \ No newline at end of file diff --git a/src/state/interface.ts b/src/state/interface.ts index 5e23e3e95..7212c0211 100644 --- a/src/state/interface.ts +++ b/src/state/interface.ts @@ -8,6 +8,8 @@ import {Transition} from "../transition/transition"; import {TransitionStateHookFn} from "../transition/interface"; import {ResolvePolicy, ResolvableLiteral} from "../resolve/interface"; import {Resolvable} from "../resolve/resolvable"; +import {UIRInjector} from "../common/interface"; +import {TargetState} from "./targetState"; export type StateOrName = (string|StateDeclaration|State); @@ -388,12 +390,73 @@ export interface StateDeclaration { /** * An inherited property to store state data * - * This is a spot for you to store inherited state metadata. Child states' `data` object will - * prototypically inherit from the parent state . + * This is a spot for you to store inherited state metadata. + * Child states' `data` object will prototypally inherit from their parent state. * * This is a good spot to put metadata such as `requiresAuth`. + * + * Note: because prototypal inheritance is used, changes to parent `data` objects reflect in the child `data` objects. + * Care should be taken if you are using `hasOwnProperty` on the `data` object. + * Properties from parent objects will return false for `hasOwnProperty`. */ data?: any; + + /** + * Synchronously or asynchronously redirects Transitions to a different state/params + * + * If this property is defined, a Transition directly to this state will be redirected based on the property's value. + * + * - If the value is a `string`, the Transition is redirected to the state named by the string. + * + * - If the property is an object with a `state` and/or `params` property, + * the Transition is redirected to the named `state` and/or `params`. + * + * - If the value is a [[TargetState]] the Transition is redirected to the `TargetState` + * + * - If the property is a function: + * - The function is called with two parameters: + * - The current [[Transition]] + * - An injector which can be used to get dependencies using [[UIRInjector.get]] + * - The return value is processed using the previously mentioned rules. + * - If the return value is a promise, the promise is waited for, then the resolved async value is processed using the same rules. + * + * @example + * ```js + * + * // a string + * .state('A', { + * redirectTo: 'A.B' + * }) + * // a {state, params} object + * .state('C', { + * redirectTo: { state: 'C.D', params: { foo: 'index' } } + * }) + * // a fn + * .state('E', { + * redirectTo: () => "A" + * }) + * // a fn conditionally returning a {state, params} + * .state('F', { + * redirectTo: (trans) => { + * if (trans.params().foo < 10) + * return { state: 'F', params: { foo: 10 } }; + * } + * }) + * // a fn returning a promise for a redirect + * .state('G', { + * redirectTo: (trans, injector) => { + * let svc = injector.get('SomeService') + * let promise = svc.getAsyncRedirect(trans.params.foo); + * return promise; + * } + * }) + */ + redirectTo?: ( + ($transition$: Transition, $injector: UIRInjector) => TargetState | + { state: (string|StateDeclaration), params: { [key: string]: any }} | + string + ) + /** * A Transition Hook called with the state is being entered. See: [[IHookRegistry.onEnter]] * diff --git a/src/transition/transitionService.ts b/src/transition/transitionService.ts index 856282b1e..f4610a98e 100644 --- a/src/transition/transitionService.ts +++ b/src/transition/transitionService.ts @@ -14,6 +14,7 @@ import {loadEnteringViews, activateViews} from "../hooks/views"; import {UiRouter} from "../router"; import {val} from "../common/hof"; import {updateUrl} from "../hooks/url"; +import {redirectToHook} from "../hooks/redirectTo"; /** * The default [[Transition]] options. @@ -54,6 +55,7 @@ export class TransitionService implements IHookRegistry { loadViews: Function; activateViews: Function; updateUrl: Function; + redirectTo: Function; }; constructor(private _router: UiRouter) { @@ -65,6 +67,9 @@ export class TransitionService implements IHookRegistry { private registerTransitionHooks() { let fns = this._deregisterHookFns; + + // Wire up redirectTo hook + fns.redirectTo = this.onStart({to: (state) => !!state.redirectTo}, redirectToHook); // Wire up onExit/Retain/Enter state hooks fns.onExit = this.onExit({exiting: state => !!state.onExit}, makeEnterExitRetainHook('onExit')); @@ -75,6 +80,7 @@ export class TransitionService implements IHookRegistry { fns.eagerResolve = this.onStart({}, $eagerResolvePath, {priority: 1000}); fns.lazyResolve = this.onEnter({ entering: val(true) }, $lazyResolveState, {priority: 1000}); + // Wire up the View management hooks fns.loadViews = this.onStart({}, loadEnteringViews); fns.activateViews = this.onSuccess({}, activateViews); diff --git a/test/core/hooksSpec.ts b/test/core/hooksSpec.ts new file mode 100644 index 000000000..7a29bbc46 --- /dev/null +++ b/test/core/hooksSpec.ts @@ -0,0 +1,122 @@ + +import {UiRouter} from "../../src/router"; +import {tree2Array} from "../stateHelper.ts"; +import {find} from "../../src/common/common"; + + +let statetree = { + A: { + AA: { + AAA: { + url: "/:fooId", params: { fooId: "" } + } + } + } +}; + +describe("hooks", () => { + let router, $state, states, init; + beforeEach(() => { + router = new UiRouter(); + $state = router.stateService; + states = tree2Array(statetree, false); + init = () => { + states.forEach(state => router.stateRegistry.register(state)); + router.stateRegistry.stateQueue.autoFlush($state); + } + }) + + describe('redirectTo:', () => { + it("should redirect to a state by name from the redirectTo: string", (done) => { + find(states, s => s.name === 'A').redirectTo = "AAA"; + init(); + + $state.go('A').then(() => { + expect(router.globals.current.name).toBe('AAA') + done() + }) + }) + + it("should redirect to a state by name from the redirectTo: object", (done) => { + find(states, s => s.name === 'A').redirectTo = { state: "AAA" } + init(); + + $state.go('A').then(() => { + expect(router.globals.current.name).toBe('AAA') + done() + }) + }) + + it("should redirect to a state and params by name from the redirectTo: object", (done) => { + find(states, s => s.name === 'A').redirectTo = { state: "AAA", params: { fooId: 'abc'} }; + init(); + + $state.go('A').then(() => { + expect(router.globals.current.name).toBe('AAA') + expect(router.globals.params.fooId).toBe('abc') + done() + }) + }) + + it("should redirect to a TargetState returned from the redirectTo: function", (done) => { + find(states, s => s.name === 'A').redirectTo = + () => $state.target("AAA"); + init(); + + $state.go('A').then(() => { + expect(router.globals.current.name).toBe('AAA') + done() + }) + }) + + it("should redirect after waiting for a promise for a state name returned from the redirectTo: function", (done) => { + find(states, s => s.name === 'A').redirectTo = () => new Promise((resolve) => { + setTimeout(() => resolve("AAA"), 50) + }); + init(); + + $state.go('A').then(() => { + expect(router.globals.current.name).toBe('AAA'); + done() + }) + }) + + it("should redirect after waiting for a promise for a {state, params} returned from the redirectTo: function", (done) => { + find(states, s => s.name === 'A').redirectTo = () => new Promise((resolve) => { + setTimeout(() => resolve({ state: "AAA", params: { fooId: "FOO" } }), 50) + }); + init(); + + $state.go('A').then(() => { + expect(router.globals.current.name).toBe('AAA'); + expect(router.globals.params.fooId).toBe('FOO'); + done() + }) + }) + + it("should redirect after waiting for a promise for a TargetState returned from the redirectTo: function", (done) => { + find(states, s => s.name === 'A').redirectTo = () => new Promise((resolve) => { + setTimeout(() => resolve($state.target("AAA")), 50) + }); + init(); + + $state.go('A').then(() => { + expect(router.globals.current.name).toBe('AAA'); + done() + }) + }) + + it("should not redirect if the redirectTo: function returns something other than a string, { state, params}, TargetState (or promise for)", (done) => { + find(states, s => s.name === 'A').redirectTo = () => new Promise((resolve) => { + setTimeout(() => resolve(12345), 50) + }); + init(); + + $state.go('A').then(() => { + expect(router.globals.current.name).toBe('A'); + done() + }) + }) + }) + +}); \ No newline at end of file