From acd82b37e35d1727ef5d3c893203e918c74035d7 Mon Sep 17 00:00:00 2001 From: David Rekow Date: Thu, 11 Dec 2014 21:00:30 -0800 Subject: [PATCH] Observable.remove() and tests. - Add Observable.prototype.remove() - panoptic() now optionally takes root Observable - Add tests for remove() and replace() - Add remove(), updated replace() in README and extern --- README.md | 43 ++++++++++- dist/panoptic.min.js | 7 +- package.json | 2 +- src/extern.js | 7 +- src/index.js | 109 ++++++++++++++++++--------- test/spec.js | 172 +++++++++++++++++++++++++++++++++++++++---- 6 files changed, 286 insertions(+), 54 deletions(-) diff --git a/README.md b/README.md index a7b765c..b7350f0 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ Add to your `dependencies` in `package.json`: ```javascript ... "dependencies": { - "panoptic": "~0.0.5", + "panoptic": "~0.0.6", ... }, ... @@ -75,12 +75,51 @@ observable.set({ // as a fully-structured object - will be set as diff ``` Setting via object property syntax only works if the key has already been seen - if you're adding a new key, use `set()` to ensure the observation chain is set up. +###removing +```javascript +observable.remove('a.b'); +observable.set('a.b', null); +observable.a.b = null; +``` +To remove a key from an observable object call `remove()`, or simply set it to `null`. +If the key currently points to a nested object, watchers for any existing nested +properties will be invoked before removing the key: +```javascript +observable = panoptic({ + a: { + b: { + c: 1 + } + } +}); + +observable.watch('a.b.c', function (value) { + console.log('"a.b.c" value: ' + value); +}); +observable.watch('a.b.d', function (value) { + console.log('"a.b.d" value: ' + value); +}); +observable.watch('a.b', function (value) { + console.log('"a.b" value: ' + value); +}); + +observable.remove('a.b'); +``` +outputs +``` +"a.b.c" value: null +"a.b" value: null +``` +Because the key `a.b.d` had not yet been set, its watcher did not fire. ###replacing ```javascript observable.replace({key: 'newValue'}); ``` Calling `replace()` replaces the current observed data entirely with the passed data, -triggering watchers for removed, modified and added keys. +triggering watchers for removed, modified and added keys. This method uses `remove()` +behind the scenes, so only watchers for existing properties will fire upon removal +or modification. Any already-registered watchers for properties being added will be +invoked. ###watching ```javascript observable.watch('a.b.c', function (newValue) { diff --git a/dist/panoptic.min.js b/dist/panoptic.min.js index 366734b..9f70449 100644 --- a/dist/panoptic.min.js +++ b/dist/panoptic.min.js @@ -2,7 +2,8 @@ David Rekow 2014 */ function e(a,b,c){var d;this._prop={};this._cb={};b&&(c=c||"",Object.defineProperty(this,"_root",{value:b}),Object.defineProperty(this,"_path",{value:c}));for(d in a)a.hasOwnProperty(d)&&"function"!==typeof a[d]&&this.bind(d,a[d])} -e.prototype={get:function(a){return k(this,a)},set:function(a,b){if("object"===typeof a&&"undefined"===typeof b)for(a in b=a,b)b.hasOwnProperty(a)&&k(this,a,b[a]);else k(this,a,b)},watch:function(a,b){if(this._root)return this._root.watch(this._path+"."+a,b);this._cb[a]||(this._cb[a]=[]);this._cb[a].push(b)},unwatch:function(a,b){var c;if(this._root)return this._root.unwatch(this._path+"."+a,b);this._cb[a]&&(b?(c=this._cb[a].indexOf(b),-1} data */ @@ -45,6 +50,6 @@ Observable.prototype.unwatch = function (key, observer) {}; Observable.prototype.toJSON = function () {}; /** - * @typedef {function(?): Observable} + * @typedef {function(?, Observable=): Observable} */ var panoptic; diff --git a/src/index.js b/src/index.js index 0e497d2..68f484e 100644 --- a/src/index.js +++ b/src/index.js @@ -66,26 +66,62 @@ Observable.prototype = { return; } + Observable.resolve(this, /** @type {string} */(key), value); }, + /** + * Removes a key from the observed object, triggering all nested watchers. + * @expose + * @param {string} key + */ + remove: function (key) { + var k; + + if (this._root) + return this._root.remove(this._path + '.' + key); + + for (k in this._cb) { + if (this._cb.hasOwnProperty(k) && + k.indexOf(key) === 0 && + k !== key && + this.get(k)) + this.emit(k, null, this); + } + + this[key] = undefined; + }, + /** * Replaces the current observed object with a new object. Triggers change events for * all removed, modified and added keys. - * @param {Object.} data + * @expose + * @param {!Object.} data + * @throws {TypeError} If data is not an object. */ replace: function (data) { - var current = this._prop, - key; + /*jshint eqnull:true */ + var added = {}, + key, value; - for (key in current) { - if (current.hasOwnProperty(key) && data[key] == null) - this.set(key, null); + if (typeof data !== 'object') + throw new TypeError('Fragment.replace() expects an object as the only parameter.'); + + for (key in this._prop) { + if (this._prop.hasOwnProperty(key) && data[key] === undefined) + this[key] = null; } - this._prop = {}; + for (key in data) { + if (data.hasOwnProperty(key)) { + value = this.get(key); + + if (value instanceof Observable) + value.replace(data[key] || {}); - this.set(data); + this.set(key, data[key]); + } + } }, /** @@ -159,29 +195,6 @@ Observable.prototype = { */ constructor: Observable, - /** - * Emits a change event for a particular key. - * @private - * @param {string} key - * @param {?} value - * @param {Observable} observed - */ - emit: function (key, value, observed) { - var observers; - - if (this._root) - return this._root.emit(this._path + '.' + key, value, observed); - - observers = this._cb[key]; - - if (!observers) - return; - - observers.forEach(function (observer) { - observer.call(observed, value); - }); - }, - /** * Binds observation to a particular key path and value. * @private @@ -211,6 +224,9 @@ Observable.prototype = { * @param {?} value */ set: function (value) { + if (value === null) + return this.remove(key); + this._prop[key] = value; this.emit(key, value, this); } @@ -219,6 +235,29 @@ Observable.prototype = { this.set(key, value); }, + /** + * Emits a change event for a particular key. + * @private + * @param {string} key + * @param {?} value + * @param {Observable} observed + */ + emit: function (key, value, observed) { + var observers; + + if (this._root) + return this._root.emit(this._path + '.' + key, value, observed); + + observers = this._cb[key]; + + if (!observers) + return; + + observers.forEach(function (observer) { + observer.call(observed, value); + }); + }, + /** * Sets another Observable object as the root for the current object. * @private @@ -298,10 +337,14 @@ Observable.resolve = function (observed, key, value) { * Return a new Observable object. * @expose * @param {?} data + * @param {Observable=} root * @return {Observable} */ -var panoptic = function (data) { - return new Observable(data); +var panoptic = function (data, root) { + if (data instanceof Observable) + return root ? (data._root = root, data) : data; + + return new Observable(data, root); }; if (typeof window !== 'undefined' && window.self === window) { diff --git a/test/spec.js b/test/spec.js index 616b474..45b39b9 100644 --- a/test/spec.js +++ b/test/spec.js @@ -35,27 +35,32 @@ describe("Panopt module", function () { }); it("instantiates an observable object proxy, also proxying subobjects", function () { - equal(observed.constructor.name, "Observable", "observed object's constructor is Observable"); - equal(observed.b.constructor.name, "Observable", "nested observed object is also Observable"); - equal(observed.b.f.constructor.name, "Observable", "deeply nested observed object is also Observable"); + equal(observed.constructor.name, "Observable", + "observed object's constructor should be Observable, got " + observed.constructor.name); + equal(observed.b.constructor.name, "Observable", + "nested observed object should be Observable, got " + observed.b.constructor.name); + equal(observed.b.f.constructor.name, "Observable", + "deeply nested observed object should be Observable, got " + observed.b.f.constructor.name); }); it("only observes and proxies instance properties", function () { var ct = function () { this.b = 2; }; ct.prototype.a = 1; observed = panoptic(new ct()); - equal(observed.a, undefined, "prototype.a is undefined on observable proxy"); - equal(observed.b, 2, "b is 2"); - }) + equal(observed.a, undefined, + "prototype property 'a' should be undefined on observable proxy, got " + observed.a); + equal(observed.b, 2, + "instance property 'b' should be 2 on observable proxy, got " + observed.b); + }); it("resolves object properties", function () { - equal(observed.a, 1, "a is 1"); - equal(observed.b.c, 2, "b.c is 2"); - equal(observed.b.d, "3", "b.d is '3'"); - equal(observed.b.e[0], 4, "b.e[0] is 4"); - equal(observed.b.f.g, "5", "b.f.g is '5'"); - equal(observed.b.h, 6, "b.h is 6"); - equal(observed.b.i, undefined, "b.i is undefined"); + equal(observed.a, 1, "a should be 1, got " + observed.a); + equal(observed.b.c, 2, "b.c should be 2, got " + observed.b.c); + equal(observed.b.d, "3", "b.d should be '3', got " + observed.b.d); + equal(observed.b.e[0], 4, "b.e[0] should be 4, got " + observed.b.e[0]); + equal(observed.b.f.g, "5", "b.f.g should be '5', got " + observed.b.f.g); + equal(observed.b.h, 6, "b.h should be 6, got " + observed.b.h); + equal(observed.b.i, undefined, "b.i should be undefined, got " + observed.b.i); }); it("resolves nested namespaces from the root", function () { @@ -362,6 +367,145 @@ describe("Panopt module", function () { }); it("serializes to JSON", function () { - equal(JSON.stringify(observed), JSON.stringify(data), "JSON strings are equal"); + equal(JSON.stringify(observed), JSON.stringify(data), + "expected JSON " + JSON.stringify(observed) + " to equal JSON " + JSON.stringify(data)); }); + + it("removes a key from the observed object, triggering any nested watchers", function () { + var updated = 0, + called = []; + + observed.watch('b', function () { + called.push('b'); updated++; + }); + observed.watch('b.c', function () { + called.push('b.c'); updated++; + }); + observed.watch('b.d', function () { + called.push('b.d'); updated++; + }); + observed.watch('b.e', function () { + called.push('b.e'); updated++; + }); + observed.watch('b.f.g', function () { + called.push('b.f.g'); updated++; + }); + observed.watch('b.h', function () { + called.push('b.h'); updated++; + }); + + observed.remove('b'); + + equal(updated, 6, + "expected exactly 6 watchers to be called, called " + updated + ": " + called.join(',')); + }); + + it("removes a key from the observed object, only triggering watchers for existing properties", function () { + var updated = 0, + called = []; + + observed.watch('b', function () { + called.push('b'); updated++; + }); + observed.watch('b.c', function () { + called.push('b.c'); updated++; + }); + observed.watch('b.d', function () { + called.push('b.d'); updated++; + }); + observed.watch('b.e', function () { + called.push('b.e'); updated++; + }); + observed.watch('b.f.g', function () { + called.push('b.f.g'); updated++; + }); + observed.watch('b.h', function () { + called.push('b.h'); updated++; + }); + observed.watch('b.f.i', function () { + called.push('b.f.i'); updated++; + }); + observed.watch('b.j', function () { + called.push('b.j'); updated++; + }); + + observed.remove('b'); + + equal(updated, 6, + "expected exactly 6 watchers to be called, called " + updated + ": " + called.join(',')); + }); + + it("replaces the entire watched dataset, triggering any nested watchers", function () { + var updated = 0, + called = []; + + observed.watch('a', function () { + called.push('a'); updated++; + }); + observed.watch('b', function () { + called.push('b'); updated++; + }); + observed.watch('b.c', function () { + called.push('b.c'); updated++; + }); + observed.watch('b.d', function () { + called.push('b.d'); updated++; + }); + observed.watch('b.e', function () { + called.push('b.e'); updated++; + }); + observed.watch('b.f.g', function () { + called.push('b.f.g'); updated++; + }); + observed.watch('b.h', function () { + called.push('b.h'); updated++; + }); + observed.watch('i', function () { + called.push('i'); updated++; + }); + + observed.replace({i: 2}); + + equal(updated, 8, + "expected exactly 8 watchers to be called, called " + updated + ": " + called.join(',')); + }); + + it("replaces the entire watched dataset, only triggering watchers for existing properties", function () { + var updated = 0, + called = []; + + observed.watch('a', function () { + called.push('a'); updated++; + }); + observed.watch('b', function () { + called.push('b'); updated++; + }); + observed.watch('b.c', function () { + called.push('b.c'); updated++; + }); + observed.watch('b.d', function () { + called.push('b.d'); updated++; + }); + observed.watch('b.e', function () { + called.push('b.e'); updated++; + }); + observed.watch('b.f.g', function () { + called.push('b.f.g'); updated++; + }); + observed.watch('b.h', function () { + called.push('b.h'); updated++; + }); + observed.watch('i', function () { + called.push('i'); updated++; + }); + observed.watch('X', function () { + called.push('X'); updated++; + }); + + observed.replace({i: 2}); + + equal(updated, 8, + "expected exactly 8 watchers to be called, called " + updated + ": " + called.join(',')); + }); + });