using new private methods and fields

added testing
using new obj-nested-prop module functions
master
Kebler Network System Administrator 2021-05-09 21:05:07 -07:00
parent d257ed459d
commit 543cad8bac
7 changed files with 427 additions and 203 deletions

10
.babelrc Normal file
View File

@ -0,0 +1,10 @@
{
"presets": [
[
"@babel/preset-env",
{
"shippedProposals": true
}
]
]
}

16
.eslintrc.yml Normal file
View File

@ -0,0 +1,16 @@
env:
node: true
es2021: true
mocha: true
extends:
- standard
parser: "@babel/eslint-parser"
parserOptions:
ecmaVersion: 12
sourceType: module
rules:
indent: ["error", 2]
no-console: 0
semi: ["error", "never"]
# linebreak-style: ["error", "unix"]
quotes: ["error", "single"]

1
.npmrc Normal file
View File

@ -0,0 +1 @@
engine-strict=true

View File

@ -1,13 +1,21 @@
{
"name": "@uci-utils/rx-class",
"version": "0.1.5",
"description": "class that support reactive properites",
"description": "class thats support reactive properites",
"main": "src/rx-class.js",
"type": "module",
"engines": {
"node": ">=16"
},
"scripts": {
"example": "node -r esm examples/example",
"example:dev": "./node_modules/.bin/nodemon -r esm examples/example",
"example:tra": "./node_modules/.bin/nodemon -r esm examples/traverse",
"test": "./node_modules/.bin/mocha -r esm --timeout 30000"
"example": "node examples/example",
"example:dev": "./node_modules/.bin/nodemon examples/example",
"example:dev:tra": "./node_modules/.bin/nodemon examples/traverse",
"test": "./node_modules/.bin/mocha --timeout 30000",
"test:dev": "UCI_ENV=dev UCI_LOG_PRETTY='verbose' ./node_modules/.bin/nodemon --exec './node_modules/.bin/mocha --timeout 30000' || exit 0",
"test:dev:trace": "UCI_LOG_LEVEL='trace' npm run test:dev",
"test:dev:debug": "UCI_LOG_LEVEL='debug' npm run test:dev",
"test:dev:error": "UCI_LOG_LEVEL='error' npm run test:dev"
},
"author": "David Kebler",
"license": "MIT",
@ -23,19 +31,21 @@
},
"homepage": "https://github.com/uCOMmandIt/uci-utils#readme",
"dependencies": {
"@uci-utils/set-value": "^3.0.3",
"deep-equal": "^2.0.3",
"get-value": "^3.0.1",
"is-plain-obj": "^2.1.0",
"is-plain-object": "^3.0.0",
"rxjs": "^6.5.5",
"traverse": "^0.6.6",
"unset-value": "^1.0.0"
"@uci-utils/logger": "^0.1.0",
"@uci-utils/obj-nested-prop": "^0.1.1",
"@uci-utils/type": "^0.6.2",
"deep-equal": "^2.0.5",
"rxjs": "^7.0.0",
"traverse": "^0.6.6"
},
"devDependencies": {
"chai": "^4.2.0",
"esm": "^3.2.25",
"mocha": "^7.2.0",
"nodemon": "^2.0.4"
"@babel/core": "^7.14.0",
"@babel/eslint-parser": "^7.13.14",
"@babel/preset-env": "^7.14.1",
"chai": "^4.3.4",
"eslint": "^7.26.0",
"eslint-config-standard": "^16.0.2",
"mocha": "^8.4.0",
"nodemon": "^2.0.7"
}
}

View File

@ -2,17 +2,25 @@
A Great Class to Extend! It has built in support to make reactive any/all class properties and their nested leaves.
## ES2021 Dependency
Not running nodejs 16+ which supports much of ES2021+? then STOP you can't use this class! It contains both private fields and methods and is written with esm modules. You might be able to transpile it but that is not supported nor encouraged.
## Why?
To have reactivity similar to vue and react but in any backend object?
This allow a backend app to maintain app settings and allow "clients" to get updated values when the app changes state. Essentially will create a 'mini' state event bus.
To allow and object to have reactivity similar to vue and react without those packages and thus with any nodejs code. This a "backend" app could maintain app settings and allow non vue/react "clients" to get updated values when the app changes state. Essentially this class will create instances of 'mini' state event bus.
## Features
Will both emit and allow subscriptions to any property 'path' that has been made reactive.
Will both emit and allow subscriptions to any property (i.e. object path) that has been made reactive.
Allows custom registration of unlimited state change event hooks.
Allows custom registration of an unlimited number of state change event hooks.
Allows additional rxjs operators for the pipe the observer for each property.
Allows additional rxjs operators for the pipe of the observer for each property.
See Examples folder
# How
The class maintain a private `rx` field. When an object (i.e. property) of the class is made reactive it is converted into a getter/setter (much like vue) and it's actual value is stored within the private rx field. One can access the value of that propery like before but

