Search code examples
angularngrx-entityangular-ngrx-datangrx-data

how to EXTEND Custom Entity Collection Reducers in @ngrx/data


After reading and trying everything so far to update my additionalCollectionStates to my EntityCollection (based on the few infos from the docs, this SO Question, this other SO Question, another SO question and an issue from the github repository) I find myself stuck with what should be a fairly common task to do and was no problem at all before using ngrx: sorting of a stored collection of entities with ngrx. Searching for solutions takes me back to the same 10 links I have already seen 5 times and most examples are either too basic or outdated or don't use ngrx/data at all or similar questions don't have answers at all.

Environment: Angular 9.1.7, ngrx 9.1.2, macOS 10.14.6

Goal

Add and update own additionalCollectionState to my EntityCollection as described in the docs for sorting and pagination. I do not get the sortQuery Properties (sortField, sortDirection) from the server but rather set it in my GUI and sort the collection based on them. I know there exists the sortComparer setting on EntityMetadataMap but it's not flexible enough for my means, I want to dynamically sort the collections in different MatTables across the app. This additional property should be updated, whenever the sort order / field in a MatTable changes.

What I have achieved so far:

The ngrx/data way of additionalCollectionState as desribed above works, my collection is initialized with the data I provided in the EntityMetadataMap. Updating the custom collection properties however does not work in the way it is understood by me from the docs. Since I don't get the sortQuery property from the server, the ResultsHandler is not called.

I approached this problem in two ways: a) using ngrx & ngrx/entity Methods and b) the ngrx/data way by trying to extend a custom EntityCollectionReducer

a) I was successful in creating a custom Action and custom Reducer and updating a state property but not the previously defined collection property. My action is basically:

export const selectResourceSortQuery = createAction('[Resource] Get Last Sort Query', props<{ sortQuery: any }>());

It is called from a service with:

    this.store.dispatch(selectResourceSortQuery({ sortQuery }));

and handled in the reducer:

import { EntityState, EntityAdapter, createEntityAdapter } from '@ngrx/entity';
import { Action, createReducer, on } from '@ngrx/store';
import * as ResourceActions from './resource.actions';
import { Resource } from 'src/app/sdk';

export interface State extends EntityState<Resource> {
    // additional state property
    sortQuery: any | null;
}

export const adapter: EntityAdapter<Resource> = createEntityAdapter<Resource>();

export const initialState: State = adapter.getInitialState({
    // additional entity state properties
    sortQuery: {
        sortDirection: 'asc',
        sortField: 'product'
    },
});

export const resourceReducer = createReducer(
    initialState,
    on(ResourceActions.selectResourceSortQuery, (state, { sortQuery }) => {
        console.log(`REDUCER called`);
        return { ...state, sortQuery };
    }),
);

export function reducer(state: State | undefined, action: Action) {
    return resourceReducer(state, action);
}

The reducers are exported in reducers/index.ts:

import {
  ActionReducer,
  ActionReducerMap,
  createFeatureSelector,
  createSelector,
  MetaReducer
} from '@ngrx/store';
import { environment } from 'src/environments/environment';
import * as fromResourceReducers from 'src/app/store/entities/resource/resource.reducer';


export interface State {

}

export const reducers: ActionReducerMap<State> = {
  resourceSort: fromResourceReducers.reducer
};


export const metaReducers: MetaReducer<State>[] = !environment.production ? [] : [];

In app.module this is setup as:

StoreModule.forRoot(reducers, {
  metaReducers: [
    ngrxEntityRelationshipReducer //ignore this
  ],
  runtimeChecks: {
    strictStateImmutability: true,
    strictActionImmutability: true
  }
}),

This creates a resourceSort Collection with the sortQuery property as visible from this screenshot: enter image description here and updates it also according to the changed sortQuery property. However, I would prefer using the already initialized sortQuery property in the Resource entityCache collection (yeah, I also tried adding it to the entityCache feature with StoreModule.forFeature('entityCache', reducers) ignore that):

enter image description here

b) Therefore, another approach was to use the EntityCollectionReducerRegistry in app.module:

