diff --git a/js/src/observe.js b/js/src/observe.js index d1feddf..c6f14cc 100644 --- a/js/src/observe.js +++ b/js/src/observe.js @@ -44,7 +44,26 @@ export default class uObserve { } /** - * Observe object's proporty. On change call observer + * Notify callback + * @callback ObserveCallback + * @param {*} value + */ + + /** + * Notify observers + * @param {Set} observers + * @param {*} value + */ + static notify(observers, value) { + for (const observer of observers) { + (async () => { + await observer(value); + })(); + } + } + + /** + * Observe object's property. On change call observer * @param {Object} obj * @param {?string} property * @param {ObserveCallback} observer @@ -52,7 +71,7 @@ export default class uObserve { static observeProperty(obj, property, observer) { this.addObserver(obj, observer, property); if (!obj.hasOwnProperty('_values')) { - Object.defineProperty(obj, '_values', { enumerable: false, configurable: false, value: [] }); + Object.defineProperty(obj, '_values', { enumerable: false, configurable: false, value: {} }); } obj._values[property] = obj[property]; Object.defineProperty(obj, property, { @@ -131,21 +150,100 @@ export default class uObserve { } /** - * Notify observers - * @param {Set} observers - * @param {*} value + * Remove observer from object's property or all it's properties + * unobserve(obj, prop, observer) observes given property prop; + * unobserve(obj, observer) observes all properties of object obj. + * @param {Object} obj + * @param {(string|ObserveCallback)} p1 + * @param {ObserveCallback=} p2 */ - static notify(observers, value) { - for (const observer of observers) { - (async () => { - await observer(value); - })(); + static unobserve(obj, p1, p2) { + if (typeof p2 === 'function') { + this.unobserveProperty(obj, p1, p2); + } else if (typeof p1 === 'function') { + if (Array.isArray(obj)) { + this.unobserveArray(obj, p1); + } else { + this.unobserveRecursive(obj, p1); + } + } else { + throw new Error('Invalid arguments'); } } /** - * Notify callback - * @callback ObserveCallback - * @param {*} value + * Remove observer from object's property + * @param {Object} obj + * @param {?string} property + * @param {ObserveCallback} observer */ + static unobserveProperty(obj, property, observer) { + if (Array.isArray(obj[property])) { + this.unobserveArray(obj[property], observer); + } + this.removeObserver(obj, observer, property); + if (!obj._observers[property].size) { + delete obj[property]; + obj[property] = obj._values[property]; + delete obj._values[property]; + } + } + + /** + * Recursively remove observers from all properties + * @param {Object} obj + * @param {ObserveCallback} observer + */ + static unobserveRecursive(obj, observer) { + for (const prop in obj) { + if (obj.hasOwnProperty(prop)) { + uObserve.unobserveProperty(obj, prop, observer); + } + } + } + + /** + * Remove observer from array + * @param {Object} arr + * @param {ObserveCallback} observer + */ + static unobserveArray(arr, observer) { + this.removeObserver(arr, observer); + if (!arr._observers.size) { + [ 'pop', 'push', 'reverse', 'shift', 'sort', 'splice', 'unshift' ].forEach( + (operation) => { + const descriptor = Object.getOwnPropertyDescriptor(Array.prototype, operation); + Object.defineProperty(arr, operation, descriptor); + }); + } + } + + /** + * Remove observer from object's property + * @param {Object} obj Object + * @param {string} property Optional property + * @param {ObserveCallback} observer Observer + */ + static removeObserver(obj, observer, property) { + if (!obj.hasOwnProperty('_observers')) { + return; + } + let observers; + if (arguments.length === 3) { + if (!obj._observers[property]) { + return; + } + observers = obj._observers[property]; + console.log(`Removing observer for ${property}…`) + } else { + observers = obj._observers; + console.log('Removing observer for object…') + } + observers.forEach((obs) => { + if (obs === observer) { + console.log('Removed'); + observers.delete(obs); + } + }); + } } diff --git a/js/test/observe.test.js b/js/test/observe.test.js index 247b24a..2d77913 100644 --- a/js/test/observe.test.js +++ b/js/test/observe.test.js @@ -27,6 +27,8 @@ describe('Observe tests', () => { beforeEach(() => { object = { observed: 1, nonObserved: 1 }; result = false; + // eslint-disable-next-line no-undefined + resultValue = undefined; }); describe('when object is observed', () => { @@ -49,6 +51,30 @@ describe('Observe tests', () => { expect(resultValue).toBe(2); }); + it('should notify multiple observers when observed property is modified', () => { + // given + let result2 = false; + let resultValue2; + uObserve.observe(object, 'observed', (value) => { + result = true; + resultValue = value; + }); + uObserve.observe(object, 'observed', (value) => { + result2 = true; + resultValue2 = value; + }); + // when + expect(result).toBe(false); + expect(result2).toBe(false); + object.observed = 2; + // then + expect(result).toBe(true); + expect(resultValue).toBe(2); + expect(result2).toBe(true); + // noinspection JSUnusedAssignment + expect(resultValue2).toBe(2); + }); + it('should not notify observers when non-observed property is modified', () => { // given uObserve.observe(object, 'observed', () => { @@ -114,7 +140,7 @@ describe('Observe tests', () => { expect(resultValue).toEqual(array); }); - it('should notify observers when observed array object is modified', () => { + it('should notify observers when observed array is modified', () => { // given const array = [ 1, 2 ]; uObserve.observe(array, (value) => { @@ -150,6 +176,166 @@ describe('Observe tests', () => { }); }); + describe('when object is unobserved', () => { + + it('should throw error if removed observer is missing', () => { + expect(() => { + uObserve.unobserve(object, 'unobserved'); + }).toThrow(new Error('Invalid arguments')); + }); + + it('should not notify observers when unobserved property is modified', () => { + // given + const observer = (value) => { + result = true; + resultValue = value; + }; + uObserve.observe(object, 'observed', observer); + // when + uObserve.unobserve(object, 'observed', observer); + + expect(result).toBe(false); + object.observed = 2; + // then + expect(result).toBe(false); + // eslint-disable-next-line no-undefined + expect(resultValue).toBe(undefined); + expect(object.observed).toBe(2); + }); + + it('should not notify observers when any unobserved object property is modified', () => { + // given + const observer = (value) => { + result = true; + resultValue = value; + }; + uObserve.observe(object, observer); + // when + uObserve.unobserve(object, observer); + + expect(result).toBe(false); + object.observed = 2; + // then + expect(result).toBe(false); + // eslint-disable-next-line no-undefined + expect(resultValue).toBe(undefined); + expect(object.observed).toBe(2); + + // given + result = false; + // eslint-disable-next-line no-undefined + resultValue = undefined; + + // when + expect(result).toBe(false); + object.nonObserved = 2; + // then + expect(result).toBe(false); + // eslint-disable-next-line no-undefined + expect(resultValue).toBe(undefined); + expect(object.nonObserved).toBe(2); + }); + + it('should not notify observers when unobserved array property is modified', () => { + // given + const observer = (value) => { + result = true; + resultValue = value; + }; + const array = [ 1, 2 ]; + object = { array: array }; + uObserve.observe(object, 'array', observer); + // when + uObserve.unobserve(object, 'array', observer); + + expect(result).toBe(false); + array.push(3); + // then + expect(result).toBe(false); + // eslint-disable-next-line no-undefined + expect(resultValue).toEqual(undefined); + expect(array).toEqual([ 1, 2, 3 ]); + }); + + it('should not notify observers when unobserved array is modified', () => { + // given + const observer = (value) => { + result = true; + resultValue = value; + }; + const array = [ 1, 2 ]; + uObserve.observe(array, observer); + // when + uObserve.unobserve(array, observer); + + expect(result).toBe(false); + array.push(3); + // then + expect(result).toBe(false); + // eslint-disable-next-line no-undefined + expect(resultValue).toEqual(undefined); + expect(array).toEqual([ 1, 2, 3 ]); + }); + + it('should remove one of two observers of object property', () => { + // given + let result2 = false; + let resultValue2; + const observer = (value) => { + result = true; + resultValue = value; + }; + const observer2 = (value) => { + result2 = true; + resultValue2 = value; + }; + uObserve.observe(object, 'observed', observer); + uObserve.observe(object, 'observed', observer2); + // when + uObserve.unobserve(object, 'observed', observer2); + + expect(result).toBe(false); + expect(result2).toBe(false); + object.observed = 2; + // then + expect(result).toBe(true); + expect(resultValue).toBe(2); + expect(result2).toBe(false); + // noinspection JSUnusedAssignment + expect(resultValue2).toBe(undefined);// eslint-disable-line no-undefined + }); + + it('should remove one of two observers from array', () => { + // given + let result2 = false; + let resultValue2; + const observer = (value) => { + result = true; + resultValue = value; + }; + const observer2 = (value) => { + result2 = true; + resultValue2 = value; + }; + const array = [ 1, 2 ]; + uObserve.observe(array, observer); + uObserve.observe(array, observer2); + // when + uObserve.unobserve(array, observer2); + + expect(result).toBe(false); + expect(result2).toBe(false); + array.push(3); + // then + expect(result).toBe(true); + expect(result2).toBe(false); + expect(resultValue).toEqual(array); + // noinspection JSUnusedAssignment + expect(resultValue2).toEqual(undefined);// eslint-disable-line no-undefined + expect(array).toEqual([ 1, 2, 3 ]); + }); + }); + describe('when notify is called directly', () => { it('should call observers with given value', () => { // given