Repository: erikras/redux-form
Pull request #637 (2016-02-11 18:03:40)

Normalize nested fields
This is my first hacky stab at fixing #516 after I realized I needed this functionality yesterday :)

The code has some repetitiveness and isn't the cleanest at the moment. All current tests pass, and I added some minimal nested fields to existing tests to ensure they work. Regardless, we might want some more tests. I also made sure to replicate the work done in #562 recently to preserve non-enumerable keys, but we might want tests for that as well.

I took the approach of nested normalizers instead of using the nested key sytax (e.g. `foo[].bar`). I found it easier to walk down the state and normalizer objects at the same time with this approach. Using the nested key syntax was a little more difficult.

For an idea of the syntax here is a test I changed:

```js
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',
      person: {
        name: (name) => name && name.toUpperCase()
      },
      'pets[]': {
        name: (name) => name && name.toLowerCase()
      }
    }
  });
  const state = normalize({
    foo: {
      name: {
        value: 'dog'
      },
      person: {
        name: {
          value: 'John Doe',
        }
      },
      pets: [
        { name: { value: 'Fido' } },
        { name: { value: 'Tucker' } }
      ]
    }
  });
  expect(state)
    .toExist()
    .toBeA('object');
  expect(state.foo)
    .toExist()
    .toBeA('object')
    .toEqual({
      ...defaultFields,
      name: {
        value: 'normalized'
      },
      person: {
        name: {
          value: 'JOHN DOE'
        }
      },
      pets: [
        { name: { value: 'fido' } },
        { name: { value: 'tucker' } }
      ]
    });
});
```

One thing I'm still figuring out is how to handle this array situation:

```js
const normalize = reducer.normalize({
  // Array contains string field values
  'hobbies[]': (value) => value && value.toUpperCase()

  // Or array IS the field value
  'hobbies[]': (array) => array && array.map(hobby => hobby.toUpperCase())

  // Or array contains nested fields
  'people[]': (person) => {
    if (person) {
      return { name: person.name.toUpperCase() };
    }
    return { name: '' };
  }
});
```

Basically, if you don't specify nested properties, do you assume the array is a field value or that it still contains nested field values? What happens if the nested values have nested properties of their own? What would you expect to be the use case for this syntax? Or don't support it? If those questions don't make sense, let me know. Right now, I just have it assume that the array itself is the field value if you don't specify nested properties.

Please let me know if this is what you would like to see for normalizing nested fields and what I need to modify with this implementation.

jfairbank: Woops. I forgot I temporarily disabled linting when I was testing. Let me fix that :disappointed:
erikras: I think I vote for
```js
'hobbies[]': (array) => array && array.map(hobby => hobby.toUpperCase())
```
It's not hard to wrap your normalizing function in an `array.map()`. And this also allows you to do stuff like limiting array length or sorting or something.

How about this format? So that the keys match up exactly with the `fields` array syntax.
```js
'people[].name': name => name.toUpperCase()
```
jfairbank: > I think I vote for
> 
> `'hobbies[]': (array) => array && array.map(hobby => hobby.toUpperCase())`

That works for me. Seems least surprising.

> How about this format? So that the keys match up exactly with the fields array syntax.
> 
> `'people[].name': name => name.toUpperCase()`

Last night when I started fiddling with this, that syntax was not as easy to work with, but it may have been I was more tired at the end of my day ;)

The nested syntax which I did implement made it easier for walking down nested state even for arrays:

```js
normalize({
  foo: {
    'people[]': {
      name: (name) => name && name.toUpperCase()
    }
  }
});
```

This doesn't mean the fields syntax `'people[].name'` isn't doable, but I was also worried if I would have to walk the normalizer object more times. For example, if you wanted to specify `'people[].firstName'` and `'people[].lastName'`, then you would re-walk the state from the top. With the nested `'people[]': { firstName: ..., lastName: ... }`, you keep the context of where you are at as you walk down the state. If I'm not explaining that well, let me know.

