Skip to content

Commit

Permalink
feat: replace createFormGroupReducerWithUpdate with `createFormStat…
Browse files Browse the repository at this point in the history
…eReducerWithUpdate`
  • Loading branch information
MrWolfZ committed Apr 29, 2018
1 parent cdc53f8 commit 1b6114c
Show file tree
Hide file tree
Showing 9 changed files with 227 additions and 158 deletions.
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ This release requires TypeScript >=2.8.0 for the conditional type support.
* `validate`: move `state` parameter to first position for uncurried overload and add rest param overloads
* due to rework of `updateArray`, `updateGroup`, and `updateRecursive` update functions it is now invalid to call any of these functions without parameters (which made no sense anyway) but it is still possible to call the functions with an empty array as parameter (which is useful in dynamic situations)
* remove `payload` property from all actions and move corresponding properties into action itself ([6f955e9](https://github.com/MrWolfZ/ngrx-forms/commit/6f955e9))
* change the reducer created by `createFormGroupReducerWithUpdate` to only apply the provided update function objects if the state changed as a result of applying the action to the form state (this is only relevant in cases where the update function is closing over variables that may change without the form state changing in which case you can simply manually call the `formGroupReducer` and the `updateGroup` function); this provides a performance improvement for large form states and their update function objects ([b1f49cd](https://github.com/MrWolfZ/ngrx-forms/commit/b1f49cd))
* replace `createFormGroupReducerWithUpdate` with `createFormStateReducerWithUpdate` (which takes any kind of form state, see the [user guide](docs/UPDATING_THE_STATE.md#createFormStateReducerWithUpdate) for more details)
* mark all state properties as `readonly` to make it more clear the state is not meant to be modified directly ([291e0da](https://github.com/MrWolfZ/ngrx-forms/commit/291e0da))

#### Features
Expand Down
84 changes: 44 additions & 40 deletions docs/UPDATING_THE_STATE.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,8 +96,47 @@ const updateMyFormGroup = updateGroup<MyFormValue>({
});
```

#### `createFormGroupReducerWithUpdate`
This update function combines a `formGroupReducer` and the `updateGroup` function by taking update objects of the same shape as `updateGroup` and returns a reducer which first calls the `formGroupReducer` and afterwards applies all update functions by calling `updateGroup` if the form state changes as a result of calling `formGroupReducer`. Combining all we have seen so far our final reducer would therefore look something like this:
If you need to update the form state based on data not contained in the form state itself you can simply parameterize the form update function. In the following example we validate that `someNumber` is greater than some other number from the state.

```typescript
const createMyFormUpdateFunction = (otherNumber: number) => updateGroup<MyFormValue>({
nested: updateGroup({
otherNumber: validate(v => v > otherNumber ? {} : { tooSmall: otherNumber }),
}),
});

export function appReducer(state = initialState, action: Action): AppState {
let myForm = formGroupReducer(state.myForm, action);
myForm = createMyFormUpdateFunction(state.someOtherNumber)(myForm);
if (myForm !== state.myForm) {
state = { ...state, myForm };
}

switch (action.type) {
case 'some action type':
// modify state
return state;

case 'some action type of an action that changes `someOtherNumber`':
// we need to update the form state as well since the parameters changed
myForm = createMyFormUpdateFunction(action.someOtherNumber)(state.myForm);
return {
...state,
someOtherNumber: action.someOtherNumber,
myForm,
};

default: {
return state;
}
}
}
```

#### `createFormStateReducerWithUpdate`
This function combines the `formStateReducer` with a variable number of update functions and returns a reducer function that applies the provided update functions in order after reducing the form state with the action. However, the update functions are only applied if the form state changed as result of applying the action (this provides a performance improvement for large form states). If you need the update functions to be applied regardless of whether the state changed (e.g. because the update function closes over variables that may change independently of the form state) you can simply apply the update manually (e.g. `updateFunction(formStateReducer(state, action))`).

Combining all we have seen so far we could have a reducer that looks something like this:

```typescript
export interface AppState {
Expand All @@ -119,7 +158,7 @@ const initialState: AppState = {
myForm: initialFormState,
};

const myFormReducer = createFormGroupReducerWithUpdate<MyFormValue>({
const validateAndUpdateFormState = updateGroup<MyFormValue>({
someTextInput: validate(required),
nested: updateGroup({
someNumber: validate([required, min(2)]),
Expand All @@ -138,36 +177,10 @@ const myFormReducer = createFormGroupReducerWithUpdate<MyFormValue>({
})(nested)
});

export function appReducer(state = initialState, action: Action): AppState {
const myForm = myFormReducer(state.myForm, action);
if (myForm !== state.myForm) {
state = { ...state, myForm };
}

switch (action.type) {
case 'some action type':
// modify state
return state;

default: {
return state;
}
}
}
```

If you need to update the form state based on data not contained in the form state itself you can simply parameterize the form update function. In the following example we validate that `someNumber` is greater than some other number from the state.

```typescript
const createMyFormUpdateFunction = (otherNumber: number) => updateGroup<MyFormValue>({
nested: updateGroup({
otherNumber: validate(v => v > otherNumber ? {} : { tooSmall: otherNumber }),
}),
});
const myFormReducer = createFormStateReducerWithUpdate<MyFormValue>(validateAndUpdateFormState);

export function appReducer(state = initialState, action: Action): AppState {
let myForm = formGroupReducer(state.myForm, action);
myForm = createMyFormUpdateFunction(state.someOtherNumber)(myForm);
const myForm = myFormReducer(state.myForm, action);
if (myForm !== state.myForm) {
state = { ...state, myForm };
}
Expand All @@ -177,15 +190,6 @@ export function appReducer(state = initialState, action: Action): AppState {
// modify state
return state;

case 'some action type of an action that changes `someOtherNumber`':
// we need to update the form state as well since the parameters changed
myForm = createMyFormUpdateFunction(action.someOtherNumber)(state.myForm);
return {
...state,
someOtherNumber: action.someOtherNumber,
myForm,
};

default: {
return state;
}
Expand Down
2 changes: 1 addition & 1 deletion docs/USER_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ The following sections will explain and showcase the different features of `ngrx
* [Updating the State](UPDATING_THE_STATE.md)
* [`updateArray`](UPDATING_THE_STATE.md#updatearray)
* [`updateGroup`](UPDATING_THE_STATE.md#updategroup)
* [`createFormGroupReducerWithUpdate`](UPDATING_THE_STATE.md#createformgroupreducerwithupdate)
* [`createFormStateReducerWithUpdate`](UPDATING_THE_STATE.md#createformstatereducerwithupdate)
* [`updateRecursive`](UPDATING_THE_STATE.md#updaterecursive)
* [Validation](VALIDATION.md)
* [Asynchronous Validation](VALIDATION.md#asynchronous-validation)
Expand Down
4 changes: 2 additions & 2 deletions src/ngrx-forms.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ export {
export { formControlReducer } from './control/reducer';
export { formGroupReducer } from './group/reducer';
export { formArrayReducer } from './array/reducer';
export { formStateReducer } from './reducer';
export { formStateReducer, createFormStateReducerWithUpdate } from './reducer';

export * from './update-function/update-array';
export * from './update-function/update-group';
Expand All @@ -47,7 +47,7 @@ export * from './update-function/remove-group-control';
export * from './update-function/set-user-defined-property';
export * from './update-function/reset';

export { compose, ProjectFn2 } from './update-function/util';
export { compose, ProjectFn, ProjectFn2 } from './update-function/util';

export { FormViewAdapter, NGRX_FORM_VIEW_ADAPTER } from './view-adapter/view-adapter';
export { NgrxCheckboxViewAdapter } from './view-adapter/checkbox';
Expand Down
127 changes: 127 additions & 0 deletions src/reducer.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import { MarkAsTouchedAction, SetValueAction } from './actions';
import { createFormStateReducerWithUpdate, formStateReducer } from './reducer';
import { FORM_CONTROL_ID, FORM_CONTROL_INNER5_ID, FORM_CONTROL_INNER_ID, FormGroupValue, INITIAL_STATE } from './update-function/test-util';
import { updateGroup } from './update-function/update-group';

describe(formStateReducer.name, () => {
it('should apply the action to controls', () => {
const resultState = formStateReducer<FormGroupValue['inner']>(INITIAL_STATE.controls.inner, new MarkAsTouchedAction(FORM_CONTROL_INNER_ID));
expect(resultState).not.toBe(INITIAL_STATE.controls.inner);
});

it('should apply the action to groups', () => {
const resultState = formStateReducer<FormGroupValue>(INITIAL_STATE, new MarkAsTouchedAction(FORM_CONTROL_ID));
expect(resultState).not.toBe(INITIAL_STATE);
});

it('should apply the action to arrays', () => {
const resultState = formStateReducer<FormGroupValue['inner5']>(INITIAL_STATE.controls.inner5, new MarkAsTouchedAction(FORM_CONTROL_INNER5_ID));
expect(resultState).not.toBe(INITIAL_STATE.controls.inner5);
});

it('should throw if state is undefined', () => {
expect(() => formStateReducer(undefined, { type: '' })).toThrowError();
});

it('should throw if state is not a form state', () => {
expect(() => formStateReducer({} as any, { type: '' })).toThrowError();
});
});

describe(createFormStateReducerWithUpdate.name, () => {
it('should apply the action and the provided functions to controls', () => {
const value = 'A';
const resultState = createFormStateReducerWithUpdate<FormGroupValue['inner']>(s => ({ ...s, value }))(
INITIAL_STATE.controls.inner,
new MarkAsTouchedAction(FORM_CONTROL_INNER_ID),
);
expect(resultState.isTouched).toBe(true);
expect(resultState.value).toBe(value);
});

it('should apply the action and the provided functions to groups', () => {
const userDefinedProperties = { value: 'A' };
const resultState = createFormStateReducerWithUpdate<FormGroupValue>(s => ({ ...s, userDefinedProperties }))(
INITIAL_STATE,
new MarkAsTouchedAction(FORM_CONTROL_ID),
);
expect(resultState.isTouched).toBe(true);
expect(resultState.userDefinedProperties).toEqual(userDefinedProperties);
});

it('should apply the action and the provided functions to arrays', () => {
const userDefinedProperties = { value: 'A' };
const resultState = createFormStateReducerWithUpdate<FormGroupValue['inner5']>(s => ({ ...s, userDefinedProperties }))(
INITIAL_STATE.controls.inner5,
new MarkAsTouchedAction(FORM_CONTROL_INNER5_ID),
);
expect(resultState.isTouched).toBe(true);
expect(resultState.userDefinedProperties).toEqual(userDefinedProperties);
});

it('should apply multiple update functions', () => {
const value = 'A';
const resultState = createFormStateReducerWithUpdate<FormGroupValue['inner']>(
s => ({ ...s, value: `${s.value}${value}` }),
s => ({ ...s, value: `${s.value}${value}` }),
)(
INITIAL_STATE.controls.inner,
new MarkAsTouchedAction(FORM_CONTROL_INNER_ID),
);
expect(resultState.isTouched).toBe(true);
expect(resultState.value).toBe(`${value}${value}`);
});

it('should apply an array of update functions', () => {
const value = 'A';
const resultState = createFormStateReducerWithUpdate<FormGroupValue['inner']>([
s => ({ ...s, value: `${s.value}${value}` }),
s => ({ ...s, value: `${s.value}${value}` }),
])(
INITIAL_STATE.controls.inner,
new MarkAsTouchedAction(FORM_CONTROL_INNER_ID),
);
expect(resultState.isTouched).toBe(true);
expect(resultState.value).toBe(`${value}${value}`);
});

it('should first apply the action and then the provided functions', () => {
const expected = { ...INITIAL_STATE.controls.inner, value: 'A' };
const resultState = createFormStateReducerWithUpdate<FormGroupValue['inner']>(() => expected)(
INITIAL_STATE.controls.inner,
new MarkAsTouchedAction(FORM_CONTROL_INNER_ID),
);
expect(resultState).toBe(expected);
});

it('should apply multiple provided function objects one after another', () => {
const updatedInner1 = { ...INITIAL_STATE.controls.inner, value: 'A' };
const expectedInner1 = { ...INITIAL_STATE.controls.inner, value: 'B' };
const expectedInner3 = { ...INITIAL_STATE.controls.inner3!, value: { inner4: 'A' } };
const resultState = createFormStateReducerWithUpdate<FormGroupValue>(
updateGroup<FormGroupValue>(
{
inner: () => updatedInner1,
inner3: () => expectedInner3,
},
),
updateGroup<FormGroupValue>(
{
inner: () => expectedInner1,
},
),
)(INITIAL_STATE, new SetValueAction(FORM_CONTROL_INNER_ID, 'D'));
expect(resultState.controls.inner).toBe(expectedInner1);
expect(resultState.controls.inner3).toBe(expectedInner3);
});

it('should not apply the update functions if the form state did not change', () => {
const expected = { ...INITIAL_STATE.controls.inner, value: 'A' };
const resultState = createFormStateReducerWithUpdate<FormGroupValue['inner']>(() => expected)(INITIAL_STATE.controls.inner, { type: '' });
expect(resultState).toBe(INITIAL_STATE.controls.inner);
});

it('should throw if state is undefined', () => {
expect(() => createFormStateReducerWithUpdate<any>(s => s)(undefined, { type: '' })).toThrowError();
});
});
52 changes: 49 additions & 3 deletions src/reducer.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import { Action } from '@ngrx/store';
import { Action, ActionReducer } from '@ngrx/store';

import { formArrayReducer } from './array/reducer';
import { formControlReducer } from './control/reducer';
import { formGroupReducer } from './group/reducer';
import { AbstractControlState, FormControlState, FormState, isArrayState, isFormState, isGroupState } from './state';
import { FormControlState, FormState, isArrayState, isFormState, isGroupState } from './state';
import { ProjectFn } from './update-function/util';

export function formStateReducer<TValue>(
state: FormState<TValue> | AbstractControlState<TValue> | undefined,
state: FormState<TValue> | undefined,
action: Action,
): FormState<TValue> {
if (!state) {
Expand All @@ -27,3 +28,48 @@ export function formStateReducer<TValue>(

return formControlReducer(state as FormControlState<any>, action) as any;
}

/**
* This function creates a reducer function that first applies an action to the state
* and afterwards applies all given update functions one after another to the resulting
* form state. However, the update functions are only applied if the form state changed
* as result of applying the action. If you need the update functions to be applied
* regardless of whether the state changed (e.g. because the update function closes
* over variables that may change independently of the form state) you can simply apply
* the update manually (e.g. `updateFunction(formStateReducer(state, action))`).
*
* The following (contrived) example uses this function to create a reducer that after
* each action validates the child control `name` to be required and sets the child
* control `email`'s value to be `''` if the name is invalid.
*
* ```typescript
* interface FormValue {
* name: string;
* email: string;
* }
*
* const updateFormState = updateGroup<FormValue>(
* {
* name: validate(required),
* },
* {
* email: (email, parentGroup) =>
* parentGroup.controls.name.isInvalid
* ? setValue('', email)
* : email,
* },
* );
*
* const reducer = createFormStateReducerWithUpdate<FormValue>(updateFormState);
* ```
*/
export function createFormStateReducerWithUpdate<TValue>(
updateFnOrUpdateFnArr: ProjectFn<FormState<TValue>> | ProjectFn<FormState<TValue>>[],
...updateFnArr: ProjectFn<FormState<TValue>>[]
): ActionReducer<FormState<TValue>> {
updateFnArr = [...(Array.isArray(updateFnOrUpdateFnArr) ? updateFnOrUpdateFnArr : [updateFnOrUpdateFnArr]), ...updateFnArr];
return (state: FormState<TValue> | undefined, action: Action): FormState<TValue> => {
const newState = formStateReducer(state, action);
return newState === state ? state : updateFnArr.reduce((s, f) => f(s), newState);
};
}
1 change: 1 addition & 0 deletions src/update-function/test-util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { createFormGroupState } from '../state';
export const FORM_CONTROL_ID = 'test ID';
export const FORM_CONTROL_INNER_ID = `${FORM_CONTROL_ID}.inner`;
export const FORM_CONTROL_INNER2_ID = `${FORM_CONTROL_ID}.inner2`;
export const FORM_CONTROL_INNER5_ID = `${FORM_CONTROL_ID}.inner5`;
export interface NestedValue { inner4: string; }
export interface FormGroupValue { inner: string; inner2?: string; inner3?: NestedValue; inner5: string[]; }
export const INITIAL_FORM_CONTROL_VALUE: FormGroupValue = { inner: '', inner3: { inner4: '' }, inner5: [''] };
Expand Down
Loading

0 comments on commit 1b6114c

Please sign in to comment.