├── .gitignore ├── index.js ├── package.json ├── LICENSE ├── README.md ├── npm-debug.log └── lib ├── store.service.js └── store.service.ts /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | var brainfreeze = require('./lib/store.service'); 3 | exports.Brainfreeze = brainfreeze.StoreService; -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "brain-freeze", 3 | "version": "1.1.7", 4 | "description": "Redux-Like State Management Service", 5 | "main": "index.js", 6 | "typings": "index.d.ts", 7 | "scripts": { 8 | "test": "echo \"Error: no test specified\" && exit 1" 9 | }, 10 | "repository": { 11 | "type": "git", 12 | "url": "git+https://github.com/Brain-Day/brain-freeze.git" 13 | }, 14 | "keywords": [ 15 | "Angular 2", 16 | "Angular", 17 | "State", 18 | "Redux", 19 | "Freeze", 20 | "JavaScript", 21 | "React" 22 | ], 23 | "author": "Brain Day", 24 | "license": "MIT", 25 | "bugs": { 26 | "url": "https://github.com/Brain-Day/brain-freeze/issues" 27 | }, 28 | "homepage": "https://github.com/Brain-Day/brain-freeze#readme", 29 | "dependencies": { 30 | "@angular/core": "^2.3.0" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2016 Brain-Day 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Brain Freeze 2 | #Synopsis 3 | 4 | Brainfreeze takes advantage of redux architecture. That means that a lot of how Brainfreeze works is very similar to how redux, or a redux-like library works. The basic structure of Brainfreeze follows the dispatch an action => reducer => update/change state => return a new state. 5 | 6 | Brainfreeze still follows the functional approach that redux follows, we feel that is an appropriate approach to state containers. 7 | 8 | Where does Brainfreeze differentiate itself? 9 | 10 | Brainfreeze saw an issue where as applications scaled, the amount of 'listeners' on the page that were reacting to a state change could become taxing on performance. Brainfreeze focuses on the developer gaining more control over their application state. We allow for this by creating a flattened state model that is able to create a mapping of the state object. By doing this we allow specific pieces of state to map to their specific listener. The listener now will only react to the changes that are neccessary to them when state is set. 11 | 12 | Doing this, we also enabled middleware functionality for more control over the state object. By creating this model, we set out create less business logic needed in the reducers and create more room for the developer to code what they want to code, and let the library hash out the unwanted logic. We are excited to dive into the depths of what Brainfreeze can do. 13 | 14 | #API Reference 15 | Brainfreeze aims to provide a familiar and predictable state container. 16 | 17 | #Installation 18 | `npm i brain-freeze --save` 19 | 20 | #Documentation 21 | 22 | ```CombineReducers : Accepts object of reducers in the shape of state.``` 23 | 24 | ```getState : Returns state``` 25 | 26 | ```dispatch : Takes in action objects and checks for lock related commands (see locked state) before running state through reducers``` 27 | 28 | ```subscribe : Subscribes a listener function to state changes (globally or to a specific key path) and returns a function to unsubscribe the same listener function. In order to subscribe to a specific key path, the developer passes in a second argument after the listener, which is the string that is the key path they wish to subscribe to. This must be done in dot notation, even with arrays.``` 29 | 30 | ```Lock State : By attaching a 'lockState' property to the action object upon dispatch, the dispatch method will lock the state and refuse to change state until it receives an action object with the 'unlockState' property. If the dispatch method sees the 'lockState' or 'unlockState' property it will lock or unlock state respectively, and then exit.``` 31 | 32 | ```Locking Specific Keys : This gives the developer the ability to make pieces of state immutable. This is done by dispatching an action, called 'lockKeys'. If an action is dispatched to this key while it's leave of state is locked, the action will be intercepted and the change will not occur. In certain modes, like dev-mode, the console will log out that the key is locked, and will give the developer some feedback.``` 33 | 34 | # Under The Hood 35 | 36 | ```getAllKeys : Returns flattened object from nested object``` 37 | 38 | ```keyPathsChanged : Takes two objects and returns object of keys from first object that are not the same in second object. Will not return keys from second object that are not in first object.``` 39 | 40 | ```saveHistory : Saves a history of state in the form of an array of deep cloned, deep frozen copies.``` 41 | 42 | ## Contributors 43 | [![Image of Edward](https://avatars3.githubusercontent.com/u/10620846?v=3&s=190)](https://github.com/Eviscerare) 44 | [![Image of Thai](https://avatars3.githubusercontent.com/u/20631126?v=3&s=190)](https://github.com/soleiluwedu) 45 | [![Image of Ryan](https://avatars1.githubusercontent.com/u/18267769?v=3&s=190)](https://github.com/ryanbas21) 46 | ## License 47 | MIT 48 | -------------------------------------------------------------------------------- /npm-debug.log: -------------------------------------------------------------------------------- 1 | 0 info it worked if it ends with ok 2 | 1 verbose cli [ '/usr/local/bin/node', '/usr/local/bin/npm', 'publish' ] 3 | 2 info using npm@3.10.8 4 | 3 info using node@v7.0.0 5 | 4 verbose publish [ '.' ] 6 | 5 silly cache add args [ '.', null ] 7 | 6 verbose cache add spec . 8 | 7 silly cache add parsed spec Result { 9 | 7 silly cache add raw: '.', 10 | 7 silly cache add scope: null, 11 | 7 silly cache add escapedName: null, 12 | 7 silly cache add name: null, 13 | 7 silly cache add rawSpec: '.', 14 | 7 silly cache add spec: '/Users/RyanBas/Documents/web_development/codesmith/angular2/bdss', 15 | 7 silly cache add type: 'directory' } 16 | 8 verbose addLocalDirectory /Users/RyanBas/.npm/brain-freeze/1.1.7/package.tgz not in flight; packing 17 | 9 verbose correctMkdir /Users/RyanBas/.npm correctMkdir not in flight; initializing 18 | 10 info lifecycle brain-freeze@1.1.7~prepublish: brain-freeze@1.1.7 19 | 11 silly lifecycle brain-freeze@1.1.7~prepublish: no script for prepublish, continuing 20 | 12 verbose tar pack [ '/Users/RyanBas/.npm/brain-freeze/1.1.7/package.tgz', 21 | 12 verbose tar pack '/Users/RyanBas/Documents/web_development/codesmith/angular2/bdss' ] 22 | 13 verbose tarball /Users/RyanBas/.npm/brain-freeze/1.1.7/package.tgz 23 | 14 verbose folder /Users/RyanBas/Documents/web_development/codesmith/angular2/bdss 24 | 15 verbose addLocalTarball adding from inside cache /Users/RyanBas/.npm/brain-freeze/1.1.7/package.tgz 25 | 16 verbose correctMkdir /Users/RyanBas/.npm correctMkdir not in flight; initializing 26 | 17 silly cache afterAdd brain-freeze@1.1.7 27 | 18 verbose afterAdd /Users/RyanBas/.npm/brain-freeze/1.1.7/package/package.json not in flight; writing 28 | 19 verbose correctMkdir /Users/RyanBas/.npm correctMkdir not in flight; initializing 29 | 20 verbose afterAdd /Users/RyanBas/.npm/brain-freeze/1.1.7/package/package.json written 30 | 21 silly publish { name: 'brain-freeze', 31 | 21 silly publish version: '1.1.7', 32 | 21 silly publish description: 'Redux-Like State Management Service', 33 | 21 silly publish main: 'index.js', 34 | 21 silly publish typings: 'index.d.ts', 35 | 21 silly publish scripts: { test: 'echo "Error: no test specified" && exit 1' }, 36 | 21 silly publish repository: 37 | 21 silly publish { type: 'git', 38 | 21 silly publish url: 'git+https://github.com/Brain-Day/brain-freeze.git' }, 39 | 21 silly publish keywords: 40 | 21 silly publish [ 'Angular 2', 41 | 21 silly publish 'Angular', 42 | 21 silly publish 'State', 43 | 21 silly publish 'Redux', 44 | 21 silly publish 'Freeze', 45 | 21 silly publish 'JavaScript', 46 | 21 silly publish 'React' ], 47 | 21 silly publish author: { name: 'Brain Day' }, 48 | 21 silly publish license: 'MIT', 49 | 21 silly publish bugs: { url: 'https://github.com/Brain-Day/brain-freeze/issues' }, 50 | 21 silly publish homepage: 'https://github.com/Brain-Day/brain-freeze#readme', 51 | 21 silly publish dependencies: { '@angular/core': '^2.3.0' }, 52 | 21 silly publish readme: '# Brain Freeze\n#Synopsis\n\nBrainfreeze takes advantage of redux architecture. That means that a lot of how Brainfreeze works is very similar to how redux, or a redux-like library works. The basic structure of Brainfreeze follows the dispatch an action => reducer => update/change state => return a new state.\n\nBrainfreeze still follows the functional approach that redux follows, we feel that is an appropriate approach to state containers.\n\nWhere does Brainfreeze differentiate itself?\n\nBrainfreeze saw an issue where as applications scaled, the amount of \'listeners\' on the page that were reacting to a state change could become taxing on performance. Brainfreeze focuses on the developer gaining more control over the their application state. We allow for this by creating a flattened state model that is able to create a mapping of the state object. By doing this we allow specific pieces of state to map to their specific listener. The listener now will only react to the changes that are neccessary to them when state is set.\n\nDoing this, we also enabled middleware functionality for more control over the state object. By creating this model, we set out create less business logic needed in the reducers and create more room for the developer to code what they want to code, and let the library hash out the unwanted logic. We are excited to dive into the depths of what Brainfreeze can do.\n\n#API Reference\nBrainfreeze aims to provide a familiar and predictable state container.\n\n#Installation\n npm i brain-freeze --save\n\n#Documentation\nCombineReducers [optional:action.types:string] : Void\nAdds reducers to be run on state on invocation of dispatch.\nThe reducer object should take the same shape as the state object.\n\n```Get State : Object\nObject returns state````\n\n````Dispatch : Object\nTakes in action objects and checks for lock related commands (see locked state) before running state through reducers````\n\n````Subscribe : Function\nSubscribes a listener function to state changes (globally or to a specific key path)\n and returns a function to unsubscribe the same listener function.\n In order to subscribe to a specific key path, the developer must pass in a second argument after the listener,\n that is the key path within the state object, as a string. This must be done in dot notation, even with arrays.````\n\n ```` Actions are generally made by the developer. These are strings that are dispatched to a reducer, that tells the reducer what to do. Brain-Freeze comes with a few of these out of the box to take\n full advantage of what the library has to offer.````\n\n Lock State\n\n By attaching a \'lockState\' property to the action object upon dispatch, the dispatch method will lock the state\n and refuse to change state until it receives an action object with the \'unlockState\' property. If the\n dispatch method sees the \'lockState\' or \'unlockState\' property it will lock or unlock state respectively,\n and then exit.\n\n ````Locking Specific Key Paths\n Locking specific keys gives the developer the ability to make pieces of state immutable.\n This is done by dispatching an action, called \'lockKeys\'. If an action is dispatched to this key while it\'s leave of state is locked, the action will be intercepted and the change will not occur\n In certain modes, like dev-mode, the console will log out that the key is locked, and will give the developer some feedback.````\n\n# Under The Hood\n\n````Get ALl Keys : Function\nReturns flattened object from nested object````\n\n````Key Paths Changed : Function\nReturns array of keys from obj1 that are not the same in obj2. Will not return keys from obj2 that are not in obj1.````\n\n````Save History : Function\nSaves a history of state in the form of an array of deep cloned, deep frozen copies.````\n\n\n## Tests\nTo Come ...\n## Contributors\n[![Image of Edward](https://avatars3.githubusercontent.com/u/10620846?v=3&s=190)](https://github.com/Eviscerare)\n[![Image of Thai](https://avatars3.githubusercontent.com/u/20631126?v=3&s=190)](https://github.com/soleiluwedu)\n[![Image of Ryan](https://avatars1.githubusercontent.com/u/18267769?v=3&s=190)](https://github.com/ryanbas21)\n## License\nMIT\n', 53 | 21 silly publish readmeFilename: 'README.md', 54 | 21 silly publish gitHead: 'ddda5485a8916989896120fe33e20d12d5c39841', 55 | 21 silly publish _id: 'brain-freeze@1.1.7', 56 | 21 silly publish _shasum: '1051f92bc468dcc83e038e146fb29b0bfab8ed8e', 57 | 21 silly publish _from: '.' } 58 | 22 verbose getPublishConfig undefined 59 | 23 silly mapToRegistry name brain-freeze 60 | 24 silly mapToRegistry using default registry 61 | 25 silly mapToRegistry registry https://registry.npmjs.org/ 62 | 26 silly mapToRegistry data Result { 63 | 26 silly mapToRegistry raw: 'brain-freeze', 64 | 26 silly mapToRegistry scope: null, 65 | 26 silly mapToRegistry escapedName: 'brain-freeze', 66 | 26 silly mapToRegistry name: 'brain-freeze', 67 | 26 silly mapToRegistry rawSpec: '', 68 | 26 silly mapToRegistry spec: 'latest', 69 | 26 silly mapToRegistry type: 'tag' } 70 | 27 silly mapToRegistry uri https://registry.npmjs.org/brain-freeze 71 | 28 verbose publish registryBase https://registry.npmjs.org/ 72 | 29 silly publish uploading /Users/RyanBas/.npm/brain-freeze/1.1.7/package.tgz 73 | 30 verbose stack Error: auth required for publishing 74 | 30 verbose stack at CachingRegistryClient.publish (/usr/local/lib/node_modules/npm/node_modules/npm-registry-client/lib/publish.js:30:14) 75 | 30 verbose stack at /usr/local/lib/node_modules/npm/lib/publish.js:138:14 76 | 30 verbose stack at mapToRegistry (/usr/local/lib/node_modules/npm/lib/utils/map-to-registry.js:62:3) 77 | 30 verbose stack at publish_ (/usr/local/lib/node_modules/npm/lib/publish.js:107:3) 78 | 30 verbose stack at Array. (/usr/local/lib/node_modules/npm/node_modules/slide/lib/bind-actor.js:15:8) 79 | 30 verbose stack at LOOP (/usr/local/lib/node_modules/npm/node_modules/slide/lib/chain.js:15:14) 80 | 30 verbose stack at LOOP (/usr/local/lib/node_modules/npm/node_modules/slide/lib/chain.js:14:28) 81 | 30 verbose stack at chain (/usr/local/lib/node_modules/npm/node_modules/slide/lib/chain.js:20:5) 82 | 30 verbose stack at /usr/local/lib/node_modules/npm/lib/publish.js:73:5 83 | 30 verbose stack at RES (/usr/local/lib/node_modules/npm/node_modules/inflight/inflight.js:23:14) 84 | 31 verbose cwd /Users/RyanBas/Documents/web_development/codesmith/angular2/bdss 85 | 32 error Darwin 15.6.0 86 | 33 error argv "/usr/local/bin/node" "/usr/local/bin/npm" "publish" 87 | 34 error node v7.0.0 88 | 35 error npm v3.10.8 89 | 36 error code ENEEDAUTH 90 | 37 error need auth auth required for publishing 91 | 38 error need auth You need to authorize this machine using `npm adduser` 92 | 39 verbose exit [ 1, true ] 93 | -------------------------------------------------------------------------------- /lib/store.service.js: -------------------------------------------------------------------------------- 1 | // Purpose of Store is to have one state container for the whole app. 2 | // Only COMBINEREDUCERS, GETSTATE, DISPATCH, and SUBSCRIBE should be invoked from outside this component. 3 | 4 | function StoreService() { 5 | // Set to 'dev' to save action objects, listener arrays, locked key path arrays, and state objects. 6 | // Set to anything else to save only action objects. 7 | this.mode = 'normal' 8 | 9 | // Current state. 10 | this.state = {} 11 | 12 | // When set to true (triggered by presence of 'lockState' property on action object), state cannot 13 | // be mutated until it is unlocked (triggered by presence of 'unlockState' property on action object). 14 | this.stateLocked = false 15 | 16 | // Partial locking. Contains array of state properties (in dot notation, even for arrays) that should be locked. 17 | this.lockedKeyPaths = {} 18 | 19 | // Array of listeners to be trigger on all state changes. 20 | this.globalListeners = [] 21 | 22 | // Keys in this object are key paths. Values are array of listeners. 23 | this.partialListeners = {} 24 | 25 | // Object in the same shape of desired state object. 26 | this.mainReducer = {} 27 | 28 | // Should contain copies of action objects (all modes), listener arrays (dev mode only), 29 | // and locked key path arrays (dev mode only). 30 | this.history = [] 31 | 32 | // Return either type of input or a Boolean of whether or not input matches a given type 33 | this.typeOf = (input, check = null) => { 34 | const type = Object.prototype.toString.call(input).match(/\s([a-zA-Z]+)/)[1].toLowerCase() 35 | return check ? type === check : type 36 | } 37 | 38 | // Returns a deep clone and optionally deep frozen copy of an object. 39 | this.deepClone = (obj, freeze = false) => { 40 | if (!this.typeOf(obj, 'object') && !this.typeOf(obj, 'array')) return obj 41 | const newObj = this.typeOf(obj, 'array') ? [] : {} 42 | for (let key in obj) newObj[key] = (this.typeOf(obj, 'object') || this.typeOf(obj, 'array')) ? this.deepClone(obj[key]) : obj[key] 43 | return freeze ? Object.freeze(newObj) : newObj 44 | } 45 | 46 | // Compares two objects at every level and returns boolean indicating if they are the same. 47 | this.deepCompare = (obj1, obj2) => { 48 | if (this.typeOf(obj1) !== this.typeOf(obj2)) return false 49 | if (this.typeOf(obj1, 'function')) return obj1.toString() === obj2.toString() 50 | if (!this.typeOf(obj1, 'object') && !this.typeOf(obj1, 'array')) return obj1 === obj2 51 | if (Object.keys(obj1).sort().toString() !== Object.keys(obj2).sort().toString()) return false 52 | for (let key in obj1) if (!this.deepCompare(obj1[key], obj2[key])) return false 53 | return true 54 | } 55 | 56 | // Takes dot notation key path and returns nested value 57 | this.getNestedValue = (obj, keyPath) => { 58 | return eval(`obj['${keyPath.replace(/\./g, "']['")}']`) 59 | } 60 | 61 | // Takes dot notation key path and returns nested value 62 | this.copyNestedValue = (target, source, keyPath) => { 63 | eval(`target['${keyPath.replace(/\./g, "']['")}'] = source['${keyPath}']`) 64 | } 65 | 66 | // Returns flattened object from nested object. 67 | this.getAllKeys = (obj, keyPath = null) => { 68 | if (!this.typeOf(obj, 'object') && !this.typeOf(obj, 'array')) return {} 69 | const keyPaths = {} 70 | const prefix = keyPath === null ? '' : `${keyPath}.` 71 | for (let key in obj) { 72 | keyPaths[`${prefix}${key}`] = true 73 | if (this.typeOf(obj[key], 'object') || this.typeOf(obj[key], 'array')) { 74 | for (let nestKey in this.getAllKeys(obj[key], `${prefix}${key}`)) { 75 | keyPaths[nestKey] = true 76 | } 77 | } 78 | } 79 | return keyPaths 80 | } 81 | 82 | // Returns array of keys from obj1 that are not the same in obj2. Will not return keys from obj2 that are not in obj1. 83 | this.getKeyPathsChanged = (obj1, obj2) => { 84 | if (typeof obj1 !== 'object') { 85 | if (obj1 !== obj2) return { VALUE_BEFORE: obj1, VALUE_AFTER: obj2 } 86 | return null 87 | } 88 | const allKeyPaths1 = this.getAllKeys(obj1) 89 | const allKeyPaths2 = this.getAllKeys(obj2) 90 | const changedKeyPaths = {} 91 | const needToCheck = {} 92 | // Separating key paths in obj1 into two groups: Found in obj2, and not found in obj2. 93 | for (let keyPath in allKeyPaths1) { 94 | // Key path found in obj2. Saving it to deep compare later in this function. 95 | if (keyPath in allKeyPaths2) needToCheck[keyPath] = true 96 | // Key path not found in obj2. Record as changed to undefined. 97 | else changedKeyPaths[keyPath] = { VALUE_BEFORE: this.getNestedValue(obj1, keyPath), VALUE_AFTER: undefined } 98 | } 99 | // Deep comparing key paths in obj1 that were found in obj2. 100 | for (let keyPath in needToCheck) { 101 | const val1 = this.getNestedValue(obj1, keyPath) 102 | const val2 = this.getNestedValue(obj2, keyPath) 103 | // Values are the same. 104 | if (this.deepCompare(val1, val2)) { 105 | // If key path passes deep compare check, then all key paths part branching from this key path do not need to be checked. 106 | for (let lookKey in needToCheck) { 107 | const firstDotIndex = lookKey.indexOf('.') 108 | if (firstDotIndex !== -1 && lookKey.slice(0, firstDotIndex) === keyPath) delete needToCheck[lookKey] 109 | } 110 | } 111 | // Values are not the same. 112 | else changedKeyPaths[keyPath] = { VALUE_BEFORE: val1, VALUE_AFTER: val2 } 113 | } 114 | // Returning object describing changes. Keys are key paths that have changed. Values are objects with VALUE_AFTER and VALUE_BEFORE. 115 | return changedKeyPaths 116 | } 117 | 118 | // Saves a history of state in the form of an array of deep cloned, deep frozen copies. 119 | this.saveHistory = (action, changeType) => { 120 | const newHistoryObj = {} 121 | newHistoryObj['CHANGE_TYPE'] = changeType 122 | newHistoryObj['ACTION'] = action 123 | if (this.mode === 'dev') { 124 | newHistoryObj['CURRENT_LISTENERS'] = { 125 | GLOBAL: this.globalListeners, 126 | PARTIAL: this.partialListeners 127 | } 128 | newHistoryObj['CURRENT_LOCKED_KEYS'] = this.lockedKeyPaths 129 | newHistoryObj['STATE'] = this.state 130 | } 131 | this.history.push(newHistoryObj) 132 | if (this.mode === 'dev') { 133 | console.groupCollapsed(`Store.SAVEHISTORY: ${changeType}`) 134 | console.dir(this.history.filter(e => e['CHANGE_TYPE'] === changeType)) 135 | console.groupEnd() 136 | } 137 | } 138 | 139 | // Takes in reducer object just like in Redux. 140 | this.combineReducers = (reducerObj) => { 141 | // Saving reducer. 142 | this.mainReducer = reducerObj 143 | 144 | // Initializing state. 145 | const newState = {} 146 | for (let key in this.mainReducer) newState[key] = this.mainReducer[key](null, {}) 147 | this.state = newState 148 | } 149 | 150 | // Returns a deep clone of state. 151 | this.getState = () => { return this.state } 152 | 153 | // Takes in an action object. Checks for mode setting and locking/unlocking before passing action to reducers. 154 | this.dispatch = (action) => { 155 | if (this.mode === 'dev') { 156 | console.log(`Action object received:`) 157 | console.dir(action) 158 | } 159 | 160 | // Checking for Dev Mode command. If set to true, history is not saved and 161 | // console.groupEnd is never called, putting all console logs in one group. 162 | if ('mode' in action) { 163 | this.mode = action['mode'] 164 | return 165 | } 166 | 167 | // Locking specific key paths. 168 | if (action['lockKeyPaths'] !== undefined) { 169 | const keyPathsToLockArray = typeof action['lockKeyPaths'] === 'string' ? [action['lockKeyPaths']] : action['lockKeyPaths'] 170 | keyPathsToLockArray.forEach(keyPath => this.lockedKeyPaths[keyPath] = this.deepClone(this.getNestedValue(this.state, keyPath), false)) 171 | return 172 | } 173 | 174 | // Unlocking specific key paths. 175 | if (action['unlockKeyPaths'] !== undefined) { 176 | const keyPathsToUnlockArray = typeof action['unlockKeyPaths'] === 'string' ? 177 | [action['unlockKeyPaths']] 178 | : action['unlockKeyPaths'] 179 | keyPathsToUnlockArray.forEach(keyPath => delete this.lockedKeyPaths[keyPath]) 180 | return 181 | } 182 | 183 | // Checking for lockState command. 184 | if (action['lockState'] !== undefined) { 185 | this.stateLocked = true 186 | console.log(`State now locked.`) 187 | return 188 | } 189 | 190 | // Checking for unlockState command. 191 | if (action['unlockState'] !== undefined) { 192 | this.stateLocked = false 193 | console.log(`State unlocked.`) 194 | return 195 | } 196 | 197 | // Checking if entire state is locked. 198 | if (this.stateLocked) { 199 | console.warn("State change operation rejected: State is locked.") 200 | return 201 | } 202 | 203 | // Need 'KEYPATHS_TO_CHANGE' property in action object if making state changes. 204 | if (!('KEYPATHS_TO_CHANGE' in action)) throw Error("Action object needs KEYPATHS_TO_CHANGE property with array of all key paths that will be changed.") 205 | 206 | // Updating state object. 207 | for (let key in this.mainReducer) this.state[key] = this.mainReducer[key](this.state[key], action) 208 | 209 | // Protecting locked key paths. 210 | for (let keyPath in this.lockedKeyPaths) this.copyNestedValue(this.state, this.lockedKeyPaths, keyPath) 211 | 212 | // Checking key paths for changes. 213 | const keyPathsChanged = {} 214 | let keyPathsToChangeArray 215 | 216 | // Check key paths in KEYPATHS_TO_CHANGE property. 217 | keyPathsToChangeArray = typeof action['KEYPATHS_TO_CHANGE'] === 'string' 218 | ? [action['KEYPATHS_TO_CHANGE']] 219 | : action['KEYPATHS_TO_CHANGE'] 220 | keyPathsToChangeArray.forEach(keyPath => { if (!(keyPath in this.lockedKeyPaths)) keyPathsChanged[keyPath] = true }) 221 | 222 | // Recording changes. 223 | for (let keyPath in keyPathsChanged) { 224 | // Record key changes at every level of nesting BELOW the specified key paths that were changed (if any). 225 | // this.getAllKeys will handle every level of nesting below `keyPath`, so no need to iterate. 226 | const changedKeyPath = this.getNestedValue(this.state, keyPath) 227 | if (typeof changedKeyPath === 'object') { 228 | const subKeyPaths = this.getAllKeys(changedKeyPath) 229 | for (let subKey in subKeyPaths) { 230 | if (subKey in this.partialListeners) keyPathsChanged[`${keyPath}.${subKey}`] = true 231 | } 232 | } 233 | } 234 | 235 | // Record key changes at every level of nesting ABOVE the specified key paths that were changed (if any). 236 | for (let keyPath in keyPathsChanged) { 237 | let nextDotIndex = keyPath.indexOf('.') 238 | let nextLevel = keyPath.slice(0, nextDotIndex) 239 | let remainingLevels = keyPath.slice(nextDotIndex + 1) 240 | while (nextDotIndex > -1) { 241 | if (!(nextLevel in keyPathsChanged)) keyPathsChanged[nextLevel] = true 242 | const testNextDotIndex = keyPath.slice(nextDotIndex + 1).indexOf('.') 243 | nextDotIndex = testNextDotIndex === -1 ? -1 : testNextDotIndex + nextDotIndex + 1 244 | nextLevel = keyPath.slice(0, nextDotIndex) 245 | remainingLevels = keyPath.slice(nextDotIndex + 1) 246 | } 247 | } 248 | 249 | // Saves history. Note: SAVEHISTORY method behaves differently according to this.mode setting. 250 | this.saveHistory(action, 'STATE') 251 | 252 | // Loop through all arrays of partial listeners attached to changed key paths. 253 | for (let keyPath in keyPathsChanged) { 254 | if (keyPath in this.partialListeners) { 255 | // Invokes partial listener and passes in new value. 256 | this.partialListeners[keyPath].forEach(listener => { 257 | ++this.listeners 258 | listener() 259 | }) 260 | } 261 | } 262 | 263 | // Loop through the global array of listeners. 264 | this.globalListeners.forEach(listener => { 265 | ++this.listeners 266 | listener() 267 | }) 268 | } 269 | 270 | // Subscribes a listener function to state changes and returns a function to unsubscribe the same listener function. 271 | this.subscribe = (fn, keyPath = null) => { 272 | 273 | // Key path is passed in. Subscribe listener to that specific key path only. 274 | if (keyPath !== null) { 275 | this.partialListeners[`${keyPath}`] = this.partialListeners[`${keyPath}`] !== undefined 276 | ? this.partialListeners[`${keyPath}`].concat(fn) 277 | : [fn] 278 | this.saveHistory({}, 'ADD_PARTIAL_LISTENER') 279 | 280 | // Return partial unsubscribe function. 281 | return () => { 282 | this.partialListeners[`${keyPath}`] = this.partialListeners[`${keyPath}`].filter(func => func !== fn) 283 | if (!this.partialListeners[`${keyPath}`].length) delete this.partialListeners[`${keyPath}`] 284 | this.saveHistory({}, 'DEL_PARTIAL_LISTENER') 285 | } 286 | } 287 | 288 | // Key path not passed in. Subscribe listener to entire state object. 289 | this.globalListeners = this.globalListeners.concat(fn) 290 | this.saveHistory({}, 'ADD_GLOBAL_LISTENER') 291 | 292 | // Return global unsubscribe function. 293 | return () => { 294 | this.globalListeners = this.globalListeners.filter(func => func !== fn) 295 | this.saveHistory({}, 'DEL_GLOBAL_LISTENER') 296 | } 297 | } 298 | } 299 | 300 | module.exports = StoreService 301 | -------------------------------------------------------------------------------- /lib/store.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | 3 | // Purpose of Store is to have one state container for the whole app. 4 | // Only COMBINEREDUCERS, GETSTATE, DISPATCH, and SUBSCRIBE should be invoked from outside this component. 5 | 6 | @Injectable() 7 | export class StoreService { 8 | 9 | // Set to 'dev' to save action objects, listener arrays, locked key path arrays, and state objects. 10 | // Set to anything else to save only action objects. 11 | private mode: string = 'dev' 12 | 13 | // Current state. 14 | private state: Object 15 | 16 | // When set to true (triggered by presence of 'lockState' property on action object), state cannot 17 | // be mutated until it is unlocked (triggered by presence of 'unlockState' property on action object). 18 | private stateLocked: boolean = false 19 | 20 | // Partial locking. Contains array of state properties (in dot notation, even for arrays) that should be locked. 21 | private lockedKeyPaths: Object = {} 22 | 23 | // Array of listeners to be trigger on all state changes. 24 | private globalListeners: Function[] = [] 25 | 26 | // Keys in this object are key paths. Values are array of listeners. 27 | private partialListeners: Object = {} 28 | 29 | // Object in the same shape of desired state object. 30 | private mainReducer: Object 31 | 32 | // Should contain copies of action objects (all modes), listener arrays (dev mode only), 33 | // and locked key path arrays (dev mode only). 34 | private history: Object[] = [] 35 | 36 | // Return either type of input or a Boolean of whether or not input matches a given type 37 | typeOf(input: any, check: string = null): string { 38 | const type = Object.prototype.toString.call(input).match(/\s([a-zA-Z]+)/)[1].toLowerCase() 39 | return check ? type === check : type 40 | } 41 | 42 | // Returns a deep clone and optionally deep frozen copy of an object. 43 | deepClone(obj: Object, freeze: boolean = false): Object { 44 | if (!this.typeOf(obj, 'object') && !this.typeOf(obj, 'array')) return obj 45 | const newObj = this.typeOf(obj, 'array') ? [] : {} 46 | for (let key in obj) newObj[key] = (this.typeOf(obj, 'object') || this.typeOf(obj, 'array')) ? this.deepClone(obj[key]) : obj[key] 47 | return freeze ? Object.freeze(newObj) : newObj 48 | } 49 | 50 | // Compares two objects at every level and returns boolean indicating if they are the same. 51 | deepCompare(obj1: Object, obj2: Object): boolean { 52 | if (this.typeOf(obj1) !== this.typeOf(obj2)) return false 53 | if (this.typeOf(obj1, 'function')) return obj1.toString() === obj2.toString() 54 | if (!this.typeOf(obj1, 'object') && !this.typeOf(obj1, 'array')) return obj1 === obj2 55 | if (Object.keys(obj1).sort().toString() !== Object.keys(obj2).sort().toString()) return false 56 | for (let key in obj1) if (!this.deepCompare(obj1[key], obj2[key])) return false 57 | return true 58 | } 59 | 60 | // Takes dot notation key path and returns nested value 61 | getNestedValue(obj: Object, keyPath: string): any { 62 | // let path = keyPath.replace(/\./g, "']['")}']` 63 | // obj[path] 64 | // obj=> obj.key.path.nested 65 | return eval(`obj['${keyPath.replace(/\./g, "']['")}']`) 66 | } 67 | 68 | // Takes dot notation key path and returns nested value 69 | copyNestedValue(target: Object, source: Object, keyPath: string): any { 70 | eval(`target['${keyPath.replace(/\./g, "']['")}'] = source['${keyPath}']`) 71 | } 72 | 73 | // Returns flattened object from nested object. 74 | getAllKeys(obj: Object, keyPath: string = null): Object { 75 | if (!this.typeOf(obj, 'object') && !this.typeOf(obj, 'array')) return {} 76 | const keyPaths = {} 77 | const prefix = keyPath === null ? '' : `${keyPath}.` 78 | for (let key in obj) { 79 | keyPaths[`${prefix}${key}`] = true 80 | if (this.typeOf(obj[key], 'object') || this.typeOf(obj[key], 'array')) { 81 | for (let nestKey in this.getAllKeys(obj[key], `${prefix}${key}`)) { 82 | keyPaths[nestKey] = true 83 | } 84 | } 85 | } 86 | return keyPaths 87 | } 88 | 89 | // Returns array of keys from obj1 that are not the same in obj2. Will not return keys from obj2 that are not in obj1. 90 | getKeyPathsChanged(obj1: Object, obj2: Object): Object { 91 | if (typeof obj1 !== 'object') { 92 | if (obj1 !== obj2) return { VALUE_BEFORE: obj1, VALUE_AFTER: obj2 } 93 | return null 94 | } 95 | const allKeyPaths1 = this.getAllKeys(obj1) 96 | const allKeyPaths2 = this.getAllKeys(obj2) 97 | const changedKeyPaths = {} 98 | const needToCheck = {} 99 | // Separating key paths in obj1 into two groups: Found in obj2, and not found in obj2. 100 | for (let keyPath in allKeyPaths1) { 101 | // Key path found in obj2. Saving it to deep compare later in this function. 102 | if (keyPath in allKeyPaths2) needToCheck[keyPath] = true 103 | // Key path not found in obj2. Record as changed to undefined. 104 | else changedKeyPaths[keyPath] = { VALUE_BEFORE: this.getNestedValue(obj1, keyPath), VALUE_AFTER: undefined } 105 | } 106 | // Deep comparing key paths in obj1 that were found in obj2. 107 | for (let keyPath in needToCheck) { 108 | const val1 = this.getNestedValue(obj1, keyPath) 109 | const val2 = this.getNestedValue(obj2, keyPath) 110 | // Values are the same. 111 | if (this.deepCompare(val1, val2)) { 112 | // If key path passes deep compare check, then all key paths part branching from this key path do not need to be checked. 113 | for (let lookKey in needToCheck) { 114 | const firstDotIndex = lookKey.indexOf('.') 115 | if (firstDotIndex !== -1 && lookKey.slice(0, firstDotIndex) === keyPath) delete needToCheck[lookKey] 116 | } 117 | } 118 | // Values are not the same. 119 | else changedKeyPaths[keyPath] = { VALUE_BEFORE: val1, VALUE_AFTER: val2 } 120 | } 121 | // Returning object describing changes. Keys are key paths that have changed. Values are objects with VALUE_AFTER and VALUE_BEFORE. 122 | return changedKeyPaths 123 | } 124 | 125 | // Saves a history of state in the form of an array of deep cloned, deep frozen copies. 126 | saveHistory(action: Object, changeType: string): void { 127 | const newHistoryObj = {} 128 | newHistoryObj['CHANGE_TYPE'] = changeType 129 | newHistoryObj['ACTION'] = action 130 | if (this.mode === 'dev') { 131 | newHistoryObj['CURRENT_LISTENERS'] = { 132 | GLOBAL: this.globalListeners, 133 | PARTIAL: this.partialListeners 134 | } 135 | newHistoryObj['CURRENT_LOCKED_KEYS'] = this.lockedKeyPaths 136 | newHistoryObj['STATE'] = this.state 137 | } 138 | this.history.push(newHistoryObj) 139 | console.groupCollapsed(`Store.SAVEHISTORY: ${changeType}`) 140 | console.dir(this.history.filter(e => e['CHANGE_TYPE'] === changeType)) 141 | console.groupEnd() 142 | } 143 | 144 | // Takes in reducer object just like in Redux. 145 | combineReducers(reducerObj: Object): void { 146 | // Saving reducer. 147 | this.mainReducer = reducerObj 148 | 149 | // Initializing state. 150 | const newState = {} 151 | for (let key in this.mainReducer) newState[key] = this.mainReducer[key](null, {}) 152 | this.state = newState 153 | } 154 | 155 | // Returns a deep clone of state. 156 | getState(): Object { return this.state } 157 | 158 | // Takes in an action object. Checks for mode setting and locking/unlocking before passing action to reducers. 159 | dispatch(action: Object): Object { 160 | console.log(`Action object received:`) 161 | console.dir(action) 162 | 163 | // Checking for Dev Mode command. If set to true, history is not saved and 164 | // console.groupEnd is never called, putting all console logs in one group. 165 | if ('mode' in action) { 166 | this.mode = action['mode'] 167 | return 168 | } 169 | 170 | // Locking specific key paths. 171 | if (action['lockKeyPaths'] !== undefined) { 172 | const keyPathsToLockArray = typeof action['lockKeyPaths'] === 'string' ? [action['lockKeyPaths']] : action['lockKeyPaths'] 173 | keyPathsToLockArray.forEach(keyPath => this.lockedKeyPaths[keyPath] = this.deepClone(this.getNestedValue(this.state, keyPath), false)) 174 | return 175 | } 176 | 177 | // Unlocking specific key paths. 178 | if (action['unlockKeyPaths'] !== undefined) { 179 | const keyPathsToUnlockArray = typeof action['unlockKeyPaths'] === 'string' ? 180 | [action['unlockKeyPaths']] 181 | : action['unlockKeyPaths'] 182 | keyPathsToUnlockArray.forEach(keyPath => delete this.lockedKeyPaths[keyPath]) 183 | return 184 | } 185 | 186 | // Checking for lockState command. 187 | if (action['lockState'] !== undefined) { 188 | this.stateLocked = true 189 | console.log(`State now locked.`) 190 | return 191 | } 192 | 193 | // Checking for unlockState command. 194 | if (action['unlockState'] !== undefined) { 195 | this.stateLocked = false 196 | console.log(`State unlocked.`) 197 | return 198 | } 199 | 200 | // Checking if entire state is locked. 201 | if (this.stateLocked) { 202 | console.warn("State change operation rejected: State is locked.") 203 | return 204 | } 205 | 206 | if (!('KEYPATHS_TO_CHANGE' in action)) throw Error("Action object needs KEYPATHS_TO_CHANGE property with array of all key paths that will be changed.") 207 | 208 | // Updating state object. 209 | for (let key in this.mainReducer) this.state[key] = this.mainReducer[key](this.state[key], action) 210 | 211 | // Protecting locked key paths. 212 | for (let keyPath in this.lockedKeyPaths) this.copyNestedValue(this.state, this.lockedKeyPaths, keyPath) 213 | 214 | // Checking key paths for changes. 215 | const keyPathsChanged = {} 216 | let keyPathsToChangeArray 217 | 218 | // Check key paths in KEYPATHS_TO_CHANGE property. 219 | keyPathsToChangeArray = typeof action['KEYPATHS_TO_CHANGE'] === 'string' 220 | ? [action['KEYPATHS_TO_CHANGE']] 221 | : action['KEYPATHS_TO_CHANGE'] 222 | keyPathsToChangeArray.forEach(keyPath => { if (!(keyPath in this.lockedKeyPaths)) keyPathsChanged[keyPath] = true }) 223 | 224 | // Recording changes. 225 | for (let keyPath in keyPathsChanged) { 226 | // Record key changes at every level of nesting BELOW the specified key paths that were changed (if any). 227 | // this.getAllKeys will handle every level of nesting below `keyPath`, so no need to iterate. 228 | const changedKeyPath = this.getNestedValue(this.state, keyPath) 229 | if (typeof changedKeyPath === 'object') { 230 | const subKeyPaths = this.getAllKeys(changedKeyPath) 231 | for (let subKey in subKeyPaths) { 232 | if (subKey in this.partialListeners) keyPathsChanged[`${keyPath}.${subKey}`] = true 233 | } 234 | } 235 | } 236 | 237 | // Record key changes at every level of nesting ABOVE the specified key paths that were changed (if any). 238 | for (let keyPath in keyPathsChanged) { 239 | let nextDotIndex = keyPath.indexOf('.') 240 | let nextLevel = keyPath.slice(0, nextDotIndex) 241 | let remainingLevels = keyPath.slice(nextDotIndex + 1) 242 | while (nextDotIndex > -1) { 243 | if (!(nextLevel in keyPathsChanged)) keyPathsChanged[nextLevel] = true 244 | const testNextDotIndex = keyPath.slice(nextDotIndex + 1).indexOf('.') 245 | nextDotIndex = testNextDotIndex === -1 ? -1 : testNextDotIndex + nextDotIndex + 1 246 | nextLevel = keyPath.slice(0, nextDotIndex) 247 | remainingLevels = keyPath.slice(nextDotIndex + 1) 248 | } 249 | } 250 | 251 | // Saves history. Note: SAVEHISTORY method behaves differently according to this.mode setting. 252 | this.saveHistory(action, 'STATE') 253 | 254 | // Loop through all arrays of partial listeners attached to changed key paths. 255 | for (let keyPath in keyPathsChanged) { 256 | if (keyPath in this.partialListeners) { 257 | // Invokes partial listener and passes in new value. 258 | this.partialListeners[keyPath].forEach(listener => listener(keyPathsChanged[keyPath])) 259 | } 260 | } 261 | 262 | // Loop through the global array of listeners. 263 | this.globalListeners.forEach(listener => listener()) 264 | } 265 | 266 | // Subscribes a listener function to state changes and returns a function to unsubscribe the same listener function. 267 | subscribe(fn: Function, keyPath: String = null): Function { 268 | 269 | // Key path is passed in. Subscribe listener to that specific key path only. 270 | if (keyPath !== null) { 271 | this.partialListeners[`${keyPath}`] = this.partialListeners[`${keyPath}`] !== undefined 272 | ? this.partialListeners[`${keyPath}`].concat(fn) 273 | : [fn] 274 | this.saveHistory({}, 'ADD_PARTIAL_LISTENER') 275 | 276 | // Return partial unsubscribe function. 277 | return () => { 278 | this.partialListeners[`${keyPath}`] = this.partialListeners[`${keyPath}`].filter(func => func !== fn) 279 | if (!this.partialListeners[`${keyPath}`].length) delete this.partialListeners[`${keyPath}`] 280 | this.saveHistory({}, 'DEL_PARTIAL_LISTENER') 281 | } 282 | } 283 | 284 | // Key path not passed in. Subscribe listener to entire state object. 285 | this.globalListeners = this.globalListeners.concat(fn) 286 | this.saveHistory({}, 'ADD_GLOBAL_LISTENER') 287 | 288 | // Return global unsubscribe function. 289 | return () => { 290 | this.globalListeners = this.globalListeners.filter(func => func !== fn) 291 | this.saveHistory({}, 'DEL_GLOBAL_LISTENER') 292 | } 293 | } 294 | } 295 | --------------------------------------------------------------------------------