diff --git a/.babelrc b/.babelrc index 36ef793..b636fed 100644 --- a/.babelrc +++ b/.babelrc @@ -1,3 +1,14 @@ { - "presets": ["es2015", "stage-1"] + "presets": ["es2015", "stage-1"], + "env": { + "commonjs": { + "plugins": [ + ["transform-es2015-modules-commonjs", { "loose": true }] + ] + }, + "es": { + "plugins": [ + ] + } + } } diff --git a/.eslintrc b/.eslintrc new file mode 100644 index 0000000..1fec5b0 --- /dev/null +++ b/.eslintrc @@ -0,0 +1,23 @@ +{ + "env": { + "es6": true, + "browser": true, + "node": true + }, + "extends": "eslint:recommended", + "parserOptions": { + "sourceType": "module" + }, + "globals": { + "describe": true, // describe() is part of our unit test framework + "it": true // it() is part of our unit test framework + }, + "rules": { + "strict": ["error", "never"], // ES6 Modules imply a 'use strict'; ... specifying this is redundant + "indent": ["off", 2], // allow any indentation + "no-unused-vars": ["error", {"args": "none"}], // allow unsed parameter declaration + "linebreak-style": "off", // allow both unix/windows carriage returns + "quotes": "off", // allow single or double quotes string literals + "semi": ["error", "always"] // enforce semicolons + } +} diff --git a/.gitignore b/.gitignore index 713ec97..59c997b 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,8 @@ # bundled distribution (generated via "npm run build") /dist/ +/lib/ +/es/ # documentation (generated via "npm run docs") /docs/ diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..b9a920d --- /dev/null +++ b/.travis.yml @@ -0,0 +1,10 @@ +language: node_js +node_js: + - "6" + - "7" + - node # current node version (may be duplicate, but that's OK) +script: + - npm run prepublish # lint, clean, build (bundles), test (bundles) +branches: + except: + - gh-pages diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..a77e307 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,7 @@ +# Change Log + +The [astx-redux-util](https://astx-redux-util.js.org) project adheres +to [Semantic Versioning](http://semver.org/). + +Each release, along with migration instructions, is documented on the +[Github Releases](https://github.com/KevinAst/astx-redux-util/releases) page. diff --git a/LICENSE b/LICENSE.md similarity index 100% rename from LICENSE rename to LICENSE.md diff --git a/README.md b/README.md index 0680605..d9e6276 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,33 @@ # astx-redux-util -The [astx-redux-util] library provides several redux reducer composition -utilities, of which the most prevalent is **reducerHash()** which -allows you to displace the dreaded switch statement ... **but there is -much more!** +The [astx-redux-util] library promotes several redux reducer +composition utilities, which blend multiple reducers together forming +a richer abstraction through functional decomposition +(i.e. higher-order functions). +Reducer composition is not new. Redux itself provides the innovative +[combineReducers](http://redux.js.org/docs/api/combineReducers.html) +utility which allows you to fuse individual reducers together to build +up the overall shape of your application state. -## Documentation +The most prevalent [astx-redux-util] utility is **reducerHash()**, +which lets you combine sub-reducers in such a way as to eliminate +the switch statement commonly used to delineate action type. -Comprehensive documentation can be found at: [astx-redux-util]. +**Additionally**, [astx-redux-util] promotes other reducer compositions that +can be used in conjunction with one another. + + + +[![Build Status](https://travis-ci.org/KevinAst/astx-redux-util.svg?branch=master)](https://travis-ci.org/KevinAst/astx-redux-util) +[![NPM Version Badge](https://img.shields.io/npm/v/astx-redux-util.svg)](https://www.npmjs.com/package/astx-redux-util) + + +## Comprehensive Documentation + +Complete documentation can be found at +https://astx-redux-util.js.org/, which includes both **API** details, +and a **User Guide** with full and thorough **examples**! ## Install @@ -20,26 +39,144 @@ npm install --save astx-redux-util ## Usage +### Basics + +The following example uses **reducerHash()** to combine a set of +sub-reducer functions (indexed by the standard action.type), +eliminating the switch statement commonly used to delineate action +type. + +**Don't miss the [astx-redux-util] documentation**, *which fully explores +this example, and details the API.* + ```JavaScript - import {reducerHash} from 'astx-redux-util'; +import { reducerHash } from 'astx-redux-util'; + +const reduceWidget = reducerHash({ + "widget.edit": (widget, action) => action.widget, + "widget.edit.close": (widget, action) => null, +}); + +export default function widget(widget=null, action) { + return reduceWidget(widget, action); +} +``` - const myReducer = reducerHash({ - [ActionType.widget.edit] (widget, action) => action.widget, - [ActionType.widget.edit.close] (widget, action) => null, - }); - export default function widget(widget=null, action) { - return myReducer(widget, action); - } +### Joining Reducers + +Building on the previous example, our widget now takes on more detail: + - we manage x/y properties (through the standard + [combineReducers](http://redux.js.org/docs/api/combineReducers.html)) + - the widget itself can take on a null value (an indication it is NOT + being edited) + +We manage these new requirements by combining multiple reducers +through a functional decomposition (as opposed to procedural code). +To accomplish this, we add to our repertoire by introducing +**joinReducers()** and **conditionalReducer()**. + +**Don't miss the [astx-redux-util] documentation**, *which fully explores +this example, and details the API.* + +```JavaScript +import * as Redux from 'redux'; +import * as AstxReduxUtil from 'astx-redux-util'; +import x from '../appReducer/x'; +import y from '../appReducer/y'; + +const reduceWidget = + AstxReduxUtil.joinReducers( + // FIRST: determine content shape (i.e. {} or null) + AstxReduxUtil.reducerHash({ + "widget.edit": (widget, action) => action.widget, + "widget.edit.close": (widget, action) => null + }), + + AstxReduxUtil.conditionalReducer( + // SECOND: maintain individual x/y fields + // ONLY when widget has content (i.e. is being edited) + (widget, action, originalReducerState) => widget !== null, + Redux.combineReducers({ + x, + y + })) + ); + +export default function widget(widget=null, action) { + return reduceWidget(widget, action); +} ``` +### Full Example +Building even more on the prior examples: + - our widget adds a curHash property (which is a determinate of + whether application content has changed) -## Don't Miss +We manage this new property in the parent widget reducer, because it +has a unique vantage point of knowing when the widget has changed +(under any circumstance, regardless of how many properties are +involved). + +We accomplish this by simply combining yet another reducer (using a +functional approach). This also demonstrates how **composition can be +nested!** + +**Don't miss the [astx-redux-util] documentation**, *which fully explores +this example, and details the API.* + +```JavaScript +import * as Redux from 'redux'; +import * as AstxReduxUtil from 'astx-redux-util'; +import x from '../appReducer/x'; +import y from '../appReducer/y'; +import Widget from '../appReducer/Widget'; + +const reduceWidget = + AstxReduxUtil.joinReducers( + // FIRST: determine content shape (i.e. {} or null) + AstxReduxUtil.reducerHash({ + "widget.edit": (widget, action) => action.widget, + "widget.edit.close": (widget, action) => null + }), + + AstxReduxUtil.conditionalReducer( + // NEXT: maintain individual x/y fields + // ONLY when widget has content (i.e. is being edited) + (widget, action, originalReducerState) => widget !== null, + AstxReduxUtil.joinReducers( + Redux.combineReducers({ + x, + y, + curHash: placeboReducer + }), + AstxReduxUtil.conditionalReducer( + // LAST: maintain curHash + // ONLY when widget has content (see condition above) -AND- has changed + (widget, action, originalReducerState) => originalReducerState !== widget, + (widget, action) => { + widget.curHash = Widget.hash(widget); // OK to mutate (because of changed instance) + return widget; + }) + ) + ) + ); + +export default function widget(widget=null, action) { + return reduceWidget(widget, action); +} + +// placeboReducer WITH state initialization (required for Redux.combineReducers()) +function placeboReducer(state=null, action) { + return state; +} +``` -For a more complete and thorough example of how these utilities can be -used, don't miss the **Full Documentation** at [astx-redux-util] -which includes a **Comprehensive Example**. +This represents a very comprehensive example of how **Reducer +Composition** can **simplify your life**! We have combined multiple +reducers into one, applying conditional logic as needed through +functional decomposition! [astx-redux-util]: https://astx-redux-util.js.org/ diff --git a/SCRIPTS.md b/SCRIPTS.md new file mode 100644 index 0000000..12fa634 --- /dev/null +++ b/SCRIPTS.md @@ -0,0 +1,154 @@ +# NPM Scripts + +The following npm scripts are available for this project. + +``` +DEVELOPMENT +=========== + +start ... convenience alias to 'dev' (that launches development process) + +dev ...... launch development process (continuous build/test) + + NOTE: This REALLY only needs continuous testing ('test:lib:watch'), + because that script targets the master src (i.e. no building + required). + + HOWEVER an advantage of the continuous build is that + auto-linting is performed! + + +BUILDING +======== + +build ................... bundle library for publication (same as 'build:plat:bundle') +build:watch ............ ditto (continuously) + +build:plat:{platform} ... bundle library for specified Target Platform (see below) +build:plat:bundle +build:plat:bundle.min +build:plat:lib +build:plat:es +build:plat:all +build:clean ............. clean all machine-generated build directories + +prepublish .............. cleanly build/test all machine generated resources, + a pre-publication utility: + - check code quality (lint) + - clean (delete) ALL machine generated resources + - build/test all bundled libraries (for publication) + - build documentation + + +TESTING +======= + +test ................... run ALL unit tests on master src (same as 'test:all' or 'test:plat:src') + + Following runs SELECTED tests ON master src + =========================================== +test:lib ............... run unit tests that are part of our published library +test:lib:watch ......... ditto (continuously) +test:samples ........... run unit tests from our sample code (in the User Guide) +test:samples:watch ..... ditto (continuously) +test:all ............... run ALL our unit tests +test:all:watch ......... ditto (continuously) + + Following runs ALL tests ON specified target + ============================================ +test:plat:{platform} ... run ALL unit tests on specified Target Platform (see below) +test:plat:src +test:plat:bundle +test:plat:bundle.min +test:plat:lib +test:plat:es +test:plat:all + + +DOCUMENTATION +============= + +docs ......... build docs from JavaDoc comments (src/*.js), and User Guide (src/docs) +docs:clean ... clean the machine-generated docs directory + + +CODE QUALITY +============ + +lint .... verify code quality, linting BOTH production and test code. + NOTE: Real-time linting is ALSO applied on production code + through our WebPack bundler (via 'build:watch')! + +check ... convenience script to: + - verify code quality (lint) and + - run tests (against our master src) + + +MISC +==== + +clean ... cleans ALL machine-generated directories (build, and docs) +``` + + + +## Testing Dynamics + +Our unit tests have the ability to dynamically target each of our +published platforms, through the `test:plat:{platform}` script (see the +[Target Platform](#target-platform) discussion below). + +- During development, our tests typically target the master src + directly, and continuously (through the `test:lib:watch` script). + +- However, before any bundle is published, it is a good practice to run + our test suite against each of the published bundles (through the + `test:plat:all` script). + +Testing dynamics is accomplished by our unit tests importing +[ModuleUnderTest](src/tooling/ModuleUnderTest.js), which in turn +dynamically exports the desired test module, as controlled by the +MODULE_PLATFORM environment variable. + +**There is one slight QUIRK in this process** ... that is: *ALL +supported platforms MUST exist before you can test one of them*. The +reason for this is that +[ModuleUnderTest.js](src/tooling/ModuleUnderTest.js) must import all +the platforms and then decide which one to export *(this is due to the +static nature of ES6 imports)*. + +**As it turns out, this is not a big deal**, it's just a bit of +un-expected behavior. During development, our tests typically +continuously target the master src (which doesn't require any +re-building). So the `build:plat:all` script **does NOT have to be run +continuously ... just once, after a clean** (to prime the pump). + + + + +## Target Platform + +Some npm scripts target a platform (i.e. the JS module ecosystem), +using 'plat nomenclature (i.e. platform). + +Specifically: + + - `build:plat:{platform}` + - `test:plat:{platform}` + +Supported platforms are: + +``` +MODULE_PLATFORM What Bindings Found In NOTES +=============== =================== ======== ===================== ======================== +src master ES6 source ES src/*.js DEFAULT +bundle bundled ES5 CommonJS dist/{project}.js +bundle.min bundled/minified ES5 CommonJS dist/{project}.min.js +lib ES5 source CommonJS lib/*.js +es ES5 source ES es/*.js +all all of the above Used in npm scripts ONLY +``` + +The 'plat' nomenclature helps disambiguate the difference between (for example): + - `test:all: ` identifies WHICH tests to run (i.e. all tests) + - `test:plat:all:` identifies WHICH platform to run tests on (i.e. all platforms) diff --git a/package.json b/package.json index 977e24a..01eebfb 100644 --- a/package.json +++ b/package.json @@ -1,25 +1,42 @@ { "name": "astx-redux-util", - "version": "1.0.0", + "version": "0.1.0", "description": "Several redux reducer composition utilities.", - "main": "src/index.js", + "main": "lib/index.js", "scripts": { - "start:COMMENT": "start: convenience script to initiate development process", - "start": "npm run dev", - "dev:COMMENT": "dev: launch development processes in parallel (continous build/test)", - "dev": "npm-run-all --parallel build:watch test:watch", - "build:COMMENT": "build: bundle JS modules in 'dist' dir (for development or publication)", - "build": "webpack --progress --colors", - "build:watch": "npm run build -- --watch", - "build:clean": "rimraf dist", - "test:COMMENT": "test: run unit tests", - "test": "mocha --compilers js:babel-core/register --colors \"src/**/*.spec.js\"", - "test:watch": "npm run test -- --watch", - "docs:COMMENT": "docs: documentation builder", - "docs": "./node_modules/.bin/jsdoc --configure ./src/docs/jsdoc.conf.json --verbose", - "docs:clean": "rimraf docs", - "clean:COMMENT": "clean: orchestrate ALL clean processes", - "clean": "npm run build:clean && npm run docs:clean" + "COMMENT1": "***--------------------------------------------------------------------------***", + "COMMENT2": "*** Please refer to SCRIPTS.md for an overview of the project's npm scripts. ***", + "COMMENT3": "***--------------------------------------------------------------------------***", + "COMMENT4": " ", + "build": "cross-env BABEL_ENV=commonjs webpack --progress --colors", + "build:clean": "rimraf dist lib es", + "build:plat:all": "npm-run-all build:plat:bundle build:plat:bundle.min build:plat:lib build:plat:es", + "build:plat:bundle": "cross-env NODE_ENV=development npm run build", + "build:plat:bundle.min": "cross-env NODE_ENV=production npm run build", + "build:plat:es": "cross-env BABEL_ENV=es babel src --out-dir es --ignore spec,tooling", + "build:plat:lib": "cross-env BABEL_ENV=commonjs babel src --out-dir lib --ignore spec,tooling", + "build:watch": "npm run build -- --watch", + "check": "npm-run-all lint test", + "clean": "npm-run-all build:clean docs:clean", + "dev": "npm-run-all --parallel build:watch test:lib:watch", + "docs": "./node_modules/.bin/jsdoc --configure ./src/docs/jsdoc.conf.json --verbose", + "docs:clean": "rimraf docs", + "lint": "eslint src", + "prepublish": "npm-run-all lint clean build:plat:all test:plat:all docs", + "start": "npm run dev", + "test": "mocha --compilers js:babel-core/register --colors \"src/**/*.spec.js\"", + "test:all": "npm run test --", + "test:all:watch": "npm run test:all -- --watch", + "test:lib": "npm run test:samples -- --invert", + "test:lib:watch": "npm run test:lib -- --watch", + "test:samples": "npm run test -- --grep \"verify sample\"", + "test:samples:watch": "npm run test:samples -- --watch", + "test:plat:all": "npm-run-all test:plat:src test:plat:bundle test:plat:bundle.min test:plat:lib test:plat:es", + "test:plat:bundle": "cross-env MODULE_PLATFORM=bundle npm run test:all", + "test:plat:bundle.min": "cross-env MODULE_PLATFORM=bundle.min npm run test:all", + "test:plat:es": "cross-env MODULE_PLATFORM=es npm run test:all", + "test:plat:lib": "cross-env MODULE_PLATFORM=lib npm run test:all", + "test:plat:src": "cross-env MODULE_PLATFORM=src npm run test:all" }, "repository": { "type": "git", @@ -30,10 +47,12 @@ "flux", "redux", "reducer", + "redux reducer", "action", "compose", "composition", - "higher-order", + "higher order", + "functional decomposition", "switch", "case", "utility", @@ -42,24 +61,33 @@ "helper", "helpers" ], - "author": "Kevin J. Bridges", + "author": "Kevin J. Bridges (https://github.com/KevinAst)", "license": "MIT", "bugs": { "url": "https://github.com/KevinAst/astx-redux-util/issues" }, "homepage": "https://github.com/KevinAst/astx-redux-util", "devDependencies": { + "babel-cli": "^6.23.0", "babel-core": "^6.22.1", + "babel-eslint": "^7.1.1", "babel-loader": "^6.2.10", "babel-preset-es2015": "^6.22.0", "babel-preset-stage-1": "^6.22.0", + "cross-env": "^3.2.3", "docdash": "^0.4.0", + "eslint": "^3.16.1", + "eslint-loader": "^1.6.3", "expect": "^1.20.2", "jsdoc": "^3.4.3", "jsdoc-babel": "^0.3.0", "mocha": "^3.2.0", "npm-run-all": "^4.0.1", + "redux": "^3.6.0", "rimraf": "^2.5.4", "webpack": "^2.2.1" + }, + "dependencies": { + "lodash.identity": "^3.0.0" } } diff --git a/src/docs/guide/conceptConditional.md b/src/docs/guide/conceptConditional.md new file mode 100644 index 0000000..1ae65f3 --- /dev/null +++ b/src/docs/guide/conceptConditional.md @@ -0,0 +1,48 @@ +There are times where you may wish to conditionally apply a reduction. + +There can be many reasons for this. Take a simple example where you +wish to bypass a reduction process upon determination that an action +will not impact an entire branch of your state tree. In this example +the conditional aspect is purely an optimization. + +This can be accomplished through the {@link conditionalReducer} utility. + +```JavaScript +import * as Redux from 'redux'; +import * as AstxReduxUtil from 'astx-redux-util'; +import x from '../appReducer/x'; +import y from '../appReducer/y'; + +const reduceWidget = + AstxReduxUtil.conditionalReducer( + // conditionally apply when action.type begins with 'widget.edit' + (curState, action, originalReducerState) => action.type.startsWith('widget.edit'), + Redux.combineReducers({ + x, + y + })); + +export default function widget(widget={}, action) { + return reduceWidget(widget, action); +} +``` + +Here we only invoke the supplied reducer +([Redux.combineReducers](http://redux.js.org/docs/api/combineReducers.html)) +when the action.type begins with 'widget.edit'. In our contrived +example, our action types are organized with a federated namespace, so +it is easy to isolate which actions will impact various parts of our +state. + +**Note:** Because we did not supply "elseReducerFn" to {@link +conditionalReducer} (the third parameter), the default [identity +function](https://lodash.com/docs#identity) is used for the else +condition, in essence retaining the same state for a falsy directive. + +**More to Come:** This example is merely intended to introduce you to +the concept of conditional reduction. It is somewhat "contrived", +allowing us to discuss the topic in isolation. In reality, this +example may be inappropriate because the optimization is minimal, and +it tends to make the code more brittle. Keep in mind, however, +**there are more legitimate reasons to apply conditional reduction** +... we will see this in subsequent discussions. diff --git a/src/docs/guide/conceptHash.md b/src/docs/guide/conceptHash.md index a47a548..a566f1d 100644 --- a/src/docs/guide/conceptHash.md +++ b/src/docs/guide/conceptHash.md @@ -1,52 +1,54 @@ -Reducers commonly reason about the action.type, driving conditional -logic through a switch statement: +Reducers frequently reason about the action.type, very often using a +switch statement to drive conditional logic: ```JavaScript - export default function widget(widget=null, action) { +export default function widget(widget=null, action) { - switch (action.type) { + switch (action.type) { - case ActionType.widget.edit: - return action.widget; + case 'widget.edit': + return action.widget; - case ActionType.widget.edit.close: - return null; + case 'widget.edit.close': + return null; - default: - return state; - } + default: + return widget; } +} ``` The {@link reducerHash} function *(the most common composition reducer)* provides a more elegant solution, eliminating the switch -statement altogether. It creates a higher-order reducer, by combining +statement altogether. It creates a higher-order reducer, by combining a set of sub-reducer functions that are indexed by the standard action.type. *The following snippet, is equivalent to the one above:* ``` - import { reducerHash } from 'astx-redux-util'; +import { reducerHash } from 'astx-redux-util'; - const reduceWidget = reducerHash({ - [ActionType.widget.edit] (widget, action) => action.widget, - [ActionType.widget.edit.close] (widget, action) => null, - }); +const reduceWidget = reducerHash({ + "widget.edit": (widget, action) => action.widget, + "widget.edit.close": (widget, action) => null, +}); - export default function widget(widget=null, action) { - return reduceWidget(widget, action); - } +export default function widget(widget=null, action) { + return reduceWidget(widget, action); +} ``` Not only is the conditional logic better encapsulated, but the default -pass-through logic is automatically applied ... passing the original -state when no action.type is acted on. +pass-through logic is automatically applied (using the [identity +function](https://lodash.com/docs#identity)), passing through the +original state when no action.type is acted on. + -**Please note** that because {@link reducerHash} is a higher-order +**Please Note** that because {@link reducerHash} is a higher-order creator function, it is invoked outside the scope of the widget() reducer. This is an optimization, so as to not incur the creation overhead on each reducer invocation. -**Also note** that because {@link reducerHash} is so central to the +**Also Note** that because {@link reducerHash} is so central to the rudimentary aspects of reduction, it is common to provide a value-added {@tutorial logExt}. diff --git a/src/docs/guide/conceptJoin.md b/src/docs/guide/conceptJoin.md index 004d51d..c554b5a 100644 --- a/src/docs/guide/conceptJoin.md +++ b/src/docs/guide/conceptJoin.md @@ -1,10 +1,10 @@ Occasionally it is necessary for a state to have multiple reductions -applied to it. This typically occurs when the involved reducers +applied to it. Typically this occurs when the reducers involved represent fundamentally different operational types. -Let's review an example to clarify this concept. +An example should clarify this concept. -Say we have a widget state, that contains x/y properties: +Let's say we have a widget that contains x/y properties: ```JavaScript { @@ -15,13 +15,13 @@ Say we have a widget state, that contains x/y properties: } ``` -Typically this would be controlled through a standard -Redux.combineReducers: +The individual x/y properties are nicely managed by the standard +[Redux.combineReducers] function: ```JavaScript import * as Redux from 'redux'; -import x from './myAppReducer.x; -import y from './myAppReducer.y; +import x from '../appReducer/x'; +import y from '../appReducer/y'; const contentReducer = Redux.combineReducers({ @@ -34,20 +34,20 @@ export default function widget(widget={}, action) { } ``` -What happens, however, if the widget can take on a null value (say for -example, it only exists when it is being edited)? +However, **what happens if the widget can take on a null value** (say for +example, it only exists when it is being edited)? -In this case, there is more work to do because +In this case, **we have more work to do**: 1. we need to apply the editor open/close logic, and - 2. conditionally apply the Redux.combineReducer because it cannot - operate on null state. + 2. conditionally manage it's content *(because + [Redux.combineReducers] cannot operate on null state)*. -One way to accomplish this is through procedural logic, as follows: +One way to accomplish this is through the following procedural logic: ```JavaScript import * as Redux from 'redux'; -import x from './myAppReducer.x; -import y from './myAppReducer.y; +import x from '../appReducer/x'; +import y from '../appReducer/y'; const contentReducer = Redux.combineReducers({ @@ -57,15 +57,15 @@ const contentReducer = export default function widget(widget=null, action) { - // first: determine content shape (i.e. null or {}) + // FIRST: determine content shape (i.e. {} or null) let nextState = widget; switch (action.type) { - case 'editOpen': + case 'widget.edit': nextState = action.widget; break; - case 'editClose': + case 'widget.edit.close': nextState = null; break; @@ -73,9 +73,10 @@ export default function widget(widget=null, action) { nextState = widget; } - // second: maintain individual x/y fields ONLY when there is content + // SECOND: maintain individual x/y fields + // ONLY when widget has content (i.e. is being edited) if (nextState !== null) { - nextState = contentReducer.reduce(nextState, action); + nextState = contentReducer(nextState, action); } // are we done yet? ... that was painful!! @@ -83,37 +84,31 @@ export default function widget(widget=null, action) { } ``` -A more elegant solution can be accomplished using reducer composition, -eliminating the procedural code completely. - -**Enter two new higher-order reducers** *(in addition to {@link -reducerHash} which we have covered previously)*: - -1. {@link joinReducers} combines two or more reducers, logically - executing each in sequence. - -2. {@link conditionalReducer} conditionally executes a reducerFn when - the conditionalFn returns truthy. +A more elegant solution can be accomplished by employing reducer +composition, eliminating the procedural code completely. We have +already discussed {@link reducerHash} and {@link conditionalReducer}. +A third utility, the {@link joinReducers} function, combines two or +more reducers logically executing each in sequence. *The following snippet, is equivalent to the one above:* ```JavaScript import * as Redux from 'redux'; -import * as AstReduxUtil from 'astx-redux-util'; -import x from './myAppReducer.x; -import y from './myAppReducer.y; +import * as AstxReduxUtil from 'astx-redux-util'; +import x from '../appReducer/x'; +import y from '../appReducer/y'; const reduceWidget = - AstReduxUtil.joinReducers( - // first reducer: determines content shape (i.e. null or {}) - AstReduxUtil.reducerHash({ - editOpen (widget, action) => action.widget, - editClose(widget, action) => null + AstxReduxUtil.joinReducers( + // FIRST: determine content shape (i.e. {} or null) + AstxReduxUtil.reducerHash({ + "widget.edit": (widget, action) => action.widget, + "widget.edit.close": (widget, action) => null }), - // second reducer: detailing individual x/y fields - // ... only executed when there is content - AstReduxUtil.conditionalReducer( - (curState, action, originalReducerState) => curState !== null, + AstxReduxUtil.conditionalReducer( + // SECOND: maintain individual x/y fields + // ONLY when widget has content (i.e. is being edited) + (widget, action, originalReducerState) => widget !== null, Redux.combineReducers({ x, y @@ -128,16 +123,20 @@ export default function widget(widget=null, action) { Here the joinReducers combines multiple reducers together as one. - The first reducer (reducerHash) interprets the - 'editOpen'/'editClose' action.type, providing content shape or not - (i.e. null). + `'widget.edit`/`'widget.edit.close'` action type, providing object + content or not (i.e. null). - The second reducer (conditionalReducer) conditionally invokes the - third reducer (combineReducers), only when the state has content + third reducer ([Redux.combineReducers]), only when the state has content (i.e. non-null). **Reducer composition provides a more elegant solution that is -functional in nature.** +purely functional in nature.** -**Please note** that the higher-order reducer functions are invoked +**Please Note** that the higher-order reducer functions are invoked outside the scope of the widget() reducer, as an optimization, so as to not incur the creation overhead on each reducer invocation. + + + +[Redux.combineReducers]: http://redux.js.org/docs/api/combineReducers.html diff --git a/src/docs/guide/fullExample.md b/src/docs/guide/fullExample.md index 0fbfde0..6df6aee 100644 --- a/src/docs/guide/fullExample.md +++ b/src/docs/guide/fullExample.md @@ -1,7 +1,7 @@ If we take our widget example one step further (from our {@tutorial conceptJoin} discussion), let's say in addition to the x/y properties, -we now have a curHash - a determinate of whether application content -has changed. +we now introduce a curHash - which is a determinate of whether +application content has changed. ```JavaScript { @@ -18,51 +18,58 @@ has a unique vantage point for this task, because it is a central clearing house that has knowledge anytime the widget state changes. This is even independent of how many properties the widget has! Our immutable pattern dictates that if our state changes, a new instance -will be created. Therefore, we can safely change the curHash anytime +will be introduced. Therefore, we can safely change the curHash anytime the widget instance has changed. -Building on our last example (in {@tutorial conceptJoin}), we -can accomplish this new requirement by simply adding a third -sub-reducer to our reduceWidget function. +Building on our last example (in {@tutorial conceptJoin}), we can +accomplish this new requirement by simply adding yet another reducer +to our reduceWidget function. ```JavaScript import * as Redux from 'redux'; -import * as AstReduxUtil from 'astx-redux-util'; -import x from './myAppReducer.x; -import y from './myAppReducer.y; +import * as AstxReduxUtil from 'astx-redux-util'; +import x from '../appReducer/x'; +import y from '../appReducer/y'; +import Widget from '../appReducer/Widget'; const reduceWidget = - AstReduxUtil.joinReducers( - // first reducer: determines content shape (i.e. null or {}) - AstReduxUtil.reducerHash({ - editOpen (widget, action) => action.widget, - editClose(widget, action) => null + AstxReduxUtil.joinReducers( + // FIRST: determine content shape (i.e. {} or null) + AstxReduxUtil.reducerHash({ + "widget.edit": (widget, action) => action.widget, + "widget.edit.close": (widget, action) => null }), - // second reducer: detailing individucal x/y fields - // ... only executed when there is content - AstReduxUtil.conditionalReducer( - (curState, action, originalReducerState) => curState !== null, - Redux.combineReducers({ - x, - y, - curHash: AstReduxUtil.reducerPassThrough - })), - - // third reducer: maintaining the curHash (NEW from last example) - // ... only executed when widget has changed - AstReduxUtil.conditionalReducer( - (curState, action, originalReducerState) => curState !== null && - originalReducerState !== curState, - (curState, action) => { - curState.curHash = someHashOf(curState); // OK to mutate (different instance) - return curState; - }) + AstxReduxUtil.conditionalReducer( + // NEXT: maintain individual x/y fields + // ONLY when widget has content (i.e. is being edited) + (widget, action, originalReducerState) => widget !== null, + AstxReduxUtil.joinReducers( + Redux.combineReducers({ + x, + y, + curHash: placeboReducer + }), + AstxReduxUtil.conditionalReducer( + // LAST: maintain curHash + // ONLY when widget has content (see condition above) -AND- has changed + (widget, action, originalReducerState) => originalReducerState !== widget, + (widget, action) => { + widget.curHash = Widget.hash(widget); // OK to mutate (because of changed instance) + return widget; + }) + ) + ) ); export default function widget(widget=null, action) { return reduceWidget(widget, action); } + +// placeboReducer WITH state initialization (required for Redux.combineReducers()) +function placeboReducer(state=null, action) { + return state; +} ``` This represents a very comprehensive example of how **Reducer @@ -70,13 +77,53 @@ Composition** can **simplify your life**! We have combined 3 sub-reducers into one, applying conditional logic as needed through functional decomposition! -**Please note** that we use the {@tutorial originalReducerState} to -determine if the widget has changed from ANY of the prior sub-reducers -(see the discussion of this topic in the provided link). +**Please NOTE**: + +1. The curHash should only be maintained when the widget **has + content** (i.e. non-null), -AND- **has changed** . + + - The former condition is accomplished through conditionalReducer + **nesting**. In other words, the outer conditionalReducer insures + the widget is non-null. + + - The latter condition utilizes the `originalReducerState` + parameter to determine when the widget has changed from ANY of + the prior sub-reducers. This parameter is useful when multiple + reducers are combined, because it represents the state prior to + the start of reduction process. Please refer to the {@tutorial + originalReducerState} discussion for more insight. + +2. Contrary to any **red flags** that may have been raised on your + initial glance of the code, **it is OK** to mutate the `widget` + state in the last reducer, because we know one of the prior + reducers has injected a new widget instance (via the + `originalReducerState !== widget` condition). + +3. The placeboReducer is slightly different than lodash.identity in + that it defaults the state parameter to null. + + This is required in conjunction + [Redux.combineReducers()](http://redux.js.org/docs/api/combineReducers.html), + and is related to our technique of maintaining curHash in the + parent widget reducer (*which has visibility to all widget + properties*), verses using an individual property reducer (*which + does NOT have visibility to other widget properties*). + + The placeboReducer works around the following + [Redux.combineReducers()](http://redux.js.org/docs/api/combineReducers.html) + issues: + + - with NO curHash entry ... + WARNING: + Unexpected key "curHash" found in previous state received by the reducer. + Expected to find one of the known reducer keys instead: "x", "y". + Unexpected keys will be ignored. -**Also note** that contrary to any **red flags** that may have -been raised on your initial glance of the code, **it is OK** to mutate -the curState variable in our third reducer, because we know a new instance -has already been created (via one of the prior reducers). + - with curHash entry, using lodash.identity ... + ERROR: + Reducer "curHash" returned undefined during initialization. + If the state passed to the reducer is undefined, you must explicitly return the initial state. + The initial state may not be undefined. -**Life is GOOD!** + - with curHash entry, using placeboReducer ... + Life is GOOD! diff --git a/src/docs/guide/history.md b/src/docs/guide/history.md new file mode 100644 index 0000000..fcab917 --- /dev/null +++ b/src/docs/guide/history.md @@ -0,0 +1,35 @@ +This project adheres to [Semantic Versioning](http://semver.org/). +Each release, along with migration instructions, is documented on this +page, as well as the [Github +Releases](https://github.com/KevinAst/astx-redux-util/releases) page. + +This [astx-redux-util](https://astx-redux-util.js.org) link always +documents the latest release. + + + +[![Build Status](https://travis-ci.org/KevinAst/astx-redux-util.svg?branch=master)](https://travis-ci.org/KevinAst/astx-redux-util) +[![NPM Version Badge](https://img.shields.io/npm/v/astx-redux-util.svg)](https://www.npmjs.com/package/astx-redux-util) + + + + + +

v0.1.0 - Initial Release *(Mar 8, 2017)*

+ +[Full Docs](https://astx-redux-util.js.org/0.1.0) +• +[GitHub Release](https://github.com/KevinAst/astx-redux-util/releases/tag/v0.1.0) +• +[GitHub Content](https://github.com/KevinAst/astx-redux-util/tree/v0.1.0) + +*I'll explain when you're older!* diff --git a/src/docs/guide/originalReducerState.md b/src/docs/guide/originalReducerState.md index 4489c88..ca8fce0 100644 --- a/src/docs/guide/originalReducerState.md +++ b/src/docs/guide/originalReducerState.md @@ -1,49 +1,50 @@ -The {@link conditionalReducer} function exposes an -"originalReducerState" parameter to it's ({@link conditionalReducerCB}). - -The originalReducerState represents the immutable state at the time of -the start of the reduction process. This is useful in determining -that state has changed within a series of reductions (see {@link joinReducers}), +This sidebar discussion provides some insight into +**originalReducerState** (*mostly an internal implementation detail*). + +A fundamental aspect of sequentially joining reducers is that each +reducer should be able to build on what has been accomplished by a +prior reducer. In essence it is an accumulative process. + +The {@link joinReducers} utility handles this by cumulatively passing the +state parameter that was returned from any prior reducer (in the chain +of reducers to execute). + +While this does NOT relax the immutable constraint of the reducer's +state parameter, it is possible for a down-stream reducer to receive a +state parameter that is a different instance from the start of the +reduction process (because an up-stream reducer needed to alter it in +some way). + +As it turns out, this is typically NOT a concern to a client, rather +merely an implementation detail. + +There are edge cases, however, where a client needs visibility to the +**originalReducerState**: *the immutable state at the start of the +reduction process*. One case in particular is determining that state +has changed within a series of reductions (i.e. {@link joinReducers}), because each individual reducer only has visibility of the state -within it's own reduction process. - -There is an example of this in the {@tutorial fullExample}. - -Because this deals with how state changes over a series of reducers -(run in sequence), it is natural that the {@link joinReducers} utility -automatically maintains the originalReducerState. - -By an overwhelming majority (99.9% of the time), you should never have -to worry about how this state is maintained, because {@link joinReducers} does -this for you. - -With that said, the way in which originalReducerState is communicated -(internally), is by passing it as a 3rd parameter through the reducer -chain. While from a redux perspective, this is non-standard, it -doesn't really hurt, because reducer functions are NOT reasoning about -a 3rd parameter. The only risk is if redux should (at some -future point) start to employ additional reducer parameters. - -Here are the significant take-away points of interest: - -- If your {@link conditionalReducer} conditionalFn never reasons about - originalReducerState: - * Then you have NO worries whatsoever! - -- If your {@link conditionalReducer} conditionalFn DOES reason about - originalReducerState: - - * In the normal use case (where your {@link conditionalReducer} is - orchestrated by a {@link joinReducers} - in the first order), then you - STILL have NOTHING to worry about! - - **Please Note:** These points cover 99.9% of all use cases! - - * If however, your {@link conditionalReducer} is invoked in a less - conventional way, then you must manually supply the appropriate - originalReducerState 3rd parameter when invoking the reducer. - - - This could be when you are invoking the {@link conditionalReducer} - directly (outside of a {@link joinReducers} utility). - - - Or if you have a nested {@link joinReducers} combination. +within it's own reduction process. This case is higlighted in +{@tutorial fullExample}. + +As a result, the **originalReducerState** is **publicly exposed** as +the 3rd parameter to the {@link conditionalReducerCB} function (the +{@link conditionalReducer} callback parameter that makes this +determination). + +Internally, the way in which astx-redux-util manages the +originalReducerState is by passing it as a 3rd parameter to any +reducer it is in control of (i.e. invokes). While from a redux +perspective, this is non-standard, it doesn't really hurt, because +reducer functions are NOT reasoning about a 3rd parameter. The only +risk is if redux should (at some future point) start to employ +additional reducer parameters. + +By an overwhelming majority of the time (99.9%), **you should seldom have +to worry about how originalReducerState is maintained**, because +astx-redux-util does this for you. + +**The only time any of this concerns you** is if your application +reducer invokes one of the astx-redux-util reducers. In this case +(which is rare), your code is responsible for passing +originalReducerState (the 3rd reducer parameter) to the downstream +reducer. diff --git a/src/docs/guide/start.md b/src/docs/guide/start.md index 3e59adb..852dd27 100644 --- a/src/docs/guide/start.md +++ b/src/docs/guide/start.md @@ -59,8 +59,6 @@ define(['astx-redux-util', 'otherModule'], function(AstxReduxUtil, otherModule) diff --git a/src/docs/guide/toc.json b/src/docs/guide/toc.json index 0e84e1c..3e14287 100644 --- a/src/docs/guide/toc.json +++ b/src/docs/guide/toc.json @@ -7,24 +7,36 @@ "order": 2, "title": "Basics" }, - "conceptJoin": { + "conceptConditional": { "order": 3, - "title": "Composition" + "title": "Conditional Reduction" }, - "fullExample": { + "conceptJoin": { "order": 4, - "title": "A Most Excellent Example" + "title": "Joining Reducers" }, - "logExt": { + "fullExample": { "order": 5, - "title": "Logging Extension" + "title": "A Most Excellent Example" }, "originalReducerState": { "order": 6, "title": "originalReducerState" }, - "LICENSE": { + "why": { "order": 7, + "title": "Why astx-redux-util?" + }, + "logExt": { + "order": 8, + "title": "Logging Extension" + }, + "history": { + "order": 9, + "title": "Revision History" + }, + "LICENSE": { + "order": 10, "title": "MIT License" } } diff --git a/src/docs/guide/why.md b/src/docs/guide/why.md new file mode 100644 index 0000000..ea95122 --- /dev/null +++ b/src/docs/guide/why.md @@ -0,0 +1,28 @@ +This section provides some insight into why astx-redux-util was +created, and how it compares to other similar utilities. + +One of the astx-redux-util functions ({@link reducerHash}) is a stock +feature in other reducer libraries, such as [redux-actions +handleactions](https://www.npmjs.com/package/redux-actions#handleactionsreducermap-defaultstate). +With that said, astx-redux-util promotes other reducer compositions +that can be used in conjunction with one another. + +**SideBar**: One underlying reason astx-redux-util was created was to provide my +initial exposure into npm publishing. Because I am new to both Node and +GitHub, and I wanted a small project, where I could focus on the setup +and tooling required for publishing a micro-library. This also +includes various aspects of project documentation (something that is +important to me). + +The astx-redux-util library was pulled from a sandbox project that I +used to study several technologies and frameworks. + +My hope is to eventually promote additional astx-redux-util +functionality from my sandbox project. One aspect in particular is +related to managing action creators with a twist: *consistently +maintaining action types in conjunction with their corresponding +action creators*. + +I hope you enjoy this effort, and comments are always welcome. + +</Kevin> diff --git a/src/docs/home.md b/src/docs/home.md index ca336d2..f23779c 100644 --- a/src/docs/home.md +++ b/src/docs/home.md @@ -1,17 +1,56 @@ # astx-redux-util -The astx-redux-util library provides several redux reducer composition -utilities, of which the most prevalent is {@link reducerHash} which -allows you to displace the dreaded switch statement ... *but there is -much more!* +The astx-redux-util library promotes several redux reducer composition +utilities, which blend multiple reducers together forming a richer +abstraction through functional decomposition (i.e. higher-order +functions). +Reducer composition is not new. Redux itself provides the innovative +[combineReducers](http://redux.js.org/docs/api/combineReducers.html) +utility which allows you to fuse individual reducers together to build +up the overall shape of your application state. + +The most prevalent astx-redux-util utility is {@link reducerHash}, +which lets you combine sub-reducers in such a way as to eliminate +the switch statement commonly used to delineate action type. + +Additionally, astx-redux-util promotes other reducer compositions that +can be used in conjunction with one another. + + + +[![Build Status](https://travis-ci.org/KevinAst/astx-redux-util.svg?branch=master)](https://travis-ci.org/KevinAst/astx-redux-util) +[![NPM Version Badge](https://img.shields.io/npm/v/astx-redux-util.svg)](https://www.npmjs.com/package/astx-redux-util) ## At a Glance - {@tutorial start} ... installation and access -- Basic Concepts - - {@tutorial conceptHash} ... eliminate the reducer switch statement - - {@tutorial conceptJoin} ... team up multiple reducers -- {@tutorial fullExample} ... more complete example of astx-redux-util -- {@tutorial logExt} ... conceptual extension for reducer-based centralized logging -- SideBar: {@tutorial originalReducerState} ... sidebar discussion of originalReducerState + +- Concepts: + + - {@tutorial conceptHash} ... using {@link reducerHash}, eliminate + the switch statement commonly found in reducers *("look ma, no + switch")* + + - {@tutorial conceptConditional} ... using {@link + conditionalReducer}, invoke a reducer only when certain + constraints are met *("to reduce or NOT to reduce; that is the + question")* + + - {@tutorial conceptJoin} ... using {@link joinReducers}, team up + multiple reducers to promote higher order functionality *("working + together is success" - Henry Ford)* + +- {@tutorial fullExample} ... a more complete example employing many + of the astx-redux-util utility functions + +- {@tutorial originalReducerState} ... a sidebar discussion of + originalReducerState + +- {@tutorial why} ... why was astx-redux-util created, and how does it + compare to other utilities + +- {@tutorial logExt} ... conceptual extension for reducer-based + centralized logging + +- {@tutorial history} ... peruse various revisions diff --git a/src/docs/jsdoc.conf.json b/src/docs/jsdoc.conf.json index 841675b..305bed1 100644 --- a/src/docs/jsdoc.conf.json +++ b/src/docs/jsdoc.conf.json @@ -8,8 +8,8 @@ "include": [ "./src" ], - "includePattern": ".js$" -// "excludePattern": "index.js" // for future reference :-) + "includePattern": ".js$", + "excludePattern": ".*spec.*" // not interested in any of my tests :-) }, "plugins": [ "plugins/markdown", diff --git a/src/index.js b/src/index.js index 7b8495d..fcc2c74 100644 --- a/src/index.js +++ b/src/index.js @@ -1,41 +1,29 @@ -'use strict' - import conditionalReducer from './reducer/conditionalReducer'; import joinReducers from './reducer/joinReducers'; import reducerHash from './reducer/reducerHash'; -import reducerPassThrough from './reducer/reducerPassThrough'; - - -// TODO: promote this doc through a JSDoc mechanism. //*** //*** Promote all library utilities through a single module. //*** -// TODO: validate following examples are correct. - // NOTE: This non-default export supports ES6 imports. // Example: -// import { reducerPassThrough } from 'astx-redux-util'; +// import { reducerHash } from 'astx-redux-util'; // -or- -// import * as AstxReduxUtil from 'astx-redux-util'; +// import * as AstxReduxUtil from 'astx-redux-util'; export { conditionalReducer, joinReducers, reducerHash, - reducerPassThrough, }; -// NOTE: This default export supports CommonJS modules (because Babel does NOT promote them otherwise). +// NOTE: This default export supports CommonJS modules (otherwise Babel does NOT promote them). // Example: -// const { reducerPassThrough } = require('astx-redux-util'); -// -or- -// const AstxReduxUtil = require('astx-redux-util'); +// const { reducerHash } = require('astx-redux-util'); // -or- -// import AstxReduxUtil from 'astx-redux-util'; +// const AstxReduxUtil = require('astx-redux-util'); export default { conditionalReducer, joinReducers, reducerHash, - reducerPassThrough, }; diff --git a/src/reducer/conditionalReducer.js b/src/reducer/conditionalReducer.js index 9b45c7b..4233ac9 100644 --- a/src/reducer/conditionalReducer.js +++ b/src/reducer/conditionalReducer.js @@ -1,32 +1,49 @@ -'use strict'; - -import {} from '../reduxAPI'; // TODO: placebo import required for JSDoc (ISSUE: JSDoc seems to require at least one import to expose these items) +import identity from 'lodash.identity'; /** - * Create a higher-order reducer that conditionally executes the - * supplied reducerFn, when the conditionalFn returns truthy. - * - * **Examples** of conditionalReducer can be found in {@tutorial - * conceptJoin} and {@tutorial fullExample}. + * Create a higher-order reducer that conditionally executes one of + * the supplied reducerFns, based on the conditionalFn() return + * directive. + * + * The **User Guide** discusses conditionalReducer() in more detail + * (see {@tutorial conceptConditional}), and additional examples can + * be found in {@tutorial conceptJoin} and {@tutorial fullExample}. * * @param {conditionalReducerCB} conditionalFn - a callback function - * which determines when the supplied reducerFn will be executed. + * whose return value determines which reducerFn is executed + * ... truthy: thenReducerFn(), falsy: elseReducerFn(). + * + * @param {reducerFn} thenReducerFn - the "wrapped" reducer invoked + * when conditionalFn returns truthy. * - * @param {reducerFn} reducerFn - the "wrapped" reducer function that - * is conditionally executed. + * @param {reducerFn} [elseReducerFn=identity] - the + * optional "wrapped" reducer invoked when conditionalFn returns + * falsy. DEFAULT: [identity function](https://lodash.com/docs#identity) * * @returns {reducerFn} a newly created reducer function (described above). */ -export default function conditionalReducer(conditionalFn, reducerFn) { +export default function conditionalReducer(conditionalFn, thenReducerFn, elseReducerFn=identity) { // TODO: consider validation of conditionalReducer() params // expose our new higher-order reducer - // ... which conditionally executes reducerFn(), when directed by conditionalFn() - // NOTE: For more info on he originalReducerState parameter, refer to the User Guide {@tutorial originalReducerState} - return (state, action, originalReducerState) => conditionalFn(state, action, originalReducerState) - ? reducerFn(state, action, originalReducerState) - : state; + // NOTE: For more info on he originalReducerState parameter, refer to the User Guide {@tutorial originalReducerState} + return (state, action, originalReducerState) => { + + // maintain the originalReducerState as the immutable state + // at the time of the start of the reduction process + // ... in support of joinReducers() + // ... for more info, refer to the User Guide {@tutorial originalReducerState} + if (originalReducerState === undefined) { + originalReducerState = state; + } + + // execute either thenReducerFn or elseReducerFn, based on conditionalFn + return conditionalFn(state, action, originalReducerState) + ? thenReducerFn(state, action, originalReducerState) + : elseReducerFn(state, action, originalReducerState); + }; + } @@ -36,13 +53,13 @@ export default function conditionalReducer(conditionalFn, reducerFn) { //*** /** - * A callback function (used in {@link conditionalReducer}) which - * conditionally determines whether it's supplied reducerFn will be - * executed. + * A callback function (used in {@link conditionalReducer}) whose + * return value determines which reducerFn is executed. * * @callback conditionalReducerCB * - * @param {*} state - The current immutable state that is the reduction target. + * @param {*} state - The current immutable state that is the + * reduction target. * * @param {Action} action - The standard redux Action object that * drives the reduction process. @@ -51,12 +68,13 @@ export default function conditionalReducer(conditionalFn, reducerFn) { * of the start of the reduction process. * * This is useful in determining whether state has changed within a - * series of reductions ... because each individual reducer only has - * visibility of the state within it's own reduction process. + * series of reductions {@link joinReducers} ... because each + * individual reducer only has visibility of the state within it's own + * reduction process. * * Further information can be found in the {@tutorial * originalReducerState} discussion of the User Guide. * - * @returns {truthy} A truthy value indicating whether the reducerFn - * should be executed or not. + * @returns {truthy} A truthy value indicating which reducerFn is + * executed ... truthy: thenReducerFn(), falsy: elseReducerFn(). */ diff --git a/src/reducer/joinReducers.js b/src/reducer/joinReducers.js index 290ff47..5671f6e 100644 --- a/src/reducer/joinReducers.js +++ b/src/reducer/joinReducers.js @@ -1,15 +1,25 @@ -'use strict'; - -import {} from '../reduxAPI'; // TODO: placebo import required for JSDoc (ISSUE: JSDoc seems to require at least one import to expose these items) +// TODO: placebo import required for JSDoc (ISSUE: JSDoc seems to require at least one import to expose these items) +import identity from 'lodash.identity'; // eslint-disable-line no-unused-vars /** * Create a higher-order reducer by combining two or more reducers, * logically executing each in sequence (in essence combining their * functionality into one). This is useful when combining various * reducer types into one logical construct. - * - * **Examples** of joinReducers can be found in {@tutorial - * conceptJoin} and {@tutorial fullExample}. + * + * **Please Note:** Because each reducer is able to build on what has + * been accomplished by a prior reducer, joinReducers cumulatively + * passes the state parameter that was returned from any prior reducer + * (in the chain of reducers to execute). In essence this is an + * accumulative process. While this does NOT relax the immutable + * constraint of the reducer's state parameter, it is possible for a + * down-stream reducer to receive a state parameter that is a + * different instance from the start of the reduction process (because + * an up-stream reducer needed to alter it in some way). + * + * The **User Guide** discusses joinReducers() in more detail + * (see {@tutorial conceptJoin}), and additional examples can + * be found in {@tutorial fullExample}. * * @param {...reducerFn} reducerFns two or more reducer functions to join * together. @@ -18,14 +28,15 @@ import {} from '../reduxAPI'; // TODO: placebo import required for JSDoc (ISSUE: */ export default function joinReducers(...reducerFns) { - // TODO: consider validation of joinReducers() params ... two or more reducerFns + // TODO: consider validation of joinReducers() params ... an array WITH 0,1,2? or more reducerFns // expose our new higher-order reducer return (state, action, originalReducerState) => { // maintain the originalReducerState as the immutable state - // at the time of the start of the reduction process. - // ... please refer to the User Guide {@tutorial originalReducerState} + // at the time of the start of the reduction process + // ... in support of joinReducers() + // ... for more info, refer to the User Guide {@tutorial originalReducerState} if (originalReducerState === undefined) { originalReducerState = state; } @@ -36,5 +47,5 @@ export default function joinReducers(...reducerFns) { }, state); - } + }; } diff --git a/src/reducer/reducerHash.js b/src/reducer/reducerHash.js index 994ae6d..007eb6f 100644 --- a/src/reducer/reducerHash.js +++ b/src/reducer/reducerHash.js @@ -1,21 +1,21 @@ -'use strict'; - -import reducerPassThrough from './reducerPassThrough'; +import identity from 'lodash.identity'; /** * Create a higher-order reducer by combining a set of sub-reducer * functions that are indexed by the standard action.type. When no * action.type is acted on, the original state is merely - * passed-through. + * passed-through (using the [identity + * function](https://lodash.com/docs#identity)). * * This is one of the more prevalent composition reducers, and * provides an alternative to the switch statement (commonly used to * provide this control mechanism). * - * **Examples** can be found in the {@tutorial conceptHash} - * discussion, which contains more information about reducerHash. + * The **User Guide** discusses reducerHash() in more detail (see + * {@tutorial conceptHash}), and additional examples can be found in + * {@tutorial conceptJoin} and {@tutorial fullExample}. * - * Because this function is so central to the rudimentary aspects of + * **NOTE**: Because this function is so central to the rudimentary aspects of * reduction, it is common to provide a value-added {@tutorial logExt}. * * @param {ActionReducerHash} actionHandlers - a hash of reducer functions, @@ -27,10 +27,25 @@ export default function reducerHash(actionHandlers) { // TODO: consider validation of actionHandlers param. - const locateHandler = (action) => actionHandlers[action.type] || reducerPassThrough; + // internal function: locate handler from actionHandlers action.type hash lookup + // ... default: identity pass-through + const locateHandler = (action) => actionHandlers[action.type] || identity; + + // expose the new reducer fn, which resolves according the the supplied actionHandlers + return (state, action, originalReducerState) => { + + // maintain the originalReducerState as the immutable state + // at the time of the start of the reduction process + // ... in support of joinReducers() + // ... for more info, refer to the User Guide {@tutorial originalReducerState} + if (originalReducerState === undefined) { + originalReducerState = state; + } + + // execute the handler indexed by the action.type (or the identity pass-through) + return locateHandler(action)(state, action, originalReducerState); + }; - // expose the new reducer fn, which resolves according the the supplied hash - return (state, action) => locateHandler(action)(state, action); } @@ -40,12 +55,11 @@ export default function reducerHash(actionHandlers) { //*** /** + * @typedef {Object} ActionReducerHash + * * A hash of reducer functions, indexed by the standard redux * action.type. * - * @namespace ActionReducerHash - * @type Object - * * @property {reducerFn} actionType1 - The reducer function servicing: 'actionType1'. * @property {reducerFn} actionType2 - The reducer function servicing: 'actionType2'. * @property {reducerFn} ...more - ...etc. diff --git a/src/reducer/reducerPassThrough.js b/src/reducer/reducerPassThrough.js deleted file mode 100644 index fad400e..0000000 --- a/src/reducer/reducerPassThrough.js +++ /dev/null @@ -1,15 +0,0 @@ -'use strict'; - -import {} from '../reduxAPI'; // TODO: placebo import required for JSDoc (ISSUE: JSDoc seems to require at least one import to expose these items) - -/** - * A "placebo" reducer that passes through it's supplied state as-is. - * - * @param {*} state - The current immutable state that is the reduction target. - * @param {Action} action - The standard redux Action object that drives the reduction process. - * - * @returns {*} The resulting state after reduction *(in this case the supplied state as-is)*. - */ -export default function reducerPassThrough(state, action) { - return state; -} diff --git a/src/reducer/spec/conditionalReducer.spec.js b/src/reducer/spec/conditionalReducer.spec.js new file mode 100644 index 0000000..70e71dc --- /dev/null +++ b/src/reducer/spec/conditionalReducer.spec.js @@ -0,0 +1,51 @@ +import expect from 'expect'; +import AstxReduxUtil from '../../tooling/ModuleUnderTest'; + +const initialState = 'initialState'; +const thenState = 'thenState'; +const elseState = 'elseState'; + +const thenAction = 'thenAction'; + +const elseCondition = 'elseContidion'; + +const thenReducer = (state, action) => thenState; +const elseReducer = (state, action) => elseState; + +function performTest(reducer, actionType, expectedState) { + it(`process: '${actionType}'`, () => { + expect(reducer(initialState, {type: actionType})).toBe(expectedState); + }); +} + +describe('conditionalReducer() tests', () => { + + + describe('conditionalReducer with if only', () => { + + const reducerUnderTest = AstxReduxUtil.conditionalReducer( + (state, action, originalReducerState) => action.type === thenAction, + thenReducer + ); + + performTest(reducerUnderTest, thenAction, thenState); + performTest(reducerUnderTest, elseCondition, initialState); + + }); + + + describe('conditionalReducer with if/then', () => { + + const reducerUnderTest = AstxReduxUtil.conditionalReducer( + (state, action, originalReducerState) => action.type === thenAction, + thenReducer, + elseReducer + ); + + performTest(reducerUnderTest, thenAction, thenState); + performTest(reducerUnderTest, elseCondition, elseState); + + }); + + // TODO: test edge case: validating parameters +}); diff --git a/src/reducer/spec/joinReducers.spec.js b/src/reducer/spec/joinReducers.spec.js new file mode 100644 index 0000000..a078643 --- /dev/null +++ b/src/reducer/spec/joinReducers.spec.js @@ -0,0 +1,25 @@ +import expect from 'expect'; +import AstxReduxUtil from '../../tooling/ModuleUnderTest'; + +const initialState = 1; + +const reducerIncrement = (state, action) => state + 1; +const reducerDecrement = (state, action) => state - 1; +const reducerDouble = (state, action) => state * 2; + +function performTest(desc, reducer, expectedState) { + it(`process: '${desc}'`, () => { + expect(reducer(initialState, {type: 'notUsed'})).toBe(expectedState); + }); +} + +describe('joinReducers() tests', () => { + // TODO: test parameter validation +//performTest('increment, double (with array params)', AstxReduxUtil.joinReducers([reducerIncrement, reducerDouble]), 4); // TODO: this errors, should be validated + performTest('NO REDUCERS', AstxReduxUtil.joinReducers(), 1); // TODO: should this be a validation error? + performTest('increment', AstxReduxUtil.joinReducers(reducerIncrement), 2); + performTest('increment, increment', AstxReduxUtil.joinReducers(reducerIncrement, reducerIncrement), 3); + performTest('increment, double', AstxReduxUtil.joinReducers(reducerIncrement, reducerDouble), 4); + performTest('increment, double, double', AstxReduxUtil.joinReducers(reducerIncrement, reducerDouble, reducerDouble), 8); + performTest('increment, double, double, decrement', AstxReduxUtil.joinReducers(reducerIncrement, reducerDouble, reducerDouble, reducerDecrement), 7); +}); diff --git a/src/reducer/spec/originalReducerState.spec.js b/src/reducer/spec/originalReducerState.spec.js new file mode 100644 index 0000000..4719d89 --- /dev/null +++ b/src/reducer/spec/originalReducerState.spec.js @@ -0,0 +1,103 @@ +import expect from 'expect'; +import AstxReduxUtil from '../../tooling/ModuleUnderTest'; + +describe('verify originalReducerState is correctly passed through nested reducers', () => { + + function check(desc, value, expected) { + it(desc, () => { + expect(value).toBe(expected); + }); + } + + describe('with joinReducers() at the top', () => { + + AstxReduxUtil.joinReducers( + (state, action, originalReducerState) => { // myReducer1 + check('myReducer1 state', state, 'originalState'); + check('myReducer1 originalReducerState', originalReducerState, 'originalState'); + return 'myReducer1'; + }, + (state, action, originalReducerState) => { // myReducer2 + check('myReducer2 state', state, 'myReducer1'); + check('myReducer2 originalReducerState', originalReducerState, 'originalState'); + return 'myReducer2'; + }, + AstxReduxUtil.joinReducers( + (state, action, originalReducerState) => { // myReducer3 + check('myReducer3 state', state, 'myReducer2'); + check('myReducer3 originalReducerState', originalReducerState, 'originalState'); + return 'myReducer3'; + }, + AstxReduxUtil.conditionalReducer( + (state, action, originalReducerState) => { // CONDITION + check('myReducer4 CONDITION state', state, 'myReducer3'); + check('myReducer4 CONDITION originalReducerState', originalReducerState, 'originalState'); + return true; // always execute + }, + (state, action, originalReducerState) => { // myReducer4 + check('myReducer4 state', state, 'myReducer3'); + check('myReducer4 originalReducerState', originalReducerState, 'originalState'); + return 'myReducer4'; + } + ), + (state, action, originalReducerState) => { // myReducer5 + check('myReducer5 state', state, 'myReducer4'); + check('myReducer5 originalReducerState', originalReducerState, 'originalState'); + return 'myReducer5'; + }, + AstxReduxUtil.conditionalReducer( + (state, action, originalReducerState) => { // CONDITION + check('myReducer6 CONDITION state', state, 'myReducer5'); + check('myReducer6 CONDITION originalReducerState', originalReducerState, 'originalState'); + return true; // always execute + }, + AstxReduxUtil.reducerHash({ + 'myAction': (state, action, originalReducerState) => { // myReducer6 + check('myReducer6 state', state, 'myReducer5'); + check('myReducer6 originalReducerState', originalReducerState, 'originalState'); + return 'myReducer6'; + } + }) + ) + ) + )('originalState', {type: 'myAction'}); // execute reducer + + }); + + describe('with conditionalReducer() at the top', () => { + + AstxReduxUtil.conditionalReducer( + (state, action, originalReducerState) => { // CONDITION + check('myReducer1 CONDITION state', state, 'originalState'); + check('myReducer1 CONDITION originalReducerState', originalReducerState, 'originalState'); + return true; // always execute + }, + AstxReduxUtil.joinReducers( + (state, action, originalReducerState) => { // myReducer1 + check('myReducer1 state', state, 'originalState'); + check('myReducer1 originalReducerState', originalReducerState, 'originalState'); + return 'myReducer1'; + }, + (state, action, originalReducerState) => { // myReducer2 + check('myReducer2 state', state, 'myReducer1'); + check('myReducer2 originalReducerState', originalReducerState, 'originalState'); + return 'myReducer2'; + } + ) + )('originalState', {type: 'myAction'}); // execute reducer + + }); + + describe('with reducerHash() at the top', () => { + + AstxReduxUtil.reducerHash({ + 'myAction': (state, action, originalReducerState) => { // myReducer1 + check('myReducer1 state', state, 'originalState'); + check('myReducer1 originalReducerState', originalReducerState, 'originalState'); + return 'myReducer1'; + } + })('originalState', {type: 'myAction'}); // execute reducer + + }); + +}); diff --git a/src/reducer/spec/reducerHash.spec.js b/src/reducer/spec/reducerHash.spec.js index 0d503ce..51bf1b6 100644 --- a/src/reducer/spec/reducerHash.spec.js +++ b/src/reducer/spec/reducerHash.spec.js @@ -1,29 +1,25 @@ -'use strict'; +import expect from 'expect'; +import AstxReduxUtil from '../../tooling/ModuleUnderTest'; -import expect from 'expect'; -import AstxReduxUtil from '../../index'; // module under test (NOTE: we vary import techniques) - -const reduceWidget = AstxReduxUtil.reducerHash({ - 'widget.edit': (widget, action) => action.widget, - 'widget.edit.close': (widget, action) => null, +const reducerUnderTest = AstxReduxUtil.reducerHash({ + 'edit': (state, action) => action.payload, + 'edit.close': (state, action) => null, }); -function widget(widget=null, action) { - return reduceWidget(widget, action); -} - -const stateWidget = 'stateWidget'; -const actionWidget = 'actionWidget'; +const initialState = 'initialState'; +const actionPayload = 'actionPayload'; -function baseTest(actionType, expectedState) { +function performTest(actionType, expectedState) { it(`process: '${actionType}'`, () => { - expect(widget(stateWidget, {type: actionType, widget: actionWidget})).toBe(expectedState); + expect(reducerUnderTest(initialState, {type: actionType, payload: actionPayload})).toBe(expectedState); }); } describe('reducerHash() tests', () => { - baseTest('widget.edit', actionWidget); - baseTest('widget.edit.close', null); - baseTest('some.other.task', stateWidget); + performTest('edit', actionPayload); + performTest('edit.close', null); + performTest('other.action', initialState); + // TODO: test edge case: a) validating hash, and b) hash containing an undefined key + }); diff --git a/src/reducer/spec/reducerPassThrough.spec.js b/src/reducer/spec/reducerPassThrough.spec.js deleted file mode 100644 index f46f371..0000000 --- a/src/reducer/spec/reducerPassThrough.spec.js +++ /dev/null @@ -1,26 +0,0 @@ -'use strict'; - -import expect from 'expect'; -import {reducerPassThrough} from '../../index'; // module under test - -describe('reducerPassThrough() tests', () => { - - describe('insure state is passed through untouched', () => { - testIt('string', 'myStr'); - testIt('number', 123); - testIt('boolean', true); - testIt('object', {myObjState: 55.0}); - testIt('Symbol', Symbol('Wow a Symbol')); - testIt('undefined', undefined); - testIt('null', null); - }); - -}); - -const action = { type: 'type-not-referenced', payload: 'not-referenced' }; - -function testIt(stateType, state) { - it(`for ${stateType}`, () => { - expect(reducerPassThrough(state, action)).toBe(state); - }); -} diff --git a/src/reducer/spec/samples/appReducer/Widget.js b/src/reducer/spec/samples/appReducer/Widget.js new file mode 100644 index 0000000..17814a3 --- /dev/null +++ b/src/reducer/spec/samples/appReducer/Widget.js @@ -0,0 +1,26 @@ +/* + * Widget provides a number of utilities in support of the widget + * JSON structure. In essance it is a pseudo class definition for + * widget (as close as we can get for a JSON structure). + */ +const Widget = { + + /* + * Calculate the hash for the supplied widget object. + * + * @param {Widget} widget the widget object to hash. + * + * @return {string} the hash representing the supplied widget object. + */ + hash(widget) { + + // YES: I know this is NOT a hash! + // HOWEVER: + // - this is sample code - that I don't really want more dev dependencies on + // - AND it can be systematically validated + return 'x:' + widget.x + ',y:' + widget.y; + }, + +}; + +export default Widget; diff --git a/src/reducer/spec/samples/appReducer/x.js b/src/reducer/spec/samples/appReducer/x.js new file mode 100644 index 0000000..6e2326f --- /dev/null +++ b/src/reducer/spec/samples/appReducer/x.js @@ -0,0 +1,10 @@ +import AstxReduxUtil from '../../../../tooling/ModuleUnderTest'; // REALLY: 'astx-redux-util' + +const reduceX = AstxReduxUtil.reducerHash({ + "widget.edit.x.increment": (x, action) => x+1, + "widget.edit.x.decrement": (x, action) => x-1, +}); + +export default function x(x=0, action) { + return reduceX(x, action); +} diff --git a/src/reducer/spec/samples/appReducer/y.js b/src/reducer/spec/samples/appReducer/y.js new file mode 100644 index 0000000..19495cc --- /dev/null +++ b/src/reducer/spec/samples/appReducer/y.js @@ -0,0 +1,10 @@ +import AstxReduxUtil from '../../../../tooling/ModuleUnderTest'; // REALLY: 'astx-redux-util' + +const reduceY = AstxReduxUtil.reducerHash({ + "widget.edit.y.increment": (y, action) => y+1, + "widget.edit.y.decrement": (y, action) => y-1, +}); + +export default function y(y=0, action) { + return reduceY(y, action); +} diff --git a/src/reducer/spec/samples/conditional/verifySampleConditional.spec.js b/src/reducer/spec/samples/conditional/verifySampleConditional.spec.js new file mode 100644 index 0000000..5f91d99 --- /dev/null +++ b/src/reducer/spec/samples/conditional/verifySampleConditional.spec.js @@ -0,0 +1,29 @@ +import expect from 'expect'; +import widget from './widget'; // sample reducer + +function performTestSeries(reducer) { + // eslint ISSUE: thinks assigned state is unused, but it is clearly passed as an argument :-( + let state = undefined; // eslint-disable-line no-unused-vars + // runningState, action, reducer, expectedNextState + // ===== ============================================ ======= ================= + state = performTest(state, {type:'app.bootstrap.init'}, reducer, {}); // NOTE: proves condidition reducer did NOT execute (because no x/y properties) + state = performTest(state, {type:'widget.edit'}, reducer, {x:0, y:0}); + state = performTest(state, {type:'other.action'}, reducer, {x:0, y:0}); + state = performTest(state, {type:'widget.edit.x.increment'}, reducer, {x:1, y:0}); + state = performTest(state, {type:'widget.edit.x.increment'}, reducer, {x:2, y:0}); + state = performTest(state, {type:'widget.edit.y.increment'}, reducer, {x:2, y:1}); + state = performTest(state, {type:'widget.edit.x.decrement'}, reducer, {x:1, y:1}); + state = performTest(state, {type:'other.action'}, reducer, {x:1, y:1}); +} + +function performTest(state, action, reducer, expectedNextState) { + const nextState = reducer(state, action); + it(`process: '${action.type}'`, () => { + expect(nextState).toEqual(expectedNextState); + }); + return nextState; +} + +describe('example conditionalReducer(): verify sample code found in documentation', () => { + performTestSeries(widget); +}); diff --git a/src/reducer/spec/samples/conditional/widget.js b/src/reducer/spec/samples/conditional/widget.js new file mode 100644 index 0000000..f4cb20f --- /dev/null +++ b/src/reducer/spec/samples/conditional/widget.js @@ -0,0 +1,17 @@ +import * as Redux from 'redux'; +import AstxReduxUtil from '../../../../tooling/ModuleUnderTest'; // REALLY: 'astx-redux-util' +import x from '../appReducer/x'; +import y from '../appReducer/y'; + +const reduceWidget = + AstxReduxUtil.conditionalReducer( + // conditionally apply when action.type begins with 'widget.edit' + (curState, action, originalReducerState) => action.type.startsWith('widget.edit'), + Redux.combineReducers({ + x, + y + })); + +export default function widget(widget={}, action) { + return reduceWidget(widget, action); +} diff --git a/src/reducer/spec/samples/full/verifySampleFull.spec.js b/src/reducer/spec/samples/full/verifySampleFull.spec.js new file mode 100644 index 0000000..152fa28 --- /dev/null +++ b/src/reducer/spec/samples/full/verifySampleFull.spec.js @@ -0,0 +1,30 @@ +import expect from 'expect'; +import widget from './widget'; + +function performTestSeries(reducer) { + // eslint ISSUE: thinks assigned state is unused, but it is clearly passed as an argument :-( + let state = undefined; // eslint-disable-line no-unused-vars + // runningState, action, reducer, expectedNextState + // ===== ============================================ ======= ================= + state = performTest(state, {type:'app.bootstrap.init'}, reducer, null); + state = performTest(state, {type:'widget.edit', widget:{x:11, y:22}}, reducer, {x:11, y:22, curHash: "x:11,y:22"}); + state = performTest(state, {type:'widget.edit.x.increment'}, reducer, {x:12, y:22, curHash: "x:12,y:22"}); + state = performTest(state, {type:'widget.edit.x.increment'}, reducer, {x:13, y:22, curHash: "x:13,y:22"}); + state = performTest(state, {type:'widget.edit.y.increment'}, reducer, {x:13, y:23, curHash: "x:13,y:23"}); + state = performTest(state, {type:'other.action.while.edit'}, reducer, {x:13, y:23, curHash: "x:13,y:23"}); + state = performTest(state, {type:'widget.edit.x.decrement'}, reducer, {x:12, y:23, curHash: "x:12,y:23"}); + state = performTest(state, {type:'widget.edit.close'}, reducer, null); + state = performTest(state, {type:'other.action.while.not.edit'}, reducer, null); +} + +function performTest(state, action, reducer, expectedNextState) { + const nextState = reducer(state, action); + it(`process: '${action.type}'`, () => { + expect(nextState).toEqual(expectedNextState); + }); + return nextState; +} + +describe('example full example: verify sample code found in documentation', () => { + performTestSeries(widget); +}); diff --git a/src/reducer/spec/samples/full/widget.js b/src/reducer/spec/samples/full/widget.js new file mode 100644 index 0000000..42a9135 --- /dev/null +++ b/src/reducer/spec/samples/full/widget.js @@ -0,0 +1,71 @@ +import * as Redux from 'redux'; +import AstxReduxUtil from '../../../../tooling/ModuleUnderTest'; // REALLY: 'astx-redux-util' +import x from '../appReducer/x'; +import y from '../appReducer/y'; +import Widget from '../appReducer/Widget'; + +const reduceWidget = + AstxReduxUtil.joinReducers( + // FIRST: determine content shape (i.e. {} or null) + AstxReduxUtil.reducerHash({ + "widget.edit": (widget, action) => action.widget, + "widget.edit.close": (widget, action) => null + }), + + AstxReduxUtil.conditionalReducer( + // NEXT: maintain individual x/y fields + // ONLY when widget has content (i.e. is being edited) + (widget, action, originalReducerState) => widget !== null, + AstxReduxUtil.joinReducers( + Redux.combineReducers({ + x, + y, + curHash: placeboReducer + }), + AstxReduxUtil.conditionalReducer( + // LAST: maintain curHash + // ONLY when widget has content (see condition above) -AND- has changed + (widget, action, originalReducerState) => originalReducerState !== widget, + (widget, action) => { + widget.curHash = Widget.hash(widget); // OK to mutate (because of changed instance) + return widget; + }) + ) + ) + ); + +export default function widget(widget=null, action) { + return reduceWidget(widget, action); +} + +// placeboReducer WITH state initialization (required for Redux.combineReducers()) +function placeboReducer(state=null, action) { + return state; +} + +// NOTE: The placeboReducer is slightly different than lodash.identity in +// that it defaults the state parameter to null. +// +// This is required in conjunction Redux.combineReducers(), and is +// related to our technique of maintaining curHash in the parent +// widget reducer (which has visibility to all widget properties), +// verses using an individual property reducer (which does NOT have +// visibility to other widget properties). +// +// The placeboReducer works around the following +// Redux.combineReducers() issues: +// +// - with NO curHash entry ... +// WARNING: +// Unexpected key "curHash" found in previous state received by the reducer. +// Expected to find one of the known reducer keys instead: "x", "y". +// Unexpected keys will be ignored. +// +// - with curHash entry, using lodash.identity ... +// ERROR: +// Reducer "curHash" returned undefined during initialization. +// If the state passed to the reducer is undefined, you must explicitly return the initial state. +// The initial state may not be undefined. +// +// - with curHash entry, using placeboReducer ... +// Life is GOOD! diff --git a/src/reducer/spec/samples/hash/verifySampleHash.spec.js b/src/reducer/spec/samples/hash/verifySampleHash.spec.js new file mode 100644 index 0000000..7af100a --- /dev/null +++ b/src/reducer/spec/samples/hash/verifySampleHash.spec.js @@ -0,0 +1,37 @@ +import expect from 'expect'; +import widgetOld from './widgetOld'; // sample reducer (old style) +import widgetNew from './widgetNew'; // sample reducer (new style) + +const widgetUnderEdit = 'widgetUnderEdit'; + +function performTestSeries(reducer) { + // eslint ISSUE: thinks assigned state is unused, but it is clearly passed as an argument :-( + let state = undefined; // eslint-disable-line no-unused-vars + // runningState, action, reducer, expectedNextState + // ===== ============================================ ======= ================= + state = performTest(state, {type:'app.bootstrap.init'}, reducer, null); + state = performTest(state, {type:'widget.edit', widget:widgetUnderEdit}, reducer, widgetUnderEdit); + state = performTest(state, {type:'other.action.while.edit'}, reducer, widgetUnderEdit); + state = performTest(state, {type:'widget.edit.close'}, reducer, null); + state = performTest(state, {type:'other.action.while.not.edit'}, reducer, null); +} + +function performTest(state, action, reducer, expectedNextState) { + const nextState = reducer(state, action); + it(`process: '${action.type}'`, () => { + expect(nextState).toBe(expectedNextState); + }); + return nextState; +} + +describe('example reducerHash(): verify sample code found in documentation', () => { + + describe('old way', () => { + performTestSeries(widgetOld); + }); + + describe('new way', () => { + performTestSeries(widgetNew); + }); + +}); diff --git a/src/reducer/spec/samples/hash/widgetNew.js b/src/reducer/spec/samples/hash/widgetNew.js new file mode 100644 index 0000000..8139f94 --- /dev/null +++ b/src/reducer/spec/samples/hash/widgetNew.js @@ -0,0 +1,11 @@ +import AstxReduxUtil from '../../../../tooling/ModuleUnderTest'; // REALLY: 'astx-redux-util' +const { reducerHash } = AstxReduxUtil; // TODO: figure out how to import { reducerHash } within ModuleUnderTest + +const reduceWidget = reducerHash({ + "widget.edit": (widget, action) => action.widget, + "widget.edit.close": (widget, action) => null, +}); + +export default function widget(widget=null, action) { + return reduceWidget(widget, action); +} diff --git a/src/reducer/spec/samples/hash/widgetOld.js b/src/reducer/spec/samples/hash/widgetOld.js new file mode 100644 index 0000000..70a188b --- /dev/null +++ b/src/reducer/spec/samples/hash/widgetOld.js @@ -0,0 +1,13 @@ +export default function widget(widget=null, action) { + switch (action.type) { + + case 'widget.edit': + return action.widget; + + case 'widget.edit.close': + return null; + + default: + return widget; + } +} diff --git a/src/reducer/spec/samples/join/verifySampleJoin.spec.js b/src/reducer/spec/samples/join/verifySampleJoin.spec.js new file mode 100644 index 0000000..b4515b6 --- /dev/null +++ b/src/reducer/spec/samples/join/verifySampleJoin.spec.js @@ -0,0 +1,39 @@ +import expect from 'expect'; +import widgetOld from './widgetOld'; // sample reducer (old style) +import widgetNew from './widgetNew'; // sample reducer (new style) + +function performTestSeries(reducer) { + // eslint ISSUE: thinks assigned state is unused, but it is clearly passed as an argument :-( + let state = undefined; // eslint-disable-line no-unused-vars + // runningState, action, reducer, expectedNextState + // ===== ============================================ ======= ================= + state = performTest(state, {type:'app.bootstrap.init'}, reducer, null); + state = performTest(state, {type:'widget.edit', widget:{x:11, y:22}}, reducer, {x:11, y:22}); + state = performTest(state, {type:'widget.edit.x.increment'}, reducer, {x:12, y:22}); + state = performTest(state, {type:'widget.edit.x.increment'}, reducer, {x:13, y:22}); + state = performTest(state, {type:'widget.edit.y.increment'}, reducer, {x:13, y:23}); + state = performTest(state, {type:'other.action.while.edit'}, reducer, {x:13, y:23}); + state = performTest(state, {type:'widget.edit.x.decrement'}, reducer, {x:12, y:23}); + state = performTest(state, {type:'widget.edit.close'}, reducer, null); + state = performTest(state, {type:'other.action.while.not.edit'}, reducer, null); +} + +function performTest(state, action, reducer, expectedNextState) { + const nextState = reducer(state, action); + it(`process: '${action.type}'`, () => { + expect(nextState).toEqual(expectedNextState); + }); + return nextState; +} + +describe('example joinReducers(): verify sample code found in documentation', () => { + + describe('old way', () => { + performTestSeries(widgetOld); + }); + + describe('new way', () => { + performTestSeries(widgetNew); + }); + +}); diff --git a/src/reducer/spec/samples/join/widgetNew.js b/src/reducer/spec/samples/join/widgetNew.js new file mode 100644 index 0000000..3d20be8 --- /dev/null +++ b/src/reducer/spec/samples/join/widgetNew.js @@ -0,0 +1,26 @@ +import * as Redux from 'redux'; +import AstxReduxUtil from '../../../../tooling/ModuleUnderTest'; // REALLY: 'astx-redux-util' +import x from '../appReducer/x'; +import y from '../appReducer/y'; + +const reduceWidget = + AstxReduxUtil.joinReducers( + // FIRST: determine content shape (i.e. {} or null) + AstxReduxUtil.reducerHash({ + "widget.edit": (widget, action) => action.widget, + "widget.edit.close": (widget, action) => null + }), + + AstxReduxUtil.conditionalReducer( + // SECOND: maintain individual x/y fields + // ONLY when widget has content (i.e. is being edited) + (widget, action, originalReducerState) => widget !== null, + Redux.combineReducers({ + x, + y + })) + ); + +export default function widget(widget=null, action) { + return reduceWidget(widget, action); +} diff --git a/src/reducer/spec/samples/join/widgetOld.js b/src/reducer/spec/samples/join/widgetOld.js new file mode 100644 index 0000000..876c14d --- /dev/null +++ b/src/reducer/spec/samples/join/widgetOld.js @@ -0,0 +1,37 @@ +import * as Redux from 'redux'; +import x from '../appReducer/x'; +import y from '../appReducer/y'; + +const contentReducer = + Redux.combineReducers({ + x, + y + }); + +export default function widget(widget=null, action) { + + // FIRST: determine content shape (i.e. {} or null) + let nextState = widget; + switch (action.type) { + + case 'widget.edit': + nextState = action.widget; + break; + + case 'widget.edit.close': + nextState = null; + break; + + default: + nextState = widget; + } + + // SECOND: maintain individual x/y fields + // ONLY when widget has content (i.e. is being edited) + if (nextState !== null) { + nextState = contentReducer(nextState, action); + } + + // are we done yet? ... that was painful!! + return nextState; +} diff --git a/src/reduxAPI.js b/src/reduxAPI.js deleted file mode 100644 index d539dfe..0000000 --- a/src/reduxAPI.js +++ /dev/null @@ -1,69 +0,0 @@ -//*** -//*** This file contains NO executable code, rather "JSDoc tags only", -//*** providing more concise API documentation for redux expectations. -//*** - -'use strict'; - - -//*** -//*** Specification: reducerFn -//*** - -// TODO: consider reducerFn: defined with @function instead of @callback -// - @callback is hidden [NOT indexed] in GLOBAL, and appears under Type definitions -// - while @function is promoted in the GLOBAL index, and appears with all other functions - -/** - * A standard [redux reducer function]{@link http://redux.js.org/docs/basics/Reducers.html} - * that is responsible for state changes. - * - * @callback reducerFn - * - * @param {*} state - The current immutable state that is the reduction target. - * @param {Action} action - The standard redux action which drives the reduction process. - * - * @returns {*} The resulting state after reduction. - */ - - - -//*** -//*** Specification: Action -//*** - -// TODO: consider Action: defined with @namespace/@property (promoted as NAMESPACES) -// - only bad thing is it insists on placing description at bottom -// (even when using @description) - -/** - * A standard [redux Action object]{@link http://redux.js.org/docs/basics/Actions.html} - * that drives the reduction process. - * - * @namespace Action - * @type Object - * - * @property {string|Symbol} type - The action type. - * @property {*} whatever - Additional app-specific payload (as needed). - */ - - -// TODO: consider Action: defined with @interface (promoted as INTERFACES) - -/* - * A standard [redux Action object]{@link http://redux.js.org/docs/basics/Actions.html} - * that drives the reduction process. - * @interface Action - */ - -/* - * The action type. - * @name Action#type - * @type string|Symbol - */ - -/* - * Additional app-specific payload (as needed). - * @name Action#whatever - * @type * - */ diff --git a/src/tooling/ModuleUnderTest.js b/src/tooling/ModuleUnderTest.js new file mode 100644 index 0000000..f425755 --- /dev/null +++ b/src/tooling/ModuleUnderTest.js @@ -0,0 +1,80 @@ +import moduleFromDevSrc from '../index'; +import moduleFromBundle from '../../dist/astx-redux-util'; +import moduleFromBundleMin from '../../dist/astx-redux-util.min'; +import moduleFromLib from '../../lib/index'; +import moduleFromEs from '../../es/index'; + +/* + * This export module allows our unit tests to dynamically reference + * different module platforms (i.e. the JS module ecosystem), which + * will eventually be published to npm. + * + * It should be used by ALL our unit tests (as opposed to directly + * importing the module from src). + * + * It exports the module under test, as controlled by the MODULE_PLATFORM + * environment variable. Supported platforms (JS module ecosystems) are: + * + * MODULE_PLATFORM What Bindings Found In NOTES + * =============== =================== ======== ===================== ================================== + * src master ES6 source ES src/*.js DEFAULT (when platform is omitted) + * bundle bundled ES5 CommonJS dist/{project}.js + * bundle.min bundled/minified ES5 CommonJS dist/{project}.min.js + * lib ES5 source CommonJS lib/*.js + * es ES5 source ES es/*.js + * all all of the above APPLICABLE to npm scripts ONLY + * + * NOTE: Due to the static nature of ES6 imports, this is the closest + * thing we can get to dynamic imports! + * + * We basically import ALL variations (see above) and dynamically + * promote just one of them. + * + * The only QUIRKY thing about this technique, is that all platforms + * must pre-exist, even to test just one of them ... because the + * static import (above) will fail! + * + * As it turns out, this is NOT that big a deal, as all you have to + * do is (especially after an "npm run clean"): + * $ npm run build:all + */ + + +/* eslint-disable no-console */ + + +//*** +//*** dynamically define our moduleUnderTest (dynamically driven from the MODULE_PLATFORM env var) +//*** + +const { MODULE_PLATFORM } = process.env; + +let moduleUnderTest = moduleFromDevSrc; + +switch (MODULE_PLATFORM) { + case 'src': + case undefined: + console.log(`*** Testing Module Platform found in: src/*.js (MODULE_PLATFORM: ${MODULE_PLATFORM})`); + moduleUnderTest = moduleFromDevSrc; + break; + case 'bundle': + console.log(`*** Testing Module Platform found in: dist/astx-redux-util.js (MODULE_PLATFORM: ${MODULE_PLATFORM})`); + moduleUnderTest = moduleFromBundle; + break; + case 'bundle.min': + console.log(`*** Testing Module Platform found in: dist/astx-redux-util.min.js (MODULE_PLATFORM: ${MODULE_PLATFORM})`); + moduleUnderTest = moduleFromBundleMin; + break; + case 'lib': + console.log(`*** Testing Module Platform found in: lib/index.js (MODULE_PLATFORM: ${MODULE_PLATFORM})`); + moduleUnderTest = moduleFromLib; + break; + case 'es': + console.log(`*** Testing Module Platform found in: es/index.js (MODULE_PLATFORM: ${MODULE_PLATFORM})`); + moduleUnderTest = moduleFromEs; + break; + default: + throw new Error(`*** ERROR *** moduleUnderTest(): Unrecognized MODULE_PLATFORM environment variable value: ${MODULE_PLATFORM}`); +} + +export default moduleUnderTest; diff --git a/src/tooling/reduxAPI.js b/src/tooling/reduxAPI.js new file mode 100644 index 0000000..9c3b5d2 --- /dev/null +++ b/src/tooling/reduxAPI.js @@ -0,0 +1,38 @@ +// NOTE: This file contains NO executable code, rather "JSDoc tags only", +// providing more concise API documentation for redux expectations. + +// NOTE: This file tucked away in a spec/ directory, so as to NOT be included +// in published npm source (it can be anywhere as long as it is seen by JSDoc). + + +//*** +//*** Specification: reducerFn +//*** + +/** + * A standard [redux reducer function]{@link http://redux.js.org/docs/basics/Reducers.html} + * that is responsible for state changes. + * + * @callback reducerFn + * + * @param {*} state - The current immutable state that is the reduction target. + * @param {Action} action - The standard redux action which drives the reduction process. + * + * @returns {*} The resulting state after reduction. + */ + + + +//*** +//*** Specification: Action +//*** + +/** + * @typedef {Object} Action + * + * A standard [redux Action object]{@link http://redux.js.org/docs/basics/Actions.html} + * that drives the reduction process. + * + * @property {string|Symbol} type - The action type. + * @property {*} whatever - Additional app-specific payload (as needed). + */ diff --git a/webpack.config.babel.js b/webpack.config.babel.js index 08cdcb1..6cab768 100644 --- a/webpack.config.babel.js +++ b/webpack.config.babel.js @@ -9,7 +9,7 @@ * - Consumable by least-common-denominator (ES5) * * transpiled via babel * - * - Bundle accessable through all module + * - Bundle accessable through all module * * npm package (via $ npm install) * * - UMD (Universal Module Definition) @@ -20,7 +20,7 @@ * *