From 6743a6070b8f4c1a0351dc972a87e7f449b1b28c Mon Sep 17 00:00:00 2001 From: Chris Thielen Date: Thu, 9 Jun 2016 20:02:44 -0500 Subject: [PATCH] feat(Resolve): Switch state.resolve to be an array of Resolvables This is a prerequisite to supporting ng2 providers BC-BREAK: - Removed the built-in `$resolve$` resolve value, added in a previous alpha BC-BREAK: - `Transition.addResolves()` replaced with `Transition.addResolvable()` BC-BREAK: - The (private API) State object's .resolve property is now pre-processed as an array of Resolvables using statebuilder --- src/common/hof.ts | 5 +-- src/common/strings.ts | 4 +-- src/ng1/legacy/resolveService.ts | 10 +++--- src/ng1/statebuilders/resolve.ts | 35 ++++++++++++++++---- src/path/node.ts | 2 +- src/resolve/resolvable.ts | 57 ++++++++++++++++++++------------ src/resolve/resolveContext.ts | 12 +++---- src/state/hooks/resolveHooks.ts | 8 ++--- src/state/stateQueueManager.ts | 2 +- src/transition/transition.ts | 14 ++++---- test/core/resolveSpec.ts | 7 +++- test/ng1/ng1StateBuilderSpec.ts | 5 +-- test/ng1/transitionSpec.ts | 9 ++--- test/ng1/viewSpec.ts | 2 +- test/stateHelper.ts | 5 +-- 15 files changed, 106 insertions(+), 71 deletions(-) diff --git a/src/common/hof.ts b/src/common/hof.ts index be251a528..a57f7c7cc 100644 --- a/src/common/hof.ts +++ b/src/common/hof.ts @@ -4,6 +4,7 @@ * @module common_hof */ +import {Predicate} from "./common"; /** * Returns a new function for [Partial Application](https://en.wikipedia.org/wiki/Partial_application) of the original function. * @@ -126,7 +127,7 @@ export const not = (fn) => (...args) => !fn.apply(null, args); * Given two functions that return truthy or falsey values, returns a function that returns truthy * if both functions return truthy for the given arguments */ -export function and(fn1, fn2): Function { +export function and(fn1, fn2): Predicate { return (...args) => fn1.apply(null, args) && fn2.apply(null, args); } @@ -134,7 +135,7 @@ export function and(fn1, fn2): Function { * Given two functions that return truthy or falsey values, returns a function that returns truthy * if at least one of the functions returns truthy for the given arguments */ -export function or(fn1, fn2): Function { +export function or(fn1, fn2): Predicate { return (...args) => fn1.apply(null, args) || fn2.apply(null, args); } diff --git a/src/common/strings.ts b/src/common/strings.ts index 6f28397ac..fea9316f5 100644 --- a/src/common/strings.ts +++ b/src/common/strings.ts @@ -65,10 +65,10 @@ export function fnToString(fn: IInjectable) { return _fn && _fn.toString() || "undefined"; } -const isTransitionRejectionPromise = Rejection.isTransitionRejectionPromise; - let stringifyPatternFn = null; let stringifyPattern = function(value) { + let isTransitionRejectionPromise = Rejection.isTransitionRejectionPromise; + stringifyPatternFn = stringifyPatternFn || pattern([ [not(isDefined), val("undefined")], [isNull, val("null")], diff --git a/src/ng1/legacy/resolveService.ts b/src/ng1/legacy/resolveService.ts index e0a3dbc36..d16c8fbd1 100644 --- a/src/ng1/legacy/resolveService.ts +++ b/src/ng1/legacy/resolveService.ts @@ -1,8 +1,8 @@ import {State} from "../../state/stateObject"; import {PathNode} from "../../path/node"; import {ResolveContext} from "../../resolve/resolveContext"; -import {Resolvable} from "../../resolve/resolvable"; import {map} from "../../common/common"; +import {makeResolvables} from "../statebuilders/resolve"; export const resolveFactory = () => ({ /** @@ -12,14 +12,14 @@ export const resolveFactory = () => ({ * @param parent a promise for a "parent resolve" */ resolve: (invocables, locals = {}, parent?) => { - let parentNode = new PathNode(new State( { params: {} })); - let node = new PathNode(new State( { params: {} })); + let parentNode = new PathNode(new State( { params: {}, resolve: [] })); + let node = new PathNode(new State( { params: {}, resolve: [] })); let context = new ResolveContext([parentNode, node]); - context.addResolvables(Resolvable.makeResolvables(invocables), node.state); + context.addResolvables(makeResolvables(invocables), node.state); const resolveData = (parentLocals) => { - const rewrap = _locals => Resolvable.makeResolvables( map(_locals, local => () => local)); + const rewrap = _locals => makeResolvables( map(_locals, local => () => local)); context.addResolvables(rewrap(parentLocals), parentNode.state); context.addResolvables(rewrap(locals), node.state); return context.resolvePath(); diff --git a/src/ng1/statebuilders/resolve.ts b/src/ng1/statebuilders/resolve.ts index 77a7066a2..7cef81d51 100644 --- a/src/ng1/statebuilders/resolve.ts +++ b/src/ng1/statebuilders/resolve.ts @@ -1,7 +1,8 @@ /** @module ng1 */ /** */ import {State} from "../../state/stateObject"; -import {forEach} from "../../common/common"; -import {isString} from "../../common/predicates"; +import {isObject, isString, isInjectable} from "../../common/predicates"; +import {Resolvable} from "../../resolve/resolvable"; +import {services} from "../../common/coreservices"; /** * This is a [[StateBuilder.builder]] function for angular1 `resolve:` block on a [[Ng1StateDeclaration]]. @@ -10,9 +11,29 @@ import {isString} from "../../common/predicates"; * handles the `resolve` property with logic specific to angular-ui-router (ng1). */ export function ng1ResolveBuilder(state: State) { - let resolve = {}; - forEach(state.resolve || {}, function (resolveFn, name: string) { - resolve[name] = isString(resolveFn) ? [ resolveFn, x => x ] : resolveFn; - }); - return resolve; + return isObject(state.resolve) ? makeResolvables(state.resolve) : []; +} + +/** Validates the result map as a "resolve:" style object, then transforms the resolves into Resolvable[] */ +export function makeResolvables(resolves: { [key: string]: Function; }): Resolvable[] { + // desugar ng1 sugar to create a resolve that is a service + // e.g., resolve: { myService: 'myService' } + const resolveServiceFromString = tuple => { + if (!isString(tuple.val)) return tuple; + + injectService.$inject = [tuple.val]; + function injectService(svc) { return svc; } + return { key: tuple.key, val: injectService }; + }; + + // Convert from object to tuple array + let tuples = Object.keys(resolves).map(key => ({key, val: resolves[key]})).map(resolveServiceFromString); + + // If a hook result is an object, it should be a map of strings to (functions|strings). + let invalid = tuples.filter(tuple => !isInjectable(tuple.val)); + if (invalid.length) + throw new Error(`Invalid resolve key/value: ${invalid[0].key}/${invalid[0].val}`); + + const deps = (resolveFn) => services.$injector.annotate(resolveFn, services.$injector.strictDi); + return tuples.map(tuple => new Resolvable(tuple.key, tuple.val, deps(tuple.val))); } diff --git a/src/path/node.ts b/src/path/node.ts index c4af3a488..751864f60 100644 --- a/src/path/node.ts +++ b/src/path/node.ts @@ -46,7 +46,7 @@ export class PathNode { this.state = state; this.paramSchema = state.parameters({ inherit: false }); this.paramValues = {}; - this.resolvables = Object.keys(state.resolve || {}).map(key => new Resolvable(key, state.resolve[key])); + this.resolvables = state.resolve.map(res => res.clone()); } } diff --git a/src/resolve/resolvable.ts b/src/resolve/resolvable.ts index 4daa72823..cfe36e10b 100644 --- a/src/resolve/resolvable.ts +++ b/src/resolve/resolvable.ts @@ -1,13 +1,12 @@ /** @module resolve */ /** for typedoc */ -import {extend, pick, map, filter} from "../common/common"; -import {not} from "../common/hof"; -import {isInjectable} from "../common/predicates"; +import {pick, map, extend} from "../common/common"; import {services} from "../common/coreservices"; import {trace} from "../common/trace"; import {Resolvables, IOptions1} from "./interface"; import {ResolveContext} from "./resolveContext"; +import {stringify} from "../common/strings"; /** * The basic building block for the resolve system. @@ -22,18 +21,39 @@ import {ResolveContext} from "./resolveContext"; * parameter to those fns. */ export class Resolvable { - name: string; + token: any; resolveFn: Function; deps: string[]; promise: Promise = undefined; + resolved: boolean = false; data: any; - - constructor(name: string, resolveFn: Function, preResolvedData?: any) { - this.name = name; - this.resolveFn = resolveFn; - this.deps = services.$injector.annotate(resolveFn, services.$injector.strictDi); - this.data = preResolvedData; + + /** + * This constructor creates a Resolvable copy + */ + constructor(resolvable: Resolvable) + + /** + * This constructor creates a new `Resolvable` + * + * @param token The new resolvable's injection token, such as `"userList"` (a string) or `UserService` (a class). + * When this token is used during injection, the resolved value will be injected. + * @param resolveFn The function that returns the resolved value, or a promise for the resolved value + * @param deps An array of dependencies, which will be injected into the `resolveFn` + * @param data Pre-resolved data. If the resolve value is already known, it may be provided here. + */ + constructor(token: any, resolveFn: Function, deps?: any[], data?: any) + constructor(token, resolveFn?: Function, deps?: any[], data?: any) { + if (token instanceof Resolvable) { + extend(this, token); + } else { + this.token = token; + this.resolveFn = resolveFn; + this.deps = deps; + this.data = data; + this.resolved = data !== undefined; + } } // synchronous part: @@ -48,7 +68,7 @@ export class Resolvable { // - store unwrapped data // - resolve the Resolvable's promise resolveResolvable(resolveContext: ResolveContext, options: IOptions1 = {}) { - let {name, deps, resolveFn} = this; + let {deps, resolveFn} = this; trace.traceResolveResolvable(this, options); // First, set up an overall deferred/promise for this Resolvable @@ -56,7 +76,7 @@ export class Resolvable { this.promise = deferred.promise; // Load a map of all resolvables for this state from the context path // Omit the current Resolvable from the result, so we don't try to inject this into this - let ancestorsByName: Resolvables = resolveContext.getResolvables(null, { omitOwnLocals: [ name ] }); + let ancestorsByName: Resolvables = resolveContext.getResolvables(null, { omitOwnLocals: [ this.token ] }); // Limit the ancestors Resolvables map to only those that the current Resolvable fn's annotations depends on let depResolvables: Resolvables = pick(ancestorsByName, deps); @@ -86,17 +106,10 @@ export class Resolvable { } toString() { - return `Resolvable(name: ${this.name}, requires: [${this.deps}])`; + return `Resolvable(token: ${stringify(this.token)}, requires: [${this.deps.map(stringify)}])`; } - /** - * Validates the result map as a "resolve:" style object, then transforms the resolves into Resolvable[] - */ - static makeResolvables(resolves: { [key: string]: Function; }): Resolvable[] { - // If a hook result is an object, it should be a map of strings to functions. - let invalid = filter(resolves, not(isInjectable)), keys = Object.keys(invalid); - if (keys.length) - throw new Error(`Invalid resolve key/value: ${keys[0]}/${invalid[keys[0]]}`); - return Object.keys(resolves).map(key => new Resolvable(key, resolves[key])); + clone(): Resolvable { + return new Resolvable(this); } } diff --git a/src/resolve/resolveContext.ts b/src/resolve/resolveContext.ts index 1e7a18be1..d8c016130 100644 --- a/src/resolve/resolveContext.ts +++ b/src/resolve/resolveContext.ts @@ -66,8 +66,8 @@ export class ResolveContext { let omitProps = (node === last) ? options.omitOwnLocals : []; let filteredResolvables = node.resolvables - .filter(r => omitProps.indexOf(r.name) === -1) - .reduce((acc, r) => { acc[r.name] = r; return acc; }, {}); + .filter(r => omitProps.indexOf(r.token) === -1) + .reduce((acc, r) => { acc[r.token] = r; return acc; }, {}); return extend(memo, filteredResolvables); }, {}); @@ -85,14 +85,14 @@ export class ResolveContext { addResolvables(newResolvables: Resolvable[], state: State) { var node = this._nodeFor(state); - var keys = newResolvables.map(r => r.name); - node.resolvables = node.resolvables.filter(r => keys.indexOf(r.name) === -1).concat(newResolvables); + var keys = newResolvables.map(r => r.token); + node.resolvables = node.resolvables.filter(r => keys.indexOf(r.token) === -1).concat(newResolvables); } /** Gets the resolvables declared on a particular state */ getOwnResolvables(state: State): Resolvables { return this._nodeFor(state).resolvables - .reduce((acc, r) => { acc[r.name] = r; return acc; }, {}); + .reduce((acc, r) => { acc[r.token] = r; return acc; }, {}); } // Returns a promise for an array of resolved path Element promises @@ -200,6 +200,6 @@ function getPolicy(stateResolvePolicyConf, resolvable: Resolvable): number { // Normalize the configuration on the state to either state-level (a string) or resolve-level (a Map of string:string) let stateLevelPolicy: string = (isString(stateResolvePolicyConf) ? stateResolvePolicyConf : null); let resolveLevelPolicies: IPolicies = (isObject(stateResolvePolicyConf) ? stateResolvePolicyConf : {}); - let policyName = resolveLevelPolicies[resolvable.name] || stateLevelPolicy || defaultResolvePolicy; + let policyName = resolveLevelPolicies[resolvable.token] || stateLevelPolicy || defaultResolvePolicy; return ResolvePolicy[policyName]; } \ No newline at end of file diff --git a/src/state/hooks/resolveHooks.ts b/src/state/hooks/resolveHooks.ts index 899f4d0cd..076d5e5c7 100644 --- a/src/state/hooks/resolveHooks.ts +++ b/src/state/hooks/resolveHooks.ts @@ -36,14 +36,10 @@ export class ResolveHooks { // A new Resolvable contains all the resolved data in this context as a single object, for injection as `$resolve$` let context = node.resolveContext; - let $resolve$ = new Resolvable("$resolve$", () => map(context.getResolvables(), (r: Resolvable) => r.data)); var options = extend({ transition: transition }, { resolvePolicy: LAZY }); - // Resolve all the LAZY resolves, then resolve the `$resolve$` object, then add `$resolve$` to the context - // return context.resolvePathElement(node.state, options) - return context.resolvePath(options) - .then(() => $resolve$.resolveResolvable(context)) - .then(() => context.addResolvables([$resolve$], node.state)); + // Resolve all the LAZY resolves + return context.resolvePath(options); } // Resolve eager resolvables before when the transition starts diff --git a/src/state/stateQueueManager.ts b/src/state/stateQueueManager.ts index 5f8771b84..9b28e2528 100644 --- a/src/state/stateQueueManager.ts +++ b/src/state/stateQueueManager.ts @@ -23,7 +23,7 @@ export class StateQueueManager { // @TODO: state = new State(extend({}, config, { ... })) let state = inherit(new State(), extend({}, config, { self: config, - resolve: config.resolve || {}, + resolve: config.resolve || [], toString: () => config.name })); diff --git a/src/transition/transition.ts b/src/transition/transition.ts index 9106d89e7..b3b8403b1 100644 --- a/src/transition/transition.ts +++ b/src/transition/transition.ts @@ -154,8 +154,8 @@ export class Transition implements IHookRegistry { PathFactory.bindResolveContexts(this._treeChanges.to); let rootResolvables: Resolvable[] = [ - new Resolvable('$transition$', () => this, this), - new Resolvable('$stateParams', () => this.params(), this.params()) + new Resolvable('$transition$', () => this, [], this), + new Resolvable('$stateParams', () => this.params(), [], this.params()) ]; let rootNode: PathNode = this._treeChanges.to[0]; rootNode.resolveContext.addResolvables(rootResolvables, rootNode.state) @@ -223,16 +223,16 @@ export class Transition implements IHookRegistry { } /** - * Adds new resolves to this transition. + * Adds a new [[Resolvable]] (`resolve`) 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) + * @param resolvable an [[Resolvable]] object + * @param state the state in the "to path" which should receive the new resolve (otherwise, the root state) */ - addResolves(resolves: { [key: string]: Function }, state: StateOrName = ""): void { + addResolvable(resolvable: Resolvable, 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); + tail(topath).resolveContext.addResolvables([resolvable], targetNode.state); } /** diff --git a/test/core/resolveSpec.ts b/test/core/resolveSpec.ts index 35fad5d3b..a8c69800f 100644 --- a/test/core/resolveSpec.ts +++ b/test/core/resolveSpec.ts @@ -3,7 +3,7 @@ import "../matchers.ts" import { - ResolveContext, State, PathNode, PathFactory + ResolveContext, State, PathNode, PathFactory, Resolvable } from "../../src/core"; import { @@ -11,6 +11,7 @@ import { } from "../../src/core"; import Spy = jasmine.Spy; +import {services} from "../../src/common/coreservices"; /////////////////////////////////////////////// @@ -66,7 +67,11 @@ beforeEach(function () { function loadStates(parent, state, name) { var thisState = pick.apply(null, [state].concat(stateProps)); var substates = omit.apply(null, [state].concat(stateProps)); + var resolve = thisState.resolve || {}; + var injector = services.$injector; + thisState.resolve = Object.keys(resolve) + .map(key => new Resolvable(key, resolve[key], injector.annotate(resolve[key]))); thisState.template = thisState.template || "empty"; thisState.name = name; thisState.parent = parent.name; diff --git a/test/ng1/ng1StateBuilderSpec.ts b/test/ng1/ng1StateBuilderSpec.ts index 850ada85c..430a3ab81 100644 --- a/test/ng1/ng1StateBuilderSpec.ts +++ b/test/ng1/ng1StateBuilderSpec.ts @@ -1,4 +1,5 @@ import {StateBuilder, StateMatcher, ng1ResolveBuilder, ng1ViewsBuilder} from "../../src/ng1"; +import {Resolvable} from "../../src/resolve/resolvable"; describe('Ng1 StateBuilder', function() { var builder, matcher, urlMatcherFactoryProvider: any = { @@ -32,7 +33,7 @@ describe('Ng1 StateBuilder', function() { var config = { resolve: { foo: "bar" } }; var locals = { "bar": 123 }; expect(builder.builder('resolve')).toBeDefined(); - var built = builder.builder('resolve')(config); - expect($injector.invoke(built.foo, null, locals)).toBe(123); + var built: Resolvable[] = builder.builder('resolve')(config); + expect($injector.invoke(built[0].resolveFn, null, locals)).toBe(123); })); }); diff --git a/test/ng1/transitionSpec.ts b/test/ng1/transitionSpec.ts index 7cc3b5bf8..ac74ef9a1 100644 --- a/test/ng1/transitionSpec.ts +++ b/test/ng1/transitionSpec.ts @@ -10,6 +10,8 @@ import {TargetState} from "../../src/state/targetState"; import {StateQueueManager} from "../../src/state/stateQueueManager"; import {Rejection} from "../../src/transition/rejectFactory"; import {ResolveHooks} from "../../src/state/hooks/resolveHooks"; +import {Resolvable} from "../../src/resolve/resolvable"; +import {Transition} from "../../src/transition/transition"; describe('transition', function () { @@ -444,11 +446,10 @@ describe('transition', function () { log.push("Entered#"+state.name); }, { priority: -1 }); - transitionProvider.onEnter({ entering: "B" }, function addResolves($transition$) { + transitionProvider.onEnter({ entering: "B" }, function addResolves($transition$: Transition) { log.push("adding resolve"); - $transition$.addResolves({ - newResolve: function () { log.push("resolving"); return defer.promise; } - }) + var resolveFn = function () { log.push("resolving"); return defer.promise; }; + $transition$.addResolvable(new Resolvable('newResolve', resolveFn)); }); transitionProvider.onEnter({ entering: "C" }, function useTheNewResolve(trans, inj) { diff --git a/test/ng1/viewSpec.ts b/test/ng1/viewSpec.ts index 63a4b54f6..0c1560b62 100644 --- a/test/ng1/viewSpec.ts +++ b/test/ng1/viewSpec.ts @@ -31,7 +31,7 @@ describe('view', function() { let registerState = curry(function(_states, stateBuilder, config) { let state = inherit(new State(), extend({}, config, { self: config, - resolve: config.resolve || {} + resolve: config.resolve || [] })); let built: State = stateBuilder.build(state); return _states[built.name] = built; diff --git a/test/stateHelper.ts b/test/stateHelper.ts index 9ef12d652..dce5d5ee9 100644 --- a/test/stateHelper.ts +++ b/test/stateHelper.ts @@ -1,7 +1,4 @@ -import {omit} from "../src/common/common"; -import {pick} from "../src/common/common"; -import {extend} from "../src/common/common"; -import {forEach} from "../src/common/common"; +import {pick, extend, forEach, omit} from "../src/core"; let stateProps = ["resolve", "resolvePolicy", "data", "template", "templateUrl", "url", "name", "params"];