Skip to content

Commit

Permalink
✨ Add ObservableOptional class
Browse files Browse the repository at this point in the history
  • Loading branch information
skerit committed May 6, 2024
1 parent 1e7a1c1 commit f43b050
Show file tree
Hide file tree
Showing 3 changed files with 296 additions and 22 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
164 changes: 142 additions & 22 deletions lib/optional.js
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -14,7 +19,6 @@ const VALUE = Symbol('value'),
*/
const Optional = Fn.inherits('Develry.Placeholder', function Optional(value) {
this[VALUE] = value;
this[CALLBACKS] = [];
});

/**
Expand Down Expand Up @@ -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 <[email protected]>
* @since 0.9.3
* @version 0.9.3
*
* @param {*} new_value
*/
Optional.setMethod(function setValue(new_value) {
this[VALUE] = new_value;
});

/**
Expand All @@ -74,19 +84,6 @@ Optional.setMethod(function toDry() {
};
});

/**
* Add a listener
*
* @author Jelle De Loecker <[email protected]>
* @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?
*
Expand Down Expand Up @@ -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 <[email protected]>
* @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 <[email protected]>
* @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 <[email protected]>
* @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 <[email protected]>
* @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 <[email protected]>
* @since 0.9.3
* @version 0.9.3
*
* @param {Function} callback
*/
ObservableOptional.setMethod(function removeListener(callback) {

if (!this[LISTENERS]) {
return;
}

this[LISTENERS].delete(callback);
});
153 changes: 153 additions & 0 deletions test/optional.js
Original file line number Diff line number Diff line change
@@ -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);
});
});
});

0 comments on commit f43b050

Please sign in to comment.