Reputation: 3675
I'm using ngrx-forms (specifically the ngrx 8 syntax) to sync my form and state store. The form state updating actions are successfully dispatched e.g.
{
controlId: 'adForm.location.community',
value: 'wst',
type: 'ngrx/forms/SET_VALUE'
}
However, the state always stays the same, the above produces no diff in the Redux dev tools.
My reducer looks as follows:
export const reducer = createReducer(
initialAdFormStoreState,
onNgrxForms(),
// Other action handlers
// ...
)
export const adFormReducer = wrapReducerWithFormStateUpdate(
reducer,
(s) => s.data.form,
adFormValidators,
)
The initial state is created as follows:
const initialAdFormStoreState: AdFormStoreState = {
data: {
form: createFormGroupState<AdForm>('adForm', { /* ... */ })
// ...
},
// ...
}
I've checked the updating state documentation countless times and everything looks correct.
Upvotes: 3
Views: 1375
Reputation: 46
As Moo said, unfortunately, onNgrxForms()
checks only the top level of the state and you need to write your own implementation.
I had a similar problem, my task was to implement some kind of virtual tabs, that can be stored and displayed independently.
I got two implementations: straight implementation that checks only two-level state (the one that I use in my production project) and more generic implementation that works recursively.
The code is based on ngrx-forms implementation of onNgrxForms()
, see links below.
https://github.com/MrWolfZ/ngrx-forms/blob/master/src/reducer.ts https://github.com/MrWolfZ/ngrx-forms/blob/master/src/state.ts
export interface MyObject {
property: string;
}
export interface MyObjectFormValue {
property: string;
}
export interface Substate {
data: MyObject;
form: FormGroupState<MyObjectFormValue>;
}
export interface State {
[id: number]: Substate;
commonData1: any[];
commonData2: boolean;
}
As you can see, the State
interface contains some Substates
, that contain FormGroupState
. Substate
can be accessed via indexer but you can write your implementation without indexer, using just your field data
.
If you want, you can add some common data (fields commonData1
, commonData2
) shared between Substates
.
import { ALL_NGRX_FORMS_ACTION_TYPES, formStateReducer } from 'ngrx-forms';
function isSubstate(object: any): object is Substate {
return (
Boolean(object) &&
Object.prototype.hasOwnProperty.call(object, 'data') &&
Object.prototype.hasOwnProperty.call(object, 'form')
);
}
export function onNgrxFormsDeep(): { reducer: ActionReducer<State>; types: string[] } {
return {
reducer: (state, action) =>
Object.entries(state)
.filter(([_, value]) => isSubstate(value))
.reduce(
(accum, [key, subState]) => ({
...accum,
[key]: {
...subState,
form: formStateReducer(subState.form, action)
}
}),
state
),
types: ALL_NGRX_FORMS_ACTION_TYPES
};
}
I use Object.entries
because I don't know what Substates
are stored inside State
, but if the keys of State are predefined, you can just simply access them.
import { ActionReducer } from '@ngrx/store';
import { produce } from 'immer';
import { ALL_NGRX_FORMS_ACTION_TYPES, FormState, formStateReducer } from 'ngrx-forms';
interface Match {
parentObjectPath: string[];
formStateKey: string;
}
export function onNgrxFormsDeep(): {
reducer: ActionReducer<any>;
types: string[];
} {
return {
reducer: (state, action) => {
if (isFormState(state)) {
return formStateReducer(state, action);
}
const matches = searchForms(state);
if (matches.length) {
return produce(state, (draftState) => {
matches.forEach((match) => {
const parent = match.parentObjectPath.reduce((acc, key) => acc[key], draftState);
const form = match.parentObjectPath.reduce((acc, key) => acc[key], state)[match.formStateKey];
parent[match.formStateKey] = formStateReducer(form, action);
});
});
}
return state;
},
types: ALL_NGRX_FORMS_ACTION_TYPES
};
}
function searchForms(state: any): Match[] {
const matches: Match[] = [];
const visits = new Set<any>();
const parentObjectPath = [];
searchFormsImpl(null, state);
return matches;
function searchFormsImpl(key: string, object: any): void {
if (visits.has(object)) {
return;
}
visits.add(object);
if (isFormState(object)) {
matches.push({ parentObjectPath: [...parentObjectPath], formStateKey: key });
} else if (isObject(object) || Array.isArray(object)) {
if (key) {
parentObjectPath.push(key);
}
Object.entries(object).forEach(([k, v]) => {
searchFormsImpl(k, v);
});
if (key) {
parentObjectPath.pop();
}
}
}
}
function isFormState<TValue = any>(state: any): state is FormState<TValue> {
return (
Boolean(state) &&
Object.prototype.hasOwnProperty.call(state, 'id') &&
Object.prototype.hasOwnProperty.call(state, 'value') &&
Object.prototype.hasOwnProperty.call(state, 'errors')
);
}
function isObject(objValue: any): boolean {
return (
Boolean(objValue) &&
typeof objValue === 'object' &&
(objValue.constructor === Object || objValue.constructor.toString().startsWith('class'))
);
}
This version uses the library immer
to update state immutably.
Unfortunately, the function isFormState
is not in public API of the library, so I copied it.
I added a set visits
to avoid cirricular references problem, because some fields can contain their parents, without it the code will fail with stack overflow error.
Besides that, I wrote the function isObject
because some properties (of type number
, string
, function
, etc) should not be checked.
Upvotes: 1
Reputation: 3675
onNgrxForms()
only checks for forms at the top level of the feature state object. It wasn't updating my state because I had the form at data.form
since we split state into ui/data. I had to hoist it up a level. Alternatively you can write your own implementation of onNgrxForms()
.
Upvotes: 2