Repository: erikras/redux-form
Pull request #518 (2016-01-07 21:41:41)

added swapFields() to deep forms
I added a SWAP_ARRAY_VALUES action, reducer and tests.
Array fields can be modified by calling 
```javascript
swapFields(indexA, indexB)
```
This gets passed down as a prop just like addField() and removeField().
Hope you like it!





Patch:
diff --git a/src/actionTypes.js b/src/actionTypes.js
index 7758b829..147e8eba 100644
--- a/src/actionTypes.js
+++ b/src/actionTypes.js
@@ -11,5 +11,6 @@ export const START_SUBMIT = 'redux-form/START_SUBMIT';
 export const STOP_ASYNC_VALIDATION = 'redux-form/STOP_ASYNC_VALIDATION';
 export const STOP_SUBMIT = 'redux-form/STOP_SUBMIT';
 export const SUBMIT_FAILED = 'redux-form/SUBMIT_FAILED';
+export const SWAP_ARRAY_VALUES = 'redux-form/SWAP_ARRAY_VALUES';
 export const TOUCH = 'redux-form/TOUCH';
 export const UNTOUCH = 'redux-form/UNTOUCH';
diff --git a/src/actions.js b/src/actions.js
index 10af1798..e85c60d0 100644
--- a/src/actions.js
+++ b/src/actions.js
@@ -1,5 +1,5 @@
 import { ADD_ARRAY_VALUE, BLUR, CHANGE, DESTROY, FOCUS, INITIALIZE, REMOVE_ARRAY_VALUE, RESET, START_ASYNC_VALIDATION,
-  START_SUBMIT, STOP_ASYNC_VALIDATION, STOP_SUBMIT, SUBMIT_FAILED, TOUCH, UNTOUCH } from './actionTypes';
+  START_SUBMIT, STOP_ASYNC_VALIDATION, STOP_SUBMIT, SUBMIT_FAILED, SWAP_ARRAY_VALUES, TOUCH, UNTOUCH } from './actionTypes';
 
 export const addArrayValue = (path, value, index) =>
   ({type: ADD_ARRAY_VALUE, path, value, index});
@@ -44,6 +44,9 @@ export const stopSubmit = errors =>
 export const submitFailed = () =>
   ({type: SUBMIT_FAILED});
 
+export const swapArrayValues = (path, indexA, indexB) =>
+  ({type: SWAP_ARRAY_VALUES, path, indexA, indexB});
+
 export const touch = (...fields) =>
   ({type: TOUCH, fields});
 