View File

@ -1,93 +1,129 @@
// native modules
import { EventEmitter } from 'events'
// reactive modules
import { BehaviorSubject, from } from 'rxjs'
import { distinctUntilChanged, skip } from 'rxjs/operators'
// ** nested object access **
import _get from 'get-value'
import _set from '@uci-utils/set-value'
import _del from 'unset-value'
// *********************
// uci modules
import { check } from '@uci-utils/type'
import { get, set, del, getKeys, pathToString } from '@uci-utils/obj-nested-prop'
import { logger } from '@uci-utils/logger'
// supporting
import equal from 'deep-equal'
import traverse from 'traverse'
import isPlainObject from 'is-plain-object'
export default class RxClass extends EventEmitter {
const log = logger({ file: '/src/rx-class.js', package: '@uci-utils/rx-class', class: 'RxClass' })
// to pass rx options use 'Symbol.for('rx-class-opts')'
class RxClass extends EventEmitter {
// private fields
#rx
#rxM
constructor (opts = {}) {
super(opts)
const rxopts = opts._rx_||{}
this._rx_ = {
const rxopts = opts[Symbol.for('rx-class-opts')] || {}
this.#rx = {
emitter: rxopts.emitter != null ? rxopts.emitter : true,
event: rxopts.event === false ? false : rxopts.event || 'changed',
handler: rxopts.handler,
props: {},
handler: rxopts.handler, // default subscription handler
props: {}, // where reactive property info is store including actual values
amendValue: rxopts.amendValue,
amend_path : rxopts.amend_path, // by default amend value with path
hooks: [], // will add to all setters
root: '', // root path/namespace to added to all get/set paths
operators:[], // will add to all pipes
// amend_path: rxopts.amend_path, // by default amend value with path
hook: rxopts.hook, // function called when setting
namespace: rxopts.namespace || 'rx', // TODO namespace to added to all public rx methods
operators: new Map(), // TODO added to all rx pipes, in order
skip: rxopts.skip == null ? 0 : rxopts.skip
}
}
// pass '__parent__' when getting objects for creating reactive prop
// otherwise it gets the rx prop object and/or one of it's props
_rxGetObj(path,prop) {
path = this.formatObjPath(path)
path = path.split('.')
const name = path.pop()
path = path.join('.')
let parent = path ? this.$get(path) : this // if path is empty string return this
// console.log(name,path,parent)
if (parent === null) parent = {}
if (prop==='__parent__') return {name:name, parent:parent, parentPath:path}
const rx = this.$get(['_rx_.props',path,name])
// console.log(['_rx_.props',path,name],rx)
return prop ? (rx || {})[prop] : rx
get (path) {
const value = this.#get(path)
if (!check.isPlainObject(value)) return value
const obj = {} // return a copy with actual values instead of getters
const self = this
traverse(this.#get(path)).map(function () {
if (this.isLeaf) {
self.#set(obj, this.path, this.node)
}
}
)
return obj
}
// if property exists it moves value to _ version and creates getter and setter and behavior subject
rxAdd(opath,opts={}) {
const path = this.formatObjPath(opath)
if (opts.traverse) {
const obj = opts.values ? opts.values : this.$get(path)
set (path, value, opts = {}) {
// console.log(path,value,opts,!this.isRx(path))
if (!opts.noRx && !this.isRx(path)) {
opts = Object.assign(check.isPlainObject(value) ? { values: value, traverse: true } : { value: value }, opts)
// console.log('set opts', opts)
this.rxAdd(path, opts)
} else {
const curValue = this.#get(path)
if (!equal(curValue, value) && value !== undefined) {
// console.log('in set', path,value)
this.#set(path, value)
// console.log('value that was set',this.#get(path))
}
return this.#get(path)
}
}
// getRaw () {
// return get(this, [this.#rx.namespace, ...arguments])
// }
// if property exists it moves value to #rx.props and creates getter and setter and behavior subject
rxAdd (path, opts = {}) {
if (opts.traverse) { // will add rx to all leaves
const obj = opts.values ? opts.values : this.#get(path)
const self = this
// console.log('object to traverse', obj)
traverse(obj).map(function () {
if (this.isLeaf) {
const lpath = this.path
lpath.unshift(path)
// console.log(`${!opts.values ? 'existing':'new'} leaf on path '${lpath}' with value: ${this.node}`)
// console.log(`#{!opts.values ? 'existing':'new'} leaf on path '#{lpath}' with value: #{this.node}`)
self.rxAdd(lpath, { value: this.node })
}
return true
}
)
return true
} // end traverse
if (this.isRx(path)) return true
const value = this.$get(path)
if (this.isRx(path) && !opts.force) return true
const value = this.#get(path) // current value
if (value === undefined) {
// console.log ('no current property or value for',path,'creating temporary null')
this.$set(path,null)
this.#set(path, null)
}
const {parent,name} = this._rxGetObj(path,'__parent__')
this.$set(['_rx_.props',path],{new:true})
let rx = this.$get(this.formatObjPath(['_rx_.props',path]))
const { parent, name } = this.#rxGetObj(path, '__parent__')
log.debug({
class: 'RxClass',
method: '#rxGetObj',
path: path,
name: name,
parent: parent,
msg: 'got name and parent'
})
this.#set(['#rx.props', path], {})
const rx = this.#get(['#rx.props', path])
// console.log('moving',opts.value != null ? opts.value : value,path)
rx.value = opts.value != null ? opts.value : value
rx.path = path
rx.amendValue = opts.amendValue || this._rx_.amendValue || ((val) => val)
if (opts.amend_path || this._rx_.amend_path) rx.amendValue = (value,path) => {return {value:value, path: path}}
// console.log(path,': initial value===>',rx.amendValue(rx.value,rx.path))
rx.amendValue = opts.amendValue || this.#rx.amendValue || ((val) => val)
rx.obs = from(new BehaviorSubject(rx.amendValue(rx.value, rx.path)).pipe(
skip(opts.skip != null ? opts.skip : this._rx_.skip),
distinctUntilChanged()
// ,
// takeUntil($get(this.$deleted,path))
skip(opts.skip != null ? opts.skip : this.#rx.skip),
distinctUntilChanged() // allow custom comparator
// allow custom/additional operators here
// takeUntil(#get(this.#deleted,path))
))
// console.log(path,'---------------\n',Object.getOwnPropertyNames(Object.getPrototypeOf(rx.obs)),rx.obs.subscribe)
rx.subs = {}
rx.hooks = []
this.rxSubscribe(rx,'_default',this._rx_.handler,)
this.rxSubscribe(rx,(opts.subscribe || {}).name,(opts.subscribe ||{}).handler)
rx.hook = opts.hook
if (check.isFunction(this.#rx.handler)) this.rxSubscribe(rx, this.#rx.handler, '_default_')
const subs = Object.entries(opts.subscribe || {})
subs.forEach(sub => {
if (check.isFunction(sub[1])) this.rxSubscribe(rx, sub[1], sub[0])
})
const self = this
Object.defineProperty(parent, name, {
configurable: true,
@ -96,64 +132,65 @@ export default class RxClass extends EventEmitter {
return rx.value
},
set (value) {
// console.log('in setter', path,value)
rx.value = value
value = rx.amendValue(value, path)
rx.obs.next(value)
if (self._rx_.emitter) {
if (opts.event) self.emit(opts.event,value)
if (self._rx_.event) self.emit(self._rx_.event,value,path)
self.emit(path,value)
rx.obs.next(value) // react
if (self.#rx.emitter) { // emit
if (opts.event) self.emit(opts.event, value, path) // custom event
if (self.#rx.event) self.emit(self.#rx.event, value, path) // global event
const spath = pathToString(path)
if (spath) self.emit(spath, value, path) // also emit path if is stringable.
self.emit(name, value, path)
}
// any hook function that already bound will use that context
self._rx_.hooks.forEach(hook=>hook.call(self,value))
rx.hooks.forEach(hook=>hook.call(self,value))
// any hook function that is already bound will use that context, otherwise this
// hooks
// TODO write with for loop and async await
if (check.isFunction(self.#rx.hook)) self.#rx.hook.call(self, value) // global hook
if (check.isFunction(rx.hook)) rx.hook.call(self, value) // property hook
}
})
return true
}
// if name is only passed remove
rxHook(func,name) {
this._rx_.hooks[name]=func
}
// rxHook (func, path) {
// this.#rx.hooks[name] = func
// }
rxAmendValue (path, func) {
let rx = {}
if (arguments.length === 0 ) {this._rx_.amendValue= null;return}
if (typeof path === 'function') {this._rx_.amendValue=path;return}
rx = (typeof path === 'string') ? this._rxGetObj(path) : path
if (!rx) return false
func = func ? func : (val => val )
console.log('amend', func)
if (arguments.length === 0) { this.#rx.amendValue = null; return }
if (typeof path === 'function') { this.#rx.amendValue = path; return }
const rx = this.#rxGetObj(path)
if (!check.isEmptyPlainObject(rx)) return false
func = func || (val => val)
rx.amendValue = func === 'path' ? (value, path) => { return { value: value, path: path } } : func
return !!rx.amendValue
}
// if name is only passed remove
rxOperator (func, name) {
this._rx_.operators[name]=func
this.#rx.operators[name] = func
}
// removes obser, subscriptions, and getter and setter and make back into plain value
rxRemove (path, opts = {}) {
if (!opts.confirm) return false // must confirm to remove
if (!this.isRx(path) && isPlainObject(this.$get(path))) {
if (!this.isRx(path) && check.isPlainObject(this.#get(path))) {
const self = this
traverse(this.$get(path)).map(function () {
traverse(this.#get(path)).map(function () {
if (this.isLeaf) {
const lpath = this.path
lpath.unshift(path)
if (self.isRx(lpath)) self.rxRemove(lpath, { confirm: true })
}
return true
})
// all done removing the leaves so remove branch
this.$del(['_rx_.props',path],true)
this.#del(['#rx.props', path], true)
return true
}
let { parent, name, parentPath } = this._rxGetObj(path,'__parent__')
const rxparent = this.$get(['_rx_.props',parentPath])
const { parent, name, parentPath } = this.#rxGetObj(path, '__parent__')
const rxparent = this.#get(['#rx.props', parentPath])
const rx = rxparent[name]
if (!rx) return true // // not reactive nothing to remove
Object.values(rx.subs).forEach(sub => sub.unsubscribe())
@ -164,114 +201,119 @@ export default class RxClass extends EventEmitter {
return true
}
rxGetObs (path) {
return (this.#rxGetObj(path) || {}).obs
}
rxGetSubs (path, name) {
const rx = this.#rxGetObj(path)
if (rx) return (name ? rx.subs[name] : Object.keys(rx.subs))
}
// pass rx as object or path
rxSubscribe(rxObj,name,handler){
// if (name ==='ha') {
// console.log(this.id,'making subscription',name,rxObj.path || rxObj,!!handler)
// }
if (typeof name !== 'string') return false
const rx = (typeof rxObj === 'string' || Array.isArray(rxObj) ) ? this._rxGetObj(rxObj) : rxObj
// if (name && name!=='_default' && (rxObj||{}).path) console.log('rx object for',rxObj.path)
if ((rx||{}).path) {
if (typeof handler==='function') {
rxSubscribe (path, handler, name) {
const rx = this.#rxGetObj(path)
if (rx) {
// TODO if no name generate a name and return with handle
if (check.isFunction(handler)) {
if (rx.subs[name]) rx.subs[name].unsubscribe() // replace if exits
rx.subs[name] = rx.obs.subscribe(handler)
// console.log(rx.path, 'current subscriptions---', Object.keys(rx.subs))
return true
return { subs: rx.subs[name], name: name }
}
}
return false
}
rxUnsubscribe(rxObj,name){
if (typeof name !== 'string') return false
const rx = (typeof rxObj === 'string' || Array.isArray(rxObj) ) ? this._rxGetObj(rxObj) : rxObj
rxRemoveSubs (path, name) {
const rx = this.#rxGetObj(path)
// if (name && name!=='_default' && (rxObj||{}).path) console.log('rx object for',rxObj.path)
if (rx) {
if (name) {
if (rx.subs[name]) {
rx.subs[name].unsubscribe()
delete rx.subs[name]
// console.log(rx.path, ':current subscriptions>',Object.keys(rx.subs))
return true
}
} else {
Object.values(rx.subs).forEach(sub => sub.unsubscribe())
rx.subs = {}
return true
}
}
return false
}
// takes several formats for a path to an objects property and return only the . string
formatObjPath(path) {
if (path==null) return path
if (Array.isArray(path)) {
path = path.filter(Boolean).join('.') // Boolean filter strips empty strings or null/undefined
}
path = path.replace(/\/:,/g, '.') // replaces /:, with .
return path
}
// // takes several formats for a path to an objects property and return only the . string
// formatObjPath (path) {
// if (path == null) return path
// if (Array.isArray(path)) {
// path = path.filter(Boolean).join('.') // Boolean filter strips empty strings or null/undefined
// }
// path = path.replace(/\/:,/g, '.') // replaces /:, with .
// return path
// }
// checks for getter and the corresponding rx object
isRx (path) {
if (this.$get(path)===undefined) return false
path = this.formatObjPath(path)
const { parent, name, parentPath } = this._rxGetObj(path,'__parent__')
const rxparent = this.$get(['_rx_.props',parentPath]) || {}
// console.log(path)
if (this.#get(path) === undefined) return false
const { parent, name, parentPath } = this.#rxGetObj(path, '__parent__')
// console.log(parent, name, parentPath)
const rxparent = this.#get(['#rx.props', parentPath]) || {}
const rx = rxparent[name]
// console.log(path, 'rxparent,rx', !!parent, !!name, !!rxparent, !!rx)
if (!rx) return false
// console.log(Object.getOwnPropertyDescriptor(parent, name)['get'] )
return !!Object.getOwnPropertyDescriptor(parent, name)['get']
// console.log(Object.getOwnPropertyDescriptor(parent, name).get)
return !!Object.getOwnPropertyDescriptor(parent, name).get
}
$set () {
let args = [...arguments]
// PRIVATE METHODS
// pass '__parent__' when getting objects for creating reactive prop
// otherwise it gets the rx prop object and/or one of it's props
#rxGetObj (path, prop) {
log.debug({ class: 'RxClass', method: '#rxGetObj', path: path, prop: prop, msg: 'getting rx object' })
let name, parent, parentPath, rx
// if (!path) parent = this
if (check.isString(path) || check.isArray(path)) { // it's a normal path
const keys = getKeys(path)
name = (keys || []).pop()
parent = this.#get(keys)
parentPath = keys
log.debug({ method: '#rxGetObj', path: path, prop: prop, key: name, parent: parent, msg: 'getting rx object' })
if (parent === null) parent = {}
if (prop === '__parent__') return { name: name, parent: parent, parentPath: parentPath }
rx = this.#get(['#rx.props', path])
} else rx = check.isPlainObject(path) ? path : rx // if path was plain object assume it's already rx object
return prop ? (rx || {})[prop] : rx
}
#set () {
const args = [...arguments]
if (args.length < 2) return false
if (args.length === 2) args.unshift(this)
else if (!args[0]) return false
args[1] = this.formatObjPath(args[1])
// console.log('in $set',args[1],args[2])
return _set(...args)
// console.log('in #set',args[1],args[2])
return set(...args)
}
$get () {
let args = [...arguments]
#get () {
const args = [...arguments]
if (args.length === 1) args.unshift(this)
args[1] = this.formatObjPath(args[1])
return _get(...args)
return get(...args)
}
$del () {
let args = [...arguments]
#del () {
const args = [...arguments]
if (args.length < 2) return false
if (args[args.length - 1] !== true) return false
if (args.length === 2) args.unshift(this)
args[1] = this.formatObjPath(args[1])
return _del(...args)
return del(...args)
}
get(path){
path = this.formatObjPath(path)
const value = this.$get(path)
if (!isPlainObject(value)) return value
let obj = {} // return a copy with actual values instead of getters
const self = this
traverse(this.$get(path)).map(function () {
if (this.isLeaf) {
self.$set(obj,this.path,this.node)
}
}
)
return obj
}
set(path,value,opts={}){
// console.log(path,value,opts,!this.isRx(path))
if (!opts.noRx && !this.isRx(path)) {
opts = Object.assign(isPlainObject(value) ? {values:value,traverse:true} : {value:value},opts)
// console.log('set opts', opts)
this.rxAdd(path,opts)
} else {
const curValue = this.$get(path)
if (!equal(curValue,value) && value!==undefined) {
// console.log('in set', path,value)
this.$set(path,value)
// console.log('value that was set',this.$get(path))
}
return this.$get(path)
}
}
} // end class
// helpers
export default RxClass
export { RxClass, set, get, del, getKeys, pathToString }

137
test/rxclass.test.js Normal file
View File

@ -0,0 +1,137 @@
import assert from 'assert'
import { RxClass } from '../src/rx-class.js'
const rxopts = Symbol.for('rx-class-opts')
const obj = {
tomorrow: { bar: 'fight', foo: 'fighters' },
today: { bing: 'bing', bong: { who: 'first', what: 'second' } }
}
class Test extends RxClass {
constructor (opts = {}) {
super(opts)
this.obj = obj
}
}
const opts = {
[rxopts]: {
skip: 1,
handler: () => {}
}
}
const test = new Test(opts)
describe('Reactive Class', function () {
it('Can be extended', function () {
assert.deepStrictEqual(test.obj, obj)
})
it('Can get non rx deep value', function () {
assert.strictEqual(test.obj.today.bing, 'bing')
assert.strictEqual(test.get('obj.today.bing'), 'bing')
assert(!test.isRx('obj.today.bing'))
})
it('rx added to value', function () {
test.rxAdd('obj.today.bing')
assert.strictEqual(test.obj.today.bing, 'bing')
assert.strictEqual(test.get('obj.today.bing'), 'bing')
assert(test.isRx('obj.today.bing'))
})
})
describe('Emitted Events', function () {
function etest (event, value, path, done) {
test.once(event, (v, p) => {
// console.log('fired:', event, value, path)
assert.strictEqual(v, value)
assert.strictEqual(p, path)
clearTimeout(timeout)
done()
})
const timeout = setTimeout(() => {
assert(false, `Event ${event} did not fire in 1000 ms.`)
done()
}, 1000)
test.set(path, value)
}
const path = 'obj.today.bing'
it('should emit global default event', function (done) {
const event = 'changed'
etest(event, event, path, done)
})
it('should emit path if stringable', function (done) {
const event = path
etest(event, event, path, done)
})
it('should emit the key name of value being changed', function (done) {
const event = path.split('.').pop()
etest(event, event, path, done)
})
it('should emit a custom set event for key/value change after force rxAdd', function (done) {
const event = 'testing'
test.rxAdd('obj.today.bing', { force: true, event: 'testing' })
etest(event, event, path, done)
})
}) // end emits
describe('Subscriptions', function () {
const subsHandler = (value, timeout, name, done, v) => {
assert.strictEqual(v, value)
clearTimeout(timeout)
test.rxRemoveSubs(name)
done()
}
function subs (name, path, value, done, handler) {
const timeout = setTimeout(() => {
assert(false, 'subscription handler did not react in 1000 ms.')
done()
}, 1000)
const subscribe = handler ? { [handler]: subsHandler.bind(null, value, timeout, name, done) } : {}
test.rxAdd(path, { force: true, subscribe: subscribe })
if (!handler) {
test.rxSubscribe('obj.today.bing',
subsHandler.bind(null, value, timeout, name, done)
, name)
}
test.set(path, value)
}
const path = 'obj.today.bing'
it('should react to default subscription', function (done) {
const name = '_default_'
subs(name, path, name, done, name)
})
it('should react to property/key subscription', function (done) {
const name = 'keytest'
subs(name, path, name, done, name)
})
it('should react to new subscription', function (done) {
const name = 'atest'
subs(name, path, name, done)
})
}) // end subscriptions
describe('TODO: Amend,Hooks,Operators', function () {
it('should run a default hook', function (done) {
done()
})
it('should run a custom hook', function (done) {
done()
})
it('should support custom rxjs operators', function (done) {
done()
})
it('should amend a value that is set', function (done) {
done()
})
}) // end hooks