With EntityComponentStore, the code that is repeated in most component stores will be reduced.
Prototype: EDIT: Improved types
export interface EntityState<
Id extends string | number,
Entity extends { id: Id }
> {
ids: Id[];
entities: Record<Id, Entity>;
}
@Injectable()
export class EntityComponentStore<
State extends EntityState<Id, Entity>,
Id extends string | number = State extends EntityState<infer I, any> ? I : never,
Entity extends { id: Id } = State extends EntityState<any, infer E> ? E : never
> extends ComponentStore<State> {
constructor(defaultState?: State) {
super(defaultState);
}
/**
* Selectors
*/
readonly ids$ = this.select((state) => state.ids);
readonly entities$ = this.select((state) => state.entities);
readonly all$ = this.select(this.ids$, this.entities$, (ids, entities) =>
ids.map((id) => entities[id])
);
readonly total$ = this.select(this.ids$, (ids) => ids.length);
/**
* Updaters
*/
readonly addOne = this.updater((state, entity: Entity) => ({
...state,
ids: [...state.ids, entity.id],
entities: { ...state.entities, [entity.id]: entity },
}));
readonly addMany = this.updater((state, entities: Entity[]) => ({
...state,
ids: [...state.ids, ...entities.map((entity) => entity.id)],
entities: {
...state.entities,
...entities.reduce(
(acc, entity) => ({
...acc,
[entity.id]: entity,
}),
{} as Record<Id, Entity>
),
},
}));
/** etc. **/
}
Usage:
export interface Movie {
id: number;
name: string;
visible: boolean;
}
export interface MoviesState extends EntityState<number, Movie> {}
@Injectable()
export class MoviesStore extends EntityComponentStore<MoviesState> {
constructor(private readonly moviesService: MoviesService) {
super({ ids: [], entities: {} });
}
readonly visibleMovies$ = this.select(this.all$, (movies) =>
movies.filter((movie) => movie.visible)
);
readonly getMovie = this.effect((id$: Observable<number>) =>
id$.pipe(
switchMap((id) =>
this.moviesService.getMovie(id).pipe(
tapResponse(
(movie) => this.addOne(movie),
(error) => console.error(error)
)
)
)
)
);
}
[x] Yes (Assistance is provided if you need help submitting a pull request)
[ ] No
I'd rather have the @ngrx/entity to be stripped from @ngrx/store dep.
I'm already using adapter with my ComponentStores and @ngrx/entity 馃檪
Granted, this is even further reduction of code.
If anything, this could be another lib (maybe even with ngrx family of libraries).
@timdeschryver @brandonroberts Thoughts? 馃檪
@alex-okrushko
Yes, @ngrx/entity and @ngrx/component-store are great combination. This advice is to have @ngrx/entity features adopted to the @ngrx/component-store object-oriented nature 馃檪
By the way, Entity Component Store as a separate library in ngrx/platform also seems better to me 馃憤
I think this can be accomplished with splitting out selectors into a separate library that we already discussed. That decouples entity from the store library also. I'm not sure about making a separate library for each Store/ComponentStore combination.
Simple solution is
// Angular specific
import { Injectable } from '@angular/core';
import { HttpClient, HttpErrorResponse } from '@angular/common/http';
import { EMPTY, Observable } from 'rxjs';
import { catchError, concatMap, finalize, tap } from 'rxjs/operators';
// NGRX specific
import { ComponentStore } from '@ngrx/component-store';
import { createEntityAdapter, EntityAdapter, EntityState } from '@ngrx/entity';
import { EntityMapOne, EntitySelectors } from '@ngrx/entity/src/models';
export interface Model {
id: string;
// ... more fields
}
interface State {
models: EntityState<Model>;
error: Error;
loading: boolean;
}
@Injectable()
export class MyStore extends ComponentStore<State> {
private entityAdapter: EntityAdapter<Model>;
private entitySelectors: EntitySelectors<Model, State>;
constructor(private modelService: ModelService) {
super();
this.entityAdapter = createEntityAdapter<Model>({
selectId: (model) => model.id,
sortComparer: null,
});
this.entitySelectors = this.entityAdapter.getSelectors(
(state) => state.models
);
this.setState({
models: this.entityAdapter.getInitialState(),
error: null,
loading: false,
});
}
/**
* Effects
* */
readonly loadAll = this.effect((origin$: Observable<void>) =>
origin$.pipe(
tap(() => this.setLoading(true)),
concatMap(() =>
this.modelService.getAll().pipe(
tap((models) => {
this.setAll(models);
}),
catchError((error: HttpErrorResponse) => {
this.setError(error.error);
return EMPTY;
}),
finalize(() => {
this.setLoading(false);
})
)
)
)
);
/**
* Updaters
* */
readonly setAll = this.updater((state, models: Model[]) => ({
...state,
models: this.entityAdapter.setAll(models, state.models),
}));
readonly mapOne = this.updater((state, map: EntityMapOne<Model>) => ({
...state,
models: this.entityAdapter.mapOne(map, state.models),
}));
readonly setError = this.updater((state, error: Error) => ({
...state,
error,
}));
readonly setLoading = this.updater((state, loading: boolean) => ({
...state,
loading,
}));
/**
* Selectors
* */
readonly loading$ = this.select((state) => state.loading);
readonly all$ = this.select((state) => this.entitySelectors.selectAll(state));
readonly entityMap$ = this.select((state) =>
this.entitySelectors.selectEntities(state)
);
readonly ids$ = this.select((state) => this.entitySelectors.selectIds(state));
readonly total$ = this.select((state) =>
this.entitySelectors.selectTotal(state)
);
/**
* Abstraction over entity (model) operations
* */
private deeplyUpdateModel(id: string) {
this.mapOne({
id,
map: (model) => ({
...model,
// update here
}),
});
}
}
@Injectable({
providedIn: 'root',
})
export class ModelService {
private readonly apiUrl: string;
constructor(private httpClient: HttpClient) {
this.apiUrl = 'https://my-api/models';
}
getAll(): Observable<Model[]> {
return this.httpClient.get<Model[]>(this.apiUrl);
}
}
function logger(state: any) {
console.groupCollapsed('%c[NewRegisteredProfiles] state', 'color: skyblue;');
console.log(state);
console.groupEnd();
}
@Ash-kosakyan thanks for suggestion.
```typescript
/**
- Effects
- */
readonly loadAll = this.effect((origin$: Observable) =>
origin$.pipe(
tap(() => this.setLoading(true)),
concatMap(() =>
this.modelService.getAll().pipe(
tap((models) => {
this.setAll(models);
}),
catchError((error: HttpErrorResponse) => {
this.setError(error.error);
return EMPTY;
}),
finalize(() => {
this.setLoading(false);
})
)
)
)
);
```
I'd rather avoid predefined effects, because that would have a lot of limitations (similar to ngrx/data limitations).
Btw, entity updaters could accept partial updater as an optional second argument, for more flexibility:
this.setAll(entities, { loading: false });