diff --git a/src/createHigherOrderComponent.js b/src/createHigherOrderComponent.js
index 579d73ec..0f72571b 100644
--- a/src/createHigherOrderComponent.js
+++ b/src/createHigherOrderComponent.js
@@ -95,7 +95,7 @@ const createHigherOrderComponent = (config,
         const allFields = this.fields;
         const {addArrayValue, asyncBlurFields, blur, change, destroy, focus, fields, form, initialValues, initialize,
           onSubmit, propNamespace, reset, removeArrayValue, returnRejectedSubmitPromise, startAsyncValidation,
-          startSubmit, stopAsyncValidation, stopSubmit, submitFailed, touch, untouch, validate,
+          startSubmit, stopAsyncValidation, stopSubmit, submitFailed, swapArrayValues, touch, untouch, validate,
           ...passableProps} = this.props; // eslint-disable-line no-redeclare
         const {allPristine, allValid, errors, formError, values} = allFields._meta;
 
@@ -164,6 +164,7 @@ const createHigherOrderComponent = (config,
       stopAsyncValidation: PropTypes.func.isRequired,
       stopSubmit: PropTypes.func.isRequired,
       submitFailed: PropTypes.func.isRequired,
+      swapArrayValues: PropTypes.func.isRequired,
       touch: PropTypes.func.isRequired,
       untouch: PropTypes.func.isRequired
     };
diff --git a/src/readField.js b/src/readField.js
index 955369dc..f47eedab 100644
--- a/src/readField.js
+++ b/src/readField.js
@@ -9,7 +9,7 @@ import updateField from './updateField';
 
 const readField = (state, fieldName, pathToHere = '', fields, syncErrors, asyncValidate, isReactNative, props, callback = () => null) => {
   const {asyncBlurFields, blur, change, focus, form, initialValues, readonly, addArrayValue,
-    removeArrayValue} = props;
+    removeArrayValue, swapArrayValues} = props;
   const dotIndex = fieldName.indexOf('.');
   const openIndex = fieldName.indexOf('[');
   const closeIndex = fieldName.indexOf(']');
@@ -32,6 +32,9 @@ const readField = (state, fieldName, pathToHere = '', fields, syncErrors, asyncV
       Object.defineProperty(fields[key], 'removeField', {
         value: index => removeArrayValue(pathToHere + key, index)
       });
+      Object.defineProperty(fields[key], 'swapFields', {
+        value: (indexA, indexB) => swapArrayValues(pathToHere + key, indexA, indexB)
+      });
     }
     const fieldArray = fields[key];
     stateArray.forEach((fieldState, index) => {
diff --git a/src/reducer.js b/src/reducer.js
index df18c549..e205fccd 100644
--- a/src/reducer.js
+++ b/src/reducer.js
@@ -1,5 +1,5 @@
 import { ADD_ARRAY_VALUE, BLUR, CHANGE, DESTROY, FOCUS, INITIALIZE, REMOVE_ARRAY_VALUE, RESET, START_ASYNC_VALIDATION,
-  START_SUBMIT, STOP_ASYNC_VALIDATION, STOP_SUBMIT, SUBMIT_FAILED, TOUCH, UNTOUCH } from './actionTypes';
+  START_SUBMIT, STOP_ASYNC_VALIDATION, STOP_SUBMIT, SUBMIT_FAILED, SWAP_ARRAY_VALUES, TOUCH, UNTOUCH } from './actionTypes';
 import mapValues from './mapValues';
 import read from './read';
 import write from './write';
@@ -133,6 +133,18 @@ const behaviors = {
       _submitFailed: true
     };
   },
+  [SWAP_ARRAY_VALUES](state, {path, indexA, indexB}) {
+    const array = read(path, state);
+    const arrayLength = array.length;
+    if (indexA === indexB || isNaN(indexA) || isNaN(indexB) || indexA >= arrayLength || indexB >= arrayLength ) {
+      return state; // do nothing
+    }
+    const stateCopy = {...state};
+    const arrayCopy = [...array];
+    arrayCopy[indexA] = array[indexB];
+    arrayCopy[indexB] = array[indexA];
+    return write(path, arrayCopy, stateCopy);
+  },
   [TOUCH](state, {fields}) {
     return {
       ...state,
diff --git a/src/wrapMapDispatchToProps.js b/src/wrapMapDispatchToProps.js
index 111fe774..5a7e0919 100644
--- a/src/wrapMapDispatchToProps.js
+++ b/src/wrapMapDispatchToProps.js
@@ -5,26 +5,26 @@ const wrapMapDispatchToProps = (mapDispatchToProps, actionCreators) => {
     if (typeof mapDispatchToProps === 'function') {
       if (mapDispatchToProps.length > 1) {
         return (dispatch, ownProps) => ({
-          dispatch,
           ...mapDispatchToProps(dispatch, ownProps),
-          ...bindActionCreators(actionCreators, dispatch)
+          ...bindActionCreators(actionCreators, dispatch),
+          dispatch
         });
       }
       return dispatch => ({
-        dispatch,
         ...mapDispatchToProps(dispatch),
-        ...bindActionCreators(actionCreators, dispatch)
+        ...bindActionCreators(actionCreators, dispatch),
+        dispatch
       });
     }
     return dispatch => ({
-      dispatch,
       ...bindActionCreators(mapDispatchToProps, dispatch),
-      ...bindActionCreators(actionCreators, dispatch)
+      ...bindActionCreators(actionCreators, dispatch),
+      dispatch
     });
   }
   return dispatch => ({
-    dispatch,
-    ...bindActionCreators(actionCreators, dispatch)
+    ...bindActionCreators(actionCreators, dispatch),
+    dispatch
   });
 };
 


Test patch:
diff --git a/src/__tests__/actions.spec.js b/src/__tests__/actions.spec.js
index c770008c..1b7e6baa 100644
--- a/src/__tests__/actions.spec.js
+++ b/src/__tests__/actions.spec.js
@@ -1,8 +1,8 @@
 import expect from 'expect';
 import { ADD_ARRAY_VALUE, BLUR, CHANGE, FOCUS, INITIALIZE, REMOVE_ARRAY_VALUE, RESET, START_ASYNC_VALIDATION,
-  START_SUBMIT, STOP_ASYNC_VALIDATION, STOP_SUBMIT, TOUCH, UNTOUCH, DESTROY } from '../actionTypes';
+  START_SUBMIT, STOP_ASYNC_VALIDATION, STOP_SUBMIT, SWAP_ARRAY_VALUES, TOUCH, UNTOUCH, DESTROY } from '../actionTypes';
 import {addArrayValue, blur, change, destroy, focus, initialize, removeArrayValue, reset, startAsyncValidation, startSubmit,
-  stopAsyncValidation, stopSubmit, touch, untouch} from '../actions';
+  stopAsyncValidation, stopSubmit, swapArrayValues, touch, untouch} from '../actions';
 
 describe('actions', () => {
   it('should create add array value action', () => {
@@ -129,6 +129,27 @@ describe('actions', () => {
     });
   });
 
+  it('should create swap array value action', () => {
+    expect(swapArrayValues('foo', 3, 6)).toEqual({
+      type: SWAP_ARRAY_VALUES,
+      path: 'foo',
+      indexA: 3,
+      indexB: 6
+    });
+    expect(swapArrayValues('foo', 3)).toEqual({
+      type: SWAP_ARRAY_VALUES,
+      path: 'foo',
+      indexA: 3,
+      indexB: undefined
+    });
+    expect(swapArrayValues('bar.baz')).toEqual({
+      type: SWAP_ARRAY_VALUES,
+      path: 'bar.baz',
+      indexA: undefined,
+      indexB: undefined
+    });
+  });
+
   it('should create touch action', () => {
     expect(touch('foo', 'bar')).toEqual({
       type: TOUCH,
diff --git a/src/__tests__/reducer.spec.js b/src/__tests__/reducer.spec.js
index 99bae4d2..b3f76bc0 100644
--- a/src/__tests__/reducer.spec.js
+++ b/src/__tests__/reducer.spec.js
@@ -2,7 +2,7 @@ import expect from 'expect';
 import reducer, {globalErrorKey} from '../reducer';
 import bindActionData from '../bindActionData';
 import {addArrayValue, blur, change, focus, initialize, removeArrayValue, reset, startAsyncValidation, startSubmit,
-  stopAsyncValidation, stopSubmit, touch, untouch, destroy} from '../actions';
+  stopAsyncValidation, stopSubmit, swapArrayValues, touch, untouch, destroy} from '../actions';
 import {isFieldValue, makeFieldValue} from '../fieldValue';
 
 describe('reducer', () => {
@@ -211,121 +211,121 @@ describe('reducer', () => {
 
   // TODO: Find a way to make this pass:
   /*
-   it('should push an array value which is a deep object', () => {
-   const state = reducer({
-   testForm: {
-   friends: [
-   {
-   name: {
-   initial: 'name-1',
-   value: 'name-1'
-   },
-   address: {
-   street: {
-   initial: 'street-1',
-   value: 'street-1'
-   },
-   postalCode: {
-   initial: 'postalCode-1',
-   value: 'postalCode-1'
-   }
-   }
-   },
-   {
-   name: {
-   initial: 'name-2',
-   value: 'name-2'
-   },
-   address: {
-   street: {
-   initial: 'street-2',
-   value: 'street-2'
-   },
-   postalCode: {
-   initial: 'postalCode-2',
-   value: 'postalCode-2'
-   }
-   }
-   }
-   ],
-   _active: undefined,
-   _asyncValidating: false,
-   _error: undefined,
-   _initialized: false,
-   _submitting: false,
-   _submitFailed: false
-   }
-   }, {
-   ...addArrayValue('friends', {
-   name: 'name-3',
-   address: {
-   street: 'street-3',
-   postalCode: 'postalCode-3'
-   }
-   }, undefined),
-   form: 'testForm'
-   });
-   expect(state.testForm)
-   .toEqual({
-   friends: [
-   {
-   name: {
-   initial: 'name-1',
-   value: 'name-1'
-   },
-   address: {
-   street: {
-   initial: 'street-1',
-   value: 'street-1'
-   },
-   postalCode: {
-   initial: 'postalCode-1',
-   value: 'postalCode-1'
-   }
-   }
-   },
-   {
-   name: {
-   initial: 'name-2',
-   value: 'name-2'
-   },
-   address: {
-   street: {
-   initial: 'street-2',
-   value: 'street-2'
-   },
-   postalCode: {
-   initial: 'postalCode-2',
-   value: 'postalCode-2'
-   }
-   }
-   },
-   {
-   name: {
-   initial: 'name-3',
-   value: 'name-3'
-   },
-   address: {
-   street: {
-   initial: 'street-3',
-   value: 'street-3'
-   },
-   postalCode: {
-   initial: 'postalCode-3',
-   value: 'postalCode-3'
-   }
-   }
-   }
-   ],
-   _active: undefined,
-   _asyncValidating: false,
-   _error: undefined,
-   _initialized: false,
-   _submitting: false,
-   _submitFailed: false
-   });
-   });
-   */
+  it('should push an array value which is a deep object', () => {
+    const state = reducer({
+      testForm: {
+        friends: [
+          {
+            name: {
+              initial: 'name-1',
+              value: 'name-1'
+            },
+            address: {
+              street: {
+                initial: 'street-1',
+                value: 'street-1'
+              },
+              postalCode: {
+                initial: 'postalCode-1',
+                value: 'postalCode-1'
+              }
+            }
+          },
+          {
+            name: {
+              initial: 'name-2',
+              value: 'name-2'
+            },
+            address: {
+              street: {
+                initial: 'street-2',
+                value: 'street-2'
+              },
+              postalCode: {
+                initial: 'postalCode-2',
+                value: 'postalCode-2'
+              }
+            }
+          }
+        ],
+        _active: undefined,
+        _asyncValidating: false,
+        _error: undefined,
+        _initialized: false,
+        _submitting: false,
+        _submitFailed: false
+      }
+    }, {
+      ...addArrayValue('friends', {
+        name: 'name-3',
+        address: {
+          street: 'street-3',
+          postalCode: 'postalCode-3'
+        }
+      }, undefined),
+      form: 'testForm'
+    });
+    expect(state.testForm)
+      .toEqual({
+        friends: [
+          {
+            name: {
+              initial: 'name-1',
+              value: 'name-1'
+            },
+            address: {
+              street: {
+                initial: 'street-1',
+                value: 'street-1'
+              },
+              postalCode: {
+                initial: 'postalCode-1',
+                value: 'postalCode-1'
+              }
+            }
+          },
+          {
+            name: {
+              initial: 'name-2',
+              value: 'name-2'
+            },
+            address: {
+              street: {
+                initial: 'street-2',
+                value: 'street-2'
+              },
+              postalCode: {
+                initial: 'postalCode-2',
+                value: 'postalCode-2'
+              }
+            }
+          },
+          {
+            name: {
+              initial: 'name-3',
+              value: 'name-3'
+            },
+            address: {
+              street: {
+                initial: 'street-3',
+                value: 'street-3'
+              },
+              postalCode: {
+                initial: 'postalCode-3',
+                value: 'postalCode-3'
+              }
+            }
+          }
+        ],
+        _active: undefined,
+        _asyncValidating: false,
+        _error: undefined,
+        _initialized: false,
+        _submitting: false,
+        _submitFailed: false
+      });
+  });
+  */
 
   it('should push a deep array value which is a nested object', () => {
     const state = reducer({
@@ -1350,6 +1350,189 @@ describe('reducer', () => {
     expect(isFieldValue(state.testForm.myField[1])).toBe(true);
   });
 
+  it('should not change empty array value on swap', () => {
+    const state = reducer({
+      testForm: {
+        myField: [],
+        _active: undefined,
+        _asyncValidating: false,
+        [globalErrorKey]: undefined,
+        _initialized: false,
+        _submitting: false,
+        _submitFailed: false
+      }
+    }, {
+      ...swapArrayValues('myField'),
+      form: 'testForm'
+    });
+    expect(state.testForm)
+        .toEqual({
+          myField: [],
+          _active: undefined,
+          _asyncValidating: false,
+          [globalErrorKey]: undefined,
+          _initialized: false,
+          _submitting: false,
+          _submitFailed: false
+        });
+  });
+
+  it('should should swap two array values at different indexes', () => {
+    const state = reducer({
+      testForm: {
+        myField: [
+          makeFieldValue({
+            value: 'foo'
+          }),
+          makeFieldValue({
+            value: 'bar'
+          }),
+          makeFieldValue({
+            value: 'baz'
+          })
+        ],
+        _active: undefined,
+        _asyncValidating: false,
+        [globalErrorKey]: undefined,
+        _initialized: false,
+        _submitting: false,
+        _submitFailed: false
+      }
+    }, {
+      ...swapArrayValues('myField', 0, 2),
+      form: 'testForm'
+    });
+    expect(state.testForm)
+        .toEqual({
+          myField: [
+            {
+              value: 'baz'
+            },
+            {
+              value: 'bar'
+            },
+            {
+              value: 'foo'
+            }
+          ],
+          _active: undefined,
+          _asyncValidating: false,
+          [globalErrorKey]: undefined,
+          _initialized: false,
+          _submitting: false,
+          _submitFailed: false
+        });
+    expect(isFieldValue(state.testForm.myField)).toBe(false);
+    expect(isFieldValue(state.testForm.myField[0])).toBe(true);
+    expect(isFieldValue(state.testForm.myField[1])).toBe(true);
+    expect(isFieldValue(state.testForm.myField[2])).toBe(true);
+
+  });
+
+  it('should not change array on swap with the same index', () => {
+    const state = reducer({
+      testForm: {
+        myField: [
+          makeFieldValue({
+            value: 'foo'
+          }),
+          makeFieldValue({
+            value: 'bar'
+          }),
+          makeFieldValue({
+            value: 'baz'
+          })
+        ],
+        _active: undefined,
+        _asyncValidating: false,
+        [globalErrorKey]: undefined,
+        _initialized: false,
+        _submitting: false,
+        _submitFailed: false
+      }
+    }, {
+      ...swapArrayValues('myField', 1, 1),
+      form: 'testForm'
+    });
+    expect(state.testForm)
+        .toEqual({
+          myField: [
+            {
+              value: 'foo'
+            },
+            {
+              value: 'bar'
+            },
+            {
+              value: 'baz'
+            }
+          ],
+          _active: undefined,
+          _asyncValidating: false,
+          [globalErrorKey]: undefined,
+          _initialized: false,
+          _submitting: false,
+          _submitFailed: false
+        });
+    expect(isFieldValue(state.testForm.myField)).toBe(false);
+    expect(isFieldValue(state.testForm.myField[0])).toBe(true);
+    expect(isFieldValue(state.testForm.myField[1])).toBe(true);
+    expect(isFieldValue(state.testForm.myField[2])).toBe(true);
+
+  });
+
+  it('should not change array on swap with out of bounds index', () => {
+    const state = reducer({
+      testForm: {
+        myField: [
+          makeFieldValue({
+            value: 'foo'
+          }),
+          makeFieldValue({
+            value: 'bar'
+          }),
+          makeFieldValue({
+            value: 'baz'
+          })
+        ],
+        _active: undefined,
+        _asyncValidating: false,
+        [globalErrorKey]: undefined,
+        _initialized: false,
+        _submitting: false,
+        _submitFailed: false
+      }
+    }, {
+      ...swapArrayValues('myField', 1, 4),
+      form: 'testForm'
+    });
+    expect(state.testForm)
+        .toEqual({
+          myField: [
+            {
+              value: 'foo'
+            },
+            {
+              value: 'bar'
+            },
+            {
+              value: 'baz'
+            }
+          ],
+          _active: undefined,
+          _asyncValidating: false,
+          [globalErrorKey]: undefined,
+          _initialized: false,
+          _submitting: false,
+          _submitFailed: false
+        });
+    expect(isFieldValue(state.testForm.myField)).toBe(false);
+    expect(isFieldValue(state.testForm.myField[0])).toBe(true);
+    expect(isFieldValue(state.testForm.myField[1])).toBe(true);
+    expect(isFieldValue(state.testForm.myField[2])).toBe(true);
+
+  });
+
   it('should reset values on reset on with previous state', () => {
     const state = reducer({
       foo: {
@@ -2785,7 +2968,6 @@ describe('reducer.normalize', () => {
         }
       });
   });
-
   it('should normalize keyed forms depending on action form key', () => {
     const defaultFields = {
       _active: undefined,
@@ -2843,39 +3025,4 @@ describe('reducer.normalize', () => {
         }
       });
   });
-
-  it('should normalize simple form values', () => {
-    const defaultFields = {
-      _active: undefined,
-      _asyncValidating: false,
-      [globalErrorKey]: undefined,
-      _initialized: false,
-      _submitting: false,
-      _submitFailed: false,
-    };
-    const normalize = reducer.normalize({
-      foo: {
-        name: () => 'normalized'
-      }
-    });
-    const state = normalize({
-      foo: {
-        name: {
-          value: 'dog'
-        }
-      }
-    });
-    expect(state)
-      .toExist()
-      .toBeA('object');
-    expect(state.foo)
-      .toExist()
-      .toBeA('object')
-      .toEqual({
-        ...defaultFields,
-        name: {
-          value: 'normalized'
-        }
-      });
-  });
 });