├── .gitignore
├── LICENSE
├── README.md
├── demo
├── index.html
├── package.json
├── src
│ ├── DataRenderer.jsx
│ ├── MessageList.jsx
│ └── index.jsx
└── webpack.config.js
├── lib
└── index.js
├── package.json
├── src
└── index.js
├── test
└── testCursor.js
└── webpack.config.js
/.gitignore:
--------------------------------------------------------------------------------
1 | .idea/
2 | node_modules/
3 | *.iml
4 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2016 Martin Snyder
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 | # seamless-immutable-cursor
2 | Compact Cursor Library built on top of the excellent [seamless-immutable][seamless-immutable-github].
3 | Cursors can be used to manage transitions and manipulations of immutable structures in an application.
4 |
5 | ## Presentation
6 | A [20 minute presentation](https://youtu.be/wQy5vxzNdV0) explains the utility of this library and the repository contents. [Slides can be viewed here](https://martinsnyder.net/presentations/revealjs/seamless-immutable-cursor.html)
7 |
8 | # Example
9 | ```javascript
10 | const rootCursor = new Cursor({
11 | users: {
12 | abby: 1,
13 | ben: 2,
14 | claire: 3,
15 | dan: 4
16 | },
17 | documents: [
18 | {
19 | name: 'CV',
20 | owner: 1,
21 | mediaType: 'application/pdf'
22 | },
23 | {
24 | name: 'References',
25 | owner: 1,
26 | mediaType: 'text/plain'
27 | }
28 | ]
29 | });
30 |
31 | // Register a function to react to new generations of our immutable data
32 | rootCursor.onChange((nextData, prevData, pathUpdated) => {
33 | console.debug('Updated ' + JSON.stringify(pathUpdated));
34 | });
35 |
36 | // Create a cursor for a limited portion of our data hierarchy
37 | const childCursor = rootCursor.refine(['documents', 0, 'name']);
38 |
39 | // firstDocumentName will be 'CV'
40 | const firstDocumentName = childCursor.data;
41 |
42 | // Update -- this switches the data owned by rootCursor to point to
43 | // a new generation of immutable data
44 | childCursor.data = 'Resume';
45 |
46 | // updatedFirstDocumentName will be 'Resume' because the cursor points
47 | // to the location, not the specific data
48 | const updatedFirstDocumentName = childCursor.data;
49 |
50 | // updatedFirstDocumentNameFromRoot will ALSO be 'Resume' because the
51 | // 'managed' data has moved to a new generation based on the prior update
52 | const updatedFirstDocumentNameFromRoot = rootCursor.data.documents[0].name;
53 | ```
54 |
55 | # React Demo
56 | The demo folder contains a simple demo that combines this library, seamless-immutable and React.
57 |
58 | [seamless-immutable-github]: https://github.com/rtfeldman/seamless-immutable
59 |
--------------------------------------------------------------------------------
/demo/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
26 |
27 |
28 | Demonstration of seamless-immutable-cursor with React.js
29 |
30 |
31 |
32 |
33 |
34 |
35 |
--------------------------------------------------------------------------------
/demo/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "seamless-immutable-cursor-demo",
3 | "version": "0.3.0",
4 | "description": "Cursor Library add-on for seamless-immutable Demo",
5 | "main": "src/index.js",
6 | "author": "Martin Snyder",
7 | "repository": {
8 | "type": "git",
9 | "url": "https://github.com/MartinSnyder/seamless-immutable-cursor.git"
10 | },
11 | "license": "MIT",
12 | "scripts": {
13 | "start": "./node_modules/.bin/webpack-dev-server"
14 | },
15 | "dependencies": {
16 | "react": "^16.8.1",
17 | "react-dom": "^16.8.1"
18 | },
19 | "devDependencies": {
20 | "babel-cli": "^6.26.0",
21 | "babel-core": "^6.26.3",
22 | "babel-loader": "^7.1.5",
23 | "babel-preset-env": "^1.7.0",
24 | "babel-preset-react": "^6.24.1",
25 | "babel-preset-react-hmre": "^1.1.1",
26 | "chai": "^3.5.0",
27 | "mocha": "^3.1.2",
28 | "seamless-immutable": "^7.1.4",
29 | "webpack": "^4.29.3",
30 | "webpack-cli": "^3.2.3",
31 | "webpack-dev-server": "^3.1.14"
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/demo/src/DataRenderer.jsx:
--------------------------------------------------------------------------------
1 | /*
2 | MIT License
3 |
4 | Copyright (c) 2016 Martin Snyder
5 |
6 | Permission is hereby granted, free of charge, to any person obtaining a copy
7 | of this software and associated documentation files (the "Software"), to deal
8 | in the Software without restriction, including without limitation the rights
9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10 | copies of the Software, and to permit persons to whom the Software is
11 | furnished to do so, subject to the following conditions:
12 |
13 | The above copyright notice and this permission notice shall be included in all
14 | copies or substantial portions of the Software.
15 |
16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22 | SOFTWARE.
23 | */
24 | import React from 'react';
25 |
26 | // ES6 'function style' react component is a good choice for 'Pure' react components like this one
27 | const DataRenderer = ({data}) => {
28 | return {JSON.stringify(data)}
;
29 | };
30 |
31 | export default DataRenderer;
32 |
--------------------------------------------------------------------------------
/demo/src/MessageList.jsx:
--------------------------------------------------------------------------------
1 | /*
2 | MIT License
3 |
4 | Copyright (c) 2016 Martin Snyder
5 |
6 | Permission is hereby granted, free of charge, to any person obtaining a copy
7 | of this software and associated documentation files (the "Software"), to deal
8 | in the Software without restriction, including without limitation the rights
9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10 | copies of the Software, and to permit persons to whom the Software is
11 | furnished to do so, subject to the following conditions:
12 |
13 | The above copyright notice and this permission notice shall be included in all
14 | copies or substantial portions of the Software.
15 |
16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22 | SOFTWARE.
23 | */
24 | import React from 'react';
25 |
26 | // ES6 'function style' react component is a good choice for 'Pure' react components like this one
27 | const MessageList = ({messages}) => {
28 | // React components don't work when frozen, but seamless-immutable is aware of this and
29 | // will safely skip them when executing this map operation.
30 | let i = 0;
31 | const messageMarkup = messages.map(message =>
32 | {message}
33 | );
34 |
35 | return {messageMarkup}
;
36 | };
37 |
38 | export default MessageList;
39 |
--------------------------------------------------------------------------------
/demo/src/index.jsx:
--------------------------------------------------------------------------------
1 | /*
2 | MIT License
3 |
4 | Copyright (c) 2016 Martin Snyder
5 |
6 | Permission is hereby granted, free of charge, to any person obtaining a copy
7 | of this software and associated documentation files (the "Software"), to deal
8 | in the Software without restriction, including without limitation the rights
9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10 | copies of the Software, and to permit persons to whom the Software is
11 | furnished to do so, subject to the following conditions:
12 |
13 | The above copyright notice and this permission notice shall be included in all
14 | copies or substantial portions of the Software.
15 |
16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22 | SOFTWARE.
23 | */
24 | import React from 'react';
25 | import ReactDOM from 'react-dom';
26 | import Cursor from '../../src/index.js'
27 | import DataRenderer from './DataRenderer'
28 | import MessageList from './MessageList'
29 |
30 | // Create a root cursor
31 | const rootCursor = new Cursor({
32 | users: {
33 | abby: 1,
34 | ben: 2,
35 | claire: 3,
36 | dan: 4
37 | },
38 | documents: [
39 | {
40 | name: 'CV',
41 | owner: 1,
42 | mediaType: 'application/pdf'
43 | },
44 | {
45 | name: 'References',
46 | owner: 1,
47 | mediaType: 'text/plain'
48 | }
49 | ],
50 | messages: ['Initialized']
51 | });
52 |
53 | // Register a change handler that renders our page using react. Our React components
54 | // will only 'see' regular JavaScript objects that are runtime-immutable. Any attempts
55 | // by a React component to modify its properties will result in a runtime exception.
56 | rootCursor.onChange((nextData) => {
57 | ReactDOM.render(
58 |
59 |
60 |
61 |
,
62 | document.getElementById('mountPoint'));
63 | });
64 |
65 | // TODO: Build your entire application around this concept!
66 | const startTime = new Date().getTime();
67 | window.setInterval(() => {
68 | // Every second, we apply a change to a refined cursor. This creates a new generation of our
69 | // immutable data and triggers a render (via the onChange handler above)
70 | const messageCursor = rootCursor.refine('messages');
71 | const currentMessages = messageCursor.data;
72 | messageCursor.data = currentMessages.concat('Pulse: ' + Math.round((new Date().getTime() - startTime) / 1000));
73 | }, 1000);
74 |
75 | // For debugging, so you can access the application state in the browser console
76 | // NOTE: you can do this even when the debugger is not stopped at a breakpoint
77 | window.rootCursor = rootCursor;
78 |
--------------------------------------------------------------------------------
/demo/webpack.config.js:
--------------------------------------------------------------------------------
1 | const webpack = require('webpack');
2 |
3 | module.exports = {
4 | entry: './src/index.jsx',
5 | devtool: 'cheap-source-map',
6 | mode: 'development',
7 | output: {
8 | path: '/',
9 | publicPath: 'http://localhost:8080/',
10 | filename: 'bundle.js'
11 | },
12 | resolve: {
13 | // Needed to require .jsx files without specifying the suffix
14 | // http://discuss.babeljs.io/t/es6-import-jsx-without-suffix/172/2
15 | extensions: ['.js', '.jsx']
16 | },
17 | module: {
18 | rules: [{
19 | test: /\.m?js$/,
20 | exclude: /node_modules/,
21 | use: {
22 | loader: 'babel-loader',
23 | options: {
24 | presets: ['babel-preset-env']
25 | }
26 | }
27 | },
28 | {
29 | test: /.jsx?$/,
30 | loader: 'babel-loader',
31 | exclude: /node_modules/,
32 | query: {
33 | // Needed to handle 'npm link'ed modules
34 | // http://stackoverflow.com/questions/34574403/how-to-set-resolve-for-babel-loader-presets/
35 | presets: ['babel-preset-env', 'babel-preset-react', 'babel-preset-react-hmre'].map(require.resolve)
36 | }
37 | }]
38 | }
39 | };
40 |
--------------------------------------------------------------------------------
/lib/index.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | Object.defineProperty(exports, "__esModule", {
4 | value: true
5 | });
6 |
7 | var _createClass = function () { function defineProperties(target, props) { for (var i = 0; i < props.length; i++) { var descriptor = props[i]; descriptor.enumerable = descriptor.enumerable || false; descriptor.configurable = true; if ("value" in descriptor) descriptor.writable = true; Object.defineProperty(target, descriptor.key, descriptor); } } return function (Constructor, protoProps, staticProps) { if (protoProps) defineProperties(Constructor.prototype, protoProps); if (staticProps) defineProperties(Constructor, staticProps); return Constructor; }; }(); /*
8 | MIT License
9 |
10 | Copyright (c) 2016 Martin Snyder
11 |
12 | Permission is hereby granted, free of charge, to any person obtaining a copy
13 | of this software and associated documentation files (the "Software"), to deal
14 | in the Software without restriction, including without limitation the rights
15 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
16 | copies of the Software, and to permit persons to whom the Software is
17 | furnished to do so, subject to the following conditions:
18 |
19 | The above copyright notice and this permission notice shall be included in all
20 | copies or substantial portions of the Software.
21 |
22 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
23 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
24 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
25 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
26 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
27 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
28 | SOFTWARE.
29 | */
30 |
31 |
32 | var _seamlessImmutable = require('seamless-immutable');
33 |
34 | var _seamlessImmutable2 = _interopRequireDefault(_seamlessImmutable);
35 |
36 | function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
37 |
38 | function _possibleConstructorReturn(self, call) { if (!self) { throw new ReferenceError("this hasn't been initialised - super() hasn't been called"); } return call && (typeof call === "object" || typeof call === "function") ? call : self; }
39 |
40 | function _inherits(subClass, superClass) { if (typeof superClass !== "function" && superClass !== null) { throw new TypeError("Super expression must either be null or a function, not " + typeof superClass); } subClass.prototype = Object.create(superClass && superClass.prototype, { constructor: { value: subClass, enumerable: false, writable: true, configurable: true } }); if (superClass) Object.setPrototypeOf ? Object.setPrototypeOf(subClass, superClass) : subClass.__proto__ = superClass; }
41 |
42 | function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } }
43 |
44 | /*
45 | * Custom getIn function
46 | *
47 | * Using custom getIn because Seamless Immutable's getIn function does not default to
48 | * the original object when path is empty.
49 | */
50 | function getIn(obj, path) {
51 | var pointer = obj;
52 | var _iteratorNormalCompletion = true;
53 | var _didIteratorError = false;
54 | var _iteratorError = undefined;
55 |
56 | try {
57 | for (var _iterator = path[Symbol.iterator](), _step; !(_iteratorNormalCompletion = (_step = _iterator.next()).done); _iteratorNormalCompletion = true) {
58 | var el = _step.value;
59 |
60 | pointer = pointer ? pointer[el] : undefined;
61 | }
62 | } catch (err) {
63 | _didIteratorError = true;
64 | _iteratorError = err;
65 | } finally {
66 | try {
67 | if (!_iteratorNormalCompletion && _iterator.return) {
68 | _iterator.return();
69 | }
70 | } finally {
71 | if (_didIteratorError) {
72 | throw _iteratorError;
73 | }
74 | }
75 | }
76 |
77 | return pointer;
78 | }
79 |
80 | /*
81 | * Cursor data that is private to the class/module.
82 | *
83 | * Instances of this class manage MUTABLE data associated with a cursor. This includes:
84 | * - The current generation of the immutable state object
85 | * - The current list of change listeners
86 | *
87 | * Because this class is private to the module and never returned to an outside caller,
88 | * its usage is known to us. It can ONLY be constructed by a root cursor and is shared
89 | * between the root cursor and any child cursors 'refined' from there.
90 | */
91 |
92 | var PrivateData = function () {
93 | function PrivateData(initialData) {
94 | _classCallCheck(this, PrivateData);
95 |
96 | this.currentData = initialData;
97 | this.changeListeners = [];
98 | }
99 |
100 | /*
101 | * Updates the portion of this.currentData referenced by 'path' with the 'newValue'
102 | */
103 |
104 |
105 | _createClass(PrivateData, [{
106 | key: 'update',
107 | value: function update(path, newValue) {
108 | // this.currentData is about to become the "previous generation"
109 | var prevData = this.currentData;
110 |
111 | if (path.length === 0) {
112 | // Replace the data entirely. We must manually force its immutability when we do this.
113 | this.currentData = (0, _seamlessImmutable2.default)(newValue);
114 | } else {
115 | // Apply the update to produce the next generation. Because this.currentData has
116 | // been processed by seamless-immutable, nextData will automatically be immutable as well.
117 | this.currentData = this.currentData.setIn(path, newValue);
118 | }
119 |
120 | // Notify all change listeners
121 | var _iteratorNormalCompletion2 = true;
122 | var _didIteratorError2 = false;
123 | var _iteratorError2 = undefined;
124 |
125 | try {
126 | for (var _iterator2 = this.changeListeners[Symbol.iterator](), _step2; !(_iteratorNormalCompletion2 = (_step2 = _iterator2.next()).done); _iteratorNormalCompletion2 = true) {
127 | var changeListener = _step2.value;
128 |
129 | var shouldUpdate = true;
130 | var shorterPathLength = Math.min(path.length, changeListener.path.length);
131 |
132 | // Only update if the change listener path is a sub-path of the update path (or vice versa)
133 | for (var i = 1; i < shorterPathLength; i++) {
134 | shouldUpdate = shouldUpdate && path[i] === changeListener.path[i];
135 | }
136 |
137 | if (shouldUpdate) {
138 | // Only call change listener if associated path data has changed
139 | if (getIn(this.currentData, changeListener.path) !== getIn(prevData, changeListener.path)) {
140 | // Pass nextData first because many listeners will ONLY care about that.
141 | changeListener(this.currentData, prevData, path);
142 | }
143 | }
144 | }
145 | } catch (err) {
146 | _didIteratorError2 = true;
147 | _iteratorError2 = err;
148 | } finally {
149 | try {
150 | if (!_iteratorNormalCompletion2 && _iterator2.return) {
151 | _iterator2.return();
152 | }
153 | } finally {
154 | if (_didIteratorError2) {
155 | throw _iteratorError2;
156 | }
157 | }
158 | }
159 | }
160 |
161 | /*
162 | * Adds a new change listener to this managed data with the following signature:
163 | * function changeListener(nextRoot, prevRoot, pathUpdated)
164 | *
165 | * Where the parameters pass to this function have the following data types
166 | * nextRoot - Next generation of the JSON-style immutable data being managed by a cursor
167 | * prevRoot - Previous generation of the JSON-style immutable data being managed by a cursor
168 | * pathUpdated - Array of String indicating the keys used to navigate a nested/hierarchical
169 | * structure to the point where the update occurred.
170 | */
171 |
172 | }, {
173 | key: 'addListener',
174 | value: function addListener(changeListener) {
175 | this.changeListeners.push(changeListener);
176 | }
177 |
178 | /*
179 | * Removes change listener
180 | */
181 |
182 | }, {
183 | key: 'removeListener',
184 | value: function removeListener(changeListener) {
185 | this.changeListeners.splice(this.changeListeners.indexOf(changeListener), 1);
186 | }
187 | }]);
188 |
189 | return PrivateData;
190 | }();
191 |
192 | /*
193 | * ES6 classes don't have a direct provision for private data, but we can associate data
194 | * with a class via a WeakMap and hide that WeakMap within the module.
195 | *
196 | * This WeakMap is of mapping of Cursor->PrivateData
197 | */
198 |
199 |
200 | var privateDataMap = new WeakMap();
201 |
202 | /*
203 | * Implementation of a cursor referencing an evolving immutable data structure.
204 | *
205 | * Note that callers of this module CAN receive instances of this class through
206 | * the normal usage pattern of constructing a 'RootCursor' object and then
207 | * calling 'refine', but they cannot construct them on their own.
208 | */
209 |
210 | var Cursor = function () {
211 | /*
212 | * This class is private to the module, so its constructor is impossible to
213 | * invoke externally. This is good since the "privateData" parameter of the
214 | * constructor is not something we want external callers to attempt to provide.
215 | */
216 | function Cursor(privateData, path) {
217 | _classCallCheck(this, Cursor);
218 |
219 | // Keep our private data hidden. This data is 'owned' by a RootCursor and
220 | // shared with all cursors 'refined' from that root (or 'refined' from a
221 | // child cursor of that root)
222 | privateDataMap.set(this, privateData);
223 |
224 | // Path will have already been locked by seamless-immutable
225 | this.path = path;
226 |
227 | // Freeze ourselves so that callers cannot re-assign the path post-construction
228 | Object.freeze(this);
229 | }
230 |
231 | /*
232 | * Property getter for 'data' property of a cursor. This returns the section of the
233 | * current generation of immutable data referred to by the path of the cursor.
234 | *
235 | * Calling this getter over time may return different results, but the data returned
236 | * is an immutable object that can be safely referenced without copy.
237 | *
238 | * This getter returns undefined in the case where the path specified by the cursor
239 | * does not exist in the current generation of the managed data.
240 | */
241 |
242 |
243 | _createClass(Cursor, [{
244 | key: 'refine',
245 |
246 |
247 | /*
248 | * Create a new child cursor from this cursor with the subPath appended to our current path
249 | */
250 | value: function refine(subPath) {
251 | if (subPath.length === 0) {
252 | return this;
253 | } else {
254 | // Because this.path is already immutable, this.path.concat returns
255 | // a new immutable array.
256 | return new Cursor(privateDataMap.get(this), this.path.concat(subPath));
257 | }
258 | }
259 |
260 | /*
261 | * Adds a new change listener to this cursor with the following signature:
262 | * function changeListener(nextRoot, prevRoot, pathUpdated)
263 | *
264 | * Where the parameters pass to this function have the following data types
265 | * nextRoot - Next generation of the JSON-style immutable data being managed by a cursor
266 | * prevRoot - Previous generation of the JSON-style immutable data being managed by a cursor
267 | * pathUpdated - Array of String indicating the keys used to navigate a nested/hierarchical
268 | * structure to the point where the update occurred.
269 | */
270 |
271 | }, {
272 | key: 'onChange',
273 | value: function onChange(changeListener) {
274 | changeListener.path = this.path;
275 | privateDataMap.get(this).addListener(changeListener, this.path);
276 | }
277 |
278 | /*
279 | * Removes change listener
280 | */
281 |
282 | }, {
283 | key: 'removeListener',
284 | value: function removeListener(changeListener) {
285 | privateDataMap.get(this).removeListener(changeListener);
286 | }
287 | }, {
288 | key: 'data',
289 | get: function get() {
290 | return getIn(privateDataMap.get(this).currentData, this.path);
291 | }
292 |
293 | /*
294 | * Property setter for 'data' property of a cursor. This creates a new generation
295 | * of the managed data object with the provided 'newValue' replacing whatever
296 | * exists in the 'path' of the current generation
297 | *
298 | * No attempt is made to address issues such as stale writes. Concurrency issues
299 | * are the responsibility of caller.
300 | */
301 | ,
302 | set: function set(newValue) {
303 | privateDataMap.get(this).update(this.path, newValue);
304 | }
305 | }]);
306 |
307 | return Cursor;
308 | }();
309 |
310 | /*
311 | * Public entry into this module.
312 | *
313 | * RootCursor objects are the same as regular cursor objects except that:
314 | * 1. The 'root' cursor can be constructed by external callers
315 | * 2. The 'root' cursor can register changeListeners
316 | */
317 |
318 |
319 | var RootCursor = function (_Cursor) {
320 | _inherits(RootCursor, _Cursor);
321 |
322 | function RootCursor() {
323 | var initialRoot = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {};
324 |
325 | _classCallCheck(this, RootCursor);
326 |
327 | // Use seamless-immutable to constrain the initial data. This is the only
328 | // place where we invoke seamless-immutable because once we do this, our
329 | // interactions with these objects will only spawn other immutable objects
330 | return _possibleConstructorReturn(this, (RootCursor.__proto__ || Object.getPrototypeOf(RootCursor)).call(this, new PrivateData((0, _seamlessImmutable2.default)(initialRoot)), (0, _seamlessImmutable2.default)([])));
331 | }
332 |
333 | return RootCursor;
334 | }(Cursor);
335 |
336 | exports.default = RootCursor;
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "seamless-immutable-cursor",
3 | "version": "0.3.0",
4 | "description": "Cursor Library add-on for seamless-immutable",
5 | "main": "lib/index.js",
6 | "author": "Martin Snyder",
7 | "repository": {
8 | "type": "git",
9 | "url": "https://github.com/MartinSnyder/seamless-immutable-cursor.git"
10 | },
11 | "license": "MIT",
12 | "scripts": {
13 | "prepublish": "./node_modules/.bin/babel src --out-dir lib",
14 | "test": "./node_modules/.bin/mocha --compilers js:babel-core/register",
15 | "start": "./node_modules/.bin/webpack-dev-server --hot --inline"
16 | },
17 | "babel": {
18 | "presets": [
19 | "env"
20 | ]
21 | },
22 | "dependencies": {
23 | "seamless-immutable": "^7.1.4"
24 | },
25 | "devDependencies": {
26 | "babel-cli": "^6.26.0",
27 | "babel-core": "^6.26.3",
28 | "babel-loader": "^7.1.5",
29 | "babel-preset-env": "^1.7.0",
30 | "chai": "^3.5.0",
31 | "mocha": "^3.1.2",
32 | "webpack": "^4.29.3",
33 | "webpack-cli": "^3.2.3",
34 | "webpack-dev-server": "^3.1.14"
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/src/index.js:
--------------------------------------------------------------------------------
1 | /*
2 | MIT License
3 |
4 | Copyright (c) 2016 Martin Snyder
5 |
6 | Permission is hereby granted, free of charge, to any person obtaining a copy
7 | of this software and associated documentation files (the "Software"), to deal
8 | in the Software without restriction, including without limitation the rights
9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10 | copies of the Software, and to permit persons to whom the Software is
11 | furnished to do so, subject to the following conditions:
12 |
13 | The above copyright notice and this permission notice shall be included in all
14 | copies or substantial portions of the Software.
15 |
16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22 | SOFTWARE.
23 | */
24 | import Immutable from 'seamless-immutable';
25 |
26 | /*
27 | * Custom getIn function
28 | *
29 | * Using custom getIn because Seamless Immutable's getIn function does not default to
30 | * the original object when path is empty.
31 | */
32 | function getIn(obj, path) {
33 | let pointer = obj;
34 | for (let el of path) {
35 | pointer = pointer
36 | ? pointer[el]
37 | : undefined;
38 | }
39 |
40 | return pointer;
41 | }
42 |
43 | /*
44 | * Cursor data that is private to the class/module.
45 | *
46 | * Instances of this class manage MUTABLE data associated with a cursor. This includes:
47 | * - The current generation of the immutable state object
48 | * - The current list of change listeners
49 | *
50 | * Because this class is private to the module and never returned to an outside caller,
51 | * its usage is known to us. It can ONLY be constructed by a root cursor and is shared
52 | * between the root cursor and any child cursors 'refined' from there.
53 | */
54 | class PrivateData {
55 | constructor(initialData) {
56 | this.currentData = initialData;
57 | this.changeListeners = [];
58 | }
59 |
60 | /*
61 | * Updates the portion of this.currentData referenced by 'path' with the 'newValue'
62 | */
63 | update(path, newValue) {
64 | // this.currentData is about to become the "previous generation"
65 | const prevData = this.currentData;
66 |
67 | if (path.length === 0) {
68 | // Replace the data entirely. We must manually force its immutability when we do this.
69 | this.currentData = Immutable(newValue);
70 | }
71 | else {
72 | // Apply the update to produce the next generation. Because this.currentData has
73 | // been processed by seamless-immutable, nextData will automatically be immutable as well.
74 | this.currentData = this.currentData.setIn(path, newValue);
75 | }
76 |
77 | // Notify all change listeners
78 | for (let changeListener of this.changeListeners) {
79 | let shouldUpdate = true;
80 | let shorterPathLength = Math.min(path.length, changeListener.path.length);
81 |
82 | // Only update if the change listener path is a sub-path of the update path (or vice versa)
83 | for(let i = 1; i < shorterPathLength; i++) {
84 | shouldUpdate = shouldUpdate && (path[i] === changeListener.path[i])
85 | }
86 |
87 | if(shouldUpdate) {
88 | // Only call change listener if associated path data has changed
89 | if(getIn(this.currentData, changeListener.path) !== getIn(prevData, changeListener.path)) {
90 | // Pass nextData first because many listeners will ONLY care about that.
91 | changeListener(this.currentData, prevData, path);
92 | }
93 | }
94 | }
95 | }
96 |
97 | /*
98 | * Adds a new change listener to this managed data with the following signature:
99 | * function changeListener(nextRoot, prevRoot, pathUpdated)
100 | *
101 | * Where the parameters pass to this function have the following data types
102 | * nextRoot - Next generation of the JSON-style immutable data being managed by a cursor
103 | * prevRoot - Previous generation of the JSON-style immutable data being managed by a cursor
104 | * pathUpdated - Array of String indicating the keys used to navigate a nested/hierarchical
105 | * structure to the point where the update occurred.
106 | */
107 | addListener(changeListener) {
108 | this.changeListeners.push(changeListener);
109 | }
110 |
111 | /*
112 | * Removes change listener
113 | */
114 | removeListener(changeListener) {
115 | this.changeListeners.splice(this.changeListeners.indexOf(changeListener), 1)
116 | }
117 | }
118 |
119 | /*
120 | * ES6 classes don't have a direct provision for private data, but we can associate data
121 | * with a class via a WeakMap and hide that WeakMap within the module.
122 | *
123 | * This WeakMap is of mapping of Cursor->PrivateData
124 | */
125 | const privateDataMap = new WeakMap();
126 |
127 | /*
128 | * Implementation of a cursor referencing an evolving immutable data structure.
129 | *
130 | * Note that callers of this module CAN receive instances of this class through
131 | * the normal usage pattern of constructing a 'RootCursor' object and then
132 | * calling 'refine', but they cannot construct them on their own.
133 | */
134 | class Cursor {
135 | /*
136 | * This class is private to the module, so its constructor is impossible to
137 | * invoke externally. This is good since the "privateData" parameter of the
138 | * constructor is not something we want external callers to attempt to provide.
139 | */
140 | constructor(privateData, path) {
141 | // Keep our private data hidden. This data is 'owned' by a RootCursor and
142 | // shared with all cursors 'refined' from that root (or 'refined' from a
143 | // child cursor of that root)
144 | privateDataMap.set(this, privateData);
145 |
146 | // Path will have already been locked by seamless-immutable
147 | this.path = path;
148 |
149 | // Freeze ourselves so that callers cannot re-assign the path post-construction
150 | Object.freeze(this);
151 | }
152 |
153 | /*
154 | * Property getter for 'data' property of a cursor. This returns the section of the
155 | * current generation of immutable data referred to by the path of the cursor.
156 | *
157 | * Calling this getter over time may return different results, but the data returned
158 | * is an immutable object that can be safely referenced without copy.
159 | *
160 | * This getter returns undefined in the case where the path specified by the cursor
161 | * does not exist in the current generation of the managed data.
162 | */
163 | get data() {
164 | return getIn(privateDataMap.get(this).currentData, this.path);
165 | }
166 |
167 | /*
168 | * Property setter for 'data' property of a cursor. This creates a new generation
169 | * of the managed data object with the provided 'newValue' replacing whatever
170 | * exists in the 'path' of the current generation
171 | *
172 | * No attempt is made to address issues such as stale writes. Concurrency issues
173 | * are the responsibility of caller.
174 | */
175 | set data(newValue) {
176 | privateDataMap.get(this).update(this.path, newValue);
177 | }
178 |
179 | /*
180 | * Create a new child cursor from this cursor with the subPath appended to our current path
181 | */
182 | refine(subPath) {
183 | if (subPath.length === 0) {
184 | return this;
185 | }
186 | else {
187 | // Because this.path is already immutable, this.path.concat returns
188 | // a new immutable array.
189 | return new Cursor(privateDataMap.get(this), this.path.concat(subPath));
190 | }
191 | }
192 |
193 | /*
194 | * Adds a new change listener to this cursor with the following signature:
195 | * function changeListener(nextRoot, prevRoot, pathUpdated)
196 | *
197 | * Where the parameters pass to this function have the following data types
198 | * nextRoot - Next generation of the JSON-style immutable data being managed by a cursor
199 | * prevRoot - Previous generation of the JSON-style immutable data being managed by a cursor
200 | * pathUpdated - Array of String indicating the keys used to navigate a nested/hierarchical
201 | * structure to the point where the update occurred.
202 | */
203 | onChange(changeListener) {
204 | changeListener.path = this.path;
205 | privateDataMap.get(this).addListener(changeListener, this.path);
206 | }
207 |
208 | /*
209 | * Removes change listener
210 | */
211 | removeListener(changeListener) {
212 | privateDataMap.get(this).removeListener(changeListener);
213 | }
214 | }
215 |
216 | /*
217 | * Public entry into this module.
218 | *
219 | * RootCursor objects are the same as regular cursor objects except that:
220 | * 1. The 'root' cursor can be constructed by external callers
221 | * 2. The 'root' cursor can register changeListeners
222 | */
223 | export default class RootCursor extends Cursor {
224 | constructor(initialRoot = {}) {
225 | // Use seamless-immutable to constrain the initial data. This is the only
226 | // place where we invoke seamless-immutable because once we do this, our
227 | // interactions with these objects will only spawn other immutable objects
228 | super(new PrivateData(Immutable(initialRoot)), Immutable([]));
229 | }
230 | }
231 |
--------------------------------------------------------------------------------
/test/testCursor.js:
--------------------------------------------------------------------------------
1 | import chai from 'chai';
2 | import Cursor from '../src/index';
3 |
4 | const assert = chai.assert;
5 |
6 | describe('Data object with primitive', () => {
7 | const cursor = new Cursor('primitive');
8 | it('constructs cleanly', () => assert.equal('primitive', cursor.data));
9 | });
10 |
11 | describe('Data object with structure', () => {
12 | const cursor = new Cursor({
13 | attr: 'structured'
14 | });
15 | it('constructs cleanly', () => assert.equal('structured', cursor.data.attr));
16 | it('exposes root attribute immutably', () => chai.expect(() => cursor.data.attr = 'updated').to.throw());
17 | });
18 |
19 | describe('Multiple Data classes', () => {
20 | const one = new Cursor('one');
21 | const two = new Cursor('two');
22 |
23 | it('Maintain their data independently (WeakMap test)', () => {
24 | assert.equal('one', one.data);
25 | assert.equal('two', two.data);
26 | assert.notEqual(one.data, two.data);
27 | });
28 | });
29 |
30 | describe('Root Cursors', () => {
31 | const root = new Cursor({
32 | interior: null
33 | });
34 |
35 | it('Allows replacement of data', () => {
36 | root.data = {
37 | interior: 5
38 | };
39 | });
40 |
41 | it('Replaces data with immutable objects', () => {
42 | root.data = {
43 | interior: 5
44 | };
45 |
46 | chai.expect(() => cursor.data.interior = 6).to.throw();
47 | });
48 | });
49 |
50 | describe('Cursors', () => {
51 | const nested = new Cursor({
52 | top: {
53 | middle: {
54 | bottom: 'nestedValue'
55 | }
56 | }
57 | });
58 |
59 | let changes = [];
60 | const changeHandler = (nextRoot, prevRoot, pathUpdated) => {
61 | changes.push({
62 | prevRoot: prevRoot,
63 | nextRoot: nextRoot,
64 | pathUpdated: pathUpdated
65 | });
66 | }
67 |
68 | nested.onChange(changeHandler);
69 |
70 | it('Descends structures correctly', () => assert.equal('nestedValue', nested.refine(['top', 'middle', 'bottom']).data));
71 | it('Safely returns if the path does not exist', () => assert.equal(undefined, nested.refine(['one', 'two', 'three']).data));
72 | it('Can be refined to produce new cursors', () => assert.equal('nestedValue', nested.refine(['top']).refine(['middle', 'bottom']).data));
73 | it('Can be used to update the managed data object', () => {
74 | const cursor = nested.refine(['top', 'middle', 'bottom']);
75 | cursor.data = 'updated';
76 |
77 | assert.equal('updated', cursor.data);
78 | });
79 | it('Can be used to add to the data object', () => {
80 | const cursor = nested.refine(['one', 'two', 'three']);
81 | cursor.data = 'added';
82 |
83 | assert.equal('added', cursor.data);
84 | });
85 | it('Makes added elements immutable as well', () => chai.expect(() => nested.root.one.two.three = 'updated').to.throw());
86 | it('Fires change events correctly', () => {
87 | assert.equal(2, changes.length);
88 |
89 | // Verify first change
90 | assert.equal('nestedValue', changes[0].prevRoot.top.middle.bottom);
91 | assert.equal('updated', changes[0].nextRoot.top.middle.bottom);
92 | assert.deepEqual(['top', 'middle', 'bottom'], changes[0].pathUpdated);
93 |
94 | // Verify Second change
95 | assert.equal(undefined, changes[1].prevRoot.one);
96 | assert.equal('added', changes[1].nextRoot.one.two.three);
97 | assert.deepEqual(['one', 'two', 'three'], changes[1].pathUpdated);
98 | });
99 | it('removes change listeners correctly', () => {
100 | nested.removeListener(changeHandler);
101 |
102 | const cursor = nested.refine(['top', 'middle', 'bottom']);
103 | cursor.data = 'second update';
104 |
105 | // Verify change does not happen
106 | assert.equal(2, changes.length);
107 | });
108 | it('Handles listeners on subcursors correctly', () => {
109 | const cursor1 = nested.refine(['top', 'middle', 'bottom']);
110 | const cursor2 = nested.refine(['one', 'two', 'three']);
111 |
112 | cursor1.onChange(changeHandler);
113 | cursor1.data = "third update";
114 | cursor2.data = "updated";
115 |
116 | // Verify cursor1 called changeHandler...
117 | assert.equal(3, changes.length);
118 | assert.equal('second update', changes[2].prevRoot.top.middle.bottom);
119 | assert.equal('third update', changes[2].nextRoot.top.middle.bottom);
120 | assert.deepEqual(['top', 'middle', 'bottom'], changes[2].pathUpdated);
121 |
122 | // ... but cursor2 did not
123 | assert.equal(3, changes.length);
124 |
125 | })
126 | });
127 |
--------------------------------------------------------------------------------
/webpack.config.js:
--------------------------------------------------------------------------------
1 | const webpack = require('webpack');
2 |
3 | module.exports = {
4 | entry: './src/index.js',
5 | devtool: 'cheap-source-map',
6 | output: {
7 | path: '/',
8 | publicPath: 'http://localhost:8080/',
9 | filename: 'bundle.js'
10 | },
11 | module: {
12 | rules: [{
13 | test: /\.m?js$/,
14 | exclude: /node_modules/,
15 | use: {
16 | loader: 'babel-loader',
17 | options: {
18 | presets: ['babel-preset-env']
19 | }
20 | }
21 | }]
22 | }
23 | };
24 |
--------------------------------------------------------------------------------