From 47e4c34ef5479d981869253b9323db4b2c9e2c4c Mon Sep 17 00:00:00 2001 From: David Kebler Date: Sun, 7 Jun 2020 21:56:54 -0700 Subject: [PATCH] A working class, only missing the tested hook and operator support --- .eslintrc.js | 37 +++++++++ .gitignore | 2 + .npmignore | 5 ++ examples/example.js | 63 +++++++++++++++ examples/traverse.js | 47 +++++++++++ nodemon.json | 3 + package.json | 42 ++++++++++ readme.md | 18 +++++ src/rx-class.js | 181 +++++++++++++++++++++++++++++++++++++++++++ 9 files changed, 398 insertions(+) create mode 100644 .eslintrc.js create mode 100644 .gitignore create mode 100644 .npmignore create mode 100644 examples/example.js create mode 100644 examples/traverse.js create mode 100644 nodemon.json create mode 100644 package.json create mode 100644 readme.md create mode 100644 src/rx-class.js diff --git a/.eslintrc.js b/.eslintrc.js new file mode 100644 index 0000000..49bac18 --- /dev/null +++ b/.eslintrc.js @@ -0,0 +1,37 @@ +module.exports = { + "ecmaFeatures": { + "modules": true, + "spread" : true, + "restParams" : true + }, + // "plugins": [ + // "unicorn" + // ], + "env": { + "es6": true, + "node": true, + "mocha": true + }, + "parserOptions": { + "ecmaVersion": 2017, + "sourceType": "module" + }, + "extends": "eslint:recommended", + "rules": { + "indent": [ + "error", + 2 + ], + // "unicorn/no-array-instanceof": "error", + "no-console": 0, + "semi": ["error", "never"], + "linebreak-style": [ + "error", + "unix" + ], + "quotes": [ + "error", + "single" + ] + } +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e61051f --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/node_modules/ +/coverage/ diff --git a/.npmignore b/.npmignore new file mode 100644 index 0000000..2f680a8 --- /dev/null +++ b/.npmignore @@ -0,0 +1,5 @@ +tests/ +test/ +*.test.js +testing/ +examples/ diff --git a/examples/example.js b/examples/example.js new file mode 100644 index 0000000..aef151f --- /dev/null +++ b/examples/example.js @@ -0,0 +1,63 @@ +import RxClass from '../src/rx-class.js' + +class Example extends RxClass { + constructor(opts={}){ + super(opts) + this.test = 10 + this.another = { bar:'bust', foo:3} + } +} + +const example = new Example({ + _rx_ : { + handler: value => {console.log('-----------default subscription handler-------', value)} + } +}) + +console.log('instance before doing rx on properties\n') +console.log(example) +console.log('--------------------------') + +example.on('changed', (prop) => console.log('emitted----a property changed-----',prop)) +example.on('test', (val) => console.log('emitted value of test',val)) + +console.log('making \'test\' reactive') +example.rxAdd('test') +console.log('changing test to 20, 30 directly') +example.test = 20 +example.test = 30 +example.rxSubscribe('test',(prop)=>console.log('#############late subscription to \'test\' after 30 ############',prop),'custom') +console.log('changing test to 40 via setPath') +example.setPath('test',40) + +console.log('making a deeper property \'another.foo\' reactive') +example.rxAdd('another.foo') + +example.another.foo = 7 + +console.log('direct access of another.foo',example.another.foo) +console.log('access via get of another.foo',example.getPath('another.foo')) + +console.log('making a deeper property \'some.thing\' reactive that did not exist') +example.rxAdd('some.thing') + +// console.log('some test',example.some.test) + +example.some.thing=15 +console.log('--------------------------') +console.log('instance after adding rx to properties\n') +console.log(example) +console.log('--------------------------') + +console.log('now removing reactivity from \'test\'') +example.rxRemove('test') +example.setPath('test',42) +console.log('\'test\' now holds unreactive value') +console.log('now removing reactivity from \'test\' again') +example.rxRemove('test') +console.log('--------------------------') +console.log('instance after removing rx from \'test\'\n') +console.log(example) +console.log('--------------------------') + +// console.log('some test',example.some.test) diff --git a/examples/traverse.js b/examples/traverse.js new file mode 100644 index 0000000..3dcdd46 --- /dev/null +++ b/examples/traverse.js @@ -0,0 +1,47 @@ +import RxClass from '../src/rx-class.js' + +class Example extends RxClass { + constructor(opts={}){ + super(opts) + this.test = { + yesterday: {bar:'bust', foo:3}, + today: {bing:5, bong:{who:'first',what:'second'}} + } + } +} + +const example = new Example({ + _rx_ : { + handler: value => {console.log('-----------default subscription handler-------', value)}, + skip: 0 + } +}) + +console.log('instance before doing rx on properties\n') +console.log(example) +console.log('--------------------------') + +example.on('changed', (prop) => console.log('emitted----a property changed-----',prop)) +example.on('test', (val) => console.log('emitted value of test',val)) + +console.log('traversing \'test\' and making all leaves reactive') +example.rxAdd('test',{traverse:true}) + +example.test.today.bong.what = 'third' +example.setPath('test.today.bong.what','forth') +example.setPath('test.today.bong.why','do not know') + +console.log('\'test\', values object >', example.getPath('test')) +console.log('\'test\', raw object >', example.getPath('test',true)) +console.log('--------------------------') +console.log('instance after adding rx to leaves\n') +console.dir(example.test) +console.dir(example._rx_.props) +console.log('--------------------------') +console.log('now remove all rx on \'test.yesterday\'\n') +example.rxRemove('test.today',{traverse:true}) +console.dir(example.test) +console.log('rx props') +console.dir(example._rx_.props) +console.log('--------------------------') +console.dir(example) diff --git a/nodemon.json b/nodemon.json new file mode 100644 index 0000000..f21684b --- /dev/null +++ b/nodemon.json @@ -0,0 +1,3 @@ +{ + "ignore":["examples/*.json"] +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..81944e7 --- /dev/null +++ b/package.json @@ -0,0 +1,42 @@ +{ + "name": "@uci-utils/data-store-rx", + "version": "0.1.3", + "description": "A Extended Class Data Store Module ", + "main": "src/datastore.js", + "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" + }, + "author": "David Kebler", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/uCOMmandIt/.git" + }, + "keywords": [ + "node.js" + ], + "bugs": { + "url": "https://github.com/uCOMmandIt/uci-utils/issues" + }, + "homepage": "https://github.com/uCOMmandIt/uci-utils#readme", + "dependencies": { + "data-store": "^4.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", + "set-value": "^3.0.2", + "traverse": "^0.6.6", + "unset-value": "^1.0.0" + }, + "devDependencies": { + "chai": "^4.2.0", + "esm": "^3.2.25", + "mocha": "^7.2.0", + "nodemon": "^2.0.4" + } +} diff --git a/readme.md b/readme.md new file mode 100644 index 0000000..3b03f33 --- /dev/null +++ b/readme.md @@ -0,0 +1,18 @@ +# uCOMmandIt Reactive Class + +A Great Class to Extend has built in support for make reactive any/all class properties and their nested leaves. + +## 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. + +## Features + +Will both emit and allow subscriptions to any property 'path' that has been made reactive. + +Allows custom registration of unlimited state change event hooks. + +Allows additional rxjs operators for the pipe the observer for each property. + +See Examples folder diff --git a/src/rx-class.js b/src/rx-class.js new file mode 100644 index 0000000..19f7875 --- /dev/null +++ b/src/rx-class.js @@ -0,0 +1,181 @@ +import { EventEmitter } from 'events' +import { BehaviorSubject, from } from 'rxjs' +import { distinctUntilChanged, skip } from 'rxjs/operators' +// ** nested object access ** +import _get from 'get-value' +import _set from 'set-value' +import _del from 'unset-value' +// ********************* +import equal from 'deep-equal' +import traverse from 'traverse' +import isPlainObject from 'is-plain-object' + +export default class RxClass extends EventEmitter { + constructor(opts){ + // console.log('constructor', opts) + super(opts) + this._rx_ = { + event: opts._rx_.event || 'changed', + handler: opts._rx_.handler, + props: {}, + hooks: [], + operators:[], + skip: opts._rx_.skip == null ? 1 : opts._rx_.skip + } + } + + // if property exists it moves value to _ version and creates getter and setter and behavior subject + rxAdd(opath,opts={}) { + const path = this._rxFormatPath(opath) + if (opts.traverse) { + const self = this + traverse(_get(this,path)).map(function () { + if (this.isLeaf) { + const lpath = this.path + lpath.unshift(path) + // console.log('leaf',lpath,this.node) + self.rxAdd(lpath) + // console.log(this.get()) + } + } + ) + return + } // end traverse + const value = _get(this,path) + if (value === undefined) { + console.log ('no current property for', path) + _set(this,path,null) + } + const {parent,name} = this.rxGetObj(path,'__parent__') + const rx = this.setPath(['_rx_.props',path],{}) + rx.value = value + rx.obs = from(new BehaviorSubject({value:value, path:path}).pipe( + // rx.obs = from(new ReplaySubject(1).pipe( + skip(this._rx_.skip), + distinctUntilChanged() + // , + // takeUntil(_get(this._deleted,path)) + )) + rx.subs = {} + this.rxSubscribe(rx,this._rx_.handler,'_default') + this.rxSubscribe(rx,(opts.subscribe ||{}).handler,(opts.subscribe || {}).name) + + const self = this + Object.defineProperty(parent, name, { + configurable: true, + enumerable: true, + get () {return rx.value}, + set (value) { + rx.value = value + rx.obs.next({value:value, path:path}) + self.emit(opts.event || self._rx_.event,{value:value, path:path}) + self.emit(path,value) + self._rx_.hooks.forEach(hook=>hook.call(self,{value:value, path:path})) + } + }) + } + + // if name is only passed remove + rxHook(func,name) { + this._rx_.hooks[name]=func + } + + // if name is only passed remove + rxOperator(func,name) { + this._rx_.operators[name]=func + } + + // pass '__new__' when getting props for new rx object + rxGetObj(path,prop) { + path = this._rxFormatPath(path) + path = path.split('.') + const name = path.pop() + path = path.join('.') + let parent = path ? _get(this,path) : this + // console.log(name,path,parent) + if (parent === null) parent = {} + if (prop==='__parent__') return {name:name, parent:parent, parentPath:path} + const rx = this.getPath(['_rx_.props',path,name],true) + // console.log(path,name,'reactive object',rx) + return prop ? (rx || {})[prop] : rx + } + + // removes obser, subscriptions, and getter and setter and make back into plain value + rxRemove(path,opts={}) { + if (opts.traverse) { + const self = this + traverse(_get(this,path)).map(function () { + if (this.isLeaf) { + const lpath = this.path + lpath.unshift(path) + self.rxRemove(lpath) + } + } + ) + this.delPath(['_rx_.props',path],true) + return + } + let { parent, name, parentPath } = this.rxGetObj(path,'__parent__') + const rxparent = this.getPath(['_rx_.props',parentPath],true) + const rx = rxparent[name] + if (rx && Object.getOwnPropertyDescriptor(parent, name)['get']) { + Object.values(rx.subs).forEach(sub=>sub.unsubscribe()) + delete parent[name] + parent[name] = rx.value + delete rxparent[name] + } // else console.warn(`property at '${path}' was not reative, remove aborted`) + } + + // pass rx as object or path + rxSubscribe(rxObj,handler,name){ + // console.log('subscription',rxObj) + if (typeof name !== 'string') return + rxObj = (typeof rxObj === 'string') ? this.rxGetObj(rxObj) : rxObj + if (typeof handler==='function') rxObj.subs[name] = rxObj.obs.subscribe(handler) + } + + _rxFormatPath(path) { + if (path==null) return path + if (Array.isArray(path)) { + path = path.filter(Boolean).join('.') + } else path = path.replace(/\/:/g, '.') + return path + } + + setObjPath () {return _set(...arguments)} + getObjPath () {return _get(...arguments)} + delObjPath () { return _del(...arguments)} + + delPath(path,confirm) { + path = this._rxFormatPath(path) + if (confirm) _del(this,path) + else console.warn('must confirm to delete path',path) + } + + getPath(path,raw){ + path = this._rxFormatPath(path) + const value = _get(this,path) + if (!isPlainObject(value) || raw) return value + let obj = {} + traverse(_get(this,path)).map(function () { + if (this.isLeaf) { + // let leaf = this.path // this path is immutable + // console.log(this.path,this.node) + // leaf = leaf.pop() + _set(obj,this.path.join('.'),this.node) + } + } + ) + return obj + } + + setPath(path,value){ + path = this._rxFormatPath(path) + const curValue = _get(this,path) + if (!equal(curValue,value) || !value) { + _set(this,path,value) + } + return _get(this,path) + } + +} // end class