That being said, I'd be happy to use what I've got to build out support for fields syntax (i.e. `'people[].name'`) because I like that mapping to the original fields syntax better too.
erikras: There a [many examples](https://github.com/erikras/redux-form/search?utf8=✓&q=openIndex) of tree crawling via the field string. Make sure you test all the possibilities, because, while it's not complicated, it's easy to make mistakes.
jfairbank: You're right, walking the tree wasn't so bad :sweat_smile:. My mind must've been exhausted or going down the wrong path the other night. I've switched the function over to support the field syntax (e.g. `'people[].name'`) instead. Here is the one test rewritten with the new syntax:

```js
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',
      'person.name': (name) => name && name.toUpperCase(),
      'pets[].name': (name) => name && name.toLowerCase()
    }
  });
  const state = normalize({
    foo: {
      name: {
        value: 'dog'
      },
      person: {
        name: {
          value: 'John Doe',
        }
      },
      pets: [
        { name: { value: 'Fido' } },
        { name: { value: 'Tucker' } }
      ]
    }
  });
  expect(state)
    .toExist()
    .toBeA('object');
  expect(state.foo)
    .toExist()
    .toBeA('object')
    .toEqual({
      ...defaultFields,
      name: {
        value: 'normalized'
      },
      person: {
        name: {
          value: 'JOHN DOE'
        }
      },
      pets: [
        { name: { value: 'fido' } },
        { name: { value: 'tucker' } }
      ]
    });
});
```

I also added a new test for arbitrarily deeply nested fields, one with and one without arrays. Are there other types of tests you would like to see?

(FYI, I'm piling commits on until we get to a satisfactory merge state and then I'll squash.)
erikras: Looks pretty good to me! :+1: :shipit: 

Patch:
diff --git a/src/normalizeFields.js b/src/normalizeFields.js
new file mode 100644
index 00000000..4972a9aa
--- /dev/null
+++ b/src/normalizeFields.js
@@ -0,0 +1,103 @@
+import {makeFieldValue} from './fieldValue';
+
+function extractKey(field) {
+  const dotIndex = field.indexOf('.');
+  const openIndex = field.indexOf('[');
+  const closeIndex = field.indexOf(']');
+
+  if (openIndex > 0 && closeIndex !== openIndex + 1) {
+    throw new Error('found [ not followed by ]');
+  }
+
+  const isArray = openIndex > 0 && (dotIndex < 0 || openIndex < dotIndex);
+  let key;
+  let nestedPath;
+
+  if (isArray) {
+    key = field.substring(0, openIndex);
+    nestedPath = field.substring(closeIndex + 1);
+
+    if (nestedPath[0] === '.') {
+      nestedPath = nestedPath.substring(1);
+    }
+  } else if (dotIndex > 0) {
+    key = field.substring(0, dotIndex);
+    nestedPath = field.substring(dotIndex + 1);
+  } else {
+    key = field;
+  }
+
+  return { isArray, key, nestedPath };
+}
+
+function normalizeField(field, fullFieldPath, state, previousState, values, previousValues, normalizers) {
+  if (field.isArray && field.nestedPath) {
+    const array = state && state[field.key] || [];
+    const previousArray = previousState && previousState[field.key] || [];
+    const nestedField = extractKey(field.nestedPath);
+
+    return array.map((nestedState, i) => {
+      nestedState[nestedField.key] = normalizeField(
+        nestedField,
+        fullFieldPath,
+        nestedState,
+        previousArray[i],
+        values,
+        previousValues,
+        normalizers
+      );
+
+      return nestedState;
+    });
+  } else if (field.nestedPath) {
+    const nestedState = state && state[field.key] || {};
+    const nestedField = extractKey(field.nestedPath);
+
+    nestedState[nestedField.key] = normalizeField(
+      nestedField,
+      fullFieldPath,
+      nestedState,
+      previousState && previousState[field.key],
+      values,
+      previousValues,
+      normalizers
+    );
+
+    return nestedState;
+  }
+
+  const finalField = state && state[field.key] || {};
+  const normalizer = normalizers[fullFieldPath];
+
+  finalField.value = normalizer(
+    finalField.value,
+    previousState && previousState[field.key] && previousState[field.key].value,
+    values,
+    previousValues
+  );
+
+  return makeFieldValue(finalField);
+}
+
+export default function normalizeFields(normalizers, state, previousState, values, previousValues) {
+  const newState = Object.keys(normalizers).reduce((accumulator, field) => {
+    const extracted = extractKey(field);
+
+    accumulator[extracted.key] = normalizeField(
+      extracted,
+      field,
+      state,
+      previousState,
+      values,
+      previousValues,
+      normalizers
+    );
+
+    return accumulator;
+  }, {});
+
+  return {
+    ...state,
+    ...newState
+  };
+}
diff --git a/src/reducer.js b/src/reducer.js
index 650f02c6..f4cfe6d6 100644
--- a/src/reducer.js
+++ b/src/reducer.js
@@ -8,6 +8,7 @@ import initializeState from './initializeState';
 import resetState from './resetState';
 import setErrors from './setErrors';
 import {makeFieldValue} from './fieldValue';
+import normalizeFields from './normalizeFields';
 
 export const globalErrorKey = '_error';
 
@@ -237,18 +238,8 @@ function decorate(target) {
               ...initialState,
               ...currentResult
             };
-            return {
-              ...formResult,
-              ...mapValues(formNormalizers, (fieldNormalizer, field) => {
-                const newValue = makeFieldValue(fieldNormalizer(
-                  formResult[field] ? formResult[field].value : undefined,         // value
-                  previous && previous[field] ? previous[field].value : undefined, // previous value
-                  getValuesFromState(formResult),                                  // all field values
-                  previousValues));                                                // all previous field values
-
-                return Object.assign(formResult[field] || {}, { value: newValue });
-              })
-            };
+            const values = getValuesFromState(formResult);
+            return normalizeFields(formNormalizers, formResult, previous, values, previousValues);
           };
           if (action.key) {
             return {


Test patch:
diff --git a/src/__tests__/reducer.spec.js b/src/__tests__/reducer.spec.js
index 88e7fae1..d6bc3343 100644
--- a/src/__tests__/reducer.spec.js
+++ b/src/__tests__/reducer.spec.js
@@ -2945,7 +2945,9 @@ describe('reducer', () => {
     it('should initialize form state when there is a normalizer', () => {
       const state = reducer.normalize({
         foo: {
-          myField: () => 'normalized'
+          'myField': () => 'normalized',
+          'person.name': () => 'John Doe',
+          'pets[].name': () => 'Fido'
         }
       })();
       expect(state)
@@ -2964,7 +2966,13 @@ describe('reducer', () => {
           _submitFailed: false,
           myField: {
             value: 'normalized'
-          }
+          },
+          person: {
+            name: {
+              value: 'John Doe'
+            }
+          },
+          pets: []
         });
     });
 
@@ -2979,7 +2987,9 @@ describe('reducer', () => {
       };
       const normalize = reducer.normalize({
         foo: {
-          myField: () => 'normalized'
+          'myField': () => 'normalized',
+          'person.name': () => 'John Doe',
+          'pets[].name': () => 'Fido'
         }
       });
       const state = normalize({
@@ -3006,7 +3016,13 @@ describe('reducer', () => {
             ...defaultFields,
             myField: {
               value: 'normalized'
-            }
+            },
+            person: {
+              name: {
+                value: 'John Doe'
+              }
+            },
+            pets: []
           }
         });
       expect(nextState.foo)
@@ -3015,13 +3031,25 @@ describe('reducer', () => {
             ...defaultFields,
             myField: {
               value: 'normalized'
-            }
+            },
+            person: {
+              name: {
+                value: 'John Doe'
+              }
+            },
+            pets: []
           },
           secondSubForm: {
             ...defaultFields,
             myField: {
               value: 'normalized'
-            }
+            },
+            person: {
+              name: {
+                value: 'John Doe'
+              }
+            },
+            pets: []
           }
         });
     });