import { resourceReducer } from './store/entities/resource/resource.reducer';

// does not work: resourceReducer is of type ActionReducer<State, Action> and the same as posted above (with the added handling of the sortQuery property), and apparently what this function expects
entityCollectionReducerRegistry.registerReducer('Resources', resourceReducer);

// this does work, but just creates the basic default entity collection reducer without my custom sortQuery handling
entityCollectionReducerRegistry.registerReducer('Resources', entityCollectionReducerFactory.create('resources'))

From the docs:

The Entity Reducer is the master reducer for all entity collections in the stored entity cache. The library doesn't have a named entity reducer type. Rather it relies on the EntityCacheReducerFactory.create() method to produce that reducer, which is an NgRx ActionReducer<EntityCache, EntityAction>.

I have a feeling, with the b) approach I might be on the right track, however I'm stuck with the lack of documentation and examples how to handle this problem - is there anybody able to help me?

If more details are needed I am happy to provide them, and if something is unclear since English is not my native language I'm happy to clarify, and if necessary try to provide a simplified stackblitz example. Thanks!


Solution

  • You've approached this the same way I did initially: trying to register a custom reducer to handle the additionalCollectionState properties. After spending some time deciphering the documentation, I was finally able to update a collection property. Here's what the docs say and how I did it:

    The NgRx Data library generates selectors for these properties, but has no way to update them. You'll have to create or extend the existing reducers to do that yourself. (additionalCollectionState)

    After this it goes on with the backend example, but I don't need that; yet, I used the information from step 2 onwards to extend (to be read hack) the EntityCollectionReducerMethods to intercept my property:

    extended-entity-collection-reducer-methods.ts

    export class ExtendedEntityCollectionReducerMethods<T> extends EntityCollectionReducerMethods<T> {
        constructor(
            public entityName: string,
            public definition: EntityDefinition<T>) {
            super(entityName, definition);
        }
    
        protected updateOne(collection: EntityCollection<T>, action: EntityAction<Update<T>>): EntityCollection<T> {
        const superMethod = {...super.updateOne(collection, action)};
    
        if (action.payload.entityName === 'Project' &&
            (action.payload.data.changes as any).id === 0) {
            (superMethod as any).lastProjectId = (action.payload.data.changes as any).lastProjectId;
        }
    
        return superMethod;
    }
    

    This then needs to be instantiated by the Factory:

    extended-entity-collection-reducer-methods-factory.ts

    @Injectable()
    export class ExtendedEntityCollectionReducerMethodsFactory {
        constructor(
            private entityDefinitionService: EntityDefinitionService
        ) {
        }
    
        create<T>(entityName: string): EntityCollectionReducerMethodMap<T> {
            const definition = this.entityDefinitionService.getDefinition<T>(entityName);
            const methodsClass = new ExtendedEntityCollectionReducerMethods(entityName, definition);
            return methodsClass.methods;
        }
    }
    

    The ExtendedEntityCollectionReducerMethodsFactory needs to replace the original one.

    app-module.ts

    providers: [
            {
                provide: EntityCollectionReducerMethodsFactory,
                useClass: ExtendedEntityCollectionReducerMethodsFactory
            }
        ]
    

    Now you have a sneaky way of doing this in your component:

    this.someService.updateOneInCache({id: 0, lastProjectId: projectId} as any);
    

    someService needs to extend the marvelous EntityCollectionServiceBase class:

    some-service.ts

    @Injectable({providedIn: 'root'})
    export class SomeService extends EntityCollectionServiceBase<SomeEntity> {
        constructor(serviceElementsFactory: EntityCollectionServiceElementsFactory) {
            super('SomeEntity', serviceElementsFactory);
        }
    }
    

    The updateOne method in ExtendedEntityCollectionReducerMethods will pick the property up through the action's payload and you can set it there before you return the result of the superMethod.

    I find this kind of cringy, but it works. I found no other way to update a collection's additional properties after trying in vain to go about using the EntityCacheReducerFactory and its weird fellows.

    I've set the id to 0 for the update method because otherwise it will complain the id is missing. Since I don't have any entity with an id of 0, it should work without any unintended consequences.