From 027c9951d59681ada40e7c3067e472cca7203a8d Mon Sep 17 00:00:00 2001 From: Chris Thielen Date: Sun, 29 Jan 2017 16:55:57 -0600 Subject: [PATCH] feat(State): Switch Internal State Object to prototypally inherit from the State Declaration In 0.x, we used prototypal inheritance, i.e. `internalImpl = inherit(stateDeclaration)`. In 1.0 alphas, we switched to `extend(new State(), stateDeclaration)` to enable us to use `instanceof State`. However, this convenience for ui-router code was inconvenient for end users so we're switching back to using the state declaration as the prototype of the internal state object. - This enables users to create classes for their states, providing (e.g.) standardized onEnter functions, such as: ```js var myState = new LoggingState('mystate', '/url'); ``` Closes https://github.com/angular-ui/ui-router/issues/3293 Closes #34 --- src/common/predicates.ts | 9 +- src/router.ts | 4 +- src/state/stateObject.ts | 46 +++++--- src/state/stateQueueManager.ts | 26 ++--- src/url/urlRule.ts | 4 +- test/stateBuilderSpec.ts | 198 ++++++++++++++++++++++++--------- 6 files changed, 200 insertions(+), 87 deletions(-) diff --git a/src/common/predicates.ts b/src/common/predicates.ts index ece248ab..d7144fe7 100644 --- a/src/common/predicates.ts +++ b/src/common/predicates.ts @@ -4,9 +4,11 @@ * Although these functions are exported, they are subject to change without notice. * * @module common_predicates - */ /** */ -import { and, not, pipe, prop, compose, or } from "./hof"; -import {Predicate} from "./common"; // has or is using + */ +/** */ +import { and, not, pipe, prop, or } from "./hof"; +import { Predicate } from "./common"; // has or is using +import { State } from "../state/stateObject"; const toStr = Object.prototype.toString; const tis = (t: string) => (x: any) => typeof(x) === t; @@ -21,6 +23,7 @@ export const isObject = (x: any) => x !== null && typeof x === 'object'; export const isArray = Array.isArray; export const isDate: (x: any) => x is Date = ((x: any) => toStr.call(x) === '[object Date]'); export const isRegExp: (x: any) => x is RegExp = ((x: any) => toStr.call(x) === '[object RegExp]'); +export const isState: (x: any) => x is State = State.isState; /** * Predicate which checks if a value is injectable diff --git a/src/router.ts b/src/router.ts index 59dd1020..94404d7e 100644 --- a/src/router.ts +++ b/src/router.ts @@ -52,13 +52,13 @@ export class UIRouter { /** * Deprecated for public use. Use [[urlService]] instead. - * @deprecated + * @deprecated Use [[urlService]] instead */ urlMatcherFactory: UrlMatcherFactory = new UrlMatcherFactory(); /** * Deprecated for public use. Use [[urlService]] instead. - * @deprecated + * @deprecated Use [[urlService]] instead */ urlRouter: UrlRouter = new UrlRouter(this); diff --git a/src/state/stateObject.ts b/src/state/stateObject.ts index 854ac746..7325f67c 100644 --- a/src/state/stateObject.ts +++ b/src/state/stateObject.ts @@ -1,17 +1,17 @@ /** * @coreapi * @module state - */ /** for typedoc */ - -import {StateDeclaration, _ViewDeclaration} from "./interface"; -import {extend, defaults, values, find} from "../common/common"; -import {propEq} from "../common/hof"; -import {Param} from "../params/param"; -import {UrlMatcher} from "../url/urlMatcher"; -import {Resolvable} from "../resolve/resolvable"; -import {TransitionStateHookFn} from "../transition/interface"; -import {TargetState} from "./targetState"; -import {Transition} from "../transition/transition"; + */ +/** for typedoc */ +import { StateDeclaration, _ViewDeclaration } from "./interface"; +import { defaults, values, find, inherit } from "../common/common"; +import { propEq } from "../common/hof"; +import { Param } from "../params/param"; +import { UrlMatcher } from "../url/urlMatcher"; +import { Resolvable } from "../resolve/resolvable"; +import { TransitionStateHookFn } from "../transition/interface"; +import { TargetState } from "./targetState"; +import { Transition } from "../transition/transition"; /** * Internal representation of a UI-Router state. @@ -96,11 +96,31 @@ export class State { ); + /** @deprecated use State.create() */ constructor(config?: StateDeclaration) { - extend(this, config); - // Object.freeze(this); + return State.create(config || {}); } + /** + * Create a state object to put the private/internal implementation details onto. + * The object's prototype chain looks like: + * (Internal State Object) -> (Copy of State.prototype) -> (State Declaration object) -> (State Declaration's prototype...) + * + * @param stateDecl the user-supplied State Declaration + * @returns {State} an internal State object + */ + static create(stateDecl: StateDeclaration): State { + let state = inherit(inherit(stateDecl, State.prototype)) as State; + stateDecl.$$state = () => state; + state['__stateObject'] = true; + state.self = stateDecl; + return state; + } + + /** Predicate which returns true if the object is an internal State object */ + static isState = (obj: any): obj is State => + obj['__stateObject'] === true; + /** * Returns true if the provided parameter is the same state. * diff --git a/src/state/stateQueueManager.ts b/src/state/stateQueueManager.ts index c6297901..2b1bf330 100644 --- a/src/state/stateQueueManager.ts +++ b/src/state/stateQueueManager.ts @@ -1,12 +1,13 @@ /** @module state */ /** for typedoc */ -import { extend, inherit, pluck } from "../common/common"; -import { isString } from "../common/predicates"; +import { extend, inherit, pluck, inArray } from "../common/common"; +import { isString, isDefined } from "../common/predicates"; import { StateDeclaration } from "./interface"; import { State } from "./stateObject"; import { StateBuilder } from "./stateBuilder"; import { StateRegistryListener, StateRegistry } from "./stateRegistry"; import { Disposable } from "../interface"; import { UrlRouter } from "../url/urlRouter"; +import { prop } from "../common/hof"; /** @internalapi */ export class StateQueueManager implements Disposable { @@ -26,19 +27,14 @@ export class StateQueueManager implements Disposable { this.queue = []; } - register(config: StateDeclaration) { - let {states, queue} = this; - // Wrap a new object around the state so we can store our private details easily. - // @TODO: state = new State(extend({}, config, { ... })) - let state = inherit(new State(), extend({}, config, { - self: config, - resolve: config.resolve || [], - toString: () => config.name - })); - - if (!isString(state.name)) throw new Error("State must have a valid name"); - if (states.hasOwnProperty(state.name) || pluck(queue, 'name').indexOf(state.name) !== -1) - throw new Error(`State '${state.name}' is already defined`); + register(stateDecl: StateDeclaration) { + let queue = this.queue; + let state = State.create(stateDecl); + let name = state.name; + + if (!isString(name)) throw new Error("State must have a valid name"); + if (this.states.hasOwnProperty(name) || inArray(queue.map(prop('name')), name)) + throw new Error(`State '${name}' is already defined`); queue.push(state); this.flush(); diff --git a/src/url/urlRule.ts b/src/url/urlRule.ts index 42c76bc9..2952c303 100644 --- a/src/url/urlRule.ts +++ b/src/url/urlRule.ts @@ -3,7 +3,7 @@ * @module url */ /** */ import { UrlMatcher } from "./urlMatcher"; -import { isString, isDefined, isFunction } from "../common/predicates"; +import { isString, isDefined, isFunction, isState } from "../common/predicates"; import { UIRouter } from "../router"; import { identity, extend } from "../common/common"; import { is, pattern } from "../common/hof"; @@ -38,7 +38,7 @@ export class UrlRuleFactory { const makeRule = pattern([ [isString, (_what: string) => makeRule(this.compile(_what))], [is(UrlMatcher), (_what: UrlMatcher) => this.fromUrlMatcher(_what, handler)], - [is(State), (_what: State) => this.fromState(_what, this.router)], + [isState, (_what: State) => this.fromState(_what, this.router)], [is(RegExp), (_what: RegExp) => this.fromRegExp(_what, handler)], [isFunction, (_what: UrlRuleMatchFn) => new BaseUrlRule(_what, handler as UrlRuleHandlerFn)], ]); diff --git a/test/stateBuilderSpec.ts b/test/stateBuilderSpec.ts index e7e702e2..32403aa3 100644 --- a/test/stateBuilderSpec.ts +++ b/test/stateBuilderSpec.ts @@ -1,45 +1,37 @@ import { StateMatcher, StateBuilder, UrlMatcher, extend } from "../src/index"; import { ParamTypes } from "../src/params/paramTypes"; +import { UIRouter } from "../src/router"; +import { State } from "../src/state/stateObject"; let paramTypes = new ParamTypes(); describe('StateBuilder', function() { - - let states; + let states, router, registry, matcher, urlMatcherFactory, builder; beforeEach(function() { - states = {}; - states[''] = { name: '', parent: null }; - states['home'] = { name: 'home', parent: states[''] }; - states['home.about'] = { name: 'home.about', parent: states['home'] }; - states['home.about.people'] = { name: 'home.about.people', parent: states['home.about'] }; - states['home.about.people.person'] = { name: 'home.about.people.person', parent: states['home.about.people'] }; - states['home.about.company'] = { name: 'home.about.company', parent: states['home.about'] }; - states['other'] = { name: 'other', parent: states[''] }; - states['other.foo'] = { name: 'other.foo' }; - states['other.foo.bar'] = { name: 'other.foo.bar' }; - states['home.error'] = { name: 'home.error', parent: states['home'] }; - - states['home.withData'] = { - name: 'home.withData', - data: { val1: "foo", val2: "bar" }, - parent: states['home'] - }; - states['home.withData.child'] = { - name: 'home.withData.child', - data: { val2: "baz" }, - parent: states['home.withData'] - }; - }); + router = new UIRouter(); + + registry = router.stateRegistry; + urlMatcherFactory = router.urlMatcherFactory; + matcher = registry.matcher; + builder = registry['builder']; + builder.builder('views', (state, parent) => { return state.views || { $default: {} }; }); - let builder, matcher, urlMatcherFactoryProvider: any = { - compile: function() {}, - isMatcher: function() {} - }; + registry.register({ name: 'home' }); + registry.register({ name: 'home.about' }); + registry.register({ name: 'home.about.people' }); + registry.register({ name: 'home.about.people.person' }); + registry.register({ name: 'home.about.company' }); + registry.register({ name: 'other' }); + registry.register({ name: 'other.foo' }); + registry.register({ name: 'other.foo.bar' }); + + registry.register({ name: 'home.withData', data: { val1: "foo", val2: "bar" } }); + registry.register({ name: 'home.withData.child', data: { val2: "baz" } }); + + states = registry.get().reduce((acc, state) => (acc[state.name] = state, acc), {}); + }); beforeEach(function() { - matcher = new StateMatcher(states); - builder = new StateBuilder(matcher, urlMatcherFactoryProvider); - builder.builder('views', (state, parent) => { return state.views || { $default: {} }; }); // builder.builder('resolve', uiRouter.ng1ResolveBuilder); }); @@ -68,45 +60,49 @@ describe('StateBuilder', function() { expect(builder.parentName(states[''])).toBe(""); }); it('should error if parent: is specified *AND* the state name has a dot (.) in it', function() { - expect(() => builder.parentName(states['home.error'])).toThrowError(); + let errorState = { name: 'home.error', parent: 'home' }; + expect(() => builder.parentName(errorState)).toThrowError(); }); }); }); describe('state building', function() { it('should build parent property', function() { - expect(builder.builder('parent')({ name: 'home.about' })).toBe(states['home']); + let about = State.create({ name: 'home.about' }); + expect(builder.builder('parent')(about)).toBe(states['home'].$$state()); }); it('should inherit parent data', function() { - let state = extend(states['home.withData.child'], { self: {} }); + let state = State.create(states['home.withData.child']); expect(builder.builder('data')(state)).toEqualData({ val1: "foo", val2: "baz" }); - state = extend(states['home.withData'], { self: {} }); + state = State.create(states['home.withData']); expect(builder.builder('data')(state)).toEqualData({ val1: "foo", val2: "bar" }); }); it('should compile a UrlMatcher for ^ URLs', function() { let url = new UrlMatcher('/', paramTypes, null); - spyOn(urlMatcherFactoryProvider, 'compile').and.returnValue(url); - spyOn(urlMatcherFactoryProvider, 'isMatcher').and.returnValue(true); + spyOn(urlMatcherFactory, 'compile').and.returnValue(url); + spyOn(urlMatcherFactory, 'isMatcher').and.returnValue(true); expect(builder.builder('url')({ url: "^/foo" })).toBe(url); - expect(urlMatcherFactoryProvider.compile).toHaveBeenCalledWith("/foo", { + expect(urlMatcherFactory.compile).toHaveBeenCalledWith("/foo", { params: {}, paramMap: jasmine.any(Function) }); - expect(urlMatcherFactoryProvider.isMatcher).toHaveBeenCalledWith(url); + expect(urlMatcherFactory.isMatcher).toHaveBeenCalledWith(url); }); it('should concatenate URLs from root', function() { - let root = states[''] = { url: { append: function() {} } }, url = {}; - spyOn(root.url, 'append').and.returnValue(url); - spyOn(urlMatcherFactoryProvider, 'isMatcher').and.returnValue(true); - spyOn(urlMatcherFactoryProvider, 'compile').and.returnValue(url); - - expect(builder.builder('url')({ url: "/foo" })).toBe(url); - expect(root.url.append).toHaveBeenCalledWith(url); + let root = states[''].$$state(); + spyOn(root.url, 'append').and.callThrough(); + + let childstate = State.create({ name: 'asdf', url: "/foo" }); + builder.builder('url')(childstate); + + expect(root.url.append).toHaveBeenCalled(); + let args = root.url.append.calls.argsFor(0); + expect(args[0].pattern).toBe('/foo') }); it('should pass through empty URLs', function() { @@ -114,23 +110,121 @@ describe('StateBuilder', function() { }); it('should pass through custom UrlMatchers', function() { - let root = states[''] = { url: { append: function() {} } }; + let root = states[''].$$state(); let url = new UrlMatcher("/", paramTypes, null); - spyOn(urlMatcherFactoryProvider, 'isMatcher').and.returnValue(true); + spyOn(urlMatcherFactory, 'isMatcher').and.returnValue(true); spyOn(root.url, 'append').and.returnValue(url); expect(builder.builder('url')({ url: url })).toBe(url); - expect(urlMatcherFactoryProvider.isMatcher).toHaveBeenCalledWith(url); + expect(urlMatcherFactory.isMatcher).toHaveBeenCalledWith(url); expect(root.url.append).toHaveBeenCalledWith(url); }); it('should throw on invalid UrlMatchers', function() { - spyOn(urlMatcherFactoryProvider, 'isMatcher').and.returnValue(false); + spyOn(urlMatcherFactory, 'isMatcher').and.returnValue(false); expect(function() { builder.builder('url')({ toString: function() { return "foo"; }, url: { foo: "bar" } }); }).toThrowError(Error, "Invalid url '[object Object]' in state 'foo'"); - expect(urlMatcherFactoryProvider.isMatcher).toHaveBeenCalledWith({ foo: "bar" }); + expect(urlMatcherFactory.isMatcher).toHaveBeenCalledWith({ foo: "bar" }); }); }); + + describe('state definitions with prototypes', () => { + function fooResolve() {} + let proto = { + name: 'name_', + abstract: true, + resolve: { foo: fooResolve}, + resolvePolicy: {}, + url: 'name/', + params: { foo: 'foo' }, + // views: {}, + data: { foo: 'foo' }, + onExit: function () { }, + onRetain: function () { }, + onEnter: function () { }, + lazyLoad: function () { }, + redirectTo: 'target_', + }; + + MyStateClass.prototype = proto; + function MyStateClass () { } + + let nestedProto = { + parent: "name_", + name: 'nested', + }; + + MyNestedStateClass.prototype = nestedProto; + function MyNestedStateClass () { } + + let router, myBuiltState: State, myNestedBuiltState: State; + beforeEach(() => { + router = new UIRouter(); + myNestedBuiltState = router.stateRegistry.register(new MyNestedStateClass()); + myBuiltState = router.stateRegistry.register(new MyStateClass()); + }); + + it('should use `parent` from the prototype', () => { + expect(myNestedBuiltState.parent).toBe(myBuiltState); + }); + + it('should use `name` from the prototype', () => { + expect(myBuiltState.name).toBe(proto.name); + }); + + it('should use `abstract` from the prototype', () => { + expect(myBuiltState.abstract).toBe(proto.abstract); + }); + + it('should use `resolve` from the prototype', () => { + expect(myBuiltState.resolvables.length).toBe(1); + expect(myBuiltState.resolvables[0].token).toBe('foo'); + expect(myBuiltState.resolvables[0].resolveFn).toBe(fooResolve); + }); + + it('should use `resolvePolicy` from the prototype', () => { + expect(myBuiltState.resolvePolicy).toBe(proto.resolvePolicy); + }); + + it('should use `url` from the prototype', () => { + expect(myBuiltState.url.pattern).toBe(proto.url); + }); + + it('should use `params` from the prototype', () => { + expect(myBuiltState.parameter('foo')).toBeTruthy(); + expect(myBuiltState.parameter('foo').config.value).toBe('foo'); + }); + + // ui-router-core doesn't have views builder + // it('should use `views` from the prototype', () => { + // expect(myState.views).toBe(proto.views); + // expect(built.views).toBe(proto.views); + // }); + + it('should use `data` from the prototype', () => { + expect(myBuiltState.data.foo).toBe(proto.data.foo); + }); + + it('should use `onExit` from the prototype', () => { + expect(myBuiltState.onExit).toBe(proto.onExit); + }); + + it('should use `onRetain` from the prototype', () => { + expect(myBuiltState.onRetain).toBe(proto.onRetain); + }); + + it('should use `onEnter` from the prototype', () => { + expect(myBuiltState.onEnter).toBe(proto.onEnter); + }); + + it('should use `lazyLoad` from the prototype', () => { + expect(myBuiltState.lazyLoad).toBe(proto.lazyLoad); + }); + + it('should use `redirectTo` from the prototype', () => { + expect(myBuiltState.redirectTo).toBe(proto.redirectTo); + }); + }) });