diff --git a/.babelrc b/.babelrc index 47a2435..1eadf1c 100755 --- a/.babelrc +++ b/.babelrc @@ -27,7 +27,7 @@ "presets": [ ["@babel/preset-env", { "targets": { - "node": "10.0.0" + "node": "current" } }] ] diff --git a/README.md b/README.md index c0a78e2..132ec39 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,11 @@ npm install @ultraq/redux-utils API --- +### backedByStorage(reducer, storage, key) + +A convenience method for composing a reducer with the [`initiatedFromStorage`](#initiatedfromstoragereducer-storage-key) +and [`syncedToStorage`](#syncedtostoragereducer-storage-key) functions together. + ### initialStateFromDom(selector, [slice]) Create an initial state from JSON data in a DOM element. Used for creating an @@ -33,6 +38,24 @@ could be read. state, then specify the name of the slice so that it can be set in the right place. +### initiatedFromStorage(reducer, storage, key) + +Wraps and returns a reducer that has an initial state from the given storage +mechanism. + +This initial load is also deferred until the reducer is first used, which helps +with tests using ES6 modules that would normally have, by their static nature, +caused the reducer initial state to be run before the tests are even executed. + + - **reducer**: The redux reducer to have its initial state come from a JSON + string in the given storage mechanism. + - **storage**: One of the browser's `sessionStorage` or `localStorage`, or any + object that implements the `Storage` interface. + - **key**: The key mapped to the data used to load this reducer from. It is + recommended that this key is combined with something that uniquely identifies + the current context so that the same state from different uses of the page + don't clash. + ### observe(store, select, handler) Observe the store for changes, passing the value picked out by the `select` @@ -56,3 +79,16 @@ then given to the handler function. that is to be observed for changes - **handler**: the function that, when the value picked out by `select` changes, is invoked with the changed value + +### syncedToStorage(reducer, storage, key) + +Wraps and returns a reducer that saves the state from the original reducer to +the browser's local storage. The storage is mapped by the given keys. + + - **reducer**: The redux reducer to have its state saved to a given storage + mechanism whenever that state changes. + - **storage**: One of the browser's `sessionStorage` or `localStorage`, or any + object that implements the `Storage` interface. + - **key**: The key to use for storing this data. It is recommended that this + key is combined with something that uniquely identifies the current context + so that the same state from different uses of the page don't clash. diff --git a/__mocks__/@ultraq/dom-utils.js b/__mocks__/@ultraq/dom-utils.js index e2a7571..cbba614 100644 --- a/__mocks__/@ultraq/dom-utils.js +++ b/__mocks__/@ultraq/dom-utils.js @@ -13,26 +13,26 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - -const mockDomUtils = jest.genMockFromModule('@ultraq/dom-utils'); - +/* eslint-env jest, node */ let mockJson = null; -/** - * Mock and return the value of future calls to the `parseJsonFromElement` - * function. - * - * @param {Object} data - * @return {Object} - * @private - */ -mockDomUtils.__setMockJsonFromElement = function(data) { - mockJson = data; - return data; -} +module.exports = { + ...jest.genMockFromModule('@ultraq/dom-utils'), -mockDomUtils.parseJsonFromElement = function(selector) { - return mockJson; -} + /** + * Mock and return the value of future calls to the `parseJsonFromElement` + * function. + * + * @param {Object} data + * @return {Object} + * @private + */ + __setMockJsonFromElement(data) { + mockJson = data; + return data; + }, -module.exports = mockDomUtils; + parseJsonFromElement(selector) { + return mockJson; + } +}; diff --git a/jest.config.js b/jest.config.js index f176b0e..ab74211 100644 --- a/jest.config.js +++ b/jest.config.js @@ -7,5 +7,8 @@ module.exports = { 'html', 'lcov', 'text-summary' + ], + setupFilesAfterEnv: [ + 'mock-local-storage' ] }; diff --git a/package-lock.json b/package-lock.json index a08d592..7092671 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "@ultraq/redux-utils", - "version": "0.3.0", + "version": "0.4.0", "lockfileVersion": 1, "requires": true, "dependencies": { @@ -4338,6 +4338,12 @@ "integrity": "sha1-Z29us8OZl8LuGsOpJP1hJHSPV40=", "dev": true }, + "core-js": { + "version": "0.8.4", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-0.8.4.tgz", + "integrity": "sha1-wiZl8eDRucPF4bCNq9HxCGleT88=", + "dev": true + }, "core-js-compat": { "version": "3.6.5", "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.6.5.tgz", @@ -4541,6 +4547,12 @@ "esutils": "^2.0.2" } }, + "dom-walk": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/dom-walk/-/dom-walk-0.1.2.tgz", + "integrity": "sha512-6QvTW9mrGeIegrFXdtQi9pk7O/nSK6lSdXW2eqUspN5LWD7UTji2Fqw5V2YLjBpHEoU9Xl/eUWNpDeZvoyOv2w==", + "dev": true + }, "domexception": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/domexception/-/domexception-1.0.1.tgz", @@ -5243,6 +5255,16 @@ } } }, + "global": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/global/-/global-4.4.0.tgz", + "integrity": "sha512-wv/LAoHdRE3BeTGz53FAamhGlPLhlssK45usmGFThIi4XqnBmjKQ16u+RNbP7WvigRZDxUsM0J3gcQ5yicaL0w==", + "dev": true, + "requires": { + "min-document": "^2.19.0", + "process": "^0.11.10" + } + }, "globals": { "version": "11.12.0", "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", @@ -7609,6 +7631,15 @@ "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", "dev": true }, + "min-document": { + "version": "2.19.0", + "resolved": "https://registry.npmjs.org/min-document/-/min-document-2.19.0.tgz", + "integrity": "sha1-e9KC4/WELtKVu3SM3Z8f+iyCRoU=", + "dev": true, + "requires": { + "dom-walk": "^0.1.0" + } + }, "minimatch": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz", @@ -7654,6 +7685,16 @@ "minimist": "^1.2.5" } }, + "mock-local-storage": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/mock-local-storage/-/mock-local-storage-1.1.12.tgz", + "integrity": "sha512-LBxvtbUE384RV+rQbeklJ/Ii08qmDpod65XkcnFybZD9o4YaT1CBwk77kjtCQxi4CKzjqr5dStUuy2KynOQvNg==", + "dev": true, + "requires": { + "core-js": "^0.8.3", + "global": "^4.3.2" + } + }, "ms": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", @@ -8148,6 +8189,12 @@ "integrity": "sha512-VvivMrbvd2nKkiG38qjULzlc+4Vx4wm/whI9pQD35YrARNnhxeiRktSOhSukRLFNlzg6Br/cJPet5J/u19r/mg==", "dev": true }, + "process": { + "version": "0.11.10", + "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", + "integrity": "sha1-czIwDoQBYb2j5podHZGn1LwW8YI=", + "dev": true + }, "process-nextick-args": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", diff --git a/package.json b/package.json index b86b644..56fe4bf 100755 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@ultraq/redux-utils", - "version": "0.3.0", + "version": "0.4.0", "description": "A collection of wrappers/utilities for common functions in redux", "author": "Emanuel Rabina (http://www.ultraq.net.nz/)", "license": "Apache-2.0", @@ -11,13 +11,17 @@ "keywords": [ "redux", "utilities", - "observe" + "initialState", + "sessionStorage", + "localStorage", + "observe", + "observeOnce" ], "module": "redux-utils.es.js", "main": "redux-utils.cjs.js", "scripts": { "lint": "eslint \"*.js\"", - "test": "jest", + "test": "npm run lint && jest", "coverage": "cat ./coverage/lcov.info | coveralls", "build": "npm run build:cjs && npm run build:es", "build:cjs": "BABEL_ENV=cjs babel redux-utils.js --out-file redux-utils.cjs.js --source-maps", @@ -39,6 +43,7 @@ "eslint": "^6.8.0", "eslint-config-ultraq": "^2.3.3", "jest": "^25.5.4", + "mock-local-storage": "^1.1.12", "redux": "^4.0.0" }, "engines": { diff --git a/redux-utils.js b/redux-utils.js index 616180c..baf6b00 100644 --- a/redux-utils.js +++ b/redux-utils.js @@ -33,11 +33,35 @@ import {equals} from '@ultraq/object-utils'; * be read. */ export function initialStateFromDom(selector, slice) { - let data = parseJsonFromElement(selector); return data ? slice ? {[slice]: data} : data : {}; } +/** + * Create an initial state from JSON data in session or local storage. Used for + * creating an object that is suitable for the `initialState` value of Redux's + * `createStore`. + * + * @param {Storage} storage + * The storage mechanism to use, either `sessionStorage` or `localStorage`. + * @param {String} key + * The key in storage from which to get the data from. + * @param {String} [slice] + * If the JSON data only represents a slice of the entire state, then specify + * the name of the slice so that it can be set in the right place. + * @return {Object} + * The JSON data converted to an object, or an empty object if no data could + * be read. + */ +export function initialStateFromStorage(storage, key, slice) { + let item = storage.getItem(key); + if (item) { + let data = JSON.parse(item); + return slice ? { [slice]: data } : data; + } + return {}; +} + /** * Observe the store for changes, passing the value picked out by the `select` * function to the handler. @@ -51,7 +75,6 @@ export function initialStateFromDom(selector, slice) { * A function that lets the observer unsubscribe from store changes. */ export function observe(store, select, handler) { - let currentValue = select(store.getState()); return store.subscribe(function() { let nextValue = select(store.getState()); @@ -72,7 +95,6 @@ export function observe(store, select, handler) { * @param {Function} handler */ export function observeOnce(store, select, handler) { - let unsubscribe = observe(store, select, function(value) { if (value) { unsubscribe(); diff --git a/redux-utils.test.js b/redux-utils.test.js index 62a67d3..bcf4ea4 100644 --- a/redux-utils.test.js +++ b/redux-utils.test.js @@ -18,6 +18,7 @@ import {__setMockJsonFromElement} from '@ultraq/dom-utils'; import { initialStateFromDom, + initialStateFromStorage, observe, observeOnce } from './redux-utils.js'; @@ -77,6 +78,39 @@ describe('redux-utils', function() { }); }); + describe('#initialStateFromStorage', function() { + const key = 'test'; + + afterEach(function() { + localStorage.clear(); + }); + + test('JSON data loaded', function() { + let testData = { + message: 'Hello!' + }; + localStorage.setItem(key, JSON.stringify(testData)); + let result = initialStateFromStorage(localStorage, key); + expect(result).toEqual(testData); + }); + + test('JSON data loaded into slice', function() { + let testData = { + message: 'Hello!' + }; + localStorage.setItem(key, JSON.stringify(testData)); + let result = initialStateFromStorage(localStorage, key, 'slice'); + expect(result).toEqual({ + slice: testData + }); + }); + + test('Empty object returned when item not present', function() { + let result = initialStateFromStorage(localStorage, key); + expect(result).toEqual({}); + }); + }); + describe('#observe', function() { test('All changes are passed along to the handler', function() { @@ -170,5 +204,4 @@ describe('redux-utils', function() { expect(handler).toHaveBeenCalledTimes(1); }); }); - });