320 lines
11 KiB
JavaScript
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 }
|