From f7aa432e21025cca404be3b4f231e4f1b81fc0f3 Mon Sep 17 00:00:00 2001 From: Jon Rista Date: Sun, 30 Aug 2020 22:24:15 -0600 Subject: [PATCH] feat: add load if necessary action variants + Add LoadIfNecessary action + Add LoadAllIfNecessary action + Add LoadManyIfNecessary action + Add LoadPageIfNecessary action + Add LoadRangeIfNecessary action * Export new actions from public api + Add new operators for if necessary loads + Add new effects for if necessary loads * Include new effects in standard modules * Export new effects from public api + Add stateNameOfEntity utility function * Update builders to use new entityStateName function Issue #144 --- package.json | 1 + projects/ngrx-auto-entity/src/index.ts | 402 +++++++++--------- .../src/lib/actions/entity-info.ts | 5 +- .../src/lib/actions/load-many-actions.ts | 2 +- .../ngrx-auto-entity/src/lib/actions/util.ts | 5 +- .../src/lib/decorators/entity-util.ts | 6 + .../src/lib/effects/effects-all.ts | 35 +- .../src/lib/effects/effects-loads.ts | 4 +- .../src/lib/effects/if-necessary-loads.ts | 40 ++ .../effects/if-necessary-operators.spec.ts | 143 +++++++ .../src/lib/effects/if-necessary-operators.ts | 218 ++++++++++ projects/ngrx-auto-entity/src/lib/module.ts | 4 + .../src/lib/util/state-builder.ts | 21 +- 13 files changed, 662 insertions(+), 224 deletions(-) create mode 100644 projects/ngrx-auto-entity/src/lib/effects/if-necessary-loads.ts create mode 100644 projects/ngrx-auto-entity/src/lib/effects/if-necessary-operators.spec.ts create mode 100644 projects/ngrx-auto-entity/src/lib/effects/if-necessary-operators.ts diff --git a/package.json b/package.json index f02ee40..0fe4839 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "test:coverage": "jest --coverage", "test:ci": "jest --ci --runInBand --testResultsProcessor='jest-junit'", "lint": "ng lint", + "lint:fix": "ng lint --fix", "json-server": "json-server --watch data/db.json --routes data/routes.json", "link": "ng build ngrx-auto-entity && npm link dist/ngrx-auto-entity", "link:ci": "ng build ngrx-auto-entity && sudo npm link dist/ngrx-auto-entity", diff --git a/projects/ngrx-auto-entity/src/index.ts b/projects/ngrx-auto-entity/src/index.ts index 2cad7b3..b840715 100644 --- a/projects/ngrx-auto-entity/src/index.ts +++ b/projects/ngrx-auto-entity/src/index.ts @@ -1,200 +1,202 @@ -// /* -// * Public API Surface of ngrx-auto-entity -// */ -// -// /* -// * Modules -// */ -// export { -// NgrxAutoEntityModule, -// NgRxAutoEntityRootModuleWithEffects, -// NgRxAutoEntityRootModuleNoEntityEffects, -// NgRxAutoEntityRootModuleNoEffects, -// NgRxAutoEntityFeatureModule, -// NgRxAutoEntityModuleConfig, -// getNgRxAutoEntityMetaReducer -// } from './lib/module'; -// -// /* -// * Common models and types referenced throughout Auto-Entity -// */ -// export { -// IPage, -// Page, -// IFirstLastRange, -// IRangeInfo, -// ISkipTakeRange, -// IStartEndRange, -// Range, -// RangeValue, -// IPageInfo -// } from './lib/models'; -// export { EntityIdentity } from './lib/types/entity-identity'; -// export { IEntityDictionary, IEntityState } from './lib/util/entity-state'; -// export { IModelState, IModelClass } from './lib/util/model-state'; -// export { IEntityFacade } from './lib/util/facade'; -// export { ISelectorMap } from './lib/util/selector-map'; -// -// /* -// * Builders -// */ -// export { buildFacade } from './lib/util/facade-builder'; -// export { buildSelectorMap } from './lib/util/selector-map-builder'; -// export { buildFeatureState, buildState } from './lib/util/state-builder'; -// -// /* -// * Action Support -// */ -// export { EntityActionTypes } from './lib/actions/action-types'; -// export { IEntityInfo } from './lib/actions/entity-info'; -// export { EntityAction } from './lib/actions/entity-action'; -// export { ICorrelatedAction } from './lib/actions/entity-action'; -// export { fromEntityActions } from './lib/actions/action-operators'; -// export { ofEntityType } from './lib/actions/action-operators'; -// export { ofEntityAction } from './lib/actions/action-operators'; -// export { isEntityActionInstance, EntityActions } from './lib/actions/entity-actions-union'; -// -// /* -// * Actions -// */ -// export { Load, LoadFailure, LoadSuccess } from './lib/actions/load-actions'; -// export { LoadMany, LoadManyFailure, LoadManySuccess } from './lib/actions/load-many-actions'; -// export { LoadAll, LoadAllFailure, LoadAllSuccess } from './lib/actions/load-all-actions'; -// export { LoadPage, LoadPageFailure, LoadPageSuccess } from './lib/actions/load-page-actions'; -// export { LoadRange, LoadRangeFailure, LoadRangeSuccess } from './lib/actions/load-range-actions'; -// -// export { CreateMany, CreateManyFailure, CreateManySuccess } from './lib/actions/create-actions'; -// export { Create, CreateFailure, CreateSuccess } from './lib/actions/create-actions'; -// export { UpdateMany, UpdateManyFailure, UpdateManySuccess } from './lib/actions/update-actions'; -// export { Update, UpdateFailure, UpdateSuccess } from './lib/actions/update-actions'; -// export { UpsertMany, UpsertManyFailure, UpsertManySuccess } from './lib/actions/upsert-actions'; -// export { Upsert, UpsertFailure, UpsertSuccess } from './lib/actions/upsert-actions'; -// export { ReplaceMany, ReplaceManyFailure, ReplaceManySuccess } from './lib/actions/replace-actions'; -// export { Replace, ReplaceFailure, ReplaceSuccess } from './lib/actions/replace-actions'; -// export { DeleteMany, DeleteManyFailure, DeleteManySuccess } from './lib/actions/delete-actions'; -// export { Delete, DeleteFailure, DeleteSuccess } from './lib/actions/delete-actions'; -// export { -// DeleteManyByKeys, -// DeleteManyByKeysFailure, -// DeleteManyByKeysSuccess, -// DeleteByKeyFailure, -// DeleteByKeySuccess, -// DeleteByKey -// } from './lib/actions/delete-by-key-actions'; -// -// export { -// Select, -// SelectByKey, -// Selected, -// SelectedMany, -// SelectMany, -// SelectManyByKeys, -// SelectMore, -// SelectMoreByKeys -// } from './lib/actions/selection-actions'; -// export { Deselected, DeselectAll, DeselectManyByKeys, DeselectMany, Deselect } from './lib/actions/deselection-actions'; -// export { EditEnded, EndEdit, Changed, Change, EditedByKey, Edited, EditByKey, Edit } from './lib/actions/edit-actions'; -// export { Clear } from './lib/actions/actions'; -// -// /* -// * Decorators -// */ -// export { Entity } from './lib/decorators/entity-decorator'; -// export { Key } from './lib/decorators/key-decorator'; -// -// /* -// * Entity Metadata and Management -// */ -// export { ENTITY_OPTS_PROP } from './lib/decorators/entity-tokens'; -// export { IEffectExcept, IEntityOptions, IEntityTransformer, EntityAge } from './lib/decorators/entity-options'; -// export { IEffectExclusions } from './lib/decorators/effect-exclusions'; -// export { curd, loads, extra, all, matching, except } from './lib/decorators/effect-exclusion-utils'; -// -// /* -// * Entity Metadata Utilities -// */ -// export { makeEntity } from './lib/util/make-entity'; -// export { -// nameOfEntity, -// pluralNameOfEntity, -// uriNameOfEntity, -// entityComparer, -// entityTransforms -// } from './lib/decorators/entity-util'; -// export { -// getKey, -// getKeyFromModel, -// getKeyFromEntity, -// getKeyNames, -// getKeyNamesFromModel, -// getKeyNamesFromEntity, -// checkKeyName -// } from './lib/decorators/key-util'; -// -// /* -// * Reducer -// */ -// export { autoEntityReducer, autoEntityMetaReducer, stateNameFromAction } from './lib/reducer/reducer'; -// -// /* -// * Entity Service -// */ -// export { NgrxAutoEntityService } from './lib/service/service'; -// export { IAutoEntityService } from './lib/service/interface'; -// -// export { -// IEntityRangeRef, -// IEntityPageRef, -// IEntityRef, -// IEntityIdentityRef, -// IEntityIdentitiesRef -// } from './lib/service/refs'; -// export { IEntityWithRangeInfo, IEntityWithPageInfo, IEntityError } from './lib/service/wrapper-models'; -// -// /* -// * Transformation utilities -// */ -// export { -// transformEntityFromServer, -// transformEntitiesFromServer, -// transformEntityToServer, -// transformEntitiesToServer -// } from './lib/service/transformation'; -// -// /* -// * Operators -// */ -// export { EntityOperators } from './lib/effects/operators'; -// -// /* -// * Effect Groups -// */ -// export { EntityEffects } from './lib/effects/effects-all'; -// export { LoadEffects } from './lib/effects/effects-loads'; -// export { CUDEffects } from './lib/effects/effects-cud'; -// export { ExtraEffects } from './lib/effects/effects-extra'; -// -// /* -// * Effects -// */ -// export { -// LoadEffect, -// LoadAllEffect, -// LoadManyEffect, -// LoadPageEffect, -// LoadRangeEffect -// } from './lib/effects/effects-loads-discrete'; -// export { -// CreateEffect, -// CreateManyEffect, -// DeleteEffect, -// DeleteManyEffect, -// DeleteByKeyEffect, -// DeleteManyByKeysEffect, -// ReplaceEffect, -// ReplaceManyEffect, -// UpdateEffect, -// UpdateManyEffect, -// UpsertEffect, -// UpsertManyEffect -// } from './lib/effects/effects-cud-discrete'; +/* + * Public API Surface of ngrx-auto-entity + */ + +/* + * Modules + */ +export { + NgrxAutoEntityModule, + NgRxAutoEntityRootModuleWithEffects, + NgRxAutoEntityRootModuleNoEntityEffects, + NgRxAutoEntityRootModuleNoEffects, + NgRxAutoEntityFeatureModule, + NgRxAutoEntityModuleConfig, + getNgRxAutoEntityMetaReducer +} from './lib/module'; + +/* + * Common models and types referenced throughout Auto-Entity + */ +export { + IPage, + Page, + IFirstLastRange, + IRangeInfo, + ISkipTakeRange, + IStartEndRange, + Range, + RangeValue, + IPageInfo +} from './lib/models'; +export { EntityIdentity } from './lib/types/entity-identity'; +export { IEntityDictionary, IEntityState } from './lib/util/entity-state'; +export { IModelState, IModelClass } from './lib/util/model-state'; +export { IEntityFacade } from './lib/util/facade'; +export { ISelectorMap } from './lib/util/selector-map'; + +/* + * Builders + */ +export { buildFacade } from './lib/util/facade-builder'; +export { buildSelectorMap } from './lib/util/selector-map-builder'; +export { buildFeatureState, buildState } from './lib/util/state-builder'; + +/* + * Action Support + */ +export { EntityActionTypes } from './lib/actions/action-types'; +export { IEntityInfo } from './lib/actions/entity-info'; +export { EntityAction } from './lib/actions/entity-action'; +export { ICorrelatedAction } from './lib/actions/entity-action'; +export { fromEntityActions } from './lib/actions/action-operators'; +export { ofEntityType } from './lib/actions/action-operators'; +export { ofEntityAction } from './lib/actions/action-operators'; +export { isEntityActionInstance, EntityActions } from './lib/actions/entity-actions-union'; + +/* + * Actions + */ +export { Load, LoadIfNecessary, LoadFailure, LoadSuccess } from './lib/actions/load-actions'; +export { LoadMany, LoadManyIfNecessary, LoadManyFailure, LoadManySuccess } from './lib/actions/load-many-actions'; +export { LoadAll, LoadAllIfNecessary, LoadAllFailure, LoadAllSuccess } from './lib/actions/load-all-actions'; +export { LoadPage, LoadPageIfNecessary, LoadPageFailure, LoadPageSuccess } from './lib/actions/load-page-actions'; +export { LoadRange, LoadRangeIfNecessary, LoadRangeFailure, LoadRangeSuccess } from './lib/actions/load-range-actions'; + +export { CreateMany, CreateManyFailure, CreateManySuccess } from './lib/actions/create-actions'; +export { Create, CreateFailure, CreateSuccess } from './lib/actions/create-actions'; +export { UpdateMany, UpdateManyFailure, UpdateManySuccess } from './lib/actions/update-actions'; +export { Update, UpdateFailure, UpdateSuccess } from './lib/actions/update-actions'; +export { UpsertMany, UpsertManyFailure, UpsertManySuccess } from './lib/actions/upsert-actions'; +export { Upsert, UpsertFailure, UpsertSuccess } from './lib/actions/upsert-actions'; +export { ReplaceMany, ReplaceManyFailure, ReplaceManySuccess } from './lib/actions/replace-actions'; +export { Replace, ReplaceFailure, ReplaceSuccess } from './lib/actions/replace-actions'; +export { DeleteMany, DeleteManyFailure, DeleteManySuccess } from './lib/actions/delete-actions'; +export { Delete, DeleteFailure, DeleteSuccess } from './lib/actions/delete-actions'; +export { + DeleteManyByKeys, + DeleteManyByKeysFailure, + DeleteManyByKeysSuccess, + DeleteByKeyFailure, + DeleteByKeySuccess, + DeleteByKey +} from './lib/actions/delete-by-key-actions'; + +export { + Select, + SelectByKey, + Selected, + SelectedMany, + SelectMany, + SelectManyByKeys, + SelectMore, + SelectMoreByKeys +} from './lib/actions/selection-actions'; +export { Deselected, DeselectAll, DeselectManyByKeys, DeselectMany, Deselect } from './lib/actions/deselection-actions'; +export { EditEnded, EndEdit, Changed, Change, EditedByKey, Edited, EditByKey, Edit } from './lib/actions/edit-actions'; +export { Clear } from './lib/actions/actions'; + +/* + * Decorators + */ +export { Entity } from './lib/decorators/entity-decorator'; +export { Key } from './lib/decorators/key-decorator'; + +/* + * Entity Metadata and Management + */ +export { ENTITY_OPTS_PROP } from './lib/decorators/entity-tokens'; +export { IEffectExcept, IEntityOptions, IEntityTransformer, EntityAge } from './lib/decorators/entity-options'; +export { IEffectExclusions } from './lib/decorators/effect-exclusions'; +export { curd, loads, extra, all, matching, except } from './lib/decorators/effect-exclusion-utils'; + +/* + * Entity Metadata Utilities + */ +export { makeEntity } from './lib/util/make-entity'; +export { + nameOfEntity, + pluralNameOfEntity, + uriNameOfEntity, + stateNameOfEntity, + entityComparer, + entityTransforms +} from './lib/decorators/entity-util'; +export { + getKey, + getKeyFromModel, + getKeyFromEntity, + getKeyNames, + getKeyNamesFromModel, + getKeyNamesFromEntity, + checkKeyName +} from './lib/decorators/key-util'; + +/* + * Reducer + */ +export { autoEntityReducer, autoEntityMetaReducer, stateNameFromAction } from './lib/reducer/reducer'; + +/* + * Entity Service + */ +export { NgrxAutoEntityService } from './lib/service/service'; +export { IAutoEntityService } from './lib/service/interface'; + +export { + IEntityRangeRef, + IEntityPageRef, + IEntityRef, + IEntityIdentityRef, + IEntityIdentitiesRef +} from './lib/service/refs'; +export { IEntityWithRangeInfo, IEntityWithPageInfo, IEntityError } from './lib/service/wrapper-models'; + +/* + * Transformation utilities + */ +export { + transformEntityFromServer, + transformEntitiesFromServer, + transformEntityToServer, + transformEntitiesToServer +} from './lib/service/transformation'; + +/* + * Operators + */ +export { EntityOperators } from './lib/effects/operators'; + +/* + * Effect Groups + */ +export { EntityEffects } from './lib/effects/effects-all'; +export { LoadEffects } from './lib/effects/effects-loads'; +export { LoadIfNecessaryEffects } from './lib/effects/if-necessary-loads'; +export { CUDEffects } from './lib/effects/effects-cud'; +export { ExtraEffects } from './lib/effects/effects-extra'; + +/* + * Effects + */ +export { + LoadEffect, + LoadAllEffect, + LoadManyEffect, + LoadPageEffect, + LoadRangeEffect +} from './lib/effects/effects-loads-discrete'; +export { + CreateEffect, + CreateManyEffect, + DeleteEffect, + DeleteManyEffect, + DeleteByKeyEffect, + DeleteManyByKeysEffect, + ReplaceEffect, + ReplaceManyEffect, + UpdateEffect, + UpdateManyEffect, + UpsertEffect, + UpsertManyEffect +} from './lib/effects/effects-cud-discrete'; diff --git a/projects/ngrx-auto-entity/src/lib/actions/entity-info.ts b/projects/ngrx-auto-entity/src/lib/actions/entity-info.ts index b1c8ee1..52b49b2 100644 --- a/projects/ngrx-auto-entity/src/lib/actions/entity-info.ts +++ b/projects/ngrx-auto-entity/src/lib/actions/entity-info.ts @@ -1,9 +1,8 @@ -import { IEntityNames, IEntityTransformer } from '../decorators/entity-options'; +import { IEntityOptions } from '../decorators/entity-options'; /** * Descriptor of an Entity model and related metadata. */ -export interface IEntityInfo extends IEntityNames { +export interface IEntityInfo extends IEntityOptions { modelType: new () => any; - transform?: IEntityTransformer[]; } diff --git a/projects/ngrx-auto-entity/src/lib/actions/load-many-actions.ts b/projects/ngrx-auto-entity/src/lib/actions/load-many-actions.ts index 8f11f9f..bbbb0a5 100644 --- a/projects/ngrx-auto-entity/src/lib/actions/load-many-actions.ts +++ b/projects/ngrx-auto-entity/src/lib/actions/load-many-actions.ts @@ -14,7 +14,7 @@ import { EntityAction } from './entity-action'; * @param criteria - (optional) The custom criteria for this action * @param correlationId - (optional) A custom correlation id for this action; Use to correlate subsequent result actions */ -export class LoadAllIfNecessary extends EntityAction { +export class LoadManyIfNecessary extends EntityAction { constructor(type: new () => TModel, public maxAge?: number, public criteria?: any, correlationId?: string) { super(type, EntityActionTypes.LoadManyIfNecessary, correlationId); } diff --git a/projects/ngrx-auto-entity/src/lib/actions/util.ts b/projects/ngrx-auto-entity/src/lib/actions/util.ts index c3a917d..19b9956 100644 --- a/projects/ngrx-auto-entity/src/lib/actions/util.ts +++ b/projects/ngrx-auto-entity/src/lib/actions/util.ts @@ -16,10 +16,7 @@ export const setInfo = (type: any): IEntityInfo => { checkKeyName(type, modelName); return { modelType: type, - modelName, - pluralName: opts.pluralName, - uriName: opts.uriName, - transform: opts.transform + ...opts }; }; diff --git a/projects/ngrx-auto-entity/src/lib/decorators/entity-util.ts b/projects/ngrx-auto-entity/src/lib/decorators/entity-util.ts index 52dd0a8..cee15d9 100644 --- a/projects/ngrx-auto-entity/src/lib/decorators/entity-util.ts +++ b/projects/ngrx-auto-entity/src/lib/decorators/entity-util.ts @@ -1,3 +1,4 @@ +import { camelCase } from '../../util/case'; import { pipe } from '../../util/func'; import { TNew } from '../actions/model-constructor'; import { EntityComparer, IEntityOptions, IEntityTransformer } from './entity-options'; @@ -22,6 +23,8 @@ export const entityOptions = (entityOrType: TNew | TModel | TMod getEntityOptions )(entityOrType); +export const entityStateName = (modelName: string): string => camelCase(modelName); + export const nameOfEntity = (entityOrType: TNew | TModel): string | undefined => entityOptions(entityOrType).modelName; @@ -31,6 +34,9 @@ export const uriNameOfEntity = (entityOrType: TNew | TModel): st export const pluralNameOfEntity = (entityOrType: TNew | TModel): string | null | undefined => entityOptions(entityOrType).pluralName; +export const stateNameOfEntity = (entityOrType: TNew | TModel): string | null | undefined => + entityStateName(entityOptions(entityOrType).modelName); + export const mapComparer = (options: IEntityOptions, name: string): EntityComparer => !!options.comparers ? typeof options.comparers[name] === 'string' diff --git a/projects/ngrx-auto-entity/src/lib/effects/effects-all.ts b/projects/ngrx-auto-entity/src/lib/effects/effects-all.ts index 1170b64..ede8754 100644 --- a/projects/ngrx-auto-entity/src/lib/effects/effects-all.ts +++ b/projects/ngrx-auto-entity/src/lib/effects/effects-all.ts @@ -5,6 +5,7 @@ import { Observable } from 'rxjs'; import { ofEntityAction } from '../actions/action-operators'; import { EntityActionTypes } from '../actions/action-types'; +import { EntityIfNecessaryOperators } from './if-necessary-operators'; import { EntityOperators } from './operators'; /** @@ -14,35 +15,65 @@ import { EntityOperators } from './operators'; @Injectable() export class EntityEffects { @Effect() - load$: Observable = this.actions$.pipe( + load$ = this.actions$.pipe( ofEntityAction(EntityActionTypes.Load), this.ops.load() ); + @Effect() + loadIfNecessary$ = this.actions$.pipe( + ofEntityAction(EntityActionTypes.LoadIfNecessary), + this.ifnOps.loadIfNecessary() + ); + @Effect() loadAll$ = this.actions$.pipe( ofEntityAction(EntityActionTypes.LoadAll), this.ops.loadAll() ); + @Effect() + loadAllIfNecessary$ = this.actions$.pipe( + ofEntityAction(EntityActionTypes.LoadAllIfNecessary), + this.ifnOps.loadAllIfNecessary() + ); + @Effect() loadMany$ = this.actions$.pipe( ofEntityAction(EntityActionTypes.LoadMany), this.ops.loadMany() ); + @Effect() + loadManyIfNecessary$ = this.actions$.pipe( + ofEntityAction(EntityActionTypes.LoadManyIfNecessary), + this.ifnOps.loadManyIfNecessary() + ); + @Effect() loadPage$ = this.actions$.pipe( ofEntityAction(EntityActionTypes.LoadPage), this.ops.loadPage() ); + @Effect() + loadPageIfNecessary$ = this.actions$.pipe( + ofEntityAction(EntityActionTypes.LoadPageIfNecessary), + this.ifnOps.loadPageIfNecessary() + ); + @Effect() loadRange$ = this.actions$.pipe( ofEntityAction(EntityActionTypes.LoadRange), this.ops.loadRange() ); + @Effect() + loadRangeIfNecessary$ = this.actions$.pipe( + ofEntityAction(EntityActionTypes.LoadRangeIfNecessary), + this.ifnOps.loadRangeIfNecessary() + ); + @Effect() create$: Observable = this.actions$.pipe( ofEntityAction(EntityActionTypes.Create), @@ -115,5 +146,5 @@ export class EntityEffects { this.ops.deleteManyByKey() ); - constructor(private actions$: Actions, private ops: EntityOperators) {} + constructor(private actions$: Actions, private ops: EntityOperators, private ifnOps: EntityIfNecessaryOperators) {} } diff --git a/projects/ngrx-auto-entity/src/lib/effects/effects-loads.ts b/projects/ngrx-auto-entity/src/lib/effects/effects-loads.ts index 1c76ff4..82a4d05 100644 --- a/projects/ngrx-auto-entity/src/lib/effects/effects-loads.ts +++ b/projects/ngrx-auto-entity/src/lib/effects/effects-loads.ts @@ -1,7 +1,5 @@ import { Injectable } from '@angular/core'; import { Actions, Effect } from '@ngrx/effects'; -import { Action } from '@ngrx/store'; -import { Observable } from 'rxjs'; import { ofEntityAction } from '../actions/action-operators'; import { EntityActionTypes } from '../actions/action-types'; @@ -10,7 +8,7 @@ import { EntityOperators } from './operators'; @Injectable() export class LoadEffects { @Effect() - load$: Observable = this.actions$.pipe( + load$ = this.actions$.pipe( ofEntityAction(EntityActionTypes.Load), this.ops.load() ); diff --git a/projects/ngrx-auto-entity/src/lib/effects/if-necessary-loads.ts b/projects/ngrx-auto-entity/src/lib/effects/if-necessary-loads.ts new file mode 100644 index 0000000..0e0cf53 --- /dev/null +++ b/projects/ngrx-auto-entity/src/lib/effects/if-necessary-loads.ts @@ -0,0 +1,40 @@ +import { Injectable } from '@angular/core'; +import { Actions, Effect } from '@ngrx/effects'; +import { ofEntityAction } from '../actions/action-operators'; +import { EntityActionTypes } from '../actions/action-types'; +import { EntityIfNecessaryOperators } from './if-necessary-operators'; + +@Injectable() +export class LoadIfNecessaryEffects { + @Effect() + loadIfNecessary$ = this.actions$.pipe( + ofEntityAction(EntityActionTypes.LoadIfNecessary), + this.ifnOps.loadIfNecessary() + ); + + @Effect() + loadAllIfNecessary$ = this.actions$.pipe( + ofEntityAction(EntityActionTypes.LoadAllIfNecessary), + this.ifnOps.loadAllIfNecessary() + ); + + @Effect() + loadManyIfNecessary$ = this.actions$.pipe( + ofEntityAction(EntityActionTypes.LoadManyIfNecessary), + this.ifnOps.loadManyIfNecessary() + ); + + @Effect() + loadPageIfNecessary$ = this.actions$.pipe( + ofEntityAction(EntityActionTypes.LoadPageIfNecessary), + this.ifnOps.loadPageIfNecessary() + ); + + @Effect() + loadRangeIfNecessary$ = this.actions$.pipe( + ofEntityAction(EntityActionTypes.LoadRangeIfNecessary), + this.ifnOps.loadRangeIfNecessary() + ); + + constructor(private actions$: Actions, private ifnOps: EntityIfNecessaryOperators) {} +} diff --git a/projects/ngrx-auto-entity/src/lib/effects/if-necessary-operators.spec.ts b/projects/ngrx-auto-entity/src/lib/effects/if-necessary-operators.spec.ts new file mode 100644 index 0000000..b93dad6 --- /dev/null +++ b/projects/ngrx-auto-entity/src/lib/effects/if-necessary-operators.spec.ts @@ -0,0 +1,143 @@ +import { TestBed } from '@angular/core/testing'; +import { provideMockActions } from '@ngrx/effects/testing'; +import { Store } from '@ngrx/store'; +import { MockStore, provideMockStore } from '@ngrx/store/testing'; +import { hot } from 'jasmine-marbles'; +import { Observable } from 'rxjs'; +import { LoadIfNecessary } from '../actions/load-actions'; +import { Entity } from '../decorators/entity-decorator'; +import { Key } from '../decorators/key-decorator'; +import { EntityIfNecessaryOperators, NGRX_AUTO_ENTITY_APP_STORE } from './if-necessary-operators'; + +@Entity('Test') +class Test { + @Key id: number; +} + +@Entity('TestMaxAge', { + defaultMaxAge: 300 +}) +class TestMaxAge { + @Key id: number; +} + +export function testStoreFactory(store: Store) { + return store; +} + +describe('EntityIfNecessaryOperators', () => { + let actions$: Observable; + + beforeEach(() => + TestBed.configureTestingModule({ + providers: [ + EntityIfNecessaryOperators, + { provide: NGRX_AUTO_ENTITY_APP_STORE, useFactory: testStoreFactory, deps: [Store] }, + provideMockActions(() => actions$), + provideMockStore({ + initialState: { + test: { + entities: {}, + ids: [] + }, + testMaxAge: { + entities: { 1: {}, 2: {} }, + ids: [1, 2], + loadedAt: new Date().setMinutes(new Date().getMinutes() - 6) + } + } + }) + ] + }) + ); + + describe('loadIfNecessary()', () => { + test('should filter out action if no loadedAt or entities in state', () => { + const operators: EntityIfNecessaryOperators = TestBed.get(EntityIfNecessaryOperators); + const action = new LoadIfNecessary(Test, 123); + actions$ = hot('-a', { a: action }); + + const operated = actions$.pipe(operators.loadIfNecessary()); + + const expected = hot('--'); + expect(operated).toBeObservable(expected); + }); + + test('should filter out action if loadedAt set but no entities in state', () => { + const operators: EntityIfNecessaryOperators = TestBed.get(EntityIfNecessaryOperators); + const store: MockStore = TestBed.get(Store); + const action = new LoadIfNecessary(Test, 123); + actions$ = hot('-a', { a: action }); + + store.setState({ + test: { + entities: {}, + ids: [], + loadedAt: Date.now() + } + }); + + const operated = actions$.pipe(operators.loadIfNecessary()); + + const expected = hot('--'); + expect(operated).toBeObservable(expected); + }); + + test('should filter out action if loadedAt and entities in state but expired by maxAge', () => { + const operators: EntityIfNecessaryOperators = TestBed.get(EntityIfNecessaryOperators); + const store: MockStore = TestBed.get(Store); + const action = new LoadIfNecessary(Test, 123, 600); + actions$ = hot('-a', { a: action }); + + store.setState({ + test: { + entities: { 1: {}, 2: {} }, + ids: [1, 2], + loadedAt: new Date().setMinutes(new Date().getMinutes() - 11) + } + }); + + const operated = actions$.pipe(operators.loadIfNecessary()); + + const expected = hot('--'); + expect(operated).toBeObservable(expected); + }); + + test('should filter out action if loadedAt and entities in state but expired by defaultMaxAge', () => { + const operators: EntityIfNecessaryOperators = TestBed.get(EntityIfNecessaryOperators); + const action = new LoadIfNecessary(TestMaxAge, 123); + actions$ = hot('-a', { a: action }); + + const operated = actions$.pipe(operators.loadIfNecessary()); + + const expected = hot('--'); + expect(operated).toBeObservable(expected); + }); + + test('should emit new Load action if all state present and maxAge not met', () => { + const operators: EntityIfNecessaryOperators = TestBed.get(EntityIfNecessaryOperators); + const store: MockStore = TestBed.get(Store); + const action = new LoadIfNecessary(Test, 123, 600); + actions$ = hot('-a', { a: action }); + + store.setState({ + test: { + entities: { 1: {}, 2: {}, 123: {} }, + ids: [1, 2, 123], + loadedAt: new Date().setMinutes(new Date().getMinutes() - 8) + } + }); + + const operated = actions$.pipe(operators.loadIfNecessary()); + + const expected = hot('-b', { + b: expect.objectContaining({ + actionType: '[Entity] (Generic) Load', + keys: 123, + type: '[Test] (Generic) Load' + }) + }); + expect(operated).toBeObservable(expected); + }); + }); +}); diff --git a/projects/ngrx-auto-entity/src/lib/effects/if-necessary-operators.ts b/projects/ngrx-auto-entity/src/lib/effects/if-necessary-operators.ts new file mode 100644 index 0000000..3b59a02 --- /dev/null +++ b/projects/ngrx-auto-entity/src/lib/effects/if-necessary-operators.ts @@ -0,0 +1,218 @@ +import { Injectable, InjectionToken } from '@angular/core'; +import { Store } from '@ngrx/store'; +import { combineLatest, Observable, of } from 'rxjs'; +import { filter, map, mergeMap } from 'rxjs/operators'; +import { pipe } from '../../util/func'; +import { IEntityInfo } from '../actions/entity-info'; +import { Load, LoadIfNecessary } from '../actions/load-actions'; +import { LoadAll, LoadAllIfNecessary } from '../actions/load-all-actions'; +import { LoadMany, LoadManyIfNecessary } from '../actions/load-many-actions'; +import { LoadPage, LoadPageIfNecessary } from '../actions/load-page-actions'; +import { LoadRange, LoadRangeIfNecessary } from '../actions/load-range-actions'; +import { entityStateName } from '../decorators/entity-util'; +import { Page, Range } from '../models'; +import { EntityIdentity } from '../types/entity-identity'; +import { IEntityState } from '../util/entity-state'; + +export const NGRX_AUTO_ENTITY_APP_STORE = new InjectionToken('NgRx Auto-Entity App Store'); + +const getEntityState = (info: IEntityInfo) => (state: any): IEntityState => + state[entityStateName(info.modelName)] as IEntityState; +const getLoadedAt = (state: IEntityState): number => state.loadedAt; +const getCurrentPage = (state: IEntityState): Page => state.currentPage; +const getCurrentRange = (state: IEntityState): Range => state.currentRange; +const getEntityIds = (state: IEntityState): EntityIdentity[] => state.ids; +const mapToHasEntities = (ids?: EntityIdentity[]): boolean => !!ids && !!ids.length; + +const entityLoadedAt = (info: IEntityInfo) => + pipe( + getEntityState(info), + getLoadedAt + ); + +const entityCurrentPage = (info: IEntityInfo) => + pipe( + getEntityState(info), + getCurrentPage + ); + +const entityCurrentRange = (info: IEntityInfo) => + pipe( + getEntityState(info), + getCurrentRange + ); + +const entityIds = (info: IEntityInfo) => + pipe( + getEntityState(info), + getEntityIds + ); + +const hasEntitiesLoaded = (info: IEntityInfo) => + pipe( + getEntityState(info), + getEntityIds, + mapToHasEntities + ); + +const addSeconds = (date: Date, seconds: number): Date => (date.setSeconds(date.getSeconds() + seconds), date); +const nowAfterExpiry = (expiry: Date): boolean => expiry < new Date(); +const isSubsequentRange = (a: any, b: any) => + (a.start || a.first || a.skip + a.take) > (b.end || b.last || b.skip + b.take); + +@Injectable() +export class EntityIfNecessaryOperators { + constructor(private store: Store) {} + + loadIfNecessary() { + return (source: Observable>) => + source.pipe( + mergeMap(({ info, keys, maxAge, criteria, correlationId }) => + combineLatest([ + this.store.select(entityLoadedAt(info)), + this.store.select(hasEntitiesLoaded(info)), + of(info.defaultMaxAge), + this.store.select(entityIds(info)) + ]).pipe( + map(([loadedAt, hasEntities, defaultMaxAge, ids]) => ({ + loadedAt, + hasEntities, + defaultMaxAge, + missing: !loadedAt || !hasEntities || (!!ids && ids.indexOf(keys) === -1), + checkAge: !!defaultMaxAge || !!maxAge + })), + filter( + ({ missing, checkAge, loadedAt, defaultMaxAge }) => + !( + missing || + (checkAge ? nowAfterExpiry(addSeconds(new Date(loadedAt), maxAge || defaultMaxAge)) : missing) + ) + ), + map(() => new Load(info.modelType, keys, criteria, correlationId)) + ) + ) + ); + } + + loadAllIfNecessary() { + return (source: Observable>) => + source.pipe( + mergeMap(({ info, maxAge, criteria, correlationId }) => + combineLatest([ + this.store.select(entityLoadedAt(info)), + this.store.select(hasEntitiesLoaded(info)), + of(info.defaultMaxAge) + ]).pipe( + map(([loadedAt, hasEntities, defaultMaxAge]) => ({ + loadedAt, + hasEntities, + defaultMaxAge, + missing: !loadedAt || !hasEntities, + checkAge: !!defaultMaxAge || !!maxAge + })), + filter( + ({ missing, checkAge, loadedAt, defaultMaxAge }) => + !( + missing || + (checkAge ? nowAfterExpiry(addSeconds(new Date(loadedAt), maxAge || defaultMaxAge)) : missing) + ) + ), + map(() => new LoadAll(info.modelType, criteria, correlationId)) + ) + ) + ); + } + + loadManyIfNecessary() { + return (source: Observable>) => + source.pipe( + mergeMap(({ info, maxAge, criteria, correlationId }) => + combineLatest([ + this.store.select(entityLoadedAt(info)), + this.store.select(hasEntitiesLoaded(info)), + of(info.defaultMaxAge) + ]).pipe( + map(([loadedAt, hasEntities, defaultMaxAge]) => ({ + loadedAt, + hasEntities, + defaultMaxAge, + missing: !loadedAt || !hasEntities, + checkAge: !!defaultMaxAge || !!maxAge + })), + filter( + ({ missing, checkAge, loadedAt, defaultMaxAge }) => + !( + missing || + (checkAge ? nowAfterExpiry(addSeconds(new Date(loadedAt), maxAge || defaultMaxAge)) : missing) + ) + ), + map(() => new LoadMany(info.modelType, criteria, correlationId)) + ) + ) + ); + } + + loadPageIfNecessary() { + return (source: Observable>) => + source.pipe( + mergeMap(({ info, page, maxAge, criteria, correlationId }) => + combineLatest([ + this.store.select(entityLoadedAt(info)), + this.store.select(hasEntitiesLoaded(info)), + of(info.defaultMaxAge), + this.store.select(entityCurrentPage(info)) + ]).pipe( + map(([loadedAt, hasEntities, defaultMaxAge, currentPage]) => ({ + loadedAt, + hasEntities, + defaultMaxAge, + missing: !loadedAt || !hasEntities, + samePage: page.page === currentPage.page, + checkAge: !!defaultMaxAge || !!maxAge + })), + filter( + ({ missing, samePage, checkAge, loadedAt, defaultMaxAge }) => + !( + missing || + samePage || + (checkAge ? nowAfterExpiry(addSeconds(new Date(loadedAt), maxAge || defaultMaxAge)) : missing) + ) + ), + map(() => new LoadPage(info.modelType, page, criteria, correlationId)) + ) + ) + ); + } + + loadRangeIfNecessary() { + return (source: Observable>) => + source.pipe( + mergeMap(({ info, range, maxAge, criteria, correlationId }) => + combineLatest([ + this.store.select(entityLoadedAt(info)), + this.store.select(hasEntitiesLoaded(info)), + of(info.defaultMaxAge), + this.store.select(entityCurrentRange(info)) + ]).pipe( + map(([loadedAt, hasEntities, defaultMaxAge, currentRange]) => ({ + loadedAt, + hasEntities, + defaultMaxAge, + missing: !loadedAt || !hasEntities, + nonFollowingRange: !isSubsequentRange(range, currentRange), + checkAge: !!defaultMaxAge || !!maxAge + })), + filter( + ({ missing, nonFollowingRange, checkAge, loadedAt, defaultMaxAge }) => + !( + missing || + nonFollowingRange || + (checkAge ? nowAfterExpiry(addSeconds(new Date(loadedAt), maxAge || defaultMaxAge)) : missing) + ) + ), + map(() => new LoadRange(info.modelType, range, criteria, correlationId)) + ) + ) + ); + } +} diff --git a/projects/ngrx-auto-entity/src/lib/module.ts b/projects/ngrx-auto-entity/src/lib/module.ts index 9d90e05..6e64212 100644 --- a/projects/ngrx-auto-entity/src/lib/module.ts +++ b/projects/ngrx-auto-entity/src/lib/module.ts @@ -4,6 +4,7 @@ import { META_REDUCERS } from '@ngrx/store'; import { EntityEffects } from './effects/effects-all'; import { ExtraEffects } from './effects/effects-extra'; +import { EntityIfNecessaryOperators } from './effects/if-necessary-operators'; import { EntityOperators } from './effects/operators'; import { autoEntityMetaReducer } from './reducer/reducer'; import { NgrxAutoEntityService } from './service/service'; @@ -21,6 +22,7 @@ export interface NgRxAutoEntityModuleConfig { @NgModule({ providers: [ EntityOperators, + EntityIfNecessaryOperators, EntityEffects, ExtraEffects, { provide: META_REDUCERS, useFactory: getNgRxAutoEntityMetaReducer, multi: true } @@ -50,6 +52,7 @@ export class NgRxAutoEntityRootModuleWithEffects { @NgModule({ providers: [ EntityOperators, + EntityIfNecessaryOperators, ExtraEffects, { provide: META_REDUCERS, useFactory: getNgRxAutoEntityMetaReducer, multi: true } ] @@ -77,6 +80,7 @@ export class NgRxAutoEntityRootModuleNoEntityEffects { @NgModule({ providers: [ EntityOperators, + EntityIfNecessaryOperators, ExtraEffects, { provide: META_REDUCERS, useFactory: getNgRxAutoEntityMetaReducer, multi: true } ] diff --git a/projects/ngrx-auto-entity/src/lib/util/state-builder.ts b/projects/ngrx-auto-entity/src/lib/util/state-builder.ts index ba4697f..01a1af9 100644 --- a/projects/ngrx-auto-entity/src/lib/util/state-builder.ts +++ b/projects/ngrx-auto-entity/src/lib/util/state-builder.ts @@ -1,8 +1,7 @@ import { createSelector, MemoizedSelector } from '@ngrx/store'; - -import { camelCase } from '../../util/case'; import { IEntityOptions } from '../decorators/entity-options'; import { ENTITY_OPTS_PROP, NAE_KEY_NAMES, NAE_KEYS } from '../decorators/entity-tokens'; +import { entityStateName } from '../decorators/entity-util'; import { EntityIdentity } from '../types/entity-identity'; import { IEntityState } from './entity-state'; import { buildFacade } from './facade-builder'; @@ -82,17 +81,17 @@ export const buildState = , TParentState ext const opts = type[ENTITY_OPTS_PROP]; ensureModelName(opts); - const modelName = camelCase(opts.modelName); + const stateName = entityStateName(opts.modelName); const getState = (state: TParentState): TState & TExtra => { - const modelState = state[modelName]; + const modelState = state[stateName]; if (!modelState) { - const message = `State for model ${opts.modelName} could not be found! Make sure you add your entity state to the parent state with a property named exactly '${modelName}'.`; + const message = `State for model ${opts.modelName} could not be found! Make sure you add your entity state to the parent state with a property named exactly '${stateName}'.`; const example = ` Example app state: export interface AppState { // ... other states ... - ${modelName}: IEntityState<${opts.modelName}>, + ${stateName}: IEntityState<${opts.modelName}>, // ... other states ... }`; console.error('[NGRX-AE] ! ' + message + example); @@ -145,7 +144,7 @@ export const buildFeatureState = , TParentSt const opts = type[ENTITY_OPTS_PROP]; ensureModelName(opts); - const modelName = camelCase(opts.modelName); + const stateName = entityStateName(opts.modelName); (type as any)[FEATURE_AFFINITY] = featureStateName; @@ -153,20 +152,20 @@ export const buildFeatureState = , TParentSt selectParentState, (state: TParentState) => { if (!state) { - const message = `Could not retrieve feature state ${featureStateName} for model ${opts.modelName}! Make sure you add your entity state to the feature state with a property named exactly '${modelName}'.`; + const message = `Could not retrieve feature state ${featureStateName} for model ${opts.modelName}! Make sure you add your entity state to the feature state with a property named exactly '${stateName}'.`; const example = ` Example app state: export interface FeatureState { // ... other states ... - ${modelName}: IEntityState<${opts.modelName}>, + ${stateName}: IEntityState<${opts.modelName}>, // ... other states ... }`; console.error('[NGRX-AE] ! ' + message + example); throw new Error(message); } - const modelState = state[modelName]; + const modelState = state[stateName]; if (!modelState) { - const message = `State for model ${modelName} in feature ${featureStateName} could not be found!`; + const message = `State for model ${opts.modelName} in feature ${featureStateName} could not be found!`; console.error('[NGRX-AE] ! ' + message); throw new Error(message); }