uci-utils-rx-class/src/rx-class.js

370 lines
13 KiB
JavaScript

// import methods from './public-methods.js'
// native modules
import { EventEmitter } from 'events'
// reactive modules
import { BehaviorSubject, from } from 'rxjs'
import { distinctUntilChanged, skip } from 'rxjs/operators'
// uci modules
import { check } from '@uci-utils/type'
import { get, set, del, walkPath, getKeys, pathToString } from '@uci-utils/obj-nested-prop'
import { logger } from '@uci-utils/logger'
// supporting
import equal from 'deep-equal'
import traverse from 'traverse'
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
constructor (opts = {}) {
super(opts)
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, // 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
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 ? 1 : rxopts.skip
}
this.isRx.bind(this)
}
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
}
set (path, value, opts = {}) {
console.log('set:', path, value, opts, !this.isRx(path))
if (!opts.noRx && !this.isRx(path)) { // travsere
opts = Object.assign(
(check.isPlainObject(value) && opts.traverse !== false)
? { values: value, traverse: true }
: { value: value }
, opts)
console.log('adding rx from set', opts)
this.rxAdd(path, opts)
} else {
const curValue = this.#get(path)
if (!equal(curValue, value) && value !== undefined) {
console.log('setting already reactive value', 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 () {
// console.log('traversing', this)
if (this.isLeaf) {
const lpath = this.path
lpath.unshift(path)
// console.log('#{!opts.values ? \'existing\':\'new\'} leaf on path \'#{lpath}\' with value: #{this.node}')
self.rxAdd(lpath, { value: this.node })
}
}
)
return true
} // end traverse
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)
}
if (this.isRx(path) && !opts.force) return true
console.log(path, 'making reactive')
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)
rx.obs = from(new BehaviorSubject(rx.amendValue(rx.value, rx.path)).pipe(
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.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,
enumerable: true,
get () {
return rx.value
},
set (value) {
rx.value = value
value = rx.amendValue(value, path)
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 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
}
})
// console.log('rxadd done', path, opts, this.#get(['#rx.props', path]))
return true
}
// if name is only passed remove
// rxHook (func, path) {
// this.#rx.hooks[name] = func
// }
rxAmendValue (path, 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
}
// 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) && check.isPlainObject(this.#get(path))) {
const self = this
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 })
}
})
// all done removing the leaves so remove branch
this.#del(['#rx.props', path], true)
return true
}
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())
delete parent[name]
parent[name] = rx.value
delete rxparent[name]
// console.log('removed rx from', path)
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 (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 { subs: rx.subs[name], name: name }
}
}
return false
}
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]
return true
}
} else {
Object.values(rx.subs).forEach(sub => sub.unsubscribe())
rx.subs = {}
return true
}
}
return false
}
isGetSet (path) {
const keys = getKeys(path)
const prop = keys.pop()
const parent = this.#get(keys)
// console.log('isgetset', path, keys, prop, 'parent?', !!parent)
if (parent && prop) {
return !!(Object.getOwnPropertyDescriptor(parent, prop) || {}).get &&
!!(Object.getOwnPropertyDescriptor(parent, prop) || {}).set
}
return false
}
// is already reactive, checks for setter/getter and the corresponding rx object
isRx (path) {
// console.log('in isRX', path)
const keys = getKeys(path)
const cnt = keys.length
for (let index = 0; index < cnt; index++) {
// console.log('testing rx', index, cnt, keys, !!this.#get(keys), !!this.isGetSet(keys), !!this.#get(['#rx.props', keys]))
if (this.#get(keys) === undefined) return false
if (this.isGetSet(keys) && this.#get(['#rx.props', keys])) return true
keys.pop()
}
return false
// // console.log('in rx every', lpath)
// // if (this.#get(['#rx.props', ...lpath]) === undefined) {
// // return false
// // }
// //
// 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 true
// else {
// console.log(Object.getOwnPropertyDescriptor(parent, name).get)
// isRx = !!Object.getOwnPropertyDescriptor(parent, name).get
// return false
// }
// })
// return isRx
}
// isRx(path) {
// // console.log(path)
// let isRx = false
// const lpath = []
// getKeys(path).every(key => {
// lpath.push(key)
// console.log('in rx every', lpath, this.#get(lpath))
// if (this.#get(lpath) === undefined) {
// return false
// }
// // const { parent, name, parentPath } = this.#rxGetObj(lpath, '__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 true
// else {
// console.log(Object.getOwnPropertyDescriptor(parent, name).get)
// isRx = !!Object.getOwnPropertyDescriptor(parent, name).get
// return false
// }
// })
// return isRx
// }
// 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
// console.log('in #set',args[1],args[2])
return set(...args)
}
#get () {
const args = [...arguments]
if (args.length === 1) args.unshift(this)
return get(...args)
}
#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)
return del(...args)
}
} // end class
// helpers
export default RxClass
export { RxClass, set, get, del, getKeys, pathToString }