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

320 lines
11 KiB
JavaScript

// 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, 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
#rxM
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 ? 0 : rxopts.skip
}
}
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(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}`)
self.rxAdd(lpath, { value: this.node })
}
return true
}
)
return true
} // end traverse
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)
}
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
}
})
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 })
}
return 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
}
// // 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) {
// 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
}
// 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 }