diff --git a/CHANGELOG.md b/CHANGELOG.md index 5975c54..429b53c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ * Add `Blast.environment` string property and `isProduction`, `isDevelopment` and `isStaging` booleans * Add `Optional` value-wrapper class * Add `Pledge#getRejectedReason()` method +* Add `ObservableOptional` class ## 0.9.2 (2024-02-25) diff --git a/lib/optional.js b/lib/optional.js index 8055183..42b5735 100644 --- a/lib/optional.js +++ b/lib/optional.js @@ -1,5 +1,10 @@ const VALUE = Symbol('value'), - CALLBACKS = Symbol('callbacks'); + LISTENERS = Symbol('listeners'), + TEARDOWNS = Symbol('teardowns'), + STATE = Symbol('state'), + IDLE = 0, + TEARING_DOWN = 1, + CHANGING = 2; /** * An optional value @@ -14,7 +19,6 @@ const VALUE = Symbol('value'), */ const Optional = Fn.inherits('Develry.Placeholder', function Optional(value) { this[VALUE] = value; - this[CALLBACKS] = []; }); /** @@ -46,14 +50,20 @@ Optional.setStatic(function unDry(value) { Optional.setProperty(function value() { return this[VALUE]; }, function setValue(new_value) { - this[VALUE] = new_value; - - let callbacks = this[CALLBACKS], - i; + return this.setValue(new_value); +}); - for (i = 0; i < callbacks.length; i++) { - callbacks[i](new_value); - } +/** + * Set a new value + * + * @author Jelle De Loecker + * @since 0.9.3 + * @version 0.9.3 + * + * @param {*} new_value + */ +Optional.setMethod(function setValue(new_value) { + this[VALUE] = new_value; }); /** @@ -74,19 +84,6 @@ Optional.setMethod(function toDry() { }; }); -/** - * Add a listener - * - * @author Jelle De Loecker - * @since 0.9.3 - * @version 0.9.3 - * - * @param {Function} callback - */ -Optional.setMethod(function onChange(callback) { - this[CALLBACKS].push(callback); -}); - /** * Is there a value present? * @@ -131,4 +128,127 @@ Optional.setMethod(function orElse(fallback) { */ Optional.setMethod(function getResolvedValue() { return this[VALUE]; +}); + +/** + * The observable version of the optional value + * + * @constructor + * + * @author Jelle De Loecker + * @since 0.9.3 + * @version 0.9.3 + * + * @param {*} value + */ +const ObservableOptional = Fn.inherits('Develry.Optional', function ObservableOptional(value) { + this[VALUE] = value; + this[LISTENERS] = null; + this[TEARDOWNS] = null; + this[STATE] = IDLE; +}); + +/** + * Set a new value + * + * @author Jelle De Loecker + * @since 0.9.3 + * @version 0.9.3 + * + * @param {*} new_value + */ +ObservableOptional.setMethod(function setValue(new_value) { + try { + actuallySetValue(this, new_value); + } finally { + this[STATE] = IDLE; + } +}); + +/** + * Actually set the value + * + * @author Jelle De Loecker + * @since 0.9.3 + * @version 0.9.3 + * + * @param {*} new_value + */ +const actuallySetValue = (target, new_value) => { + + // If the value did not change, return + if (target[VALUE] === new_value) { + return; + } + + const old_value = target[VALUE]; + target[VALUE] = new_value; + + // If we're already in the process of changing, return + if (target[STATE] > IDLE) { + return; + } + + target[STATE] = TEARING_DOWN; + + while (target[TEARDOWNS]?.length) { + target[TEARDOWNS].shift()(new_value, old_value); + } + + target[STATE] = CHANGING; + + let listeners = target[LISTENERS]; + + if (listeners?.size) { + + let teardowns = [], + teardown, + listener; + + for (listener of listeners) { + teardown = listener(new_value); + + if (teardown && typeof teardown == 'function') { + teardowns.push(teardown); + } + } + + target[TEARDOWNS] = teardowns; + } +}; + +/** + * Add a listener + * + * @author Jelle De Loecker + * @since 0.9.3 + * @version 0.9.3 + * + * @param {Function} callback + */ +ObservableOptional.setMethod(function addListener(callback) { + + if (!this[LISTENERS]) { + this[LISTENERS] = new Set(); + } + + this[LISTENERS].add(callback); +}); + +/** + * Remove a listener + * + * @author Jelle De Loecker + * @since 0.9.3 + * @version 0.9.3 + * + * @param {Function} callback + */ +ObservableOptional.setMethod(function removeListener(callback) { + + if (!this[LISTENERS]) { + return; + } + + this[LISTENERS].delete(callback); }); \ No newline at end of file diff --git a/test/optional.js b/test/optional.js new file mode 100644 index 0000000..c71cc34 --- /dev/null +++ b/test/optional.js @@ -0,0 +1,153 @@ +let assert = require('assert'), + Blast, + Optional, + ObservableOptional; + +describe('Optional', () => { + + before(() => { + // Get the Protoblast instance + Blast = require('../index.js')(); + Optional = Blast.Classes.Develry.Optional; + ObservableOptional = Blast.Classes.Develry.ObservableOptional; + }); + + describe('Optional', () => { + describe('constructor', () => { + it('should create an Optional instance with the given value', () => { + const opt = new Optional(42); + assert.strictEqual(opt.value, 42); + }); + }); + + describe('unDry', () => { + it('should create an Optional instance from a dried value', () => { + const dried = { value: 'hello' }; + const opt = Optional.unDry(dried); + assert.strictEqual(opt.value, 'hello'); + assert.strictEqual(opt instanceof Optional, true); + }); + }); + + describe('setValue', () => { + it('should set the value of the Optional instance', () => { + const opt = new Optional(10); + opt.setValue(20); + assert.strictEqual(opt.value, 20); + }); + }); + + describe('toDry', () => { + it('should be used to dry the instance', () => { + const opt = new Optional('foo'); + let dried = Blast.Classes.JSON.dry(opt); + let undried = Blast.Classes.JSON.undry(dried); + + assert.strictEqual(undried.value, 'foo'); + assert.strictEqual(undried instanceof Optional, true); + }); + }); + + describe('isPresent', () => { + it('should return true if the Optional instance has a value', () => { + const opt = new Optional(true); + assert.strictEqual(opt.isPresent(), true); + }); + + it('should return false if the Optional instance has no value', () => { + const opt = new Optional(null); + assert.strictEqual(opt.isPresent(), false); + }); + }); + + describe('orElse', () => { + it('should return the value if present', () => { + const opt = new Optional(42); + assert.strictEqual(opt.orElse(10), 42); + }); + + it('should return the fallback value if not present', () => { + const opt = new Optional(null); + assert.strictEqual(opt.orElse(10), 10); + }); + }); + + describe('getResolvedValue', () => { + it('should return the value of the Optional instance', () => { + const opt = new Optional('bar'); + assert.strictEqual(opt.getResolvedValue(), 'bar'); + }); + }); + }); +}); + +describe('ObservableOptional', () => { + describe('constructor', () => { + it('should create an ObservableOptional instance with the given value', () => { + const opt = new ObservableOptional(42); + assert.strictEqual(opt.value, 42); + assert.strictEqual(opt instanceof ObservableOptional, true); + assert.strictEqual(opt instanceof Optional, true); + }); + }); + + describe('setValue', () => { + it('should set the value and notify listeners', () => { + const opt = new ObservableOptional(10); + let newValue; + + opt.addListener((value) => { + newValue = value; + }); + + opt.setValue(20); + assert.strictEqual(opt.value, 20); + assert.strictEqual(newValue, 20); + }); + + it('should not notify listeners if the value did not change', () => { + const opt = new ObservableOptional(10); + let notifiedCount = 0; + + opt.addListener(() => { + notifiedCount++; + }); + + opt.setValue(10); + assert.strictEqual(notifiedCount, 0); + }); + }); + + describe('addListener', () => { + it('should add a listener that is called when the value changes', () => { + const opt = new ObservableOptional(10); + let newValue; + + opt.addListener((value) => { + newValue = value; + }); + + opt.setValue(20); + assert.strictEqual(newValue, 20); + }); + }); + + describe('removeListener', () => { + it('should remove a previously added listener', () => { + const opt = new ObservableOptional(10); + let notifiedCount = 0; + + const listener = (value) => { + notifiedCount++; + }; + + opt.addListener(listener); + opt.setValue(20); + assert.strictEqual(notifiedCount, 1); + + opt.removeListener(listener); + opt.setValue(30); + assert.strictEqual(notifiedCount, 1); + }); + }); +}); \ No newline at end of file