diff --git a/src/CombinedMutator.ts b/src/CombinedMutator.ts new file mode 100644 index 0000000..bb35815 --- /dev/null +++ b/src/CombinedMutator.ts @@ -0,0 +1,31 @@ +import ActionMessage from './interfaces/ActionMessage'; +import MutatorMap from './interfaces/MutatorMap'; + +export default class CombinedMutator { + private mutatorKeys: string[]; + + constructor(private mutatorMap: MutatorMap) { + this.mutatorKeys = Object.keys(this.mutatorMap); + } + + getInitialValue() { + let initialValue: TState = {}; + this.mutatorKeys.forEach(key => { + initialValue[key] = this.mutatorMap[key].getInitialValue(); + }); + + return initialValue; + } + + handleAction( + currentState: TState, + actionMessage: ActionMessage, + replaceState: (newState: TState) => void + ) { + this.mutatorKeys.forEach(key => { + this.mutatorMap[key].handleAction(currentState[key], actionMessage, newState => { + currentState[key] = newState; + }); + }); + } +} diff --git a/src/LeafMutator.ts b/src/LeafMutator.ts new file mode 100644 index 0000000..96d170b --- /dev/null +++ b/src/LeafMutator.ts @@ -0,0 +1,47 @@ +import ActionCreator from './interfaces/ActionCreator'; +import ActionMessage from './interfaces/ActionMessage'; +import { getPrivateActionId } from './actionCreator'; + +export type MutatorHandler = ( + state: TState, + action: TAction +) => TState | void; + +// Represents a mutator for a leaf node in the state tree +export default class LeafMutator { + private handlers: { [actionId: string]: MutatorHandler } = {}; + + constructor(private initialValue: TState) {} + + getInitialValue() { + return this.initialValue; + } + + handles( + actionCreator: ActionCreator, + handler: MutatorHandler + ) { + let actionId = getPrivateActionId(actionCreator); + if (this.handlers[actionId]) { + throw new Error('A mutator may not handle the same action twice.'); + } + + this.handlers[actionId] = handler; + return this; + } + + handleAction( + currentState: TState, + actionMessage: ActionMessage, + replaceState: (newState: TState) => void + ) { + let actionId = getPrivateActionId(actionMessage); + let handler = this.handlers[actionId]; + if (handler) { + let returnValue = handler(currentState, actionMessage); + if (returnValue !== undefined) { + replaceState(returnValue); + } + } + } +} diff --git a/src/combineMutators.ts b/src/combineMutators.ts new file mode 100644 index 0000000..75961ab --- /dev/null +++ b/src/combineMutators.ts @@ -0,0 +1,6 @@ +import MutatorMap from './interfaces/MutatorMap'; +import CombinedMutator from './CombinedMutator'; + +export default function combineMutators(mutatorMap: MutatorMap) { + return new CombinedMutator(mutatorMap); +} diff --git a/src/createMutator.ts b/src/createMutator.ts new file mode 100644 index 0000000..b8b7f78 --- /dev/null +++ b/src/createMutator.ts @@ -0,0 +1,5 @@ +import LeafMutator from './LeafMutator'; + +export default function createMutator(initialValue: TState) { + return new LeafMutator(initialValue); +} diff --git a/src/createStore.ts b/src/createStore.ts index 7d6a97f..7aae056 100644 --- a/src/createStore.ts +++ b/src/createStore.ts @@ -1,5 +1,9 @@ import { action } from 'mobx'; import getRootStore from './getRootStore'; +import CombinedMutator from './CombinedMutator'; +import LeafMutator from './LeafMutator'; +import { subscribeAll } from './dispatcher'; +import wrapMutator from './wrapMutator'; let createStoreAction = action('createStore', function createStoreAction( key: string, @@ -8,7 +12,36 @@ let createStoreAction = action('createStore', function createStoreAction( getRootStore().set(key, initialState); }); -export default function createStore(key: string, initialState: T): () => T { +export default function createStore( + key: string, + arg2: TState | LeafMutator | CombinedMutator +): () => TState { + // Get the initial state (from the mutator, if necessary) + let mutator = getMutator(arg2); + let initialState = mutator ? mutator.getInitialValue() : arg2; + + // Create the store under the root store createStoreAction(key, initialState); - return () => getRootStore().get(key); + let getStore = () => getRootStore().get(key); + + // If necessary, hook the mutator up to the dispatcher + if (mutator) { + subscribeAll( + wrapMutator(actionMessage => { + mutator.handleAction(getStore(), actionMessage, newState => { + getRootStore().set(key, newState); + }); + }) + ); + } + + return getStore; +} + +function getMutator(mutator: TState | LeafMutator | CombinedMutator) { + if ((mutator).handleAction) { + return | CombinedMutator>mutator; + } + + return null; } diff --git a/src/dispatcher.ts b/src/dispatcher.ts index 6fb1ad2..7fdb5b6 100644 --- a/src/dispatcher.ts +++ b/src/dispatcher.ts @@ -3,7 +3,7 @@ import Subscriber from './interfaces/Subscriber'; import { getPrivateActionId } from './actionCreator'; import { getGlobalContext } from './globalContext'; -export function subscribe(actionId: string, callback: Subscriber) { +export function subscribe(actionId: string, callback: Subscriber) { let subscriptions = getGlobalContext().subscriptions; if (!subscriptions[actionId]) { subscriptions[actionId] = []; @@ -12,6 +12,10 @@ export function subscribe(actionId: string, callback: Subscriber) { subscriptions[actionId].push(callback); } +export function subscribeAll(callback: Subscriber) { + getGlobalContext().subscriptionsToAll.push(callback); +} + export function dispatch(actionMessage: ActionMessage) { if (getGlobalContext().inMutator) { throw new Error('Mutators cannot dispatch further actions.'); @@ -23,20 +27,27 @@ export function dispatch(actionMessage: ActionMessage) { export function finalDispatch(actionMessage: ActionMessage): void | Promise { let actionId = getPrivateActionId(actionMessage); - let subscribers = getGlobalContext().subscriptions[actionId]; + let promises: Promise[] = []; + // Callback subscribers to specific actions + let subscribers = getGlobalContext().subscriptions[actionId]; if (subscribers) { - let promises: Promise[] = []; - subscribers.forEach(subscriber => { let returnValue = subscriber(actionMessage); if (returnValue) { promises.push(returnValue); } }); + } + + // Callback subscribers to all actions + getGlobalContext().subscriptionsToAll.forEach(subscriber => { + // These subscribers must be mutators, which cannot be async + subscriber(actionMessage); + }); - if (promises.length) { - return promises.length == 1 ? promises[0] : Promise.all(promises); - } + // If multiple promises are returned, merge them + if (promises.length) { + return promises.length == 1 ? promises[0] : Promise.all(promises); } } diff --git a/src/globalContext.ts b/src/globalContext.ts index d17ee37..f4020e1 100644 --- a/src/globalContext.ts +++ b/src/globalContext.ts @@ -13,6 +13,7 @@ export interface GlobalContext { rootStore: ObservableMap; nextActionId: number; subscriptions: { [key: string]: Subscriber[] }; + subscriptionsToAll: Subscriber[]; dispatchWithMiddleware: DispatchFunction; inMutator: boolean; @@ -38,6 +39,7 @@ export function __resetGlobalContext() { rootStore: observable.map({}), nextActionId: 0, subscriptions: {}, + subscriptionsToAll: [], dispatchWithMiddleware: null, inMutator: false, legacyInDispatch: 0, diff --git a/src/index.ts b/src/index.ts index 18c434b..47943a3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,10 +9,13 @@ export { default as MutatorFunction } from './interfaces/MutatorFunction'; export { default as OrchestratorFunction } from './interfaces/OrchestratorFunction'; export { action, actionCreator } from './actionCreator'; export { default as applyMiddleware } from './applyMiddleware'; +export { default as createMutator } from './createMutator'; +export { default as combineMutators } from './combineMutators'; export { default as createStore } from './createStore'; export { dispatch } from './dispatcher'; -export { default as mutator } from './mutator'; -import { default as orchestrator } from './orchestrator'; +export { default as mutator } from './mutatorDecorator'; +export { default as LeafMutator } from './LeafMutator'; +import { default as orchestrator } from './orchestratorDecorator'; export { default as getRootStore } from './getRootStore'; export { mutatorAction, orchestratorAction } from './simpleSubscribers'; export { useStrict }; diff --git a/src/interfaces/MutatorMap.ts b/src/interfaces/MutatorMap.ts new file mode 100644 index 0000000..ff6d259 --- /dev/null +++ b/src/interfaces/MutatorMap.ts @@ -0,0 +1,8 @@ +import LeafMutator from '../LeafMutator'; +import CombinedMutator from '../CombinedMutator'; + +type MutatorMap = { + [K in keyof TState]: LeafMutator | CombinedMutator +}; + +export default MutatorMap; diff --git a/src/mutator.ts b/src/mutatorDecorator.ts similarity index 60% rename from src/mutator.ts rename to src/mutatorDecorator.ts index 8a09e65..ead582a 100644 --- a/src/mutator.ts +++ b/src/mutatorDecorator.ts @@ -6,6 +6,7 @@ import MutatorFunction from './interfaces/MutatorFunction'; import { getPrivateActionId } from './actionCreator'; import { subscribe } from './dispatcher'; import { getGlobalContext } from './globalContext'; +import wrapMutator from './wrapMutator'; export default function mutator( actionCreator: ActionCreator, @@ -16,20 +17,9 @@ export default function mutator( throw new Error('Mutators can only subscribe to action creators.'); } - // Wrap the callback in a MobX action so it can modify the store - let wrappedTarget = action((actionMessage: T) => { - try { - getGlobalContext().inMutator = true; - if (target(actionMessage)) { - throw new Error('Mutators cannot return a value and cannot be async.'); - } - } finally { - getGlobalContext().inMutator = false; - } - }); - // Subscribe to the action - subscribe(actionId, wrappedTarget); + subscribe(actionId, wrapMutator(target)); + // Return the original function so it can be exported for tests return target; } diff --git a/src/orchestrator.ts b/src/orchestratorDecorator.ts similarity index 100% rename from src/orchestrator.ts rename to src/orchestratorDecorator.ts diff --git a/src/simpleSubscribers.ts b/src/simpleSubscribers.ts index 529fddf..5ec8dbe 100644 --- a/src/simpleSubscribers.ts +++ b/src/simpleSubscribers.ts @@ -2,8 +2,8 @@ import ActionCreator from './interfaces/ActionCreator'; import SimpleAction from './interfaces/SimpleAction'; import Subscriber from './interfaces/Subscriber'; import { action } from './actionCreator'; -import mutator from './mutator'; -import orchestrator from './orchestrator'; +import mutator from './mutatorDecorator'; +import orchestrator from './orchestratorDecorator'; export function createSimpleSubscriber(decorator: Function) { return function simpleSubscriber(actionType: string, target: T): T { diff --git a/src/wrapMutator.ts b/src/wrapMutator.ts new file mode 100644 index 0000000..07919b8 --- /dev/null +++ b/src/wrapMutator.ts @@ -0,0 +1,21 @@ +import { action } from 'mobx'; +import ActionMessage from './interfaces/ActionMessage'; +import MutatorFunction from './interfaces/MutatorFunction'; +import { getGlobalContext } from './globalContext'; + +// Wraps the target function for use as a mutator +export default function wrapMutator( + target: MutatorFunction +): MutatorFunction { + // Wrap the target in a MobX action so it can modify the store + return action((actionMessage: T) => { + try { + getGlobalContext().inMutator = true; + if (target(actionMessage)) { + throw new Error('Mutators cannot return a value and cannot be async.'); + } + } finally { + getGlobalContext().inMutator = false; + } + }); +} diff --git a/test/CombinedMutatorTests.ts b/test/CombinedMutatorTests.ts new file mode 100644 index 0000000..a2b2a3a --- /dev/null +++ b/test/CombinedMutatorTests.ts @@ -0,0 +1,59 @@ +import { actionCreator } from '../src/index'; +import CombinedMutator from '../src/CombinedMutator'; +import createMutator from '../src/createMutator'; + +describe('CombinedMutator', () => { + const mutatorA = createMutator('a'); + const mutatorB = createMutator('b'); + + it('combines the initial values of its child mutators', () => { + // Act + let combinedMutator = new CombinedMutator({ + A: mutatorA, + B: mutatorB, + }); + + // Assert + expect(combinedMutator.getInitialValue()).toEqual({ A: 'a', B: 'b' }); + }); + + it('dispatches actions to each child mutator', () => { + // Arrange + const actionMessage = {}; + const spyA = spyOn(mutatorA, 'handleAction'); + const spyB = spyOn(mutatorB, 'handleAction'); + + let combinedMutator = new CombinedMutator({ + A: mutatorA, + B: mutatorB, + }); + + // Act + combinedMutator.handleAction({ A: 'a', B: 'b' }, actionMessage, null); + + // Assert + expect(spyA.calls.argsFor(0)[0]).toBe('a'); + expect(spyA.calls.argsFor(0)[1]).toBe(actionMessage); + expect(spyB.calls.argsFor(0)[0]).toBe('b'); + expect(spyB.calls.argsFor(0)[1]).toBe(actionMessage); + }); + + it('replaces a child state when the replaceState callback gets called', () => { + // Arrange + spyOn( + mutatorA, + 'handleAction' + ).and.callFake((state: any, actionMessage: any, replaceState: Function) => { + replaceState('x'); + }); + + let state = { A: 'a' }; + let combinedMutator = new CombinedMutator({ A: mutatorA }); + + // Act + combinedMutator.handleAction(state, {}, null); + + // Assert + expect(state.A).toBe('x'); + }); +}); diff --git a/test/LeafMutatorTests.ts b/test/LeafMutatorTests.ts new file mode 100644 index 0000000..41c7227 --- /dev/null +++ b/test/LeafMutatorTests.ts @@ -0,0 +1,41 @@ +import { actionCreator } from '../src/index'; +import LeafMutator from '../src/LeafMutator'; + +describe('LeafMutator', () => { + const testAction = actionCreator('testAction'); + const actionToDispatch = testAction(); + + it('can modify the state when handling an action', () => { + // Arrange + const state = { a: 1 }; + const mutator = new LeafMutator(state); + const replaceState = jasmine.createSpy('replaceState'); + + mutator.handles(testAction, (state, actionMessage) => { + state.a = 2; + }); + + // Act + mutator.handleAction(state, actionToDispatch, replaceState); + + // Assert + expect(state).toEqual({ a: 2 }); + }); + + it('can replace the state when handling an action', () => { + // Arrange + const state = { a: 1 }; + const mutator = new LeafMutator(state); + const replaceState = jasmine.createSpy('replaceState'); + + mutator.handles(testAction, (state, actionMessage) => { + return null; + }); + + // Act + mutator.handleAction(state, actionToDispatch, replaceState); + + // Assert + expect(replaceState).toHaveBeenCalledWith(null); + }); +}); diff --git a/test/createMutatorTests.ts b/test/createMutatorTests.ts new file mode 100644 index 0000000..cf5e7ec --- /dev/null +++ b/test/createMutatorTests.ts @@ -0,0 +1,14 @@ +import createMutator from '../src/createMutator'; + +describe('createMutator', () => { + it('returns a mutator with the given initial value', () => { + // Arrange + const initialValue = {}; + + // Act + const mutator = createMutator(initialValue); + + // Assert + expect(mutator.getInitialValue()).toBe(initialValue); + }); +}); diff --git a/test/createStoreTests.ts b/test/createStoreTests.ts index 2bbb039..5f2641f 100644 --- a/test/createStoreTests.ts +++ b/test/createStoreTests.ts @@ -2,11 +2,17 @@ import 'jasmine'; import getRootStore from '../src/getRootStore'; import createStore from '../src/createStore'; import { __resetGlobalContext } from '../src/globalContext'; +import LeafMutator from '../src/LeafMutator'; +import * as dispatcher from '../src/dispatcher'; +import * as wrapMutator from '../src/wrapMutator'; describe('createStore', () => { + beforeEach(() => { + __resetGlobalContext(); + }); + it('creates a subtree under rootStore', () => { // Arrange - __resetGlobalContext(); let initialState = { testProp: 'testValue' }; // Act @@ -16,4 +22,39 @@ describe('createStore', () => { expect(store).toEqual(initialState); expect(getRootStore().get('testStore')).toEqual(initialState); }); + + it('can create a store from a mutator', () => { + // Arrange + let initialState = { testProp: 'testValue' }; + let mutator = new LeafMutator(initialState); + + // Act + let store = createStore('testStore', mutator)(); + + // Assert + expect(store).toEqual(initialState); + expect(getRootStore().get('testStore')).toEqual(initialState); + }); + + it('forwards actions from the dispatcher to the mutator', () => { + // Arrange + let testAction = {}; + let initialState = { testProp: 'testValue' }; + let mutator = new LeafMutator(initialState); + + let subscribeAllSpy = spyOn(dispatcher, 'subscribeAll'); + let wrapMutatorSpy = spyOn(wrapMutator, 'default').and.callFake((target: any) => target); + let handleActionSpy = spyOn(mutator, 'handleAction'); + + let store = createStore('testStore', mutator)(); + let subscribeAllCallback = subscribeAllSpy.calls.argsFor(0)[0]; + + // Act + subscribeAllCallback(testAction); + + // Assert + expect(wrapMutatorSpy).toHaveBeenCalled(); + expect(handleActionSpy.calls.argsFor(0)[0]).toBe(store); + expect(handleActionSpy.calls.argsFor(0)[1]).toBe(testAction); + }); }); diff --git a/test/dispatcherTests.ts b/test/dispatcherTests.ts index 408c8ec..260c2d5 100644 --- a/test/dispatcherTests.ts +++ b/test/dispatcherTests.ts @@ -9,6 +9,7 @@ describe('dispatcher', () => { beforeEach(() => { mockGlobalContext = { subscriptions: {}, + subscriptionsToAll: [], dispatchWithMiddleware: jasmine.createSpy('dispatchWithMiddleware'), inMutator: false, }; @@ -146,4 +147,22 @@ describe('dispatcher', () => { expect(Promise.all).toHaveBeenCalledWith([promise1, promise2]); expect(returnValue).toBe(aggregatePromise); }); + + it('finalDispatch calls subscribers that are subscribed to all actions', () => { + // Arrange + let actionMessage = {}; + let actionId = 'testActionId'; + spyOn(actionCreator, 'getPrivateActionId').and.returnValue(actionId); + + let callback0 = jasmine.createSpy('callback0'); + let callback1 = jasmine.createSpy('callback1'); + mockGlobalContext.subscriptionsToAll = [callback0, callback1]; + + // Act + dispatcher.finalDispatch(actionMessage); + + // Assert + expect(callback0).toHaveBeenCalledWith(actionMessage); + expect(callback1).toHaveBeenCalledWith(actionMessage); + }); }); diff --git a/test/endToEndTests.ts b/test/endToEndTests.ts index b169fc5..d7ab130 100644 --- a/test/endToEndTests.ts +++ b/test/endToEndTests.ts @@ -1,11 +1,14 @@ import 'jasmine'; -import { action } from '../src/actionCreator'; -import applyMiddleware from '../src/applyMiddleware'; -import { dispatch } from '../src/dispatcher'; -import mutator from '../src/mutator'; -import orchestrator from '../src/orchestrator'; -import { mutatorAction } from '../src/simpleSubscribers'; -import createStore from '../src/createStore'; +import { + action, + applyMiddleware, + createMutator, + createStore, + dispatch, + mutator, + mutatorAction, + orchestrator, +} from '../src/index'; describe('satcheljs', () => { it('mutators subscribe to actions', () => { @@ -102,4 +105,20 @@ describe('satcheljs', () => { // Assert expect(promiseValues).toEqual([1, 2]); }); + + it('stores created from mutators handle actions as expected', () => { + // Arrange + const testAction = action('testAction'); + const testMutator = createMutator({ testProperty: 1 }).handles(testAction, state => { + state.testProperty = 2; + }); + + const testStore = createStore('testStore', testMutator)(); + + // Act + testAction(); + + // Assert + expect(testStore.testProperty).toBe(2); + }); }); diff --git a/test/mutatorTests.ts b/test/mutatorDecoratorTests.ts similarity index 98% rename from test/mutatorTests.ts rename to test/mutatorDecoratorTests.ts index cab22a3..12a2799 100644 --- a/test/mutatorTests.ts +++ b/test/mutatorDecoratorTests.ts @@ -1,5 +1,5 @@ import 'jasmine'; -import mutator from '../src/mutator'; +import mutator from '../src/mutatorDecorator'; import * as dispatcher from '../src/dispatcher'; import * as globalContext from '../src/globalContext'; import * as mobx from 'mobx'; diff --git a/test/orchestratorTests.ts b/test/orchestratorDecoratorTests.ts similarity index 95% rename from test/orchestratorTests.ts rename to test/orchestratorDecoratorTests.ts index b0ed1b7..7796477 100644 --- a/test/orchestratorTests.ts +++ b/test/orchestratorDecoratorTests.ts @@ -1,5 +1,5 @@ import 'jasmine'; -import orchestrator from '../src/orchestrator'; +import orchestrator from '../src/orchestratorDecorator'; import * as dispatcher from '../src/dispatcher'; describe('orchestrator', () => {