diff --git a/package.json b/package.json index c8a484d..f40c764 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "uglifyify": "^3.0.1" }, "dependencies": { - "array.prototype.find": "^1.0.0" + "array.prototype.find": "^1.0.0", + "rx-lite": "^3.1.2" } } diff --git a/spec/shared.js b/spec/shared.js index 564c15c..5f3ea86 100644 --- a/spec/shared.js +++ b/spec/shared.js @@ -8,6 +8,7 @@ import "./store/core/define-spec"; import "./store/core/find-spec"; import "./store/core/push-spec"; import "./store/core/remove-spec"; +import "./store/events/observable-spec"; import "./store/events/off-spec"; import "./store/events/on-spec"; import "./store/fields/attr-spec"; diff --git a/spec/store/events/observable-spec.js b/spec/store/events/observable-spec.js new file mode 100644 index 0000000..6013aca --- /dev/null +++ b/spec/store/events/observable-spec.js @@ -0,0 +1,72 @@ +import test from "tape-catch"; +import sinon from "sinon"; +import Store from "../../../src/store"; + +test("observable must fire an added event when a resource is added to the store", function (t) { + var store = new Store(); + var listener = sinon.spy(); + t.plan(5); + store.define("products", {}); + store.observable.subscribe(listener); + store.add({ + "type": "products", + "id": "1" + }); + t.equal(listener.callCount, 1); + t.equal(listener.firstCall.args[0].event, "added"); + t.equal(listener.firstCall.args[0].type, "products"); + t.equal(listener.firstCall.args[0].id, "1"); + t.equal(listener.firstCall.args[0].value, store.find("products", "1")); +}); + +test("observable must fire an updated event when a resource is update in the store", function (t) { + var store = new Store(); + var listener = sinon.spy(); + t.plan(5); + store.define("products", { + title: Store.attr() + }); + store.add({ + "type": "products", + "id": "1", + "attributes": { + "title": "foo" + } + }); + store.observable.subscribe(listener); + store.add({ + "type": "products", + "id": "1", + "attributes": { + "title": "bar" + } + }); + t.equal(listener.callCount, 1); + t.equal(listener.firstCall.args[0].event, "updated"); + t.equal(listener.firstCall.args[0].type, "products"); + t.equal(listener.firstCall.args[0].id, "1"); + t.equal(listener.firstCall.args[0].value, store.find("products", "1")); +}); + +test("observable must fire a removed event when a resource is removed from the store", function (t) { + var store = new Store(); + var listener = sinon.spy(); + t.plan(5); + store.define("products", { + title: Store.attr() + }); + store.add({ + "type": "products", + "id": "1", + "attributes": { + "title": "foo" + } + }); + store.observable.subscribe(listener); + store.remove("products", "1"); + t.equal(listener.callCount, 1); + t.equal(listener.firstCall.args[0].event, "removed"); + t.equal(listener.firstCall.args[0].type, "products"); + t.equal(listener.firstCall.args[0].id, "1"); + t.equal(listener.firstCall.args[0].value, null); +}); diff --git a/src/store.js b/src/store.js index 3846a57..cd2ff01 100644 --- a/src/store.js +++ b/src/store.js @@ -1,4 +1,5 @@ import "array.prototype.find"; +import Rx from "rx-lite"; import AjaxAdapter from "./ajax-adapter"; export default class Store { @@ -93,11 +94,15 @@ export default class Store { } constructor(adapter) { + this._adapter = adapter; - this._collectionListeners = { "added": {}, "updated": {}, "removed": {} }; this._data = {}; - this._resourceListeners = { "added": {}, "updated": {}, "removed": {} }; + this._subject = new Rx.Subject(); + this._subscriptions = {}; this._types = {}; + + this.observable = this._subject.asObservable(); + } /** @@ -120,12 +125,12 @@ export default class Store { this._addField(object, resource, definition, fieldName); } }); - if (this._resourceListeners[event][object.type] && this._resourceListeners[event][object.type][object.id]) { - this._resourceListeners[event][object.type][object.id].forEach(x => x[0].call(x[1], resource)); - } - if (this._collectionListeners[event][object.type]) { - this._collectionListeners[event][object.type].forEach(x => x[0].call(x[1], resource)); - } + this._subject.onNext({ + event: event, + type: object.type, + id: object.id, + value: resource + }); } else { throw new TypeError(`The data must have a type and id`); } @@ -329,25 +334,13 @@ export default class Store { * @return {undefined} - Nothing. */ off(event, type, id, callback) { - if (this._resourceListeners[event] && this._collectionListeners[event]) { + if (event === "added" || event === "updated" || event === "removed") { if (this._types[type]) { if (id && ({}).toString.call(id) === '[object Function]') { this.off.call(this, event, type, null, id, callback); - } else { - // TODO: Performance-wise, this can be made way better. There shouldn't be a need to maintain separate lists. - this._types[type]._names.forEach(type => { - if (id) { - if (this._resourceListeners[event][type] && this._resourceListeners[event][type][id]) { - this._resourceListeners[event][type][id] = this._resourceListeners[event][type][id].filter(x => { - return x[0] !== callback; - }); - } - } else if (this._collectionListeners[event][type]) { - this._collectionListeners[event][type] = this._collectionListeners[event][type].filter(x => { - return x[0] !== callback; - }); - } - }); + } else if (this._subscriptions[event] && this._subscriptions[event][type] && this._subscriptions[event][type][id || "*"]) { + this._subscriptions[event][type][id || "*"].dispose(); + delete this._subscriptions[event][type][id || "*"]; } } else { throw new Error(`Unknown type '${type}'`); @@ -369,26 +362,25 @@ export default class Store { * @return {undefined} - Nothing. */ on(event, type, id, callback, context) { - if (this._resourceListeners[event] && this._collectionListeners[event]) { + if (event === "added" || event === "updated" || event === "removed") { if (this._types[type]) { if (id && ({}).toString.call(id) === '[object Function]') { this.on.call(this, event, type, null, id, callback); - } else { - // TODO: Performance-wise, this can be made way better. There shouldn't be a need to maintain separate lists. - this._types[type]._names.forEach(type => { - if (id) { - this._resourceListeners[event][type] = this._resourceListeners[event][type] || {}; - this._resourceListeners[event][type][id] = this._resourceListeners[event][type][id] || []; - if (!this._resourceListeners[event][type][id].find(x => x[0] === callback)) { - this._resourceListeners[event][type][id].push([ callback, context ]); - } - } else { - this._collectionListeners[event][type] = this._collectionListeners[event][type] || []; - if (!this._collectionListeners[event][type].find(x => x[0] === callback)) { - this._collectionListeners[event][type].push([ callback, context ]); - } - } - }); + } else if (!this._subscriptions[event] || !this._subscriptions[event][type] || !this._subscriptions[event][type][id || "*"]) { + let subscription = this._subject.filter(e => e.event === event); + subscription = subscription.filter(e => this._types[type]._names.indexOf(e.type) !== -1); + if (id) { + subscription = subscription.filter(e => e.id === id); + } + subscription = subscription.map(e => this.find(e.type, e.id)); + this._subscriptions[event] = this._subscriptions[event] || {}; + if (!this._subscriptions[event][type]) { + let obj = {}; + this._types[type]._names.forEach(type => { + this._subscriptions[event][type] = obj; + }); + } + this._subscriptions[event][type][id || "*"] = subscription.subscribe(callback.bind(context)); } } else { throw new Error(`Unknown type '${type}'`); @@ -434,12 +426,12 @@ export default class Store { let resource = this._data[type] && this._data[type][id]; if (resource) { this._remove(resource); - if (this._resourceListeners["removed"][type] && this._resourceListeners["removed"][type][id]) { - this._resourceListeners["removed"][type][id].forEach(x => x[0].call(x[1], resource)); - } - if (this._collectionListeners["removed"][type]) { - this._collectionListeners["removed"][type].forEach(x => x[0].call(x[1], resource)); - } + this._subject.onNext({ + event: "removed", + type: type, + id: id, + value: null + }); } } else { Object.keys(this._data[type]).forEach(id => this.remove(type, id)); -- 2.51.2 From f4bec1df45206ad68e7a1692e01e0f8de4dc1b5e Mon Sep 17 00:00:00 2001 From: Haydn Ewers Date: Thu, 1 Oct 2015 16:56:13 +0930 Subject: [PATCH 2/4] Improve docs and consistency in the events API. --- README.md | 7 ++-- package.json | 2 +- spec/store/events/observable-spec.js | 12 +++---- src/store.js | 49 ++++++++++++++++++++++++---- 4 files changed, 54 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index b4e9ef9..f6b21a8 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,9 @@ # JSON API Store [![Build Status](https://travis-ci.org/haydn/json-api-store.svg?branch=master)](https://travis-ci.org/haydn/json-api-store) [![NPM Version](https://badge.fury.io/js/json-api-store.svg)](http://badge.fury.io/js/json-api-store) -A lightweight JavaScript library that acts as a data store and uses the -[JSON API](http://jsonapi.org) specification. You can use it in the browser to -easily communicate with a JSON API compliant server. +An isomorphic JavaScript library that acts as an in memory data store for +[JSON API](http://jsonapi.org) data. Changes are +broadcast using [RxJS](https://github.com/Reactive-Extensions/RxJS). Built to +work with [React](https://facebook.github.io/react/). ## Usage diff --git a/package.json b/package.json index f40c764..f7ee542 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "json-api-store", "version": "0.5.1", - "description": "A lightweight library for using JSON API in the browser.", + "description": "An isomorphic JavaScript library that acts as an in memory data store for JSON API data.", "repository": "haydn/json-api-store", "main": "dist/store.js", "scripts": { diff --git a/spec/store/events/observable-spec.js b/spec/store/events/observable-spec.js index 6013aca..530ded3 100644 --- a/spec/store/events/observable-spec.js +++ b/spec/store/events/observable-spec.js @@ -13,10 +13,10 @@ test("observable must fire an added event when a resource is added to the store" "id": "1" }); t.equal(listener.callCount, 1); - t.equal(listener.firstCall.args[0].event, "added"); + t.equal(listener.firstCall.args[0].name, "added"); t.equal(listener.firstCall.args[0].type, "products"); t.equal(listener.firstCall.args[0].id, "1"); - t.equal(listener.firstCall.args[0].value, store.find("products", "1")); + t.equal(listener.firstCall.args[0].resource, store.find("products", "1")); }); test("observable must fire an updated event when a resource is update in the store", function (t) { @@ -42,10 +42,10 @@ test("observable must fire an updated event when a resource is update in the sto } }); t.equal(listener.callCount, 1); - t.equal(listener.firstCall.args[0].event, "updated"); + t.equal(listener.firstCall.args[0].name, "updated"); t.equal(listener.firstCall.args[0].type, "products"); t.equal(listener.firstCall.args[0].id, "1"); - t.equal(listener.firstCall.args[0].value, store.find("products", "1")); + t.equal(listener.firstCall.args[0].resource, store.find("products", "1")); }); test("observable must fire a removed event when a resource is removed from the store", function (t) { @@ -65,8 +65,8 @@ test("observable must fire a removed event when a resource is removed from the s store.observable.subscribe(listener); store.remove("products", "1"); t.equal(listener.callCount, 1); - t.equal(listener.firstCall.args[0].event, "removed"); + t.equal(listener.firstCall.args[0].name, "removed"); t.equal(listener.firstCall.args[0].type, "products"); t.equal(listener.firstCall.args[0].id, "1"); - t.equal(listener.firstCall.args[0].value, null); + t.equal(listener.firstCall.args[0].resource, null); }); diff --git a/src/store.js b/src/store.js index cd2ff01..07b1732 100644 --- a/src/store.js +++ b/src/store.js @@ -101,6 +101,43 @@ export default class Store { this._subscriptions = {}; this._types = {}; + /** + * An observable that will emit events when any resource in added, updated + * or removed. The object passed to listeners will be in this format: + * + *

+     * { name: string, type: string, id: string, resource: object }
+     * 

+ * + * You can learn more about RxJS observables at the GitHub repo: + * https://github.com/Reactive-Extensions/RxJS + * + * @type {Rx.Observable} + * + * @example + * let store = new Store(); + * + * store.observable.filter(e => e.name === "added").subscribe(event => { + * console.log(event.name); // "added" + * console.log(event.type); // "products" + * console.log(event.id); // "1" + * console.log(event.resource); // Map {...} + * }); + * + * store.observable.filter(e => e.name === "updated").subscribe(event => { + * console.log(event.name); // "updated" + * console.log(event.type); // "products" + * console.log(event.id); // "1" + * console.log(event.resource); // Map {...} + * }); + * + * store.observable.filter(e => e.name === "removed").subscribe(event => { + * console.log(event.name); // "removed" + * console.log(event.type); // "products" + * console.log(event.id); // "1" + * console.log(event.resource); // null + * }); + */ this.observable = this._subject.asObservable(); } @@ -117,7 +154,7 @@ export default class Store { add(object) { if (object) { if (object.type && object.id) { - let event = this._data[object.type] && this._data[object.type][object.id] ? "updated" : "added"; + let name = this._data[object.type] && this._data[object.type][object.id] ? "updated" : "added"; let resource = this.find(object.type, object.id); let definition = this._types[object.type]; Object.keys(definition).forEach(fieldName => { @@ -126,10 +163,10 @@ export default class Store { } }); this._subject.onNext({ - event: event, + name: name, type: object.type, id: object.id, - value: resource + resource: resource }); } else { throw new TypeError(`The data must have a type and id`); @@ -367,7 +404,7 @@ export default class Store { if (id && ({}).toString.call(id) === '[object Function]') { this.on.call(this, event, type, null, id, callback); } else if (!this._subscriptions[event] || !this._subscriptions[event][type] || !this._subscriptions[event][type][id || "*"]) { - let subscription = this._subject.filter(e => e.event === event); + let subscription = this._subject.filter(e => e.name === event); subscription = subscription.filter(e => this._types[type]._names.indexOf(e.type) !== -1); if (id) { subscription = subscription.filter(e => e.id === id); @@ -427,10 +464,10 @@ export default class Store { if (resource) { this._remove(resource); this._subject.onNext({ - event: "removed", + name: "removed", type: type, id: id, - value: null + resource: null }); } } else { -- 2.51.2 From c4716f9980ebdc03c5ec7ae8d4c3910e20170cb8 Mon Sep 17 00:00:00 2001 From: Haydn Ewers Date: Fri, 2 Oct 2015 13:05:07 +0930 Subject: [PATCH 3/4] Add bind() polyfill to the tests. --- spec/shared.js | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/spec/shared.js b/spec/shared.js index 5f3ea86..cdc5835 100644 --- a/spec/shared.js +++ b/spec/shared.js @@ -1,3 +1,31 @@ +if (!Function.prototype.bind) { + Function.prototype.bind = function(oThis) { + if (typeof this !== 'function') { + // closest thing possible to the ECMAScript 5 + // internal IsCallable function + throw new TypeError('Function.prototype.bind - what is trying to be bound is not callable'); + } + + var aArgs = Array.prototype.slice.call(arguments, 1), + fToBind = this, + fNOP = function() {}, + fBound = function() { + return fToBind.apply(this instanceof fNOP + ? this + : oThis, + aArgs.concat(Array.prototype.slice.call(arguments))); + }; + + if (this.prototype) { + // native functions don't have a prototype + fNOP.prototype = this.prototype; + } + fBound.prototype = new fNOP(); + + return fBound; + }; +} + import "./store/clud/create-spec"; import "./store/clud/destroy-spec"; import "./store/clud/load-spec"; -- 2.51.2 From ea0bd1c05762d35f1289a49325cdcf8266b57437 Mon Sep 17 00:00:00 2001 From: Haydn Ewers Date: Fri, 2 Oct 2015 14:07:41 +0930 Subject: [PATCH 4/4] Improve documentation. --- README.md | 27 ++++++++++++++++----------- src/.eslintrc | 1 - src/store.js | 15 ++++----------- 3 files changed, 20 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index f6b21a8..69285dc 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ At the moment the primary use can for JSON API Store is in the browser: ```javascript // Create a new store instance. -var adapter = new Store.AjaxAdapter({ base: "http://example.com/api/v1" }); +var adapter = new Store.AjaxAdapter({ base: "/api/v1" }); var store = new Store(adapter); // Define the "categories" type. @@ -29,6 +29,11 @@ store.define([ "products", "product" ], { category: Store.hasOne() }); +// Subscribe to events using RxJS. +store.observable.subscribe(function (event) { + console.log(event.name, event.type, event.id, event.resource); +}); + // Load all the products. store.load("products", { include: [ "category" ] }, function (products) { @@ -47,11 +52,11 @@ store.load("products", { include: [ "category" ] }, function (products) { ### Node -You can also use JSON API Store in a Node.js environment (adapters that work in -a Node.js are in the works): +You can also use JSON API Store in a Node.js environment (currently, there +aren't any adapters that work in a Node.js): -**NOTE**: Without an adapter the `create`, `load`, `update` and `destroy` -methods cannot be used. +**NOTE**: Without an adapter the CLUD methods (`create`, `load`, `update` and +`destroy`) cannot be used. ```javascript @@ -97,6 +102,12 @@ store.find("products", "1").category.title; // "Example Category" ``` +## Documentation + +Full documentation is available on the website: + +http://particlesystem.com/json-api-store/ + ## Installing #### NPM @@ -114,9 +125,3 @@ bower i json-api-store #### Download To use directly in the browser you can grab the [store.prod.js](https://raw.githubusercontent.com/haydn/json-api-store/master/dist/store.prod.js) file. - -## Documentation - -Documentation is available on the website: - -http://particlesystem.com/json-api-store/ diff --git a/src/.eslintrc b/src/.eslintrc index 03aab31..c02382a 100644 --- a/src/.eslintrc +++ b/src/.eslintrc @@ -57,7 +57,6 @@ "no-with": 2, "radix": 2, "semi": 2, - "valid-jsdoc": 2, "vars-on-top": 2, "wrap-iife": 2, "yoda": 2 diff --git a/src/store.js b/src/store.js index 1de2295..0508cf9 100644 --- a/src/store.js +++ b/src/store.js @@ -134,6 +134,7 @@ export default class Store { * https://github.com/Reactive-Extensions/RxJS * * @type {Rx.Observable} + * @since 0.6.0 * * @example * let store = new Store(); @@ -168,9 +169,8 @@ export default class Store { * `push()` method. * * @since 0.1.0 - * @param {!Object} object - Resource Object to add. See: + * @param {!Object} object - A JSON API Resource Object to be added. See: http://jsonapi.org/format/#document-resource-objects - * @return {undefined} - Nothing. */ add(object) { if (object) { @@ -240,7 +240,6 @@ export default class Store { * @param {function} [success] - Callback on success. * @param {function} [error] - Callback on error. * @param {Object} [context] - Context for the callbacks. - * @return {undefined} - Nothing. * * @example * let adapter = new Store.AjaxAdapter(); @@ -263,7 +262,6 @@ export default class Store { * @since 0.2.0 * @param {!string|string[]} names - Name(s) of the resource. * @param {!Object} definition - The resource's definition. - * @return {undefined} - Nothing. */ define(names, definition) { names = (names.constructor === Array) ? names : [ names ]; @@ -291,7 +289,6 @@ export default class Store { * @param {function} [success] - Callback on success. * @param {function} [error] - Callback on error. * @param {Object} [context] - Context for the callbacks. - * @return {undefined} - Nothing. * * @example * let adapter = new Store.AjaxAdapter(); @@ -364,7 +361,6 @@ export default class Store { * @param {function} [success] - Callback on success. * @param {function} [error] - Callback on error. * @param {Object} [context] - Context for the callbacks. - * @return {undefined} - Nothing. * * @example * let adapter = new Store.AjaxAdapter(); @@ -384,12 +380,12 @@ export default class Store { /** * Unregister an event listener that was registered with on(). * + * @deprecated Use the store.observable property instead of this. * @since 0.4.0 * @param {string} event - Name of the event. * @param {string} type - Name of resource to originally passed to on(). * @param {string} [id] - ID of the resource to originally passed to on(). * @param {function} callback - Function originally passed to on(). - * @return {undefined} - Nothing. */ off(event, type, id, callback) { if (event === "added" || event === "updated" || event === "removed") { @@ -411,13 +407,13 @@ export default class Store { /** * Register an event listener: "added", "updated" or "removed". * + * @deprecated Use the store.observable property instead of this. * @since 0.4.0 * @param {string} event - Name of the event. * @param {string} type - Name of resource to watch. * @param {string} [id] - ID of the resource to watch. * @param {function} callback - Function to call when the event occurs. * @param {Object} [context] - Context in which to call the callback. - * @return {undefined} - Nothing. */ on(event, type, id, callback, context) { if (event === "added" || event === "updated" || event === "removed") { @@ -455,7 +451,6 @@ export default class Store { * @since 0.1.0 * @param {Object} root - Top Level Object to push. See: http://jsonapi.org/format/#document-top-level - * @return {undefined} - Nothing. */ push(root) { if (root.data.constructor === Array) { @@ -475,7 +470,6 @@ export default class Store { * @param {!string} type - Type of the resource(s) to remove. * @param {string} [id] - The id of the resource to remove. If omitted all * resources of the type will be removed. - * @return {undefined} - Nothing. */ remove(type, id) { if (type) { @@ -513,7 +507,6 @@ export default class Store { * @param {function} [success] - Callback on success. * @param {function} [error] - Callback on error. * @param {Object} [context] - Context for the callbacks. - * @return {undefined} - Nothing. * * @example * let adapter = new Store.AjaxAdapter();