// 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 }