@@ -3037,14 +3065,25 @@ describe('reducer', () => {
       };
       const normalize = reducer.normalize({
         foo: {
-          name: () => 'normalized'
+          'name': () => 'normalized',
+          'person.name': (name) => name && name.toUpperCase(),
+          'pets[].name': (name) => name && name.toLowerCase()
         }
       });
       const state = normalize({
         foo: {
           name: {
             value: 'dog'
-          }
+          },
+          person: {
+            name: {
+              value: 'John Doe',
+            }
+          },
+          pets: [
+            { name: { value: 'Fido' } },
+            { name: { value: 'Tucker' } }
+          ]
         }
       });
       expect(state)
@@ -3057,7 +3096,16 @@ describe('reducer', () => {
           ...defaultFields,
           name: {
             value: 'normalized'
-          }
+          },
+          person: {
+            name: {
+              value: 'JOHN DOE'
+            }
+          },
+          pets: [
+            { name: { value: 'fido' } },
+            { name: { value: 'tucker' } }
+          ]
         });
     });
 
@@ -3072,13 +3120,23 @@ describe('reducer', () => {
       };
       const normalizingReducer = reducer.normalize({
         foo: {
-          name: value => value && value.toUpperCase()
+          'name': value => value && value.toUpperCase(),
+          'person.name': (name) => name && name.toUpperCase(),
+          'pets[].name': (name) => name && name.toLowerCase()
         }
       });
       const empty = normalizingReducer();
-      const state = normalizingReducer(empty, {
+      let state = normalizingReducer(empty, {
         form: 'foo',
-        ...change('name', 'dog')
+        ...change('name', 'dog'),
+      });
+      state = normalizingReducer(state, {
+        form: 'foo',
+        ...change('person.name', 'John Doe'),
+      });
+      state = normalizingReducer(state, {
+        form: 'foo',
+        ...addArrayValue('pets', { name: 'Fido' })
       });
       expect(state)
         .toExist()
@@ -3090,7 +3148,18 @@ describe('reducer', () => {
           ...defaultFields,
           name: {
             value: 'DOG'
-          }
+          },
+          person: {
+            name: {
+              value: 'JOHN DOE'
+            }
+          },
+          pets: [{
+            name: {
+              initial: 'Fido',
+              value: 'fido'
+            }
+          }]
         });
       const result = normalizingReducer(state, {
         form: 'foo',
@@ -3106,7 +3175,152 @@ describe('reducer', () => {
           ...defaultFields,
           name: {
             value: undefined
-          }
+          },
+          person: {
+            name: {
+              value: undefined
+            }
+          },
+          pets: [{
+            name: {
+              initial: 'Fido',
+              value: 'fido'
+            }
+          }]
+        });
+    });
+
+    it('should normalize arbitrarily deeply nested fields', () => {
+      const defaultFields = {
+        _active: undefined,
+        _asyncValidating: false,
+        [globalErrorKey]: undefined,
+        _initialized: false,
+        _submitting: false,
+        _submitFailed: false
+      };
+      const normalize = reducer.normalize({
+        foo: {
+          'name': () => 'normalized',
+          'person.name': (name) => name && name.toUpperCase(),
+          'pets[].name': (name) => name && name.toLowerCase(),
+          'a.very.deep.object.property': (value) => value && value.toUpperCase(),
+          'my[].deeply[].nested.arrayItem': (value) => value && value.toUpperCase()
+        }
+      });
+      const state = normalize({
+        foo: {
+          person: {
+            name: {
+              value: 'John Doe',
+            }
+          },
+          pets: [
+            { name: { value: 'Fido' } },
+            { name: { value: 'Tucker' } }
+          ],
+          a: {
+            very: {
+              deep: {
+                object: {
+                  property: { value: 'test' }
+                }
+              }
+            }
+          },
+          my: [{
+            deeply: [{
+              nested: {
+                arrayItem: { value: 'hello' },
+                not: 'lost'
+              },
+              otherKey: { value: 'Goodbye' }
+            }, {
+              nested: {
+                arrayItem: { value: 'hola' },
+                not: 'lost'
+              },
+              otherKey: { value: 'Adios' }
+            }],
+            stays: 'intact'
+          }, {
+            deeply: [{
+              nested: {
+                arrayItem: { value: 'world' },
+                not: 'lost'
+              },
+              otherKey: { value: 'Later' }
+            }, {
+              nested: {
+                arrayItem: { value: 'mundo' },
+                not: 'lost'
+              },
+              otherKey: { value: 'Hasta luego' }
+            }],
+            stays: 'intact'
+          }]
+        }
+      });
+      expect(state)
+        .toExist()
+        .toBeA('object');
+      expect(state.foo)
+        .toExist()
+        .toBeA('object')
+        .toEqual({
+          ...defaultFields,
+          name: {
+            value: 'normalized'
+          },
+          person: {
+            name: {
+              value: 'JOHN DOE'
+            }
+          },
+          pets: [
+            { name: { value: 'fido' } },
+            { name: { value: 'tucker' } }
+          ],
+          a: {
+            very: {
+              deep: {
+                object: {
+                  property: { value: 'TEST' }
+                }
+              }
+            }
+          },
+          my: [{
+            deeply: [{
+              nested: {
+                arrayItem: { value: 'HELLO' },
+                not: 'lost'
+              },
+              otherKey: { value: 'Goodbye' }
+            }, {
+              nested: {
+                arrayItem: { value: 'HOLA' },
+                not: 'lost'
+              },
+              otherKey: { value: 'Adios' }
+            }],
+            stays: 'intact'
+          }, {
+            deeply: [{
+              nested: {
+                arrayItem: { value: 'WORLD' },
+                not: 'lost'
+              },
+              otherKey: { value: 'Later' }
+            }, {
+              nested: {
+                arrayItem: { value: 'MUNDO' },
+                not: 'lost'
+              },
+              otherKey: { value: 'Hasta luego' }
+            }],
+            stays: 'intact'
+          }]
         });
     });
   });