Implement batched updates and custom types
This commit is contained in:
parent
41d7ae611f
commit
6bedb267da
4
.babelrc
4
.babelrc
|
@ -1,4 +1,4 @@
|
||||||
{
|
{
|
||||||
"presets": ["es2015", "react"],
|
"presets": ["es2015", "flow"],
|
||||||
"plugins": ["transform-es2015-modules-commonjs", "transform-object-rest-spread", "transform-flow-strip-types"]
|
"plugins": ["transform-object-rest-spread"]
|
||||||
}
|
}
|
||||||
|
|
File diff suppressed because it is too large
Load Diff
36
package.json
36
package.json
|
@ -10,49 +10,39 @@
|
||||||
},
|
},
|
||||||
"author": "Kai Moseley",
|
"author": "Kai Moseley",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"jest": {
|
|
||||||
"roots": [
|
|
||||||
"<rootDir>/src"
|
|
||||||
],
|
|
||||||
"moduleFileExtensions": [
|
|
||||||
"js",
|
|
||||||
"jsx",
|
|
||||||
"json"
|
|
||||||
],
|
|
||||||
"moduleDirectories": [
|
|
||||||
"node_modules"
|
|
||||||
],
|
|
||||||
"modulePaths": [
|
|
||||||
"/src"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/TheComfyChair/redux-scc"
|
"url": "https://github.com/TheComfyChair/redux-scc"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"babel-cli": "^6.18.0",
|
"babel-cli": "^6.24.1",
|
||||||
"babel-core": "^6.18.2",
|
"babel-core": "^6.18.2",
|
||||||
"babel-eslint": "^6.1.2",
|
"babel-eslint": "^6.1.2",
|
||||||
"babel-jest": "^17.0.2",
|
"babel-jest": "^17.0.2",
|
||||||
"babel-loader": "^6.2.5",
|
"babel-loader": "^6.2.5",
|
||||||
"babel-plugin-transform-es2015-modules-commonjs": "^6.18.0",
|
"babel-plugin-transform-object-rest-spread": "^6.23.0",
|
||||||
"babel-plugin-transform-flow-strip-types": "^6.14.0",
|
"babel-preset-es2015": "^6.24.1",
|
||||||
"babel-plugin-transform-object-rest-spread": "^6.8.0",
|
"babel-preset-flow": "^6.23.0",
|
||||||
"babel-preset-es2015": "^6.18.0",
|
|
||||||
"babel-preset-react": "^6.11.1",
|
"babel-preset-react": "^6.11.1",
|
||||||
"eslint": "^3.2.2",
|
"eslint": "^3.2.2",
|
||||||
"eslint-loader": "^1.5.0",
|
"eslint-loader": "^1.5.0",
|
||||||
"eslint-plugin-babel": "^3.3.0",
|
"eslint-plugin-babel": "^3.3.0",
|
||||||
"eslint-plugin-flowtype": "^2.18.1",
|
"eslint-plugin-flowtype": "^2.18.1",
|
||||||
"eslint-plugin-react": "^6.0.0"
|
"eslint-plugin-react": "^6.0.0",
|
||||||
|
"jest": "^20.0.4",
|
||||||
|
"regenerator-runtime": "^0.10.5",
|
||||||
|
"webpack": "^3.4.1"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"lodash.filter": "^4.6.0",
|
||||||
"lodash.find": "^4.6.0",
|
"lodash.find": "^4.6.0",
|
||||||
"lodash.flowright": "^3.5.0",
|
"lodash.flowright": "^3.5.0",
|
||||||
|
"lodash.includes": "^4.3.0",
|
||||||
"lodash.isfunction": "^3.0.8",
|
"lodash.isfunction": "^3.0.8",
|
||||||
"lodash.isobject": "^3.0.2",
|
"lodash.isobject": "^3.0.2",
|
||||||
|
"lodash.keys": "^4.2.0",
|
||||||
|
"lodash.map": "^4.6.0",
|
||||||
"lodash.omit": "^4.5.0",
|
"lodash.omit": "^4.5.0",
|
||||||
"redux": "^3.6.0"
|
"redux": "^3.7.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
71
readme.md
71
readme.md
|
@ -11,13 +11,6 @@ The behaviors are purposefully simple, and do not do much extend beyond basic up
|
||||||
You can then simply put the reducer into the store and being to build your application on top of the action
|
You can then simply put the reducer into the store and being to build your application on top of the action
|
||||||
generators and selectors that have been returned.
|
generators and selectors that have been returned.
|
||||||
|
|
||||||
#### But these actions are super limited, how can I achieve 'X'?
|
|
||||||
This boils down to how you intend to construct your application. Redux-scc is very opinionated on what functionality is in the domain of the reducer, and that functionality is purely the maintenance of the store shape over time. It has no interest in specific business logic, and instead relies on that logic occuring elsewhere. That logic will dispatch redux-scc actions as required in order to update the store whenever it is relevant to do so.
|
|
||||||
|
|
||||||
Where your business logic lives is entirely up to you, but my personal choice right now would be either redux-sagas or redux-thunks, as they make the most logical sense and make it trivial to dispatch actions to redux-scc as appropriate. RxJS is also another option if you're after a more functional way to handle your side effects.
|
|
||||||
|
|
||||||
The advantage of this seperation is that the functionality you have to write is now focused entirely on that business logic - you can safely assume that the updating of the store has been taken care of and will 'just work'. That makes the functionality in question less complex, and much easier to test as a result. Plus, no more writing boilerplate reducer code!
|
|
||||||
|
|
||||||
#### How to use it
|
#### How to use it
|
||||||
|
|
||||||
To use redux-scc you need to be aware of two things: The buildStoreChunk() function, and the Types object.
|
To use redux-scc you need to be aware of two things: The buildStoreChunk() function, and the Types object.
|
||||||
|
@ -52,16 +45,43 @@ Types.string(defaultValue = '')
|
||||||
Types.number(defaultValue = 0)
|
Types.number(defaultValue = 0)
|
||||||
Types.boolean(defaultValue = false)
|
Types.boolean(defaultValue = false)
|
||||||
|
|
||||||
|
|
||||||
//Complex
|
//Complex
|
||||||
Types.arrayOf(structure, defaultValue = [])
|
Types.arrayOf(structure, defaultValue = [])
|
||||||
Types.reducer(structure)
|
Types.reducer(structure)
|
||||||
Types.shape(structure)
|
Types.shape(structure)
|
||||||
|
|
||||||
|
//Custom types
|
||||||
|
Types.custom({
|
||||||
|
validator, //(value: any) => boolean
|
||||||
|
validationErrorMessage, //(value: any) => string,
|
||||||
|
})(defaultValue)
|
||||||
```
|
```
|
||||||
|
|
||||||
The types are roughly divided into two categories: simple types (which do not have any internal structure to deal with), and complex types (which do). The structure of complex types is built up using a combination of objects containing Types, or Types. Examples can be found below.
|
The types are roughly divided into two categories: simple types (which do not have any internal structure to deal with), and complex types (which do). The structure of complex types is built up using a combination of objects containing Types, or Types. Examples can be found below.
|
||||||
|
The custom type allows you to define arbitrary validation to be applied, which allows you do anything your heart desires! An example of this may
|
||||||
|
be to define a type which only accepts objects that are an instance of moment:
|
||||||
|
|
||||||
|
```
|
||||||
|
const momentType = Types.custom({
|
||||||
|
validator: value => value instanceof moment,
|
||||||
|
validationErrorMessage: value => `${ value } is not an instance of moment!`,
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Or maybe you want to create a type with a maximum accepted value:
|
||||||
|
|
||||||
|
```
|
||||||
|
const maxValueType = (maxValue: number) => Types.custom({
|
||||||
|
validator: value => typeof value === 'number' && value < maxValue,
|
||||||
|
validationErrorMessage: value => `${ value } must be less than ${ maxValue }`,
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Like all other types, you can also use a custom type to create a reducer.
|
||||||
|
|
||||||
#### Actions API
|
#### Actions API
|
||||||
##### Primitive/any
|
##### Primitive/any/custom
|
||||||
- replace(value: any): Replaces the current reducer value with the value provided.
|
- replace(value: any): Replaces the current reducer value with the value provided.
|
||||||
- reset(): Resets the reducer value to the initial value.
|
- reset(): Resets the reducer value to the initial value.
|
||||||
|
|
||||||
|
@ -81,6 +101,41 @@ The types are roughly divided into two categories: simple types (which do not ha
|
||||||
- shift(value: any): Add the value to the beginning of the array.
|
- shift(value: any): Add the value to the beginning of the array.
|
||||||
- unshift(): Remove the first element of the array.
|
- unshift(): Remove the first element of the array.
|
||||||
|
|
||||||
|
#### Batch update
|
||||||
|
Automatically generating these actions is a nice time saver, but an issue with them at the moment is that, if you want to update several parts of the store,
|
||||||
|
these actions will need to be dispatched individually. This makes 'time travel' in Redux much more difficult, as the
|
||||||
|
various updates occur individually and cannot easily be rolled back. Additionally, each update will result in redux informing subscribers of
|
||||||
|
an update! Redux-scc avoids this by providing `batchUpdate`.
|
||||||
|
|
||||||
|
batchUpdate takes a name (so you can easily identify the action in the redux dev tools), and an array of redux-scc actions. These
|
||||||
|
actions will be performed as part of one redux update, thus avoiding the unfortunate side effects mentioned above.
|
||||||
|
|
||||||
|
```
|
||||||
|
const anExampleBatchUpdate = batchUpdate({
|
||||||
|
name: 'an example!',
|
||||||
|
actions: [
|
||||||
|
actions.someReduxSccReducer.reset(),
|
||||||
|
actions.anotherReduxSccReducer.replace('foo!'),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
If a reducer is affected by the actions multiple times, the actions will play out sequentially.
|
||||||
|
|
||||||
|
```
|
||||||
|
//We start with the reducer (which is an array type reducer) having state: [4,5,6]
|
||||||
|
|
||||||
|
const exampleBatchedUpdateHittingSameReducerMultipleTimes = batchUpdate({
|
||||||
|
name: 'multiple update funsies!',
|
||||||
|
actions: [
|
||||||
|
actions.removeAtIndex(1), //removes 5 - [4,6]
|
||||||
|
actions.replaceAtIndex(23, 1), //replaces 6 with 23 - [4, 23]
|
||||||
|
actions.push(43), //adds 43 to the end of the array - [4, 23, 43]
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
#### Examples
|
#### Examples
|
||||||
|
|
||||||
##### Basic
|
##### Basic
|
||||||
|
|
|
@ -1,117 +1,153 @@
|
||||||
import {
|
import {
|
||||||
buildStoreChunk,
|
buildStoreChunk,
|
||||||
} from '../buildStoreChunk';
|
} from '../buildStoreChunk';
|
||||||
import {
|
import {
|
||||||
Types,
|
Types,
|
||||||
} from '../structure';
|
} from '../structure';
|
||||||
import {
|
import {
|
||||||
createStore,
|
batchUpdate,
|
||||||
combineReducers,
|
} from '../reducers/batchUpdates';
|
||||||
|
import {
|
||||||
|
createStore,
|
||||||
|
combineReducers,
|
||||||
} from 'redux';
|
} from 'redux';
|
||||||
import isFunction from 'lodash/isFunction';
|
import isFunction from 'lodash/isFunction';
|
||||||
|
|
||||||
|
|
||||||
describe('buildStoreChunk', () => {
|
describe('buildStoreChunk', () => {
|
||||||
describe('buildStoreChunk', () => {
|
it('Will throw error if a structure is not defined', () => {
|
||||||
it('Will throw error if a structure is not defined', () => {
|
expect(() => buildStoreChunk('toast')).toThrowError(/structure/);
|
||||||
expect(() => buildStoreChunk('toast')).toThrowError(/structure/);
|
|
||||||
});
|
|
||||||
it('Will accept a single reducer (no nesting)', () => {
|
|
||||||
expect(Object.keys(buildStoreChunk('toast', Types.reducer(Types.string()) )) )
|
|
||||||
.toEqual(['reducers', 'actions', 'selectors']);
|
|
||||||
});
|
|
||||||
it('Will return an object containing reducers, actions, and selectors as the result', () => {
|
|
||||||
expect(Object.keys(buildStoreChunk('toast', {
|
|
||||||
example: Types.reducer(Types.string()),
|
|
||||||
}))).toEqual(['reducers', 'actions', 'selectors']);
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Resulting chunk', () => {
|
|
||||||
const chunk = buildStoreChunk('example', {
|
|
||||||
nested1: Types.reducer(Types.string('foo')),
|
|
||||||
nested2: Types.reducer(Types.shape({
|
|
||||||
foo: Types.number(),
|
|
||||||
bar: Types.string(),
|
|
||||||
})),
|
|
||||||
nested3: Types.reducer(Types.arrayOf(Types.number(), [1, 2, 3])),
|
|
||||||
nested4: Types.reducer({
|
|
||||||
innerNested1: Types.reducer(Types.string('bar')),
|
|
||||||
innerNested2: Types.reducer({
|
|
||||||
innerNested3: Types.reducer(Types.string('baz')),
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
const nonNestedChunk = buildStoreChunk('example2', Types.reducer(Types.string('foo')));
|
|
||||||
|
|
||||||
describe('Selectors', () => {
|
|
||||||
const store = createStore(combineReducers({
|
|
||||||
...chunk.reducers,
|
|
||||||
}));
|
|
||||||
|
|
||||||
it('Selectors object has the correct top level structure for a nested chunk', () => {
|
|
||||||
expect(Object.keys(chunk.selectors)).toEqual(['nested1', 'nested2', 'nested3', 'nested4']);
|
|
||||||
});
|
|
||||||
it('Selectors object is a function for a non-nested chunk', () => {
|
|
||||||
expect(isFunction(nonNestedChunk.selectors)).toBe(true);
|
|
||||||
});
|
|
||||||
it('Nested selectors object has the correct structure for a defined reducer', () => {
|
|
||||||
expect(Object.keys(chunk.selectors.nested4)).toEqual(['innerNested1', 'innerNested2']);
|
|
||||||
});
|
|
||||||
it('Selector returns correct value', () => {
|
|
||||||
expect(chunk.selectors.nested1(store.getState())).toEqual('foo');
|
|
||||||
});
|
|
||||||
it('Nested selector returns correct value', () => {
|
|
||||||
expect(chunk.selectors.nested4.innerNested1(store.getState())).toEqual('bar');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Actions', () => {
|
|
||||||
it('Actions object has the correct top level structure for a nested chunk', () => {
|
|
||||||
expect(Object.keys(chunk.actions)).toEqual(['nested1', 'nested2', 'nested3', 'nested4']);
|
|
||||||
});
|
|
||||||
it('Actions object has the correct top level structure for a non nested chunk', () => {
|
|
||||||
expect(Object.keys(nonNestedChunk.actions)).toEqual(['replace', 'reset']);
|
|
||||||
});
|
|
||||||
it('Nested actions object has the correct structure for a chunk', () => {
|
|
||||||
expect(Object.keys(chunk.actions.nested4)).toEqual(['innerNested1', 'innerNested2']);
|
|
||||||
});
|
|
||||||
it('Replace actions return an object that contains a type and payload', () => {
|
|
||||||
expect(Object.keys(chunk.actions.nested1.replace('bar'))).toEqual(['type', 'payload']);
|
|
||||||
expect(Object.keys(chunk.actions.nested2.replace({}))).toEqual(['type', 'payload']);
|
|
||||||
expect(Object.keys(chunk.actions.nested3.replace([]))).toEqual(['type', 'payload', 'index']);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Combined actions and selectors (nested chunk)', () => {
|
|
||||||
const store = createStore(combineReducers({
|
|
||||||
...chunk.reducers,
|
|
||||||
...nonNestedChunk.reducers,
|
|
||||||
}));
|
|
||||||
|
|
||||||
it('Dispatching an action should correctly update the store', () => {
|
|
||||||
store.dispatch(chunk.actions.nested1.replace('bar'));
|
|
||||||
expect(chunk.selectors.nested1(store.getState())).toEqual('bar');
|
|
||||||
|
|
||||||
store.dispatch(chunk.actions.nested1.reset());
|
|
||||||
expect(chunk.selectors.nested1(store.getState())).toEqual('foo');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Combined actions and selectors (non nested chunk)', () => {
|
|
||||||
const store = createStore(combineReducers({
|
|
||||||
...chunk.reducers,
|
|
||||||
...nonNestedChunk.reducers,
|
|
||||||
}));
|
|
||||||
|
|
||||||
it('Dispatching an action should correctly update the store', () => {
|
|
||||||
store.dispatch(nonNestedChunk.actions.replace('bar'));
|
|
||||||
expect(nonNestedChunk.selectors(store.getState())).toEqual('bar');
|
|
||||||
|
|
||||||
store.dispatch(nonNestedChunk.actions.reset());
|
|
||||||
expect(nonNestedChunk.selectors(store.getState())).toEqual('foo');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
});
|
|
||||||
|
|
||||||
});
|
});
|
||||||
|
it('Will accept a single reducer (no nesting)', () => {
|
||||||
|
expect(Object.keys(buildStoreChunk('toast', Types.reducer(Types.string()) )) )
|
||||||
|
.toEqual(['reducers', 'actions', 'selectors']);
|
||||||
|
});
|
||||||
|
it('Will return an object containing reducers, actions, and selectors as the result', () => {
|
||||||
|
expect(Object.keys(buildStoreChunk('toast', {
|
||||||
|
example: Types.reducer(Types.string()),
|
||||||
|
}))).toEqual(['reducers', 'actions', 'selectors']);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Resulting chunk', () => {
|
||||||
|
const chunk = buildStoreChunk('example', {
|
||||||
|
nested1: Types.reducer(Types.string('foo')),
|
||||||
|
nested2: Types.reducer(Types.shape({
|
||||||
|
foo: Types.number(),
|
||||||
|
bar: Types.string(),
|
||||||
|
})),
|
||||||
|
nested3: Types.reducer(Types.arrayOf(Types.number(), [1, 2, 3])),
|
||||||
|
nested4: Types.reducer({
|
||||||
|
innerNested1: Types.reducer(Types.string('bar')),
|
||||||
|
innerNested2: Types.reducer({
|
||||||
|
innerNested3: Types.reducer(Types.string('baz')),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const nonNestedChunk = buildStoreChunk('example2', Types.reducer(Types.string('foo')));
|
||||||
|
|
||||||
|
describe('Selectors', () => {
|
||||||
|
const store = createStore(combineReducers({
|
||||||
|
...chunk.reducers,
|
||||||
|
}));
|
||||||
|
|
||||||
|
it('Selectors object has the correct top level structure for a nested chunk', () => {
|
||||||
|
expect(Object.keys(chunk.selectors)).toEqual(['nested1', 'nested2', 'nested3', 'nested4']);
|
||||||
|
});
|
||||||
|
it('Selectors object is a function for a non-nested chunk', () => {
|
||||||
|
expect(isFunction(nonNestedChunk.selectors)).toBe(true);
|
||||||
|
});
|
||||||
|
it('Nested selectors object has the correct structure for a defined reducer', () => {
|
||||||
|
expect(Object.keys(chunk.selectors.nested4)).toEqual(['innerNested1', 'innerNested2']);
|
||||||
|
});
|
||||||
|
it('Selector returns correct value', () => {
|
||||||
|
expect(chunk.selectors.nested1(store.getState())).toEqual('foo');
|
||||||
|
});
|
||||||
|
it('Nested selector returns correct value', () => {
|
||||||
|
expect(chunk.selectors.nested4.innerNested1(store.getState())).toEqual('bar');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Actions', () => {
|
||||||
|
it('Actions object has the correct top level structure for a nested chunk', () => {
|
||||||
|
expect(Object.keys(chunk.actions)).toEqual(['nested1', 'nested2', 'nested3', 'nested4']);
|
||||||
|
});
|
||||||
|
it('Actions object has the correct top level structure for a non nested chunk', () => {
|
||||||
|
expect(Object.keys(nonNestedChunk.actions)).toEqual(['replace', 'reset']);
|
||||||
|
});
|
||||||
|
it('Nested actions object has the correct structure for a chunk', () => {
|
||||||
|
expect(Object.keys(chunk.actions.nested4)).toEqual(['innerNested1', 'innerNested2']);
|
||||||
|
});
|
||||||
|
it('Replace actions return an object that contains a type and payload', () => {
|
||||||
|
expect(Object.keys(chunk.actions.nested1.replace('bar'))).toEqual(['type', 'payload']);
|
||||||
|
expect(Object.keys(chunk.actions.nested2.replace({}))).toEqual(['type', 'payload']);
|
||||||
|
expect(Object.keys(chunk.actions.nested3.replace([]))).toEqual(['type', 'payload', 'index']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Combined actions and selectors (nested chunk)', () => {
|
||||||
|
const store = createStore(combineReducers({
|
||||||
|
...chunk.reducers,
|
||||||
|
...nonNestedChunk.reducers,
|
||||||
|
}));
|
||||||
|
|
||||||
|
it('Dispatching an action should correctly update the store', () => {
|
||||||
|
store.dispatch(chunk.actions.nested1.replace('bar'));
|
||||||
|
expect(chunk.selectors.nested1(store.getState())).toEqual('bar');
|
||||||
|
|
||||||
|
store.dispatch(chunk.actions.nested1.reset());
|
||||||
|
expect(chunk.selectors.nested1(store.getState())).toEqual('foo');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Combined actions and selectors (non nested chunk)', () => {
|
||||||
|
const store = createStore(combineReducers({
|
||||||
|
...chunk.reducers,
|
||||||
|
...nonNestedChunk.reducers,
|
||||||
|
}));
|
||||||
|
|
||||||
|
it('Dispatching an action should correctly update the store', () => {
|
||||||
|
store.dispatch(nonNestedChunk.actions.replace('bar'));
|
||||||
|
expect(nonNestedChunk.selectors(store.getState())).toEqual('bar');
|
||||||
|
|
||||||
|
store.dispatch(nonNestedChunk.actions.reset());
|
||||||
|
expect(nonNestedChunk.selectors(store.getState())).toEqual('foo');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
describe('Batch updates', () => {
|
||||||
|
const store = createStore(combineReducers({
|
||||||
|
...chunk.reducers,
|
||||||
|
...nonNestedChunk.reducers,
|
||||||
|
}));
|
||||||
|
|
||||||
|
it('Dispatching a batchUpdate updates the store correctly', () => {
|
||||||
|
store.dispatch(batchUpdate({
|
||||||
|
name: 'batchUpdateFunsies',
|
||||||
|
actions: [
|
||||||
|
nonNestedChunk.actions.replace('bar'),
|
||||||
|
chunk.actions.nested2.update({
|
||||||
|
foo: 4,
|
||||||
|
}),
|
||||||
|
chunk.actions.nested2.update({
|
||||||
|
bar: 'boop!',
|
||||||
|
}),
|
||||||
|
chunk.actions.nested3.replace([
|
||||||
|
4,5,6
|
||||||
|
]),
|
||||||
|
chunk.actions.nested3.removeAtIndex(1),
|
||||||
|
],
|
||||||
|
}));
|
||||||
|
expect(nonNestedChunk.selectors(store.getState())).toEqual('bar');
|
||||||
|
expect(chunk.selectors.nested2(store.getState())).toEqual({
|
||||||
|
foo: 4,
|
||||||
|
bar: 'boop!',
|
||||||
|
});
|
||||||
|
expect(chunk.selectors.nested3(store.getState())).toEqual([
|
||||||
|
4,6,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
|
@ -51,7 +51,7 @@ describe('reducers', () => {
|
||||||
|
|
||||||
describe('determineReducerType', () => {
|
describe('determineReducerType', () => {
|
||||||
it('should return the correct creator function for the default mapping', () => {
|
it('should return the correct creator function for the default mapping', () => {
|
||||||
forEach(omit(Types, 'reducer', 'wildcardKey'), structureType => {
|
forEach({ custom: Types.custom(), ...omit(Types, 'reducer', 'wildcardKey', 'custom') }, structureType => {
|
||||||
const returnVal = determineReducerType(Types.reducer(structureType()), {
|
const returnVal = determineReducerType(Types.reducer(structureType()), {
|
||||||
name: 'toast',
|
name: 'toast',
|
||||||
locationString: 'toasty',
|
locationString: 'toasty',
|
||||||
|
|
|
@ -19,6 +19,7 @@ describe('Type descriptions', () => {
|
||||||
expect(Types.number()().defaultValue).toBe(0);
|
expect(Types.number()().defaultValue).toBe(0);
|
||||||
expect(Types.boolean()().defaultValue).toBe(false);
|
expect(Types.boolean()().defaultValue).toBe(false);
|
||||||
expect(Types.arrayOf()().defaultValue).toEqual([]);
|
expect(Types.arrayOf()().defaultValue).toEqual([]);
|
||||||
|
expect(Types.custom()()().defaultValue).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('will return the default value provided (except for reducer and shape)', () => {
|
it('will return the default value provided (except for reducer and shape)', () => {
|
||||||
|
@ -26,6 +27,7 @@ describe('Type descriptions', () => {
|
||||||
expect(Types.number(5)().defaultValue).toBe(5);
|
expect(Types.number(5)().defaultValue).toBe(5);
|
||||||
expect(Types.boolean(true)().defaultValue).toBe(true);
|
expect(Types.boolean(true)().defaultValue).toBe(true);
|
||||||
expect(Types.arrayOf(Types.number(), [1, 2, 3])().defaultValue).toEqual([1, 2, 3]);
|
expect(Types.arrayOf(Types.number(), [1, 2, 3])().defaultValue).toEqual([1, 2, 3]);
|
||||||
|
expect(Types.custom()('foo')().defaultValue).toBe('foo');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('will return the correct typeofValue (for string, number, and boolean)', () => {
|
it('will return the correct typeofValue (for string, number, and boolean)', () => {
|
||||||
|
@ -40,4 +42,23 @@ describe('Type descriptions', () => {
|
||||||
expect(Types.arrayOf(structureTest)().structure).toEqual(structureTest);
|
expect(Types.arrayOf(structureTest)().structure).toEqual(structureTest);
|
||||||
expect(Types.reducer(structureTest)().structure).toEqual(structureTest);
|
expect(Types.reducer(structureTest)().structure).toEqual(structureTest);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('custom value correctly exposes the validator and validation error message properties', () => {
|
||||||
|
const customValidator = () => false;
|
||||||
|
const customErrorMessage = () => 'Hai!';
|
||||||
|
const customType = Types.custom({
|
||||||
|
validator: customValidator,
|
||||||
|
validationErrorMessage: customErrorMessage,
|
||||||
|
})()();
|
||||||
|
|
||||||
|
expect(customType.validator).toEqual(customValidator);
|
||||||
|
expect(customType.validationErrorMessage).toEqual(customErrorMessage);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('custom value acts the same as any when no configuration provided', () => {
|
||||||
|
const unconfiguredCustom = Types.custom()()();
|
||||||
|
expect(unconfiguredCustom.validator('toasty')).toBe(true);
|
||||||
|
expect(unconfiguredCustom.validator()).toBe(true);
|
||||||
|
expect(unconfiguredCustom.validator(null)).toBe(true);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import { Types } from '../structure';
|
import { Types } from '../structure';
|
||||||
import {
|
import {
|
||||||
validatePrimitive,
|
validateValue,
|
||||||
validateShape,
|
validateShape,
|
||||||
validateArray,
|
validateArray,
|
||||||
getTypeValidation,
|
getTypeValidation,
|
||||||
|
@ -10,20 +10,31 @@ import {
|
||||||
|
|
||||||
describe('Validation functionality', () => {
|
describe('Validation functionality', () => {
|
||||||
|
|
||||||
describe('Primitives', () => {
|
describe('Primitives/Custom', () => {
|
||||||
|
const customType = Types.custom({
|
||||||
|
validator: value => value === 3,
|
||||||
|
validationErrorMessage: value => `Oh noes! ${ value }`,
|
||||||
|
})();
|
||||||
|
|
||||||
it('Number primitive should allow for numbers', () => {
|
it('Number primitive should allow for numbers', () => {
|
||||||
expect(validatePrimitive(Types.number(), 3)).toBe(3);
|
expect(validateValue(Types.number(), 3)).toBe(3);
|
||||||
});
|
});
|
||||||
it('String primitive should allow for string', () => {
|
it('String primitive should allow for string', () => {
|
||||||
expect(validatePrimitive(Types.string(), 'toast')).toBe('toast');
|
expect(validateValue(Types.string(), 'toast')).toBe('toast');
|
||||||
});
|
});
|
||||||
it('Boolean primitive should allow for string', () => {
|
it('Boolean primitive should allow for string', () => {
|
||||||
expect(validatePrimitive(Types.boolean(), true)).toBe(true);
|
expect(validateValue(Types.boolean(), true)).toBe(true);
|
||||||
});
|
});
|
||||||
it('Any should allow for anything', () => {
|
it('Any should allow for anything', () => {
|
||||||
const date = new Date();
|
const date = new Date();
|
||||||
expect(validatePrimitive(Types.any(), date)).toEqual(date);
|
expect(validateValue(Types.any(), date)).toEqual(date);
|
||||||
})
|
});
|
||||||
|
it('should validate custom values using the custom validator', () => {
|
||||||
|
expect(validateValue(customType, 3)).toBe(3);
|
||||||
|
});
|
||||||
|
it('should return undefined from custom validators which failed', () => {
|
||||||
|
expect(validateValue(customType, 4)).toBeUndefined();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Arrays', () => {
|
describe('Arrays', () => {
|
||||||
|
@ -45,11 +56,12 @@ describe('Validation functionality', () => {
|
||||||
.toEqual([{test1: 3},{test1: 4}]);
|
.toEqual([{test1: 3},{test1: 4}]);
|
||||||
});
|
});
|
||||||
const testArrayStructure3 = Types.arrayOf(Types.shape({
|
const testArrayStructure3 = Types.arrayOf(Types.shape({
|
||||||
test1: Types.arrayOf(Types.number())
|
test1: Types.arrayOf(Types.number()),
|
||||||
|
test2: Types.custom({ validator: value => value === 'foo' })(),
|
||||||
}));
|
}));
|
||||||
it('Arrays should allow for complex objects - test 2', () => {
|
it('Arrays should allow for complex objects - test 2', () => {
|
||||||
expect(validateArray(testArrayStructure3, [{test1: [3,4,5]}]))
|
expect(validateArray(testArrayStructure3, [{test1: [3,4,5], test2: 'foo' }]))
|
||||||
.toEqual([{test1: [3,4,5]}]);
|
.toEqual([{test1: [3,4,5], test2: 'foo' }]);
|
||||||
});
|
});
|
||||||
it('Array should return an empty array if a non-array is passed', () => {
|
it('Array should return an empty array if a non-array is passed', () => {
|
||||||
expect(validateArray('foo')).toEqual([]);
|
expect(validateArray('foo')).toEqual([]);
|
||||||
|
@ -59,14 +71,15 @@ describe('Validation functionality', () => {
|
||||||
describe('Objects', () => {
|
describe('Objects', () => {
|
||||||
const testObjectStructure = Types.shape({
|
const testObjectStructure = Types.shape({
|
||||||
test1: Types.string(),
|
test1: Types.string(),
|
||||||
test2: Types.number()
|
test2: Types.number(),
|
||||||
|
test3: Types.custom({ validator: value => value !== 4 })(),
|
||||||
});
|
});
|
||||||
it('Object of primitives should allow all props present in the structure', () => {
|
it('Object of primitives should allow all props present in the structure', () => {
|
||||||
expect(validateShape(testObjectStructure, { test1: 'toast', test2: 3 }))
|
expect(validateShape(testObjectStructure, { test1: 'toast', test2: 3, test3: 1 }))
|
||||||
.toEqual({ test1: 'toast', test2: 3 });
|
.toEqual({ test1: 'toast', test2: 3, test3: 1 });
|
||||||
});
|
});
|
||||||
it('Object of primitives should only allow for props with values which match their config', () => {
|
it('Object of primitives should only allow for props with values which match their config', () => {
|
||||||
expect(validateShape(testObjectStructure, { test1: 5, test2: 3 }))
|
expect(validateShape(testObjectStructure, { test1: 5, test2: 3, test3: 4 }))
|
||||||
.toEqual({ test2: 3 });
|
.toEqual({ test2: 3 });
|
||||||
});
|
});
|
||||||
it('Object of primitives should strip any properties not part of the config', () => {
|
it('Object of primitives should strip any properties not part of the config', () => {
|
||||||
|
|
|
@ -1,7 +1,9 @@
|
||||||
import { Types } from './structure';
|
import { Types } from './structure';
|
||||||
import { buildStoreChunk } from './buildStoreChunk';
|
import { buildStoreChunk } from './buildStoreChunk';
|
||||||
|
import { batchUpdate } from './reducers/batchUpdates';
|
||||||
|
|
||||||
export {
|
export {
|
||||||
Types,
|
Types,
|
||||||
buildStoreChunk,
|
buildStoreChunk,
|
||||||
|
batchUpdate,
|
||||||
}
|
}
|
||||||
|
|
|
@ -49,6 +49,7 @@ export const REDUCER_CREATOR_MAPPING: { [key: PropTypeKeys]: any } = {
|
||||||
[PROP_TYPES._string]: createPrimitiveReducer,
|
[PROP_TYPES._string]: createPrimitiveReducer,
|
||||||
[PROP_TYPES._number]: createPrimitiveReducer,
|
[PROP_TYPES._number]: createPrimitiveReducer,
|
||||||
[PROP_TYPES._any]: createPrimitiveReducer,
|
[PROP_TYPES._any]: createPrimitiveReducer,
|
||||||
|
[PROP_TYPES._custom]: createPrimitiveReducer,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,61 @@
|
||||||
|
//@flow
|
||||||
|
import {
|
||||||
|
getApplicableBatchActions,
|
||||||
|
batchUpdate,
|
||||||
|
isBatchAction,
|
||||||
|
BATCH_UPDATE,
|
||||||
|
} from '../batchUpdates';
|
||||||
|
|
||||||
|
|
||||||
|
describe('getApplicableBatchActions', () => {
|
||||||
|
const exampleBehaviors = {
|
||||||
|
foo: {
|
||||||
|
reducer: () => {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const exampleBatchActions = [
|
||||||
|
{ type: 'foo', payload: 'boop' },
|
||||||
|
{ type: 'bar', payload: 'boop' },
|
||||||
|
];
|
||||||
|
|
||||||
|
it('should return an array of all applicable batch actions', () => {
|
||||||
|
expect(getApplicableBatchActions(exampleBehaviors)(exampleBatchActions)).toEqual([
|
||||||
|
{ type: 'foo', payload: 'boop' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
describe('isBatchAction', () => {
|
||||||
|
it('should return true if action contains batch action string', () => {
|
||||||
|
expect(isBatchAction(`Boop!${ BATCH_UPDATE }`)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
it('should return false if action does not contain batch action string', () => {
|
||||||
|
expect(isBatchAction('')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
it('should not crash if undefined or null is passed', () => {
|
||||||
|
expect(isBatchAction()).toBe(false);
|
||||||
|
expect(isBatchAction(null)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
describe('batchUpdate action', () => {
|
||||||
|
it('should return an action with a type including the batch update string and name', () => {
|
||||||
|
expect(batchUpdate({
|
||||||
|
name: 'boop!'
|
||||||
|
}).type).toMatch(new RegExp(`${ BATCH_UPDATE }`));
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
it('should return actions as the payload', () => {
|
||||||
|
const example = [];
|
||||||
|
expect(batchUpdate({
|
||||||
|
actions: example,
|
||||||
|
}).payload).toBe(example);
|
||||||
|
});
|
||||||
|
});
|
|
@ -38,13 +38,18 @@ export type ArraySelector = (state: Object) => Array<any>;
|
||||||
//==============================
|
//==============================
|
||||||
// JS imports
|
// JS imports
|
||||||
//==============================
|
//==============================
|
||||||
import reduce from 'lodash/reduce';
|
|
||||||
import isArray from 'lodash/isArray';
|
import isArray from 'lodash/isArray';
|
||||||
import isNumber from 'lodash/isNumber';
|
import isNumber from 'lodash/isNumber';
|
||||||
import { validateArray, validateShape, validatePrimitive } from '../validatePayload';
|
import { validateArray, validateShape, validateValue } from '../validatePayload';
|
||||||
import { createReducerBehaviors } from '../reducers';
|
import { createReducerBehaviors } from '../reducers';
|
||||||
import { updateAtIndex, removeAtIndex } from '../utils/arrayUtils';
|
import { updateAtIndex, removeAtIndex } from '../utils/arrayUtils';
|
||||||
import { PROP_TYPES } from '../structure';
|
import { PROP_TYPES } from '../structure';
|
||||||
|
import {
|
||||||
|
isBatchAction,
|
||||||
|
getApplicableBatchActions
|
||||||
|
} from './batchUpdates';
|
||||||
|
|
||||||
|
const reduce = require('lodash/fp/reduce').convert({ cap: false });
|
||||||
|
|
||||||
|
|
||||||
function checkIndex(index: ?number, payload: any = '', behaviorName: string): boolean {
|
function checkIndex(index: ?number, payload: any = '', behaviorName: string): boolean {
|
||||||
|
@ -151,17 +156,29 @@ export function createReducer(arrayTypeDescription: ArrayStructureType, behavior
|
||||||
|
|
||||||
//Return the array reducer.
|
//Return the array reducer.
|
||||||
return (state: Array<any> = initialValue, { type, payload, index }: ArrayReducerAction) => {
|
return (state: Array<any> = initialValue, { type, payload, index }: ArrayReducerAction) => {
|
||||||
//If the action type does not match any of the specified behaviors, just return the current state.
|
const matchedBehaviors = behaviors[type]
|
||||||
if (!behaviors[type]) return state;
|
? [{ type, payload }]
|
||||||
//Validating the payload of an array is more tricky, as we do not know ahead of time if the
|
: isBatchAction(type)
|
||||||
//payload should be an object, primitive, or an array. However, we can still validate here based on the
|
? getApplicableBatchActions(behaviors)(payload)
|
||||||
//payload type passed.
|
: [];
|
||||||
return behaviors[type].reducer(
|
|
||||||
state,
|
if (matchedBehaviors.length) {
|
||||||
behaviors[type].validate ? applyValidation(arrayTypeDescription, payload) : payload,
|
//Call every behaviour relevant to this reducer as part of this action call,
|
||||||
|
//and merge the result (later actions take priority).
|
||||||
|
//Sanitize the payload using the reducer shape, then apply the sanitized
|
||||||
|
//payload to the state using the behavior linked to this action type.
|
||||||
|
return reduce((interimState, matchedBehavior) => behaviors[matchedBehavior.type].reducer(
|
||||||
|
interimState,
|
||||||
|
behaviors[matchedBehavior.type].validate
|
||||||
|
? applyValidation(arrayTypeDescription, matchedBehavior.payload)
|
||||||
|
: matchedBehavior.payload,
|
||||||
initialValue,
|
initialValue,
|
||||||
index
|
index,
|
||||||
);
|
), state)(matchedBehaviors);
|
||||||
|
}
|
||||||
|
|
||||||
|
//If the action type does not match any of the specified behaviors, just return the current state.
|
||||||
|
return state;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -180,18 +197,18 @@ export function applyValidation(arrayTypeDescription: ArrayStructureType, payloa
|
||||||
// to use, by checking the structure of the array.
|
// to use, by checking the structure of the array.
|
||||||
const { structure } = arrayTypeDescription();
|
const { structure } = arrayTypeDescription();
|
||||||
if (structure().type === PROP_TYPES._shape) return validateShape(structure, payload);
|
if (structure().type === PROP_TYPES._shape) return validateShape(structure, payload);
|
||||||
return validatePrimitive(structure, payload);
|
return validateValue(structure, payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
function createActions(behaviorsConfig: ArrayReducerBehaviorsConfig, locationString: string): ArrayActions {
|
function createActions(behaviorsConfig: ArrayReducerBehaviorsConfig, locationString: string): ArrayActions {
|
||||||
//Take a reducer behavior config object, and create actions using the location string
|
//Take a reducer behavior config object, and create actions using the location string
|
||||||
return reduce(behaviorsConfig, (memo, behavior, name) => ({
|
return reduce((memo, behavior, name) => ({
|
||||||
...memo,
|
...memo,
|
||||||
[name]: (payload: Array<any>, index: ?number) => ({
|
[name]: (payload: Array<any>, index: ?number) => ({
|
||||||
type: `${locationString}.${name}`,
|
type: `${locationString}.${name}`,
|
||||||
payload: (behavior.action || (payload => payload))(payload),
|
payload: (behavior.action || (payload => payload))(payload),
|
||||||
index,
|
index,
|
||||||
})
|
})
|
||||||
}), {});
|
}), {})(behaviorsConfig);
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,36 @@
|
||||||
|
//@flow
|
||||||
|
import keys from 'lodash/fp/keys';
|
||||||
|
import includes from 'lodash/fp/includes';
|
||||||
|
import filter from 'lodash/fp/filter';
|
||||||
|
|
||||||
|
export const BATCH_UPDATE = '(redux-scc-batch-action)';
|
||||||
|
|
||||||
|
|
||||||
|
type BatchUpdateInterface = {
|
||||||
|
name?: string,
|
||||||
|
actions: Array<{ type: string, payload: any, meta?: any }>,
|
||||||
|
};
|
||||||
|
type Behaviors = {
|
||||||
|
[key: string]: {
|
||||||
|
[key: string]: {
|
||||||
|
reducer: (state: mixed, payload: mixed | void, initialState: mixed) => mixed,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const batchUpdate = ({
|
||||||
|
name = '',
|
||||||
|
actions,
|
||||||
|
}: BatchUpdateInterface) => ({
|
||||||
|
type: `${ name }${ BATCH_UPDATE }`,
|
||||||
|
payload: actions,
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
export const isBatchAction = (actionType: string) => actionType
|
||||||
|
? actionType.indexOf(BATCH_UPDATE) > -1
|
||||||
|
: false;
|
||||||
|
|
||||||
|
|
||||||
|
export const getApplicableBatchActions = (behaviors: Behaviors) =>
|
||||||
|
filter(({ type }) => includes(type)(keys(behaviors)));
|
|
@ -38,13 +38,18 @@ export type ShapeReducerOptions = {
|
||||||
//==============================
|
//==============================
|
||||||
// JS imports
|
// JS imports
|
||||||
//==============================
|
//==============================
|
||||||
import reduce from 'lodash/reduce';
|
|
||||||
import isObject from 'lodash/isObject';
|
import isObject from 'lodash/isObject';
|
||||||
import omit from 'lodash/omit';
|
import omit from 'lodash/omit';
|
||||||
|
import merge from 'lodash/fp/merge';
|
||||||
import { validateShape } from '../validatePayload';
|
import { validateShape } from '../validatePayload';
|
||||||
import { createReducerBehaviors } from '../reducers';
|
import { createReducerBehaviors } from '../reducers';
|
||||||
import { PROP_TYPES } from '../structure';
|
import { PROP_TYPES } from '../structure';
|
||||||
|
import {
|
||||||
|
isBatchAction,
|
||||||
|
getApplicableBatchActions
|
||||||
|
} from './batchUpdates';
|
||||||
|
|
||||||
|
const reduce = require('lodash/fp/reduce').convert({ cap: false });
|
||||||
|
|
||||||
//==============================
|
//==============================
|
||||||
// Shape behaviors
|
// Shape behaviors
|
||||||
|
@ -92,12 +97,12 @@ export function createShapeReducer(reducerShape: StructureType, {
|
||||||
|
|
||||||
|
|
||||||
export function calculateDefaults(reducerStructure: any) {
|
export function calculateDefaults(reducerStructure: any) {
|
||||||
return reduce(omit(reducerStructure, ['_wildcardKey']), (memo, propValue, propName) => ({
|
return reduce((memo, propValue, propName) => ({
|
||||||
...memo,
|
...memo,
|
||||||
[propName]: propValue().type === PROP_TYPES._shape
|
[propName]: propValue().type === PROP_TYPES._shape
|
||||||
? calculateDefaults(propValue().structure)
|
? calculateDefaults(propValue().structure)
|
||||||
: propValue().defaultValue,
|
: propValue().defaultValue,
|
||||||
}), {});
|
}), {})(omit(reducerStructure, ['_wildcardKey']));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@ -105,28 +110,41 @@ export function createReducer(objectStructure: StructureType, behaviors: ShapeRe
|
||||||
const initialState: Object = validateShape(objectStructure, calculateDefaults(objectStructure().structure));
|
const initialState: Object = validateShape(objectStructure, calculateDefaults(objectStructure().structure));
|
||||||
return (state = initialState, { type, payload }: ShapeReducerAction) => {
|
return (state = initialState, { type, payload }: ShapeReducerAction) => {
|
||||||
//If the action type does not match any of the specified behaviors, just return the current state.
|
//If the action type does not match any of the specified behaviors, just return the current state.
|
||||||
if (!behaviors[type]) return state;
|
const matchedBehaviors = behaviors[type]
|
||||||
|
? [{ type, payload }]
|
||||||
|
: isBatchAction(type)
|
||||||
|
? getApplicableBatchActions(behaviors)(payload)
|
||||||
|
: [];
|
||||||
|
|
||||||
//Sanitize the payload using the reducer shape, then apply the sanitized
|
if (matchedBehaviors.length) {
|
||||||
//payload to the state using the behavior linked to this action type.
|
//Sanitize the payload using the reducer shape, then apply the sanitized
|
||||||
return behaviors[type].reducer(
|
//payload to the state using the behavior linked to this action type.
|
||||||
state,
|
return reduce((interimState, matchedBehavior) => merge(
|
||||||
behaviors[type].validate ? validateShape(objectStructure, payload) : payload,
|
interimState,
|
||||||
initialState
|
behaviors[matchedBehavior.type].reducer(
|
||||||
);
|
interimState,
|
||||||
|
behaviors[matchedBehavior.type].validate
|
||||||
|
? validateShape(objectStructure, matchedBehavior.payload)
|
||||||
|
: matchedBehavior.payload,
|
||||||
|
initialState
|
||||||
|
)
|
||||||
|
), state)(matchedBehaviors);
|
||||||
|
}
|
||||||
|
|
||||||
|
return state;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
function createActions(behaviorsConfig: ShapeReducerBehaviorsConfig, locationString: string): ShapeActions {
|
function createActions(behaviorsConfig: ShapeReducerBehaviorsConfig, locationString: string): ShapeActions {
|
||||||
//Take a reducer behavior config object, and create actions using the location string
|
//Take a reducer behavior config object, and create actions using the location string
|
||||||
return reduce(behaviorsConfig, (memo, behavior, name) => ({
|
return reduce((memo, behavior, name) => ({
|
||||||
...memo,
|
...memo,
|
||||||
[name]: (payload: Object) => ({
|
[name]: (payload: Object) => ({
|
||||||
type: `${locationString}.${name}`,
|
type: `${locationString}.${name}`,
|
||||||
payload: (behavior.action || (payload => payload))(payload),
|
payload: (behavior.action || (payload => payload))(payload),
|
||||||
})
|
})
|
||||||
}), {});
|
}), {})(behaviorsConfig);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -36,9 +36,14 @@ export type PrimitiveReducerOptions = {
|
||||||
//==============================
|
//==============================
|
||||||
// JS imports
|
// JS imports
|
||||||
//==============================
|
//==============================
|
||||||
import reduce from 'lodash/reduce';
|
import { validateValue } from '../validatePayload';
|
||||||
import { validatePrimitive } from '../validatePayload';
|
|
||||||
import { createReducerBehaviors } from '../reducers';
|
import { createReducerBehaviors } from '../reducers';
|
||||||
|
import {
|
||||||
|
isBatchAction,
|
||||||
|
getApplicableBatchActions
|
||||||
|
} from './batchUpdates';
|
||||||
|
|
||||||
|
const reduce = require('lodash/fp/reduce').convert({ cap: false });
|
||||||
|
|
||||||
|
|
||||||
//==============================
|
//==============================
|
||||||
|
@ -81,31 +86,43 @@ export function createPrimitiveReducer(primitiveType: PrimitiveType, {
|
||||||
|
|
||||||
function createReducer(primitiveType: PrimitiveType, behaviors: PrimitiveReducerBehaviors): PrimitiveReducer {
|
function createReducer(primitiveType: PrimitiveType, behaviors: PrimitiveReducerBehaviors): PrimitiveReducer {
|
||||||
//Calculate and validate the initial state of the reducer
|
//Calculate and validate the initial state of the reducer
|
||||||
const initialState: mixed = validatePrimitive(primitiveType, primitiveType().defaultValue);
|
const initialState: mixed = validateValue(primitiveType, primitiveType().defaultValue);
|
||||||
return (state = initialState, { type, payload }: PrimitiveReducerAction) => {
|
return (state = initialState, { type, payload }: PrimitiveReducerAction) => {
|
||||||
//If the action type does not match any of the specified behaviors, just return the current state.
|
//If the action type does not match any of the specified behaviors, just return the current state.
|
||||||
if (!behaviors[type]) return state;
|
const matchedBehaviors = behaviors[type]
|
||||||
|
? [{ type, payload }]
|
||||||
|
: isBatchAction(type)
|
||||||
|
? getApplicableBatchActions(behaviors)(payload)
|
||||||
|
: [];
|
||||||
|
|
||||||
//Sanitize the payload using the reducer shape, then apply the sanitized
|
if (matchedBehaviors.length) {
|
||||||
//payload to the state using the behavior linked to this action type.
|
//Call every behaviour relevant to this reducer as part of this action call,
|
||||||
return behaviors[type].reducer(
|
//and merge the result (later actions take priority).
|
||||||
state,
|
//Sanitize the payload using the reducer shape, then apply the sanitized
|
||||||
behaviors[type].validate ? validatePrimitive(primitiveType, payload) : payload,
|
//payload to the state using the behavior linked to this action type.
|
||||||
initialState
|
return reduce((interimState, matchedBehavior) => behaviors[matchedBehavior.type].reducer(
|
||||||
);
|
interimState,
|
||||||
|
behaviors[matchedBehavior.type].validate
|
||||||
|
? validateValue(primitiveType, matchedBehavior.payload)
|
||||||
|
: matchedBehavior.payload,
|
||||||
|
initialState
|
||||||
|
), state)(matchedBehaviors);
|
||||||
|
}
|
||||||
|
|
||||||
|
return state;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
function createActions(behaviorsConfig: PrimitiveReducerBehaviorsConfig, locationString: string): PrimitiveActions {
|
function createActions(behaviorsConfig: PrimitiveReducerBehaviorsConfig, locationString: string): PrimitiveActions {
|
||||||
//Take a reducer behavior config object, and create actions using the location string
|
//Take a reducer behavior config object, and create actions using the location string
|
||||||
return reduce(behaviorsConfig, (memo, behavior, name) => ({
|
return reduce((memo, behavior, name) => ({
|
||||||
...memo,
|
...memo,
|
||||||
[name]: (payload: mixed) => ({
|
[name]: (payload: mixed) => ({
|
||||||
type: `${locationString}.${name}`,
|
type: `${locationString}.${name}`,
|
||||||
payload: (behavior.action || (payload => payload))(payload),
|
payload: (behavior.action || (payload => payload))(payload),
|
||||||
})
|
})
|
||||||
}), {});
|
}), {})(behaviorsConfig);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -44,6 +44,7 @@ export const PROP_TYPES = {
|
||||||
_array: '_array',
|
_array: '_array',
|
||||||
_any: '_any',
|
_any: '_any',
|
||||||
_wildcardKey: '_wildcardKey',
|
_wildcardKey: '_wildcardKey',
|
||||||
|
_custom: '_custom',
|
||||||
};
|
};
|
||||||
|
|
||||||
//The types objects are used in order to build up the structure of a store chunk, and provide/accept
|
//The types objects are used in order to build up the structure of a store chunk, and provide/accept
|
||||||
|
@ -82,5 +83,17 @@ export const Types: TypesObject = {
|
||||||
type: PROP_TYPES._shape,
|
type: PROP_TYPES._shape,
|
||||||
structure,
|
structure,
|
||||||
}),
|
}),
|
||||||
|
custom: ({
|
||||||
|
validator = () => true,
|
||||||
|
validationErrorMessage = (value: any) => `${ value } failed custom type validation`,
|
||||||
|
}: {
|
||||||
|
validator: (value: any) => boolean,
|
||||||
|
validationErrorMessage: (value: any) => string,
|
||||||
|
} = {}) => (defaultValue: any) => () => ({
|
||||||
|
type: PROP_TYPES._custom,
|
||||||
|
defaultValue,
|
||||||
|
validator,
|
||||||
|
validationErrorMessage,
|
||||||
|
}),
|
||||||
wildcardKey: () => PROP_TYPES._wildcardKey,
|
wildcardKey: () => PROP_TYPES._wildcardKey,
|
||||||
};
|
};
|
||||||
|
|
|
@ -60,10 +60,17 @@ export function validateShape(objectStructure: any, value: mixed): Object {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export function validatePrimitive(primitive: any, value: any): mixed {
|
export function validateValue(primitive: any, value: any): mixed {
|
||||||
//Validate primitives using the typeofValue property of the primitive type definitions.
|
const evaluatedPrimitive = primitive();
|
||||||
if (typeof value === primitive().typeofValue || primitive().typeofValue === 'any') return value;
|
//If this value is a custom value, then we should apply it's custom validator!
|
||||||
return console.warn(`The value, ${value}, did not match the type specified (${primitive().type}).`);
|
if (evaluatedPrimitive.type === PROP_TYPES._custom) {
|
||||||
|
if (evaluatedPrimitive.validator(value)) return value;
|
||||||
|
return console.warn(evaluatedPrimitive.validationErrorMessage(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
//Otherwise we will use the standard, basic, typeof checks.
|
||||||
|
if (typeof value === evaluatedPrimitive.typeofValue || evaluatedPrimitive.typeofValue === 'any') return value;
|
||||||
|
return console.warn(`The value, ${value}, did not match the type specified (${evaluatedPrimitive.type}).`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@ -82,12 +89,13 @@ export function validateArray(arrayStructure: any, value: Array<any>): Array<mix
|
||||||
|
|
||||||
export function getTypeValidation(type: string): validationFunction {
|
export function getTypeValidation(type: string): validationFunction {
|
||||||
const TYPE_VALIDATIONS = {
|
const TYPE_VALIDATIONS = {
|
||||||
[PROP_TYPES._string]: validatePrimitive,
|
[PROP_TYPES._string]: validateValue,
|
||||||
[PROP_TYPES._number]: validatePrimitive,
|
[PROP_TYPES._number]: validateValue,
|
||||||
[PROP_TYPES._boolean]: validatePrimitive,
|
[PROP_TYPES._boolean]: validateValue,
|
||||||
[PROP_TYPES._array]: validateArray,
|
[PROP_TYPES._array]: validateArray,
|
||||||
[PROP_TYPES._shape]: validateShape,
|
[PROP_TYPES._shape]: validateShape,
|
||||||
[PROP_TYPES._any]: validatePrimitive,
|
[PROP_TYPES._any]: validateValue,
|
||||||
|
[PROP_TYPES._custom]: validateValue,
|
||||||
};
|
};
|
||||||
const typeValidation = TYPE_VALIDATIONS[type];
|
const typeValidation = TYPE_VALIDATIONS[type];
|
||||||
if (!typeValidation) {
|
if (!typeValidation) {
|
||||||
|
|
Loading…
Reference in New Issue