diff --git a/.gitignore b/.gitignore index 93f1361..801e184 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ node_modules npm-debug.log +coverage diff --git a/README.md b/README.md index d7aec9d..f5bceef 100644 --- a/README.md +++ b/README.md @@ -8,60 +8,21 @@ Read [more about Flux here](http://facebook.github.io/flux/docs/overview.html). Dispatcher ---------- -That's rather simple: +Essential, central piece of the Flux architecture, the Dispatcher registers and dispatches action events. -```js -var Dispatcher = DocBrown.createDispatcher(); -``` - -Stores ------- - -### Definition +Creating a dispatcher is rather simple: ```js -var TimeStore = DocBrown.createStore({ - getInitialState: function() { - return {year: 2015}; - } -}); -``` - -### Usage - -```js -var store = new TimeStore(); - -console.log(store.getState().year); // 2015 - -store.subscribe(function(state) { - console.log(state.year); // 1995 - console.log(state === store.getState()); // true -}); +var Dispatcher = DocBrown.createDispatcher(); -store.setState({year: 1995}) +Dispatcher.dispatch("foo"); ``` -### Registering - -Stores need to be registered against the Dispatcher, so it can notify subscribers from state change. - -```js -var timeStore = new TimeStore(); -var plutoniumStore = new PlutoniumStore(); - -// Register stores to be notified by action events. -Dispatcher.register({ - timeStore: timeStore, - plutoniumStore: plutoniumStore -}); -``` +Most of the time, you'll never have to call anything from the Dispatcher; Actions will. Actions ------- -### Definition - Actions are defined using an array of strings, where entries are action names. Actions are responsible of dispatching events on their own, that's why they need to know about the dispatcher. ```js @@ -70,61 +31,87 @@ var TimeActions = DocBrown.createActions(Dispatcher, [ "backward", "forward" ]); + +typeof TimeActions.backward; // "function" +typeof TimeActions.forward; // "function" + +TimeActions.forward(); // dispatches a "forward" action event. ``` -### Conventions +**Note:** Arguments passed to action functions are applied to their matching store methods. -- The name of the action should match the one of the store which should be called; -- Args passed to the action function are applied to the store method. +Stores +------ + +A store reflects the current state of a given application domain data. It: + +- defines initial state; +- alters state; +- subscribes to action events and optionnaly react accordingly (eg. by altering state); +- notifies subscribers from state change events. ```js +var Dispatcher = DocBrown.createDispatcher(); +var TimeActions = DocBrown.createActions(Dispatcher, [ + "backward", + "forward" +]); var TimeStore = DocBrown.createStore({ + actions: [TimeActions], getInitialState: function() { return {year: 2015}; }, - backward: function(years) { - this.setState({year: this.getState().year - years}); + backward: function() { + this.setState({year: this.getState().year - 1}); + }, + forward: function() { + this.setState({year: this.getState().year + 1}); }, - forward: function(years) { - this.setState({year: this.getState().year + years}); - } }); + +// Usage var store = new TimeStore(); -Dispatcher.register({timeStore: timeStore}); +console.log(store.getState().year); // 2015 -timeStore.subscribe = function(state) { - console.log(state.year, state === store.getState()); -}; +store.subscribe(function(state) { + console.log(state.year); // 2016 + console.log(state === store.getState()); // true +}); -Action.forward(20); // 2035, true -Action.backward(20); // 1995, true +store.forward(); ``` -## Asynchronous actions +### Asynchronous actions -**There's no such thing as async actions.** Let's keep the initial need simple and iron out the problem; an asynchronous operation should first call a sync action and then make the store triggering new actions dedicated to handle successes and failures: +**There are no such things as async actions.** Let's keep the initial need simple and iron out the problem; an asynchronous operation should first call a sync action and then make the store triggering new actions dedicated to handle successes and failures: ```js var TimeActions = DocBrown.createActions(Dispatcher, [ "travelBackward", + "travelBackwardStarted", "travelBackwardSucceeded", "travelBackwardFailed" ]); var TimeStore = DocBrown.createStore({ + actions: [TimeActions], getInitialState: function() { return {year: 2015, error: null}; }, travelBackward: function(years) { + TimeActions.travelBackwardStarted(years); setTimeout(function() { if (Math.random() > .5) { - Actions.travelBackwardSucceeded(this.getState().years - years); + TimeActions.travelBackwardSucceeded(this.getState().years - years); } else { - Actions.travelBackwardFailed(new Error("Damn.")); + TimeActions.travelBackwardFailed(new Error("Damn.")); } }.bind(this), 50); }, + travelBackwardStarted: function(years) { + console.warn("Ignition."); + }, travelBackwardSucceeded: function(newYear) { this.setState({year: newYear}); }, @@ -137,16 +124,17 @@ var TimeStore = DocBrown.createStore({ React mixin =========== -This implementation isn't tied to [React](facebook.github.io/react/), though a React mixin is provided. A demo is available in the `demo/` directory. +This Flux implementation isn't tied to [React](facebook.github.io/react/), though a React mixin is conveniently provided. Basic usage: ```js var Dispatcher = DocBrown.createDispatcher(); -var Actions = DocBrown.createActions(Dispatcher, ["travelBy"]); +var TimeActions = DocBrown.createActions(Dispatcher, ["travelBy"]); var TimeStore = DocBrown.createStore({ + actions: [TimeActions], getInitialState: function() { return {year: new Date().getFullYear()}; }, @@ -155,14 +143,12 @@ var TimeStore = DocBrown.createStore({ } }); -Dispatcher.register({timeStore: new TimeStore()}); - var Counter = React.createClass({ - mixins: [DocBrown.storeMixin(Dispatcher, "timeStore")], + mixins: [DocBrown.storeMixin(timeStore)], travelClickHandler: function(years) { return function() { - Actions.travelBy(years); + TimeActions.travelBy(years); }; }, @@ -178,6 +164,8 @@ var Counter = React.createClass({ React.render(, document.body); ``` +A working demo is available in the `demo/` directory in this repository. + Install ======= diff --git a/demo/demo.jsx b/demo/demo.jsx index 205b2ff..0c3b54b 100644 --- a/demo/demo.jsx +++ b/demo/demo.jsx @@ -1,8 +1,9 @@ var Dispatcher = DocBrown.createDispatcher(); -var Actions = DocBrown.createActions(Dispatcher, ["travelBy"]); +var TimeActions = DocBrown.createActions(Dispatcher, ["travelBy"]); var TimeStore = DocBrown.createStore({ + actions: [TimeActions], getInitialState: function() { return {year: new Date().getFullYear()}; }, @@ -11,14 +12,12 @@ var TimeStore = DocBrown.createStore({ } }); -Dispatcher.register({timeStore: new TimeStore()}); - var Counter = React.createClass({ - mixins: [DocBrown.storeMixin(Dispatcher, "timeStore")], + mixins: [DocBrown.storeMixin(new TimeStore())], travelClickHandler: function(years) { return function() { - Actions.travelBy(years); + TimeActions.travelBy(years); }; }, diff --git a/index.js b/index.js index 71cc83f..329e897 100644 --- a/index.js +++ b/index.js @@ -4,29 +4,31 @@ function Dispatcher() { this._stores = {}; + this._registered = {}; } Dispatcher.prototype = { - get stores() { - return this._stores; + get registered() { + return this._registered; }, - dispatch: function(actionName) { - for (var storeName in this.stores) { - var store = this.stores[storeName]; - if (typeof store[actionName] === "function") { - store[actionName].apply(store, [].slice.call(arguments, 1)); - } - } - }, - register: function(stores) { - for (var name in stores) { - if (!this.registered(name)) { - this._stores[name] = stores[name]; - } + register: function(action, store) { + if (this.registeredFor(action).indexOf(store) !== -1) return; + if (this._registered.hasOwnProperty(action)) { + this._registered[action].push(store); + } else { + this._registered[action] = [store]; } }, - registered: function(name) { - return this.stores.hasOwnProperty(name); + dispatch: function(action) { + var actionArgs = [].slice.call(arguments, 1); + (this._registered[action] || []).forEach(function(store) { + if (typeof store[action] === "function") { + store[action].apply(store, actionArgs); + } else {} + }); }, + registeredFor: function(action) { + return this.registered[action] || []; + } }; DocBrown.Dispatcher = Dispatcher; @@ -41,65 +43,98 @@ if (!Array.isArray(actions)) { throw new Error("Invalid actions array"); } - return actions.reduce(function(actions, name) { + var baseActions = actions.reduce(function(actions, name) { actions[name] = dispatcher.dispatch.bind(dispatcher, name); return actions; - }, {}); - }; - - var BaseStorePrototype = { - getState: function() { - return this.__state; - }, - setState: function(state) { - this.__state = state; - this.__listeners.forEach(function(listener) { - listener(state); - }); - }, - subscribe: function(listener) { - this.__listeners.push(listener); - }, - unsubscribe: function(listener) { - this.__listeners = this.__listeners.filter(function(registered) { - return registered !== listener; + }, {_dispatcher: dispatcher, _registered: actions}); + baseActions.only = function() { + if (!arguments.length) return this; + return DocBrown.createActions(dispatcher, [].slice.call(arguments)); + }; + baseActions.drop = function() { + if (!arguments.length) return this; + var exclude = ["drop", "only"].concat([].slice.call(arguments)); + var actions = Object.keys(this).filter(function(name) { + return exclude.indexOf(name) === -1; }); - } + return DocBrown.createActions(dispatcher, actions); + }; + return baseActions; }; function merge(dest) { [].slice.call(arguments, 0).forEach(function(source) { for (var prop in source) { - dest[prop] = source[prop]; + if (prop !== "state") + dest[prop] = source[prop]; } }); return dest; } DocBrown.createStore = function(storeProto) { - function BaseStore() { - var args = [].slice.call(arguments); - this.__state = null; - this.__listeners = []; - if (typeof this.initialize === "function") { - this.initialize.apply(this, args); - } - if (typeof this.getInitialState === "function") { - this.setState(this.getInitialState()); - } + if (typeof storeProto !== "object") { + throw new Error("Invalid store prototype"); } - BaseStore.prototype = merge({}, BaseStorePrototype, storeProto); - return BaseStore; + return (function() { + var __state = {}, __listeners = []; + + // XXX name store to simplify applying mixin + // eg. storeMixin("timeStore") instead of storeMixin(timeStore) + function BaseStore() { + var args = [].slice.call(arguments); + if (typeof this.initialize === "function") { + this.initialize.apply(this, args); + } + if (typeof this.getInitialState === "function") { + this.setState(this.getInitialState()); + } + if (!Array.isArray(this.actions) || this.actions.length === 0) { + throw new Error("Stores must define a non-empty actions array"); + } + this.actions.forEach(function(Actions) { + // XXX check for valid Actions object + var dispatcher = Actions._dispatcher; + Actions._registered.forEach(function(action) { + dispatcher.register(action, this); + }, this); + }, this); + } + + BaseStore.prototype = merge({ + get state() { + return __state; + }, + getState: function() { + return __state; + }, + setState: function(state) { + if (typeof state !== "object") { + throw new Error("setState only accepts objects"); + } + merge(__state, state); + __listeners.forEach(function(listener) { + listener(__state); + }); + }, + subscribe: function(listener) { + __listeners.push(listener); + }, + unsubscribe: function(listener) { + __listeners = __listeners.filter(function(registered) { + return registered !== listener; + }); + } + }, storeProto); + + return BaseStore; + })(); }; - DocBrown.storeMixin = function(dispatcher, storeName) { - if (!(dispatcher instanceof Dispatcher)) { - throw new Error("Invalid dispatcher"); - } - if (!dispatcher.registered(storeName)) { - throw new Error("Unknown store name; did you register it?"); + DocBrown.storeMixin = function(store) { + if (!store) { + throw new Error("Missing store"); } - var store = dispatcher.stores[storeName]; return { getStore: function() { return store; @@ -125,5 +160,7 @@ module.exports = DocBrown; } else if (typeof window === "object") { window.DocBrown = DocBrown; + } else { + console.warn("[DocBrown] Only commonjs and browser DOM are supported."); } })(); diff --git a/package.json b/package.json index c932dde..b88ae88 100644 --- a/package.json +++ b/package.json @@ -4,17 +4,14 @@ "description": "Flux experiment.", "main": "index.js", "scripts": { - "tdd": "mocha watch", - "test": "mocha" + "test": "istanbul cover node_modules/.bin/_mocha --report html --report=lcov" }, "devDependencies": { "chai": "^1.10.0", + "istanbul": "^0.3.5", "mocha": "^2.1.0", "sinon": "^1.12.2" }, - "scripts": { - "test": "mocha" - }, "repository": { "type": "git", "url": "https://github.com/n1k0/docbrown.git" diff --git a/test.js b/test.js index f6cbae6..b8e0582 100644 --- a/test.js +++ b/test.js @@ -17,45 +17,32 @@ describe("DocBrown.createDispatcher()", function() { describe("#register()", function() { var a = {a: 1}, b = {b: 1}; - it("should register stores", function() { - dispatcher.register({a: a, b: b}); + it("should register stores for a given action", function() { + dispatcher.register("foo", a); - expect(dispatcher.stores).eql({a: a, b: b}); + expect(dispatcher.registeredFor("foo")).eql([a]); }); - it("should append stores", function() { - dispatcher.register({a: a}); - dispatcher.register({b: b}); + it("should append new registered store for an action", function() { + dispatcher.register("foo", a); + dispatcher.register("foo", b); - expect(dispatcher.stores).eql({a: a, b: b}); - }); - - it("should not swap stores", function() { - dispatcher.register({a: a}); - dispatcher.register({a: b}); - - expect(dispatcher.stores).eql({a: a}); + expect(dispatcher.registeredFor("foo")).eql([a, b]); }); }); - describe("#registered()", function() { - beforeEach(function() { - dispatcher.register({a: 1}); - }); - - it("should check that a store is registered", function() { - expect(dispatcher.registered("a")).eql(true); - }); + describe("#registeredFor()", function() { + it("should check that a store is registered for an action", function() { + dispatcher.register("foo", {a: 1}); - it("should check that a store is not registered", function() { - expect(dispatcher.registered("b")).eql(false); + expect(dispatcher.registeredFor("foo")).eql([{a: 1}]); }); }); describe("#dispatch()", function() { - it("should notify a listening store", function() { + it("should notify a registered store", function() { var store = {foo: sinon.spy()}; - dispatcher.register({store: store}); + dispatcher.register("foo", store); dispatcher.dispatch("foo", 1, 2, 3); @@ -63,10 +50,11 @@ describe("DocBrown.createDispatcher()", function() { sinon.assert.calledWithExactly(store.foo, 1, 2, 3); }); - it("should notify multiple listening stores", function() { + it("should notify multiple registered stores", function() { var storeA = {foo: sinon.spy()}; var storeB = {foo: sinon.spy()}; - dispatcher.register({storeA: storeA, storeB: storeB}); + dispatcher.register("foo", storeA); + dispatcher.register("foo", storeB); dispatcher.dispatch("foo"); @@ -77,7 +65,7 @@ describe("DocBrown.createDispatcher()", function() { it("should apply store context to listener", function() { var expected = null; var store = {foo: function() {expected = this;}}; - dispatcher.register({store: store}); + dispatcher.register("foo", store); dispatcher.dispatch("foo", 1, 2, 3); @@ -88,6 +76,12 @@ describe("DocBrown.createDispatcher()", function() { }); describe("DocBrown.createActions()", function() { + var dispatcher; + + beforeEach(function() { + dispatcher = DocBrown.createDispatcher(); + }); + it("should require a dispatcher", function() { expect(function() { DocBrown.createActions(); @@ -96,24 +90,23 @@ describe("DocBrown.createActions()", function() { it("should require an actions array", function() { expect(function() { - DocBrown.createActions(DocBrown.createDispatcher()); + DocBrown.createActions(dispatcher); }).to.Throw(/Invalid actions array/); }); it("should create an object with matching action methods", function() { - var actions = DocBrown.createActions(DocBrown.createDispatcher(), ["foo", "bar"]); + var actions = DocBrown.createActions(dispatcher, ["foo", "bar"]); expect(actions).to.include.keys("foo", "bar"); }); it("should create callable action methods", function() { - var actions = DocBrown.createActions(DocBrown.createDispatcher(), ["foo"]); + var actions = DocBrown.createActions(dispatcher, ["foo"]); expect(actions.foo).to.be.a("function"); }); it("should create dispatching action methods", function() { - var dispatcher = DocBrown.createDispatcher(); sinon.stub(dispatcher, "dispatch"); var actions = DocBrown.createActions(dispatcher, ["foo"]); @@ -122,21 +115,78 @@ describe("DocBrown.createActions()", function() { sinon.assert.calledOnce(dispatcher.dispatch); sinon.assert.calledWithExactly(dispatcher.dispatch, "foo", 1, 2, 3); }); + + describe("#only()", function() { + it("should select only selected actions", function() { + var actions = DocBrown.createActions(dispatcher, ["foo", "bar", "baz"]); + var onlyActions = actions.only("foo", "baz"); + + expect(onlyActions).to.include.keys("foo", "baz"); + expect(onlyActions).to.not.include.keys("bar"); + }); + + it("should return initial actions if no arg is provided", function() { + var actions = DocBrown.createActions(dispatcher, ["foo", "bar", "baz"]); + + expect(actions.only()).eql(actions); + }); + }); + + describe("#drop()", function() { + it("should drop selected actions", function() { + var actions = DocBrown.createActions(dispatcher, ["foo", "bar", "baz"]); + var dropActions = actions.drop("foo", "baz"); + + expect(dropActions).to.not.include.keys("foo", "baz"); + expect(dropActions).to.include.keys("bar"); + }); + + it("should return initial actions if no arg is provided", function() { + var actions = DocBrown.createActions(dispatcher, ["foo", "bar", "baz"]); + + expect(actions.drop()).eql(actions); + }); + }); }); describe("DocBrown.createStore()", function() { + var dispatcher, Actions; + + beforeEach(function() { + dispatcher = DocBrown.createDispatcher(); + Actions = DocBrown.createActions(dispatcher, ["foo"]); + }); + + it("should require a store prototype", function() { + expect(function() { + new (DocBrown.createStore())(); + }).to.Throw(/Invalid store prototype/); + }); + it("should create a Store constructor", function() { - expect(DocBrown.createStore({})).to.be.a("function"); + expect(DocBrown.createStore({actions: [Actions]})).to.be.a("function"); + }); + + it("should ensure an actions array is provided", function() { + expect(function() { + new (DocBrown.createStore({}))(); + }).to.Throw(/non-empty actions array/); + }); + + it("should ensure a non-empty actions array is provided", function() { + expect(function() { + new (DocBrown.createStore({actions: []}))(); + }).to.Throw(/non-empty actions array/); }); it("should allow constructing a store", function() { - var Store = DocBrown.createStore({}); + var Store = DocBrown.createStore({actions: [Actions]}); expect(new Store()).to.be.an("object"); }); it("should apply initialize with constructor args if defined", function() { - var proto = {initialize: sinon.spy()}; + var proto = {actions: [Actions], initialize: sinon.spy()}; var Store = DocBrown.createStore(proto); var store = new Store(1, 2, 3); @@ -145,21 +195,25 @@ describe("DocBrown.createStore()", function() { }); it("should apply initialize with the store context", function() { - var Store = DocBrown.createStore({initialize: function(x){this.x = x;}}); - var store = new Store(42); + var Store = DocBrown.createStore({ + actions: [Actions], + initialize: function(x){this.x = x;} + }); + var store = new Store({a: 1}); - expect(store.x).eql(42); + expect(store.x).eql({a: 1}); }); - it("should set a null state by default", function() { - var Store = DocBrown.createStore({}); + it("should set an empty state by default", function() { + var Store = DocBrown.createStore({actions: [Actions]}); var store = new Store(); - expect(store.getState()).eql(null); + expect(store.state).eql({}); }); it("should set initial state if method is defined", function() { var Store = DocBrown.createStore({ + actions: [Actions], getInitialState: function() {return {};} }); var store = new Store(); @@ -167,11 +221,91 @@ describe("DocBrown.createStore()", function() { expect(store.getState()).eql({}); }); + it("should register subscribed actions against the dispatcher", function() { + var Store = DocBrown.createStore({ + actions: [DocBrown.createActions(dispatcher, ["foo", "bar"]), + DocBrown.createActions(dispatcher, ["bar", "baz"])] + }); + var store = new Store(); + + expect(dispatcher.registeredFor("foo")).eql([store]); + expect(dispatcher.registeredFor("bar")).eql([store]); + }); + + describe("#getState()", function() { + it("should get current state", function() { + var Store = DocBrown.createStore({ + actions: [Actions], + getInitialState: function() {return {foo: 42};} + }); + var store = new Store(); + + expect(store.getState().foo).eql(42); + }); + }); + + describe("#get state()", function() { + it("should get current state", function() { + var Store = DocBrown.createStore({ + actions: [Actions], + getInitialState: function() {return {foo: 42};} + }); + var store = new Store(); + + expect(store.state.foo).eql(42); + }); + }); + + describe("#setState()", function() { + it("should set current state", function() { + var Store = DocBrown.createStore({ + actions: [Actions], + getInitialState: function() {return {foo: 42};} + }); + var store = new Store(); + + store.setState({foo: 43}); + + expect(store.state.foo).eql(43); + }); + + it("should merge state properties", function() { + var Store = DocBrown.createStore({ + actions: [Actions], + getInitialState: function() {return {foo: 42, bar: 1};} + }); + var store = new Store(); + + store.setState({foo: 43}); + + expect(store.state).eql({foo: 43, bar: 1}); + }); + + it("should notify subscribers", function() { + var Store = DocBrown.createStore({ + actions: [Actions], + getInitialState: function() {return {foo: 42};} + }); + var store = new Store(); + var subscriber1 = sinon.spy(); + var subscriber2 = sinon.spy(); + store.subscribe(subscriber1); + store.subscribe(subscriber2); + + store.setState({foo: 43}); + + sinon.assert.calledOnce(subscriber1); + sinon.assert.calledOnce(subscriber2); + sinon.assert.calledWithExactly(subscriber1, {foo: 43}); + sinon.assert.calledWithExactly(subscriber2, {foo: 43}); + }); + }); + describe("#subscribe()", function() { var store; beforeEach(function() { - var Store = DocBrown.createStore({}); + var Store = DocBrown.createStore({actions: [Actions]}); store = new Store(); }); @@ -183,7 +317,7 @@ describe("DocBrown.createStore()", function() { store.setState({}); }); - it("should notify change listener with new state", function(done) { + it("should notify subscribers with new state", function(done) { var newState = {foo: "bar"}; store.subscribe(function(state) { expect(state === newState); @@ -196,7 +330,7 @@ describe("DocBrown.createStore()", function() { describe("#unsubscribe()", function() { it("should unsubscribe a registered listener", function() { - var Store = DocBrown.createStore({}); + var Store = DocBrown.createStore({actions: [Actions]}); var store = new Store(); var listener = sinon.spy(); store.subscribe(listener); @@ -211,27 +345,21 @@ describe("DocBrown.createStore()", function() { }); describe("DocBrown.storeMixin()", function() { - it("should require a Dispatcher", function() { + it("should require a store", function() { expect(function() { DocBrown.storeMixin(); - }).to.Throw("Invalid dispatcher"); - }); - - it("should require a registered store name", function() { - expect(function() { - DocBrown.storeMixin(DocBrown.createDispatcher()); - }).to.Throw(/Unknown store name/); + }).to.Throw("Missing store"); }); describe("constructed", function() { - var dispatcher, store, mixin; + var dispatcher, Actions, store, mixin; beforeEach(function() { dispatcher = DocBrown.createDispatcher(); - var Store = DocBrown.createStore({}); + Actions = DocBrown.createActions(dispatcher, ["foo"]); + var Store = DocBrown.createStore({actions: [Actions]}); store = new Store(); - dispatcher.register({store: store}); - mixin = DocBrown.storeMixin(dispatcher, "store"); + mixin = DocBrown.storeMixin(store); }); it("should create an object", function() {