diff --git a/.prettierrc b/.prettierrc index fab40020..7a29c24c 100644 --- a/.prettierrc +++ b/.prettierrc @@ -1,5 +1,5 @@ { - "printWidth": 80, + "printWidth": 140, "singleQuote": true, "tabWidth": 2 } diff --git a/CHANGELOG.md b/CHANGELOG.md index 29dab6ef..e96eb387 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,13 +5,18 @@ Please look there. **_This_** Changelog covers changes to the repository and the demo applications. - + +# 0.6.2 (2018-06-26) + +* Significantly refactored for ngrx-data `6.0.2-beta.7`. + + # 0.6.1 (2018-05-25) * Refactored for EntityAction operators as required by Beta 6 * Add example of extending EntityDataServices with custom `HeroDataService` as described in `entity-dataservice.md` (#151). - + # 0.6.0 (2018-05-08) @@ -30,7 +35,7 @@ which still works.
- + # 0.2.13 (2018-05-04) @@ -46,7 +51,7 @@ The downside is that "Go to definition" takes you to the `d.ts` file rather than That's inconvenient. But the benefit is that the routine build process reflects what apps will experience. This is the approach followed by the Angular CLI's library support. - + # 0.2.12 (2018-03-25) @@ -55,19 +60,19 @@ This is the approach followed by the Angular CLI's library support. * Opted not to force trailing slash because as contrary to the goal of putting dev in complete control of url generation. May re-evaluate that decision later. - + # 0.2.11 (2018-03-19) * demonstrates `HttpResourceUrls` setting in config (new in beta.2) - + # 0.2.10 (2018-03-15) * Update ngPackagr from -rc to v.2.2 - + # 0.2.9 (2018-03-14) @@ -77,13 +82,13 @@ This is the approach followed by the Angular CLI's library support. None of these changes should break anything or interfere with creating the library package. - + # 0.2.8 (2018-03-12) * Update app to align with app in ngrx-data-lab (much cleaner) - + # 0.2.7 (2018-03-09) @@ -101,7 +106,7 @@ This `VillainEditor` also shows Depends on alpha.14 - + # 0.2.6 (2018-03-05) @@ -121,7 +126,7 @@ Sample revised * Those components now start with the cached version of the `getAll()` results. Press refresh or toggle the datasource to trigger a new `getAll()` - + # 0.2.5 (2018-03-05) @@ -129,19 +134,19 @@ Add HeroesComponent tests to illustrate how one might write test components. Exp Requires Alpha.11 - + # 0.2.4 (2018-02-26) App refactors based on learnings from our Angular Awesome workshop. - + # 0.2.3 (2018-02-24) Adapt to alpha.10 - + # 0.2.2 (2018-02-23) @@ -151,7 +156,7 @@ Revises the demo app and updates the docs to conform to alpha.9 * Updates the `EntityMetadata` * Adds `HeroesV1Component` to illustrate using `EntityCollectionServiceFactory` directly w/o `HeroService`. - + # 0.2.1 (2018-02-19) @@ -171,7 +176,7 @@ Revises the demo app and updates the docs to conform to alpha.9 Should not affect the builds of the _ngrx-data lib packages_! - + # 0.2.0 (2018-02-13) @@ -185,7 +190,7 @@ you must upgrade _ngrx_ to v5.1 or later, because the reducer uses the "upsert" feature, new in `@ngrx/entity` v5.1, for `QUERY_ONE_SUCCESS` and `QUERY_MANY_SUCCESS`. - + # 0.1.0 (2018-02-04) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 011406f5..db328b2b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,6 +1,6 @@ # Contributing to angular-ngrx-data -This project welcomes contributions and suggestions. Most contributions require you to agree to a +This project welcomes contributions and suggestions. Most contributions require you to agree to a Contributor License Agreement (CLA) declaring that you have the right to, and actually do, grant us the rights to use your contribution. For details, visit https://cla.microsoft.com. @@ -12,34 +12,38 @@ This project has adopted the [Microsoft Open Source Code of Conduct](https://ope For more information see the [Code of Conduct FAQ](https://opensource.microsoft.com/codeofconduct/faq/) or contact [opencode@microsoft.com](mailto:opencode@microsoft.com) with any additional questions or comments. - - [Code of Conduct](#coc) - - [Issues and Bugs](#issue) - - [Feature Requests](#feature) - - [Submission Guidelines](#submit) +* [Code of Conduct](#coc) +* [Issues and Bugs](#issue) +* [Feature Requests](#feature) +* [Submission Guidelines](#submit) + +## Code of Conduct -## Code of Conduct Help us keep this project open and inclusive. Please read and follow our [Code of Conduct](https://opensource.microsoft.com/codeofconduct/). -## Found an Issue? +## Found an Issue? + If you find a bug in the source code or a mistake in the documentation, you can help us by [submitting an issue](#submit-issue) to the GitHub Repository. Even better, you can [submit a Pull Request](#submit-pr) with a fix. -## Want a Feature? -You can *request* a new feature by [submitting an issue](#submit-issue) to the GitHub -Repository. If you would like to *implement* a new feature, please submit an issue with +## Want a Feature? + +You can _request_ a new feature by [submitting an issue](#submit-issue) to the GitHub +Repository. If you would like to _implement_ a new feature, please submit an issue with a proposal for your work first, to be sure that we can use it. * **Small Features** can be crafted and directly [submitted as a Pull Request](#submit-pr). -## Submission Guidelines +## Submission Guidelines + +### Submitting an Issue -### Submitting an Issue Before you submit an issue, search the archive, maybe your question was already answered. If your issue appears to be a bug, and hasn't been reported, open a new issue. Help us to maximize the effort we can spend fixing issues and adding new -features, by not reporting duplicate issues. Providing the following information will increase the +features, by not reporting duplicate issues. Providing the following information will increase the chances of your issue being dealt with quickly: * **Overview of the Issue** - if an error is being thrown a non-minified stack trace helps @@ -53,7 +57,8 @@ chances of your issue being dealt with quickly: You can file new issues by providing the above information at the corresponding repository's issues link: https://github.com/[organization-name]/[repository-name]/issues/new]. -### Submitting a Pull Request (PR) +### Submitting a Pull Request (PR) + Before you submit your Pull Request (PR) consider the following guidelines: * Search the repository (https://github.com/[organization-name]/[repository-name]/pulls) for an open or closed PR @@ -65,6 +70,7 @@ Before you submit your Pull Request (PR) consider the following guidelines: * Push your fork to GitHub: * In GitHub, create a pull request * If we suggest changes then: + * Make the required updates. * Rebase your fork and force push to your GitHub repository (this will update your Pull Request): diff --git a/docs/entity-actions.md b/docs/entity-actions.md index 60c46d16..6f7f0fff 100644 --- a/docs/entity-actions.md +++ b/docs/entity-actions.md @@ -20,17 +20,19 @@ export interface EntityAction

extends Action { readonly entityName: string; readonly op: EntityOp; readonly payload?: P; - readonly label?: string; + readonly tag?: string; error?: Error; + skip?: boolean } ``` -* `type` - action name, typically generated from the `label` and the `op` +* `type` - action name, typically generated from the `tag` and the `op` * `entityName` - the name of the entity type * `op` - the name of an entity operation * `payload?` - the message data for the action. -* `label?` - the label to use within the generated type. If not specified, the `entityName` is the label. +* `tag?` - the tag to use within the generated type. If not specified, the `entityName` is the tag. * `error?` - an unexpected action processing error. +* `skip?` - true if downstream consumers should skip processing the action. The `type` is the only property required by _ngrx_. It is a string that uniquely identifies the action among the set of all the types of actions that can be dispatched to the store. @@ -48,13 +50,17 @@ that the _ngrx-data_ library can perform. The `payload` is conceptually the body of the message. Its type and content should fit the requirements of the operation to be performed. -The optional `label` appears in the generated `type` text when the `EntityActionFactory` creates this `EntityAction`. +The optional `tag` appears in the generated `type` text when the `EntityActionFactory` creates this `EntityAction`. -The `entityName` is the default label that appears between brackets in the formatted `type`, +The `entityName` is the default tag that appears between brackets in the formatted `type`, e.g., `'[Hero] ngrx-data/query-all'`. The `error` property indicates that something went wrong while processing the action. [See more below](#action-error). +The `skip` property tells downstream action receivers that they should skip the usual action processing. +This flag is usually missing and is implicitly false. +[See more below](#action-skip). + ## _EntityAction_ consumers The _ngrx-data_ library ignores the `Action.type`. @@ -72,7 +78,7 @@ You can create an `EntityAction` by hand if you wish. The _ngrx-data_ library considers _any action_ with an `entityName` and `op` properties to be an `EntityAction`. The `EntityActionFactory.create()` method helps you create a consistently well-formed `EntityAction` instance -whose `type` is a string composed from the `label` (the `entityName` by default) and the `op`. +whose `type` is a string composed from the `tag` (the `entityName` by default) and the `op`. For example, the default generated `Action.type` for the operation that queries the server for all heroes is `'[Hero] ngrx-data/query-all'`. @@ -84,7 +90,7 @@ For example, the default generated `Action.type` for the operation that queries Note that **_each entity type has its own \_unique_ `Action` for each operation\_**, as if you had created them individually by hand. -## Labeling the EntityAction +## Tagging the EntityAction A well-formed action `type` can tell the reader what changed and who changed it. @@ -95,10 +101,10 @@ So you can get the same behavior from several different actions, each with its own informative `type`, as long as they share the same `entityName` and `entityOp`. -The optional `label` parameter of the `EntityActionFactory.create()` method makes +The optional `tag` parameter of the `EntityActionFactory.create()` method makes it easy to produce meaningful _EntityActions_. -You don't have to specify a label. The `entityName` is the default label that appears between brackets in the formatted `type`, +You don't have to specify a tag. The `entityName` is the default tag that appears between brackets in the formatted `type`, e.g., `'[Hero] ngrx-data/query-all'`. Here's an example that uses the injectable `EntityActionFactory` to construct the default "query all heroes" action. @@ -118,11 +124,11 @@ Thanks to the _ngrx-data **effects**_, this produces _two_ actions in the log, t [Hero] ngrx-data/query-all-success ``` -This default `entityName` label identifies the action's target entity collection. +This default `entityName` tag identifies the action's target entity collection. But you can't understand the _context_ of the action from these log entries. You don't know who dispatched the action or why. The action `type` is too generic. -You can create a more informative action by providing a label that +You can create a more informative action by providing a tag that better describes what is happening and also make it easier to find where that action is dispatched by your code. @@ -164,7 +170,7 @@ store.dispatch(action); It triggers the HTTP request via _ngrx-data effects_, as in the previous examples. Just be aware that _ngrx-data effects_ uses the `EntityActionFactory` to create the second, success Action. -Without the `label` property, it produces a generic success action. +Without the `tag` property, it produces a generic success action. The log of the two action types will look like this: @@ -209,7 +215,7 @@ That's a lot of code to write, test, and maintain. With the help of _ngrx-data_, you don't write any of it. _Ngrx-data_ creates the _actions_ and the _dispatchers_, _reducers_, and _effects_ that respond to those actions. - + ## _EntityAction.error_ @@ -229,7 +235,25 @@ The `EntityEffects` will see that such an action has an error and will return th > This is the only way we've found to prevent a bad action from getting through the effect and triggering an HTTP request. - + + +## _EntityAction.skip_ + +The `skip` property tells downstream action receivers that they should skip the usual action processing. +This flag is usually missing and is implicitly false. + +The ngrx-data sets `skip=true` when you try to delete a new entity that has not been saved. +When the `EntityEffects.persist$` method sees this flag set true on the `EntityAction` envelope, +it skips the HTTP request and dispatches an appropriate `_SUCCESS` action with the +original request payload. + +This feature allows ngrx-data to avoid making a DELETE request when you try to delete an entity +that has been added to the collection but not saved. +Such a request would have failed on the server because there is no such entity to delete. + +See the [`EntityChangeTracker`](entity-change-tracker.md) page for more about change tracking. + + ## EntityCache-level actions diff --git a/docs/entity-change-tracker.md b/docs/entity-change-tracker.md index 9c6ef09f..39809847 100644 --- a/docs/entity-change-tracker.md +++ b/docs/entity-change-tracker.md @@ -1,51 +1,269 @@ # EntityChangeTracker -Can add the current value of a cached entity in that entity's -`collection.originalValues` map and later _revert_ the entity to -this _original value_. +Ngrx-data tracks entity changes that haven't yet been saved on the server. +It also preserves "original values" for these changes and you can revert them with _undo actions_. -This feature is most valuable during _optimistic_ saves that -add, update, and delete entity data in the remote storage (e.g., a database). +Change-tracking and _undo_ are important for applications that make _optimistic saves_. -## Change tracking and optimistic saves +## Optimistic versus Pessimistic save -The `EntityActions` whose operation names end in `_OPTIMISTIC` begin -an _optimistic_ save. -The default [`EntityCollectionReducer`](entity-reducer.md) _immediately_ updates the cached collection _before_ sending the HTTP request to do the same on the server. +An _optimistic save_ stores a new or changed entity in the cache _before making a save request to the server_. +It also removes an entity from the store _before making a delete request to the server_. -The operation is _optimistic_ because it is presumed to succeed more often than not. -The _pessimistic_ versions of these actions _do not update_ the cached collection -_until_ the server responds. +> The `EntityActions` whose operation names end in `_OPTIMISTIC` start +> an _optimistic_ save. + +Many apps are easier to build when saves are "optimistic" because +the changes are immediately available to application code that is watching collection selectors. +The app doesn't have to wait for confirmation that the entity operation succeeded on the server. + +A _pessimistic save_ doesn't update the store until the server until the server confirms that the save succeeded, +which ngrx-data then turns into a "SUCCESS" action that updates the collection. +With a _pessimistic_ save, the changes won't be available in the store + +This confirmation cycle can (and usually will) take significant time and the app has to account for that gap somehow. +The app could "freeze" the UX (perhaps with a modal spinner and navigation guards) until the confirmation cycle completes. +That's tricky code to write and race conditions are inevitable. +And it's difficult to hide this gap from the user and keep the user experience responsive. + +This isn't a problem with optimistic saves because the changed data are immediately available in the store. + +> The developer always has the option to wait for confirmation of an optimistic save. +> But the changed entity data will be in the store during that wait. ### Save errors -If the server request timeouts or the server rejects the save request, -the _nrgx_ reducer is called with an error action ending in `_ERROR`. +The downside of optimistic save is that _the save could fail_ for many reasons including lost connection, +timeout, or rejection by the server. -**The reducer does nothing with save errors.** +When the client or server rejects the save request, +the _nrgx_ `EntityEffect.persist$` dispatches an error action ending in `_ERROR`. -There is no harm if the operation was _pessimistic_. -The collection had not been updated so there is no obvious inconsistency with the state -of the entity on the server. +**The default entity reducer methods do nothing with save errors.** + +There is no issue if the operation was _pessimistic_. +The collection had not been updated so there is no obvious inconsistency between the state +of the entity in the collection and on the server. + +It the operation was _optimistic_, the entity in the cached collection has been added, removed, or updated. +The entity and the collection are no longer consistent with the state on the server. -It the operation was _optimistic_, the entity in the cached collection has been updated and is no longer -consistent with the entity's state on the server. That may be a problem for your application. -You might prefer that the entity be restored to a known server state, -such as the state of the entity when it was last queried or successfully saved. +If the save fails, the entity in cache no longer accurately reflects the state of the entity on the server. +While that can happen for other reasons (e.g., a different user changed the same data), +when you get a save error, you're almost certainly out-of-sync and should be able to do something about it. + +Change tracking gives the developer the option to respond to a server error +by dispatching an _undo action_ for the entity (or entities) and +thereby reverting the entity (or entities) to the last known server state. + +_Undo_ is NOT automatic. +You may have other save error recovery strategies that preserve the user's +unsaved changes. +It is up to you if and when to dispatch one of the `UNDO_...` actions. + +## Change Tracking + +The ngrx-data tracks an entity's change-state in the collection's `changeState` property. + +When change tracking is enabled (the default), the `changeState` is a _primary key to_ `changeState` _map_. + +> You can disable change tracking for an individual action or the collection as a whole as +> described [below](#enable-change-tracking). + +### _ChangeState_ + +A `changeState` map adheres to the following interface + +``` +export interface ChangeState { + changeType: ChangeType; + originalValue: T | undefined; +} + +export enum ChangeType { + Unchanged, // the entity has not been changed. + Added, // the entity was added to the collection + Updated, // the entity in the collection was updated + Deleted, // the entity is scheduled for delete and was removed from collection. +} +``` + +A _ChangeState_ describes an entity that changed since its last known server value. +The `changeType` property tells you how it changed. + +> `Unchanged` is an _implied_ state. +> Only changed entities are recorded in the collection's `changeState` property. +> If an entity's key is not present, assume it is `Unchanged` and has not changed since it was last +> retrieved from or successfully saved to the server. + +The _original value_ is the last known value from the server. +The `changeState` object holds an entity's _original value_ for _two_ of these states: _Updated_ and _Deleted_. +For an _Unchanged_ entity, the current value is the original value so there is no need to duplicate it. +There could be no original value for an entity this is added to the collection but no yet saved. + +## EntityActions and change tracking. + +The collection is created with an empty `changeState` map. + +### Recording a change state + +Many _EntityOp_ reducer methods will record an entity's change state. +Once an entity is recorded in the `changeState`, its `changeType` and `originalValue` generally do not change. +Once "added", "deleted" or "updated", an entity stays +that way until committed or undone. + +Delete (remove) is a special case with special rules. +[See below](#delete). + +Here are the most important `EntityOps` that record an entity in the `changeState` map: + +``` +// Optimistic save operations +SAVE_ADD_ONE_OPTIMISTIC +SAVE_DELETE_ONE_OPTIMISTIC +SAVE_UPDATE_ONE_OPTIMISTIC + +// Cache operations +ADD_ONE +ADD_MANY +REMOVE_ONE +REMOVE_MANY +UPDATE_ONE +UPDATE_MANY +UPSERT_ONE +UPSERT_MANY +``` + +### Removing an entity from the _changeState_ map. + +An entity which has no entry in the `ChangeState` map is presumed to be unchanged. + +The _commit_ and _undo_ operations remove entries from the `ChangeState` which means, in effect, that they are "unchanged." + +The **commit** operations simply remove entities from the `changeState`. +They have no other effect on the collection. + +The [**undo** operations](#undo) replace entities in the collection based on +information in the `changeState` map, reverting them their last known server-side state, and removing them from the `changeState` map. +These entities become "unchanged." + +An entity ceases to be in a changed state when the server returns a new version of the entity. +Operations that put that entity in the store also remove it from the `changeState` map. + +Here are the operations that remove one or more specified entities from the `changeState` map. + +``` +QUERY_BY_KEY_SUCCESS +QUERY_MANY_SUCCESS +SAVE_ADD_ONE_SUCCESS +SAVE_ADD_ONE_OPTIMISTIC_SUCCESS, +SAVE_DELETE_ONE_SUCCESS +SAVE_DELETE_ONE_OPTIMISTIC_SUCCESS +SAVE_UPDATE_ONE_SUCCESS +SAVE_UPDATE_ONE_OPTIMISTIC_SUCCESS +COMMIT_ONE +COMMIT_MANY +UNDO_ONE +UNDO_MANY +``` + +### Operations that clear the _changeState_ map. + +The `EntityOps` that replace or remove every entity in the collection also reset the `changeState` to an empty object. +All entities in the collection (if any) become "unchanged". + +``` +ADD_ALL +QUERY_ALL_SUCCESS +REMOVE_ALL +COMMIT_ALL +UNDO_ALL +``` + +Two of these may surprise you. + +1. `ADD_ALL` is interpreted as a cache load from a known state. + These entities are presumed _unchanged_. + If you have a different intent, use `ADD_MANY`. + +2. `REMOVE_ALL` is interpreted as a cache clear with nothing to save. If you have a different intent, use _removeMany_. + +You can (re)set the `changeState` to anything with `EntityOp.SET_CHANGE_STATE`. + +This is a super-powerful operation that you should rarely perform. +It's most useful if you've created your own entity action and are +modifying the collection in some unique way. + + + +## _Undo_ (revert) an unsaved change + +You have many options for handling an optimistic save error. +One of them is to revert the change to the entity's last known state on the server by dispatching an _undo_ action. + +There are three _undo_ `EntityOps` that revert entities: +`UNDO_ONE`, `UNDO_MANY` and `UNDO_ALL`. + +For `UNDO_ONE` and `UNDO_MANY`, the id(s) of the entities to revert are in the action payload. + +`UNDO_ALL` reverts every entity in the `changeState` map. + +Each entity is reverted as follows: + +* `ADDED` - Remove from the collection and discard + +* `DELETED` - Add the _original value_ of the removed entity to the collection. + If the collection is sorted, it will be moved into place. + If unsorted, it's added to the end of the collection. + +* `UPDATED` - Update the collection with the entity's _original value_. + +If you try to undo/revert an entity whose id is not in the `changeState` map, the action is silently ignored. + + + +### Deleting/removing entities + +There are special change tracking rules for deleting/removing an entity from the collection + +#### Added entities + +When you remove or delete an "added" entity, the change tracker removes the entity from the `changeState` map because there is no server state to which such an entity could be restored. + +The reducer methods that delete and remove entities should immediately remove an _added entity_ from the collection. + +> The default delete and remove reducer methods remove these entities immediately. + +They should not send HTTP DELETE requests to the server because these entities do not exist on the server. + +> The default `EntityEffects.persist$` effect does not make HTTP DELETE requests for these entities. + +#### Updated entities -If the _ChangeTracker_ had captured that state in the `entityCollection.originalValues` _before_ the entity was changed in cache, -the app could tell the tracker to revert the entity to that state when it -sees an HTTP save error. +An entity registered in the `changeState` map as "updated" +is reclassified as "deleted". +Its `originalValue` stays the same. +Undoing the change will restore the entity to the collection in its pre-update state. -### ChangeTracker _MetaReducers_ + -The _ngrx-data_ library has optional [_MetaReducers_](entity-reducer.md#collection-meta-reducers) -that can track the entity state before an optimistic save and revert the entity -to it state when last queried or successfully saved. +### Enabling and disabling change tracking -These MetaReducers are included by default. +You can opt-out of change tracking for a collection by setting the collection's `enableChangeTracking` flag to `false` in its `entityMetadata`. +When `false`, ngrx-data does not track any changes for this collection +and the `EntityCollection.changeState` property remains an empty object. ->You can remove or replace them during `NgrxDataModule` configuration. +You can also turnoff change tracking for a specific, cache-only action by choosing one of the +"no-tracking" `EntityOps`. They all end in "\_NO_TRACK". -_More on all of this soon_ +``` +ADD_ONE_NO_TRACK +ADD_MANY_NO_TRACK +REMOVE_ONE_NO_TRACK +REMOVE_MANY_NO_TRACK +UPDATE_ONE_NO_TRACK +UPDATE_MANY_NO_TRACK +UPSERT_ONE_NO_TRACK +UPSERT_MANY_NO_TRACK +``` diff --git a/docs/entity-collection-service.md b/docs/entity-collection-service.md index 647b7e93..09ebfed1 100644 --- a/docs/entity-collection-service.md +++ b/docs/entity-collection-service.md @@ -141,7 +141,7 @@ Rather than expect a result from the command, you subscribe to a _selector$_ property that reflects the effects of the command. If the command did something you care about, a _selector$_ property should be able to tell you about it. - + ## _EntityServiceFactory_ diff --git a/docs/entity-collection.md b/docs/entity-collection.md index 2db98eaf..06067bad 100644 --- a/docs/entity-collection.md +++ b/docs/entity-collection.md @@ -3,16 +3,16 @@ The _ngrx-data_ library maintains a _cache_ (`EntityCache`) of _entity collections_ for each _entity type_ in the _ngrx store_. -An _entity_collection_ implements the [`EntityCollection` interface](../lib/src/reducers/entity-reducer.ts). +An _entity_collection_ implements the [`EntityCollection` interface](../lib/src/reducers/entity-reducer.ts). -| Property | Meaning | -| ---------- |------------------------------------------| -| `ids` | Primary key values in default sort order | -| `entities` | Map of primary key to entity data values | -| `filter` | The user's filtering criteria | -| `loaded` | Whether collection was filled by QueryAll; forced false after clear | -| `loading` | Whether currently waiting for query results to arrive from the server | -| `originalValues` | When [change-tracking](change-tracker.md) is enabled, the original values of unsaved entities | +| Property | Meaning | +| ------------- | -------------------------------------------------------------------------------------------- | +| `ids` | Primary key values in default sort order | +| `entities` | Map of primary key to entity data values | +| `filter` | The user's filtering criteria | +| `loaded` | Whether collection was filled by QueryAll; forced false after clear | +| `loading` | Whether currently waiting for query results to arrive from the server | +| `changeState` | When [change-tracking](change-tracker.md) is enabled, the `ChangeStates` of unsaved entities | -You can extend an entity types with _additional properties_ via +You can extend an entity types with _additional properties_ via [entity metadata](entity-metadata.md#additional-collection-state). diff --git a/docs/entity-dataservice.md b/docs/entity-dataservice.md index 61ce437f..c4156112 100644 --- a/docs/entity-dataservice.md +++ b/docs/entity-dataservice.md @@ -77,7 +77,7 @@ get the collection resource name. The [_Entity Metadata_](entity-metadata.md#plurals) guide explains how to configure the default `Pluralizer` . - + ### Configure the _DefaultDataService_ @@ -133,13 +133,7 @@ It only overrides what it really needs. import { Injectable } from '@angular/core'; import { HttpClient } from '@angular/common/http'; -import { - EntityCollectionDataService, - DefaultDataService, - HttpUrlGenerator, - Logger, - QueryParams -} from 'ngrx-data'; +import { EntityCollectionDataService, DefaultDataService, HttpUrlGenerator, Logger, QueryParams } from 'ngrx-data'; import { Observable } from 'rxjs'; import { map } from 'rxjs/operators'; @@ -147,19 +141,13 @@ import { Hero } from '../../core'; @Injectable() export class HeroDataService extends DefaultDataService { - constructor( - http: HttpClient, - httpUrlGenerator: HttpUrlGenerator, - logger: Logger - ) { + constructor(http: HttpClient, httpUrlGenerator: HttpUrlGenerator, logger: Logger) { super('Hero', http, httpUrlGenerator); logger.log('Created custom Hero EntityDataService'); } getAll(): Observable { - return super - .getAll() - .pipe(map(heroes => heroes.map(hero => this.mapHero(hero)))); + return super.getAll().pipe(map(heroes => heroes.map(hero => this.mapHero(hero)))); } getById(id: string | number): Observable { @@ -167,9 +155,7 @@ export class HeroDataService extends DefaultDataService { } getWithQuery(params: string | QueryParams): Observable { - return super - .getWithQuery(params) - .pipe(map(heroes => heroes.map(hero => this.mapHero(hero)))); + return super.getWithQuery(params).pipe(map(heroes => heroes.map(hero => this.mapHero(hero)))); } private mapHero(hero: Hero): Hero { diff --git a/docs/entity-metadata.md b/docs/entity-metadata.md index 6ee81a2b..4850e07e 100644 --- a/docs/entity-metadata.md +++ b/docs/entity-metadata.md @@ -66,7 +66,7 @@ class LazyModule { } ``` - + ## Metadata Properties @@ -100,7 +100,7 @@ Importantly, the default [_entity dataservice_](docs/entity-dataservice.md) crea > Of course the proper plural of "hero" is "hero**es**", not "hero**s**". > You'll see how to correct this problem [below](#plurals). - + ### _filterFn_ @@ -139,7 +139,7 @@ export function nameAndSayingFilter(entities: Villain[], pattern: string) { } ``` - + ### _selectId_ @@ -158,7 +158,7 @@ The `selectorId` function is this: selectId: (villain: Villain) => villain.key; ``` - + ### _sortComparer_ @@ -186,7 +186,7 @@ Run the demo app and try changing existing hero names or adding new heroes. Your app can call the `selectKey` selector to see the collection's `ids` property, which returns an array of the collection's primary key values in sorted order. - + ### _entityDispatcherOptions_ @@ -200,7 +200,7 @@ If the caller doesn't specify, the dispatcher chooses based on default options. The _default_ defaults are the safe ones: _optimistic_ for delete and _pessimistic_ for add and update. You can override those choices here. - + ### _additionalCollectionState_ @@ -222,7 +222,7 @@ The property values become the initial collection values for those properties wh 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. - + ## Pluralizing the entity name diff --git a/docs/entity-reducer.md b/docs/entity-reducer.md index 4267d0c4..93a1a6ba 100644 --- a/docs/entity-reducer.md +++ b/docs/entity-reducer.md @@ -2,7 +2,7 @@ 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 **`EntityReducerFactory.create()`** method to produce that reducer, @@ -36,7 +36,7 @@ If it can't find a reducer for the entity type, it [creates one](#collection-red of the injected `EntityCollectionReducerFactory`, and registers that reducer so it can use it again next time. - + ### Register custom reducers @@ -47,7 +47,7 @@ You can register several custom reducers at the same time by calling `EntityReducerFactory.registerReducer(reducerMap)` where the `reducerMap` is a hash of reducers, keyed by _entity-type-name_. - + ## Default _EntityCollectionReducer_ @@ -76,7 +76,7 @@ cache altering operations performed by the default _entity collection reducer_. The [`EntityCollectionReducerFactory`](../lib/src/reducers/entity-collection-reducer.ts`) and its tests are the authority on how the default reducer actually works. - + ## Initializing collection state @@ -95,7 +95,7 @@ the creator returns an `EntityCollection`. The _entity reducer_ then passes the new collection in the `state` argument of the _entity collection reducer_. - + ## Customizing entity reducer behavior @@ -109,7 +109,7 @@ providing a custom alternative to the [`EntityReducerFactory`](#reducer-factory) But quite often you'd like to extend a _collection reducer_ with some additional reducer logic that runs before or after. - + ## EntityCache-level actions @@ -142,7 +142,7 @@ in error-recovery or "what-if" scenarios. If you want to create and reduce additional, cache-wide actions, consider the _EntityCache MetaReducer_, described in the next section. - + ## _MetaReducers_ @@ -167,7 +167,7 @@ Ngrx-data supports two levels of MetaReducer 1. _EntityCache MetaReducer_, scoped to the entire entity cache 1. _EntityCollection MetaReducer_, scoped to a particular collection. - + ### Entity Cache _MetaReducers_ @@ -184,7 +184,7 @@ An _EntityCache MetaReducer_ reducer must satisfy three requirements: > We intend to explain how in a documentation update. > For now, see the `ngrx-data.module.spec.ts` for examples. - + ### Entity Collection _MetaReducers_ diff --git a/docs/faq.md b/docs/faq.md index 69dfdd5d..ccde94e1 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -1,6 +1,6 @@ # Ngrx-data FAQs - + ## You said I'd never write an action. But what if ... @@ -14,7 +14,7 @@ You can customize almost anything, both at the single entity-type level and for But you ONLY do so when you want to do something unusual … and that, by definition, is not boilerplate. - + ## What is an _entity_? @@ -33,7 +33,7 @@ The application's **_entity model_** is the set of all entity types in your appl In some definitions, the _entity type_ and _entity model_ describe both the data and the _logic_ that govern that data such as data integrity rules (e.g., validations) and behaviors (e.g., calculations). The _current version_ of _ngrx-data_ library is unaware of entity logic beyond what is strictly necessary to persist entity data values. - + ## Is _ngrx-data_ the answer for everything? @@ -61,7 +61,7 @@ They are still worth managing with _ngrx_. It bears repeating: the _ngrx-data_ library is good for querying, caching, and saving _entity data_ ... and that's it. - + ## What is _ngrx_? @@ -73,7 +73,7 @@ querying, caching, and saving _entity data_ ... and that's it. [@ngrx/effects](https://github.com/ngrx/platform/blob/master/docs/effects/README.md), and [@ngrx/entity](https://github.com/ngrx/platform/blob/master/docs/entity/README.md). - + ## How is _ngrx-data_ different from _@ngrx/entity_? @@ -99,7 +99,7 @@ The store, the actions, the adapter, and the entity collections remain visible a The fixes and enhancements in future _@ngrx/entity_ versions flow through _ngrx-data_ to your application. - + ## What is _redux_? @@ -137,7 +137,7 @@ It differs most significantly in replacing _events_ with _observables_. _Ngrx_ relies on [RxJS Observables](#rxjs) to listen for store events, select those that matter, and push the selected object(s) to your application. - + ## What is _state_? @@ -156,7 +156,7 @@ You replace them with new objects, created through a merge of the previous prope Arrays are completely replaced with you add, remove, or replace any of their items. - + ## What are _RxJS Observables_ @@ -164,7 +164,7 @@ Arrays are completely replaced with you add, remove, or replace any of their ite Many Angular APIs produce _RxJS Observables_ so programming "reactively" with _Observables_ is familiar to many Angular developers. Search the web for many helpful resources on _RxJS_. - + ## What's wrong with code generation? diff --git a/docs/limitations.md b/docs/limitations.md index e292123b..7c3b44e2 100644 --- a/docs/limitations.md +++ b/docs/limitations.md @@ -2,7 +2,7 @@ The _ngrx-data_ library lacks many capabilities of a [full-featured entity management](#alternatives) system. -You may be able to work-around some of the limitations without too much effort, +You may be able to work-around some of the limitations without too much effort, particularly when the shortcomings are a problem for just a few entity types. This page lists many of the serious limitations we've recognized ourselves. @@ -17,8 +17,8 @@ We could use your help. ## Deep entity cloning -This library (like the [@ngrx/entity library](https://github.com/ngrx/platform/tree/master/docs/entity) -on which it depends) assumes that entity property values +This library (like the [@ngrx/entity library](https://github.com/ngrx/platform/tree/master/docs/entity) +on which it depends) assumes that entity property values are simple data types such as strings, numbers, and dates. Nothing enforces that assumption. @@ -26,8 +26,8 @@ Many web APIs return entity data with complex properties. A property value could be a _value type_ (e.g., a _money type_ that combines a currency indicator and an amount). It could have a nested structure (e.g., an address). -This library shallow-clones the entity data in the collections. -It doesn't clone complex, nested, or array properties. +This library shallow-clones the entity data in the collections. +It doesn't clone complex, nested, or array properties. You'll have to do the deep equality tests and cloning yourself _before_ asking _ngrx-data_ to save data. ## Non-normalized server responses @@ -45,11 +45,11 @@ This library lacks the tools to help you disaggregate and normalize server respo Entities are often related to each other via _foreign keys_. These relationships can be represented as a directed graph, often with cycles. -This library tacitly assumes that entities of different types are unrelated. +This library tacitly assumes that entities of different types are unrelated. It is completely unaware of _relationships_ and _foreign keys_ that may be implicit in the entity data. -It's up to you to make something out of those relationships and keys. +It's up to you to make something out of those relationships and keys. -It's not easy to represent relationships. +It's not easy to represent relationships. A `Customer` entity could have a one-to-many relationship with `Order` entities. The `Order` entity has an `order.customer` property whose value is the primary key @@ -74,7 +74,7 @@ as entities enter and leave the cache. There will be long chains of navigations (`Customer <-> Order <-> <-> LineItem <-> Product <-> Supplier`). How should these be implemented? -_Observable selector_ properties seem logical but thorny performance traps +_Observable selector_ properties seem logical but thorny performance traps await. ## Client-side primary key generation @@ -90,14 +90,14 @@ You can create new records offline or recover if your connection to the server breaks inconveniently during the save. It's easy to generate a new _guid_ (or _uuid_) key. -It's much harder to generate integer or semantic keys because +It's much harder to generate integer or semantic keys because you need a foolproof way to enforce uniqueness. Server-supplied keys greatly complicate maintenance of a cache of inter-related entities. You'll have to find a way to hold the related entities together until you can save them. Temporary-key generation is one approach. It requires complex key-fixup logic -to replace the temporary keys in _foreign key properties_ +to replace the temporary keys in _foreign key properties_ with the server-supplied permanent keys. ## Transactions @@ -110,7 +110,7 @@ The library doesn't support that yet. ## Data integrity rules -Entities are often governed by intra- and inter-entity validation rules. +Entities are often governed by intra- and inter-entity validation rules. The `Customer.name` property may be required. The `Order.shipDate` must be after the `Order.orderDate`. The parent `Order` of a `LineItem` may have to exist. @@ -122,7 +122,8 @@ It would be great if the library knew about the rules (in `EntityMetadata`?), ra These might be features in a future version of this library. - + + ## Server/client entity mapping The representation of an entity on the server may be different than on the client. @@ -144,7 +145,7 @@ But maybe you can't connect to the server. Where do you keep the user's pending, unsaved changes? One approach is to keep track of pending changes, either in a change-tracker or in the entity data themselves. -Then you might be able to use one of the +Then you might be able to use one of the [_cache-only_ commands](../lib/src/entity-metadata/entity-commands.ts) to put hold the entity in cache while you waited for restored connectivity. @@ -165,18 +166,18 @@ while adding your own entity actions and _ngrx effects_ for these operations. ## No explicit _SAVE_UPSERT_ The default `EntityEffects` supports saving a new or existing entity but does not have an explicit -SAVE_UPSERT action that would _official_ save +SAVE*UPSERT action that would \_official* save an entity which might be either new or existing. You may be able to add a new entity with `SAVE_UPDATE` or `SAVE_UPDATE_ONE_OPTIMISTIC`, -because the `EntityCollectionReducer` implements these actions +because the `EntityCollectionReducer` implements these actions by calling the collection `upsertOne()` method. Do this _only_ if your server supports _upsert-with-PUT_ requests. ## No request concurrency checking -The user saves a new `Customer`, followed by a query for all customers. +The user saves a new `Customer`, followed by a query for all customers. It the new customer in the query response? `Ngrx-data` does not coordinate save and query requests and does not guarantee order of responses. @@ -186,11 +187,10 @@ Here's some pseudo-code that might do that for the previous example: ```javascript // add new customer, then query all customers -customerService.addEntity(newCustomer) - .pipe( - concatMap(() => customerService.queryAll()) - ) - .subscribe(custs => this.customers = custs); +customerService + .addEntity(newCustomer) + .pipe(concatMap(() => customerService.queryAll())) + .subscribe(custs => (this.customers = custs)); ``` The same reasoning applies to _any_ request that must follow in a precise sequence. @@ -208,7 +208,7 @@ What's the actual address in the database? What's the address in the user's cach It could be any of the three addresses depending on when the server saw them and when the responses arrived. You cannot know. -Many applications maintain a concurrency property that guards against updating an entity +Many applications maintain a concurrency property that guards against updating an entity that was updated by someone else. The `ngrx-data` library is unaware of this protocol. You'll have to manage concurrency yourself. @@ -231,7 +231,8 @@ This library's `getWithQuery()` command takes a query specification in the form There is no apparatus for composing queries or sending them to the server except as a query string. - + + ## An alternative to _ngrx-data_ [BreezeJS](http://www.getbreezenow.com/breezejs) is a free, open source, @@ -241,5 +242,5 @@ Many Angular (and AngularJS) applications use _Breeze_ today. It's not the library for you if you **_require_** a small library that adheres to _reactive_, _immutable_, _redux-like_ principles. ->Disclosure: one of the _ngrx-data_ authors, Ward Bell, -is an original core Breeze contributor. +> Disclosure: one of the _ngrx-data_ authors, Ward Bell, +> is an original core Breeze contributor. diff --git a/docs/publishing-checklist.md b/docs/publishing-checklist.md index a11e1fdd..0178b914 100644 --- a/docs/publishing-checklist.md +++ b/docs/publishing-checklist.md @@ -3,9 +3,9 @@ Only a few of us are authorized to publish the npm package. Here is our checklist. -1. Confirm that the library builds cleanly and that the demo app can use it in production with `npm run build-all`. +1. Run `npm run build-all` to confirm that the library builds cleanly and that the demo app can use it in production. -1. Smoke-test the production app by running `lite-server --baseDir="dist/app"` which runs the prod app in port 3000. +1. Run `lite-server --baseDir="dist/app"`to smoke-test the production app. It runs the prod app in port 3000. 1. Run `ng lint`. Should be clean. diff --git a/lib/CHANGELOG.md b/lib/CHANGELOG.md index 8ae459a6..30f47d05 100644 --- a/lib/CHANGELOG.md +++ b/lib/CHANGELOG.md @@ -1,8 +1,614 @@ # Angular ngrx-data library ChangeLog - + -# 6.0.1-beta.6 (2018-05-24) +# 6.0.2-beta.7 (2018-06-26) + +This **major release** is primarily about the new, _change tracking_ feature, +which makes _optimistic saves_ a viable choice (possibly the preferred choice), +now that you can recover from a save error by undoing (reverting) to the last known state of the entities on the server. + +This new feature provoked a cascade of changes, most of them related to change tracking. + +**Many of them are breaking changes**. +Please pay close attention to the details of this release notification. + +## Features + +The new features that we think will be most widely appreciated are: + +* ChangeTracking + + * It makes optimistic saves a viable first choice + * It lets you accumulate unsaved-changes in cache and then save them together transactionally if your server supports that. + +* The `EntityService` query and save commands return an `Observable` result. + +* Multiple queries and saves can be in progress concurrently. + +* You can cancel long-running server requests with `EntityService.cancel(correlationId)`. + +* The `MergeQuerySet` enables bulk cache updates with multiple collection query results. + +Look for these features below. + +### Change Tracking + +EntityCollections, reducers, and selectors support change tracking and undo +via the rewritten `EntityChangeTracker` and the +new `EntityCollection.changeState` property which replaces the `originalValues` property. + +Previously change tracking and undo were an incomplete, alpha feature. + +The ngrx-data now tracks changes properly by default and you can **_undo unsaved changes_**, reverting to the last know state of entity (or entities) on the server. +This gives the developer a good option for recovering from optimistic saves that failed. + +#### Effect on delete + +Thanks to change tracking, we can tell when an entity that you're about to delete has been added locally but not saved to the server. +Ngrx-data now removes such entities immediately, even when the save is pessimistic, and silently side-steps the +HTTP DELETE request, which has nothing to remove on the server and might 404. + +In order to tell `EntityEffects.persist$` to skip such deletes, we had to add a mutable `EntityAction.skip` flag to the `EntityAction`. + +The `skip` flag is `false` by default. +Ngrx-data only sets it to `true` when the app tries to save the deletion of an added, unsaved entity. + +We're not thrilled about adding another mutable property to `EntityAction`. +But we do not know of another way to tell `EntityEffects` to skip the HTTP DELETE request which might otherwise have produced an error. + +#### Disable tracking + +The new `EntityMetadata.noChangeTracking` flag, which is `false` by default, +can be set `true` in the metadata for a collection. +When `true`, ngrx-data does not track any changes for this collection +and the `EntityCollection.changeState` property remains an empty object. + +You can also turnoff change tracking for a specific, cache-only action by choosing passing the `MergeStrategy.IgnoreTracking` as an option to the command. + +See [entity-change-tracker.md](../docs/entity-change-tracker.md) for discussion and details. + +### Other Features + +#### Dispatcher query and save methods return Observables + +The dispatcher query and save methods (`add`, `delete`, `update`, and `upsert`) used to return `void`. +That was architecturally consistent with the CQRS pattern in which (c)ommands never return a result. +Only (q)ueries in the guise of selectors returned values by way of Observables. + +The CQRS principle had to give way to practicality. +Real apps often "wait" (asynchronously of course) until the save result becomes known. +Ngrx-data saves are implemented with an ngrx _effect_ and +effects decouple the HTTP request from the server result. +It was difficult to know when a save operation completed, either successfully or with an error. + +In this release, **each of these methods return a terminating Observable of the operation result** which emits when the +server responds and after the reducers have applied the result to the collection. + +> Cache-only commands, which are synchronous, continue to return `void`. + +Now you can subscribe to these observables to learn when the server request completed and +to examine the result or error. + +``` +heroService.add(newHero).subscribe( + hero => ..., + error => ... +); +``` + +> See the `EntityServices` tests for examples. + +This feature simplifies scenarios that used to be challenging. +For example, you can query for the master record and then +query for its related child records as in the following example. + +``` +heroService.getByKey(42) + .pipe(hero => { + sideKickService.getWithQuery({heroId: hero.id}), + map(sideKicks => {hero, sideKicks}) + }) + .subscribe((results: {hero: Hero, sideKicks: SideKicks}) => doSomething(results)); +``` + +Of course you can stay true to CQRS and ignore these results. +Your existing query and save command callers will continue to compile and run as before. + +This feature does introduce a _breaking change_ for those apps that create custom entity collection services +and override the base methods. +These overrides must now return an appropriate terminating Observable. + +#### Cancellation with the correlation id + +The ngrx-data associates the initiating action (e.g., `QUERY_ALL`) ngrx-data to the reply actions +(e.g,. `QUERY_ALL_SUCCESS` and `QUERY_ALL_ERROR` with a _correlation id_, +which it generates automatically. + +Alternatively you can specify the `correlationId` as an option. +You might do so in order to cancel a long-running query. + +The `EntityService` (and dispatcher) offer a new `cancel` command that dispatches an EntityAction +with the new `EntityOp.CANCEL-PERSIST`. + +You pass the command the correlation id for the action you want to cancel, along with an optional reason-to-cancel string. + +``` +// Too much time passes without a response. The user cancels +heroCollectionService.cancel(correlationId, 'User canceled'); +``` + +The `EntityCollectionReducer` responds to that action by turning off the loading flag +(it would be on for a persistence operation that is still in-flight). + +The `EntityEffects.cancel$` effect watches for `EntityOp.CANCEL-PERSIST` actions and emits the correlation id. +Meanwhile, the `EntityEffects.persist$` is processing the persistance EntityActions. +It cancels an in-flight server request when it that `cancel$` has emitted +the corresponding persistence action's correlation id. + +The observable returned by the original server request (e.g., `heroCollectionService.getAll(...)`) +will emit an error with an instance of `PersistanceCanceled`, whose `message` property contains the cancellation reason. + +This `EntityService` test demonstrates. + +``` +// Create the correlation id yourself to know which action to cancel. +const correlationId = 'CRID007'; +const options: EntityActionOptions = { correlationId }; +heroCollectionService.getAll(options).subscribe( + data => fail('should not have data but got data'), + error => { + expect(error instanceof PersistanceCanceled).toBe(true, 'PersistanceCanceled'); + expect(error.message).toBe('Test cancel'); + done(); + } +); + +heroCollectionService.cancel(correlationId, 'User canceled'); +``` + +Note that cancelling a command may not stop the browser from making the HTTP request +and it certainly can't stop the server from processing a request it received. + +It will prevent ngrx-data `EntityEffect` from creating and dispatching the success or failure +actions that would otherwise update the entity cache. + +#### HTTP requests from `EntityCollectionService` query and save commands are now concurrent. + +The `EntityCollectionService` query and save commands (e.g `getAll()` and `add()`) produce EntityActions that are handled by the `EntityEffects.persist$` effect. + +`EntityEffects.persist$` now uses `mergeMap` so that multiple HTTP requests may be in-flight concurrently. + +The `persist$` method previously used `concatMap, forcing each request to wait until the previous request finished. + +This change may improve performance for some apps. + +> It may also break an app that relied on strictly sequential requests. + +The `persist$` method used `concatMap` previously because there was no easy way to control the order of HTTP requests +or know when a particular command updated the collection. + +Now, the command observable tells you when it completed and how. +And if command A must complete before command B, you can pipe the command observables appropriately, +as seen in the "hero/sidekick" example above. + +#### _EntityDispatcherDefaultOptions_ can be provided. + +Previously the default for options governing whether saves were optimistic or pessimistic was +hardwired into the `EntityDispatcherFactory`. +The defaults were deemed to be the safest values: + +``` +/** True if added entities are saved optimistically; False if saved pessimistically. */ +optimisticAdd: false; + +/** True if deleted entities are saved optimistically; False if saved pessimistically. */ +optimisticDelete: true; + +/** True if updated entities are saved optimistically; False if saved pessimistically. */ +optimisticUpdate: false; +``` + +You could (and still can) change these options at the collection level in the entity metadata. +But you couldn't change the _defaults for all collections_. + +Now the collection defaults are in the `EntityDispatcherDefaultOptions` service class, which is +provided in the `NgrxDataWithoutEffectsModule`. + +Your app can provide an alternative to change these defaults. +For example, you could make all save operations optimistic by default. + +``` +@Injectable() +export class OptimisticDispatcherDefaultOptions { + optimisticAdd = true; + optimisticDelete = true; + optimisticUpdate = true; +} + +@NgModule({ + imports: [ NgrxDataModule.forRoot({...}) ], + providers: [ + { provide: EntityDispatcherDefaultOptions, useClass: OptimisticDispatcherDefaultOptions }, + ... + ] +}) +export class EntityStoreModule {} +``` + +#### Removed _OPTIMISTIC..._ EntityOps in favor of a flag (breaking change) + +The number of EntityOps and corresponding `EntityCollectionReducer` methods have been growing (see below). +Getting rid of the `OPTIMISTIC` ops is a welcome step in the other direction and makes reducer logic +a bit simpler. + +You can still choose an optimistic save with the action payload's `isOptimistic` option. +The dispatcher defaults (see `EntityDispatcherDefaultOptions`) have not changed. +If you don't specify `isOptimistic`, it defaults to `false` for _adds_ and _updates_ and `true` for _deletes_. + +#### `EntityAction` properties moved to the payload (breaking change) + +The properties on the `EntityAction` was also getting out of hand. +Had we continued the trend, the `MergeStrategy` and the `isOptimistic` flag would have +joined `entityName`, `op`, `tag` (FKA `label`), `skip`, and `error` as properties of the `EntityAction`. +Future options would mean more action properties and more complicated EntityAction creation. + +This was a bad trend. From the beginning we have been uncomfortable with adding any properties to the action +as ngrx actions out-of-the-box are just a _type_ and an optional _payload_. + +Now almost all properties have moved to the payload. + +``` +// entity-action.ts +export interface EntityAction

extends Action { + readonly type: string; + readonly payload: EntityActionPayload

; +} + +export interface EntityActionPayload

{ + readonly entityName: string; + readonly op: EntityOp; + readonly data?: P; + readonly correlationId?: any; + readonly isOptimistic?: boolean; + readonly mergeStrategy?: MergeStrategy; + readonly tag?: string; + + // Mutable. + error?: Error; + skip?: boolean; +} +``` + +#### New _QUERY_LOAD_ EntityOp + +`QUERY_ALL` used to fetch all entities of a collection and `QUERY_ALL_SUCCESS` +reset the collection with those entities. +That's not always correct. +Now that you can have unsaved changes, including unsaved adds and deletes, +querying for all entities should be able to leave those pending changes in place and +merely update the collection. + +That's how `QUERY_ALL...` behaves as of this release. +The `QUERY_ALL_SUCCESS` **merges** entities with the collection, based on the +change tracking + +Yet there is still a need to _both_ clear the collection _and_ reinitialize if with all fetched entities. +While you could achieve this with two actions, `REMOVE_ALL` and `QUERY_ALL`, +the new `QUERY_LOAD...` does both more efficiently. + +The new `QUERY_LOAD...` resets entity data, clears change tracking data, and +sets the loading (false) and loaded (true) flags. + +#### Added `SET_COLLECTION` EntityOp to completely replace the collection. + +Good for testing and rehydrating collection from local storage. +Dangerous. Use wisely and rarely. + +#### Added `EntityCacheAction.MERGE_QUERY_SET` + +The new `EntityCacheAction.MERGE_QUERY_SET` action and corresponding `MergeQuerySet(EntityQuerySet)` ActionCreator class can merge query results +from multiple collections into the EntityCache using the `upsert` entity collection reducer method. +These collections all update at the same time before selectors fire. + +This means that collection `selectors$` emit _after all collections have been updated_. + +Previously, you had to merge into each collection individually. +Each collections `selectors$` would emit +after each collection merge. +That behavior could provoke an unfortunate race condition +as when adding order line items before the parent order itself. + +> `EntityServices` tests demonstrate these points. + +#### Added `EntityServices.entityActionErrors$` + +An observable of **error** `EntityActions` (e.g. `QUERY_ALL_ERROR`) for all entity types. + +#### _CorrelationIdGenerator_ and _Guid_ utility functions + +Ngrx-data needs a `CorrelationIdGenerator` service to coordinate multiple EntityActions. + +The entity dispatcher save and query methods use it to generate correlation ids that +associate a start action with its corresponding success or error action. + +The ngrx-data `CorrelationIdGenerator.next()` method produces a string +consisting of 'CRID' (for "correlation **id**") plus an increasing integer. + +Correlation ids are unique for a single browser session only. +Do not use for entity ids. +Use the _GUID_ utilities to generate entity ids. + +You can replace this generator by providing an alternative implementation with a `next()` method +that returns a value of any type that serves the purpose. + +The new GUID utility functions - `getGuid()`, `getUuid()`, and `getGuidComb()` - generate pseudo-GUID strings +for client-side id generation. +The `getGuidComb()` function produces sequential guids which are sortable and often nice to SQL databases. + +All three produce 32-character hexadecimal UUID strings, not the 128-bit representation found in server-side languages and databases. That's less than ideal but we don't have a better alternative at this time. + +> The GUID utility functions are not used by ngrx-data itself at this time +> They are included as candidates for generating persistable correlation ids if that becomes desirable. +> These utilities are classified as _experimental_ and may be withdrawn or replaced in future. + +## Breaking Changes + +### Change-tracking-related + +The change tracking feature required replacement of the `EntityCollection.originalValues` with `EntityCollection.changeState`. + +The `originalValues` property is now the `originalValue` (singular) property of `changeState`, +which also has a `changeType` property that tells you what kind of unsaved change is being tracked. + +The `EntitySelectors.selectOriginalValues` and `Selectors$.originalValues$` are replaced by +`EntitySelectors.selectChangeState` and `Selectors$.selectChangeState$` + +The `EntityReducerMethods` have changed to include change tracking. +This will not affect most apps but it does mean that actions which previously did not change the collection +may do so now by virtue of updates to the collection's `changeState` value. + +The `EntityDataService.update()` method still takes an `Update` but it +returns the update entity (or null) rather than an `Update`. + +**If you wrote a custom `EntityDataService`, you must change the result of your `update` method accordingly.** + +The `EntityEffects.persist$` now handles transformation of HTTP update results +into the `Update` before creating the update "success" actions that are +dispatched to the store. + +Moving this responsibility to the `EntityEffects` makes it a little easier to +write custom `EntityDataServices`, which won't have to deal with it, +while ensuring that the success action payload dispatched to the store +arrives at the reducer with the information needed for change tracking. + +The app or its tests _might_ expect ngrx-data to make DELETE requests when processing SAVE_DELETE... actions +for entities that were added to the collection but not yet saved. + +As discussed above, when change tracking is enabled, ngrx-data no longer makes DELETE requests when processing SAVE_DELETE... actions +for entities that were added to the collection but not yet saved. + +It is possible that an app or its tests expected ngrx-data to make these DELETE requests. Please correct your code/tests accordingly. + +### Other Breaking Changes + +The ChangeTracking feature has such a profound effect on the library, involving necessary breaking changes, that the occasion seemed ripe to address longstanding problems and irritants in the architecture and names. +These repairs include additional breaking changes. + +#### Custom `EntityCollectionService` constructor changed. + +Previously, when you wrote a custom `EntityCollectionService`, you injected the `EntityCollectionServiceFactory` and passed it to your constructor like this. + +``` +import { Injectable } from '@angular/core'; +import { EntityCollectionServiceBase, EntityCollectionServiceFactory } from 'ngrx-data'; + +import { Hero } from '../core'; + +@Injectable({ providedIn: 'root' }) +export class HeroesService extends EntityCollectionServiceBase { + constructor(serviceFactory: EntityCollectionServiceFactory) { + super('Hero', serviceFactory); + } + // ... your custom service logic ... +} +``` + +This was weird. +You don't expect to inject the factory that makes that thing into the constructor of that thing. + +In fact, `EntityCollectionServiceFactory` wasn't behaving like a service factory. +It merely exposed _yet another unnamed factory_, which made the core elements necessary for the service. + +This version of ngrx-data makes that elements factory explicit (`EntityCollectionServiceElementsFactory`). + +Unfortunately, this breaks your custom services. +You'll have to modify them to inject and use the elements factory instead of the colletion service factory. + +Here's the updated `HeroesService` example: + +``` +import { Injectable } from '@angular/core'; +import { EntityCollectionServiceBase, EntityCollectionServiceElementsFactory } from 'ngrx-data'; + +import { Hero } from '../core'; + + +@Injectable({ providedIn: 'root' }) +export class HeroesService extends EntityCollectionServiceBase { + constructor(serviceElementsFactory: EntityCollectionServiceElementsFactory) { + super('Hero', serviceElementsFactory); + } + // ... your custom service logic ... +} +``` + +#### Custom `EntityServices` constructor changed. + +Those app custom `EntityServices` classes that derive from the `EntityServicesBase` class +must be modified to suit the constructor of the new `EntityServicesBase` class. + +This change simplifies construction of custom `EntityServices` classes +and reduces the risk of future change to the base class constructor. +As the base `EntityServices` class evolves it might need new dependencies. +These new dependencies can be delivered by the injected `EntityServicesElements` service, +which can grow without disturbing the application derived classes. + +This manner of insulating custom classes from future library changes +follows the _elements_ pattern used by the `EntityCollectionService`, as described above. + +Here is a custom `AppEntityServices` written for the _previous ngrx-data release_. + +``` +import { Injectable } from '@angular/core'; +import { Store } from '@ngrx/store'; +import { + EntityCache, + EntityCollectionServiceFactory, + EntityServicesBase +} from 'ngrx-data'; + +import { HeroesService } from '../../heroes/heroes.service'; +import { VillainsService } from '../../villains/villains.service'; + +@Injectable() +export class AppEntityServices extends EntityServicesBase { + constructor( + public readonly store: Store, + public readonly entityCollectionServiceFactory: EntityCollectionServiceFactory, + + // Inject custom services, register them with the EntityServices, and expose in API. + public readonly heroesService: HeroesService, + public readonly villainsService: VillainsService + ) { + super(store, entityCollectionServiceFactory); + this.registerEntityCollectionServices([heroesService, villainsService]); + } +} +``` + +The updated `AppEntityServices` is slightly smaller with fewer imports. + +``` +import { Injectable } from '@angular/core'; +import { EntityServicesElements, EntityServicesBase } from 'ngrx-data'; + +import { HeroesService } from '../../heroes/heroes.service'; +import { VillainsService } from '../../villains/villains.service'; + +@Injectable() +export class AppEntityServices extends EntityServicesBase { + constructor( + entityServicesElements: EntityServicesElements, + + // Inject custom services, register them with the EntityServices, and expose in API. + public readonly heroesService: HeroesService, + public readonly villainsService: VillainsService + ) { + super(entityServicesElements); + this.registerEntityCollectionServices([heroesService, villainsService]); + } +} +``` + +#### `EntityServices.store` and `.entityCollectionServiceFactory` no longer in the API. + +We could not see why these members should be part of the public `EntityServices` API. +Removed them so we don't have to support them. + +A custom `EntityService` could inject them in its own constructor if need be. +Developers can petition to re-expose them if they can offer good reasons. + +#### `EntityAction` properties moved to the payload. + +This change, described earlier, affects those developers who worked directly with `EntityAction` instances. + +#### `EntityAction.op` property renamed `entityOp` + +While moving entity action properties to `EntityAction.payload`, the `op` property was renamed `entityOp` for three reasons. + +1. The rename reduces the likelihood that a non-EntityAction payload has a similarly named property that + would cause ngrx-data to treat the action as an `EntityAction`. + +1. It should always have been called `entityOp` for consistency with its type as the value of the `EntityOp` enumeration. + +1. The timing is right because the relocation of properties to the payload is already a breaking change. + +#### _EntityCollectionService_ members only reference the collection. + +Formerly such services exposed the `entityCache`, the `store` and a `dispatch` method, all of which are outside of the `EntityCollection` targeted by the service. + +They've been removed from the `EntityCollectionService` API. +Use `EntityServices` instead to access the `entityCache`, the `store`, +a general dispatcher of `Action`, etc. + +#### Service dispatcher query and save methods must return an Observable + +The query and save commands should return an Observable as described above. This is a _breaking change_ for those apps that create custom entity collection services +and override the base methods. +Such overrides must now return an appropriate terminating Observable. + +#### `EntityEffects.persist$` uses `mergeMap` instead of `concatMap`. + +`EntityEffects.persist$` uses `mergeMap` so that multiple HTTP requests may be in-flight concurrently. + +Previously used `concatMap`, which meant that ngrx-data did not make a new HTTP request until the previous request finished. + +This change may break an app that counted upon strictly sequential HTTP requests. + +#### Renamed `EntityAction.label`. + +The word "label" is semantically too close to the word "type" in `Action.type` and easy to confuse with the type. +"Tag" conveys the freedom and flexibility we're looking for. +The EntityAction formatter will embed the "tag" in the type as it did the label. +We hope that relatively few are affected by this renaming. + +More significantly, the tag (FKA label) is now part of the payload rather than a property of the action. + +#### `ADD_ALL` resets the loading (false) and loaded (true) flags. + +#### Deleted `MERGE_ENTITY_CACHE`. + +The cache action was never used by ngrx-data itself. +It can be easily implemented with +`ENTITY_CACHE_SET` and a little code to get the current entity cache state. + +#### Moved `SET_ENTITY_CACHE` under `EntityCacheAction.SET_ENTITY_CACHE`. + +#### Eliminated the `OPTIMISTIC` variations of the save `EntityOps`. + +These entityOps and their corresponding reducer methods are gone. +Now there is only one _add_, _update_, and _delete_ operation. +Their reducers behave pessimistically or +optimistically based on the `isOptimistic` flag in the `EntityActionOptions` in the action payload. + +This change does not affect the primary application API and should break only those apps that delved below +the ngrx-data surface such as apps that implement their own entity action reducers. + +#### _EntityReducerFactory_ is now _EntityCacheReducerFactory_ and _EntityCollectionReducerRegistry_ + +The former `_EntityReducerFactory_` combined two purposes + +1. Creator of the reducer for actions applying to the `EntityCache` as a whole. +2. Registry of the `EntityCollectionReducers` that apply to individual collections. + +The need for separating these concerns became apparent as we enriched the actions that apply to the entire `EntityCache`. + +This change breaks apps that registered collection reducers directly with the former `_EntityReducerFactory_`. +Resolve by importing `EntityCollectionReducerRegistry` instead and calling the same registration methods on it. + +#### Renamed _DefaultEntityCollectionServiceFactory_ to _EntityCollectionServiceFactoryBase_. + +Renamed for consistence with other members of the _EntityServices_ family. +A breaking change for the rare app that referenced this factory directly. + +#### Renamed _DefaultDispatcherOptions_ to _EntityDispatcherDefaultOptions_. + +Renamed for clarity. + + + +# 6.0.0-beta.6 (2018-05-24) ## _EntityActions_ replaced by _EntityAction operators_ @@ -52,9 +658,7 @@ export class NgrxDataToastService { actions$ .where(ea => ea.op.endsWith(OP_SUCCESS) || ea.op.endsWith(OP_ERROR)) // this service never dies so no need to unsubscribe - .subscribe(action => - toast.openSnackBar(`${action.entityName} action`, action.op) - ); + .subscribe(action => toast.openSnackBar(`${action.entityName} action`, action.op)); } } ``` @@ -83,14 +687,9 @@ export class NgrxDataToastService { // filter first for EntityActions with ofEntityOp() (those with an EntityOp) ofEntityOp(), // use filter() instead of where() - filter( - (ea: EntityAction) => - ea.op.endsWith(OP_SUCCESS) || ea.op.endsWith(OP_ERROR) - ) + filter((ea: EntityAction) => ea.op.endsWith(OP_SUCCESS) || ea.op.endsWith(OP_ERROR)) ) - .subscribe(action => - toast.openSnackBar(`${action.entityName} action`, action.op) - ); + .subscribe(action => toast.openSnackBar(`${action.entityName} action`, action.op)); } } ``` @@ -114,9 +713,9 @@ Import it instead of `NgrxDataModule`, like this. export class EntityAppModule {...} ``` - + -# 6.0.1-beta.5 (2018-05-23) +# 6.0.0-beta.5 (2018-05-23) * Update to `@ngrx v.6.0.1` (updated package.json) @@ -125,7 +724,7 @@ export class EntityAppModule {...} one that is both rare and easily worked around (see [ng-packager issue 901](https://github.com/dherges/ng-packagr/issues/901)) (note: these changes were committed directly to master in SHA ede9d (ede9d39d776752ba2e2fa03d48c06a7f0bbbbf30)). - + # 6.0.0-beta.4 (2018-05-22) @@ -136,7 +735,7 @@ export class EntityAppModule {...} * Feature: Default reducer sets collection's `loaded` flag true in ADD\*ALL action. Used to only do so in QUERY_ALL_SUCCESS. This \_might be a **breaking change\*** for a very few. - + # 6.0.0-beta.3 (2018-05-18) @@ -162,7 +761,7 @@ customize it rather than simply replace a method in the `reducerMethods` diction * refactored `DefaultLogger` for better console output. * delete reducers now accept the entity as well as just the entity id - + # 6.0.0-beta.2 (2018-05-08) @@ -188,7 +787,7 @@ In a related change, the former `EntityService.entityCache$` selector has been r See the _Entity Services_ doc (in the repo at `docs/entity-services.md`) for a discussion and examples of this new class. - + # 6.0.0-beta.1 (2018-05-08) @@ -211,11 +810,11 @@ In a related change, the former `EntityService.entityCache$` selector has been r


- + # 1.0.0-beta.13 (2018-05-04) - + Fix AOT build error (#145) by removing all barrels. @@ -235,7 +834,7 @@ These fixes should not break library consumers because they cannot reach into th * Refactoring of internal utils files to put the interfaces in their own file * Update packages to the last v5 versions. - + # 1.0.0-beta.11 (2018-04-29) @@ -245,7 +844,7 @@ Add `EntityCacheSelector` which can be injected Minor _break_ in signature of `EntitySelectorFactory$` constructor to use `EntityCacheSelector` instead of `EntitySelectorFactory` - + # 1.0.0-beta.10 (2018-04-22) @@ -259,7 +858,7 @@ The **`related-entity-selectors.spec.ts`** demonstrates usage. * break: shuffled methods and pruned members that shouldn't be exposed. * break: certain create functions are now members of factories. - + # 1.0.0-beta.9 (2018-04-14) @@ -274,7 +873,7 @@ The **`related-entity-selectors.spec.ts`** demonstrates usage. * break: `EntityActionGuard` completely overhauled. Will break the few who call it directly. - + # 1.0.0-beta.8 (2018-04-14) @@ -286,9 +885,10 @@ This makes it much easier to customize the reducer methods. Formerly they were hidden within the `switch()`, which meant you had to effectively replace the entire collection reducer if you wanted to change the behavior of a single action. -The default collection reducer methods are in the `DefaultEntityCollectionReducerMethods`. -This produces a map of `EntityOps` to entity collection reducer methods. -These methods rely on the `DefaultEntityCollectionReducerMethods` class properties +The default collection reducer methods are in the `EntityCollectionReducerMethods`. +This produces a map (`EntityCollectionReducerMethodsMap`) of `EntityOps` to entity collection reducer methods. + +These methods rely on the `EntityCollectionReducerMethods` class members that you can override. Alternatively, you can replace any of them by name with your own method. You could also add new "operations" and reducer methods to meet your custom needs. @@ -297,7 +897,7 @@ You'll probably make such changes within a replacement implementation of the `EntityCollectionReducerMethodsFactory`, an abstract class with _one_ simple method, that is the injection token for a class that produces `EntityCollectionReducerMethods`. - + # 1.0.0-beta.7 (2018-04-13) @@ -309,14 +909,14 @@ that is the injection token for a class that produces `EntityCollectionReducerMe may call the new logger with the error * tests: repair a few - + # 1.0.0-beta.6 (2018-04-12) * refactor `DefaultDataService` to not use `pipe` static (gone in v6). Simplified it and deleted (internal) `make-response-delay.ts` in the process. - + # 1.0.0-beta.5 (2018-04-10) @@ -335,21 +935,21 @@ that is the same as that in `EntityEffects`. Also refactored the `EntityCollectionReducer` to be a little smarter when setting flags. - + # 1.0.0-beta.4 (2018-04-09) * Feature: add SET_LOADED and SET_LOADING entity ops * RequestData.options is now optional - + # 1.0.0-beta.3 (2018-03-24) * Feature: enable replacement of `EntityEffects` via `@ngrx/effects` backdoor; see `NgrxDataModule` and its new test. - + # release 1.0.0-beta.2 (2018-03-19) @@ -388,7 +988,7 @@ const defaultDataServiceConfig: DefaultDataServiceConfig = { } ``` - + # release 1.0.0-beta.1 (2018-03-14) @@ -396,25 +996,25 @@ WAHOO! We're in beta!
- + # release 1.0.0-alpha.18 (2018-03-14) Fix error-logging bug in `EntityReducerFactory` (thanks Colin). - + # release 1.0.0-alpha.17 (2018-03-13) Remove circular refs within the library that caused `ng build --prod` to fail. - + # release 1.0.0-alpha.16 (2018-03-10) * Feature: EntitySelectors$.errors$ makes it easier to subscribe to entity action errors in a component. - + # release 1.0.0-alpha.15 (2018-03-09) @@ -422,7 +1022,7 @@ Remove circular refs within the library that caused `ng build --prod` to fail. with `@ngrx/effects/Actions`. This is a **Breaking Change**, although we expect low impact because the feature only became viable today. - + # release 1.0.0-alpha.14 (2018-03-09) @@ -430,7 +1030,7 @@ Remove circular refs within the library that caused `ng build --prod` to fail. * Bug fix: DefaultDataService adds delay on server errors too (see `makeResponseDelay`). * Corrected text of the type constants for the QUERY_BY_KEY actions. - + # release 1.0.0-alpha.13 (2018-03-07) @@ -442,13 +1042,13 @@ New Features: * `entityCache$` observable selector on `EntityCollectionService` and `EntitySelectors$Factory` enable watching of the entire cache. - + # release 1.0.0-alpha.12 (2018-03-05) * EntityEffects.persist is now public, mostly for easier testing - + # release 1.0.0-alpha.11 (2018-03-05) @@ -461,7 +1061,7 @@ Small refactors for testability * EntityEffects: expose `persistOps` * EntityReducerFactory: new `getOrCreateReducer()` method. - + # release 1.0.0-alpha.10 (2018-02-24) @@ -479,7 +1079,7 @@ They also guard against collisions with your custom entity action types The file renaming and restructuring is for easier reading. Shouldn't affect applications which do not deep link into the library. - + # release 1.0.0-alpha.9 (2018-02-23) @@ -503,14 +1103,14 @@ See demo app's `HeroesService` and `VillainsService`. It is useful for simple service creation and when defining an entity service class with much smaller API service. - + # release 1.0.0-alpha.8 (2018-02-19) * renamed `EntityActions.filter` to `EntityActions.where`. Fixes conflict with `import 'rxjs/add/operator/filter';` #97 [minor breaking change] - + # release 1.0.0-alpha.7 (2018-02-19) @@ -533,19 +1133,19 @@ and when defining an entity service class with much smaller API service. * The previous save operations did not revert entities that failed to save. The next version will revert those entities after a save error. - + # release 1.0.0-alpha.6 (2018-02-14) * Add DefaultDataService error handling and their tests - + # release 1.0.0-alpha.5 (2018-02-14) * Workaround redux tools replay bug - + # release 1.0.0-alpha.4 (2018-02-13) @@ -561,26 +1161,26 @@ you must upgrade _ngrx_ to v5.1 or later, because the reducer uses the "upsert" feature, new in `@ngrx/entity` v5.1, for `QUERY_ONE_SUCCESS` and `QUERY_MANY_SUCCESS`. - + # 1.0.0-alpha.3 (2018-02-12) * Added `EntityCollectionMetaReducer`s for easier customization of `EntityCollectionReducer` behavior. * Documented entity reducers. - + # 1.0.0-alpha.2 (2018-02-11) Updated with more extension points - + # 1.0.0-alpha.1 (2018-02-06) Working release deployed to npm - + # 1.0.0-alpha.0 (2018-02-04) diff --git a/lib/package.json b/lib/package.json index 982673de..310c6299 100644 --- a/lib/package.json +++ b/lib/package.json @@ -1,6 +1,6 @@ { "name": "ngrx-data", - "version": "6.0.0-beta.6", + "version": "6.0.2-beta.7", "repository": "https://github.com/johnpapa/angular-ngrx-data.git", "license": "MIT", "peerDependencies": { diff --git a/lib/src/actions/entity-action-factory.spec.ts b/lib/src/actions/entity-action-factory.spec.ts new file mode 100644 index 00000000..2260eca9 --- /dev/null +++ b/lib/src/actions/entity-action-factory.spec.ts @@ -0,0 +1,159 @@ +import { EntityAction, EntityActionOptions, EntityActionPayload } from './entity-action'; +import { EntityOp } from './entity-op'; +import { EntityActionFactory } from './entity-action-factory'; +import { MergeStrategy } from './merge-strategy'; +import { CorrelationIdGenerator } from '..'; + +class Hero { + id: number; + name: string; +} + +describe('EntityActionFactory', () => { + let factory: EntityActionFactory; + + beforeEach(() => { + factory = new EntityActionFactory(); + }); + + it('#create should create an EntityAction from entityName and entityOp', () => { + const action = factory.create('Hero', EntityOp.QUERY_ALL); + const { entityName, entityOp, data } = action.payload; + expect(entityName).toBe('Hero'); + expect(entityOp).toBe(EntityOp.QUERY_ALL); + expect(data).toBeUndefined('no data property'); + }); + + it('#create should create an EntityAction with the given data', () => { + const hero: Hero = { id: 42, name: 'Francis' }; + const action = factory.create('Hero', EntityOp.ADD_ONE, hero); + const { entityName, entityOp, data } = action.payload; + expect(entityName).toBe('Hero'); + expect(entityOp).toBe(EntityOp.ADD_ONE); + expect(data).toBe(hero); + }); + + it('#create should create an EntityAction with options', () => { + const options: EntityActionOptions = { + correlationId: 'CRID42', + isOptimistic: true, + mergeStrategy: MergeStrategy.OverwriteChanges, + tag: 'Foo' + }; + + // Don't forget placeholder for missing optional data! + const action = factory.create('Hero', EntityOp.QUERY_ALL, undefined, options); + const { entityName, entityOp, data, correlationId, isOptimistic, mergeStrategy, tag } = action.payload; + expect(entityName).toBe('Hero'); + expect(entityOp).toBe(EntityOp.QUERY_ALL); + expect(data).toBeUndefined(); + expect(correlationId).toBe(options.correlationId); + expect(isOptimistic).toBe(options.isOptimistic); + expect(mergeStrategy).toBe(options.mergeStrategy); + expect(tag).toBe(options.tag); + }); + + it('#create create an EntityAction from an EntityActionPayload', () => { + const hero: Hero = { id: 42, name: 'Francis' }; + const payload: EntityActionPayload = { + entityName: 'Hero', + entityOp: EntityOp.ADD_ONE, + data: hero, + correlationId: 'CRID42', + isOptimistic: true, + mergeStrategy: MergeStrategy.OverwriteChanges, + tag: 'Foo' + }; + const action = factory.create(payload); + + const { entityName, entityOp, data, correlationId, isOptimistic, mergeStrategy, tag } = action.payload; + expect(entityName).toBe(payload.entityName); + expect(entityOp).toBe(payload.entityOp); + expect(data).toBe(payload.data); + expect(correlationId).toBe(payload.correlationId); + expect(isOptimistic).toBe(payload.isOptimistic); + expect(mergeStrategy).toBe(payload.mergeStrategy); + expect(tag).toBe(payload.tag); + }); + + it('#createFromAction should create EntityAction from another EntityAction', () => { + // pessimistic save + const hero1: Hero = { id: undefined, name: 'Francis' }; + const action1 = factory.create('Hero', EntityOp.SAVE_ADD_ONE, hero1); + + // after save succeeds + const hero: Hero = { ...hero1, id: 42 }; + const action = factory.createFromAction(action1, { entityOp: EntityOp.SAVE_ADD_ONE_SUCCESS, data: hero }); + const { entityName, entityOp, data } = action.payload; + + expect(entityName).toBe('Hero'); + expect(entityOp).toBe(EntityOp.SAVE_ADD_ONE_SUCCESS); + expect(data).toBe(hero); + const expectedType = factory.formatActionType(EntityOp.SAVE_ADD_ONE_SUCCESS, 'Hero'); + expect(action.type).toEqual(expectedType); + }); + + it('#createFromAction should copy the options from the source action', () => { + const options: EntityActionOptions = { + correlationId: 'CRID42', + isOptimistic: true, + mergeStrategy: MergeStrategy.OverwriteChanges, + tag: 'Foo' + }; + // Don't forget placeholder for missing optional data! + const sourceAction = factory.create('Hero', EntityOp.QUERY_ALL, undefined, options); + + const queryResults: Hero[] = [{ id: 1, name: 'Francis' }, { id: 2, name: 'Alex' }]; + const action = factory.createFromAction(sourceAction, { + entityOp: EntityOp.QUERY_ALL_SUCCESS, + data: queryResults + }); + + const { entityName, entityOp, data, correlationId, isOptimistic, mergeStrategy, tag } = action.payload; + expect(entityName).toBe('Hero'); + expect(entityOp).toBe(EntityOp.QUERY_ALL_SUCCESS); + expect(data).toBe(queryResults); + expect(correlationId).toBe(options.correlationId); + expect(isOptimistic).toBe(options.isOptimistic); + expect(mergeStrategy).toBe(options.mergeStrategy); + expect(tag).toBe(options.tag); + }); + + it('#createFromAction can suppress the data property', () => { + const hero: Hero = { id: 42, name: 'Francis' }; + const action1 = factory.create('Hero', EntityOp.ADD_ONE, hero); + const action = factory.createFromAction(action1, { entityOp: EntityOp.SAVE_ADD_ONE, data: undefined }); + const { entityName, entityOp, data } = action.payload; + expect(entityName).toBe('Hero'); + expect(entityOp).toBe(EntityOp.SAVE_ADD_ONE); + expect(data).toBeUndefined(); + }); + + it('#formatActionType should format type with the entityName', () => { + const action = factory.create('Hero', EntityOp.QUERY_ALL); + const expectedFormat = factory.formatActionType(EntityOp.QUERY_ALL, 'Hero'); + expect(action.type).toBe(expectedFormat); + }); + + it('#formatActionType should format type with given tag instead of the entity name', () => { + const tag = 'Hero - Tag Test'; + const action = factory.create('Hero', EntityOp.QUERY_ALL, null, { tag }); + expect(action.type).toContain(tag); + }); + + it('can re-format generated action.type with a custom #formatActionType()', () => { + factory.formatActionType = (op, entityName) => `${entityName}_${op}`.toUpperCase(); + + const expected = ('Hero_' + EntityOp.QUERY_ALL).toUpperCase(); + const action = factory.create('Hero', EntityOp.QUERY_ALL); + expect(action.type).toBe(expected); + }); + + it('should throw if do not specify entityName', () => { + expect(() => factory.create(null)).toThrow(); + }); + + it('should throw if do not specify EntityOp', () => { + expect(() => factory.create({ entityName: 'Hero', entityOp: null })).toThrow(); + }); +}); diff --git a/lib/src/actions/entity-action-factory.ts b/lib/src/actions/entity-action-factory.ts new file mode 100644 index 00000000..3f6a9beb --- /dev/null +++ b/lib/src/actions/entity-action-factory.ts @@ -0,0 +1,59 @@ +import { Injectable } from '@angular/core'; +import { Action } from '@ngrx/store'; + +import { EntityOp } from './entity-op'; +import { EntityAction, EntityActionOptions, EntityActionPayload } from './entity-action'; +@Injectable() +export class EntityActionFactory { + /** + * Create an EntityAction to perform an operation (op) for a particular entity type + * (entityName) with optional data and other optional flags + * @param entityName Name of the entity type + * @param entityOp Operation to perform (EntityOp) + * @param [data] data for the operation + * @param [options] additional options + */ + create

(entityName: string, entityOp: EntityOp, data?: P, options?: EntityActionOptions): EntityAction

; + + /** + * Create an EntityAction to perform an operation (op) for a particular entity type + * (entityName) with optional data and other optional flags + * @param payload Defines the EntityAction and its options + */ + create

(payload: EntityActionPayload

): EntityAction

; + + create

( + nameOrPayload: EntityActionPayload

| string, + entityOp?: EntityOp, + data?: P, + options?: EntityActionOptions + ): EntityAction

{ + const payload: EntityActionPayload

= + typeof nameOrPayload === 'string' ? { ...(options || {}), entityName: nameOrPayload, entityOp, data } : nameOrPayload; + + const { entityName, entityOp: op, tag } = payload; + + if (!entityName) { + throw new Error('Missing entity name for new action'); + } + if (op == null) { + throw new Error('Missing EntityOp for new action'); + } + const type = this.formatActionType(op, tag || entityName); + return { type, payload }; + } + + /** + * Create an EntityAction from another EntityAction, replacing properties with those from newPayload; + * @param from Source action that is the base for the new action + * @param newProperties New EntityAction properties that replace the source action properties + */ + createFromAction

(from: EntityAction, newProperties: Partial>): EntityAction

{ + return this.create({ ...from.payload, ...newProperties }); + } + + formatActionType(op: string, tag: string) { + return `[${tag}] ${op}`; + // return `${op} [${tag}]`.toUpperCase(); // example of an alternative + } +} diff --git a/lib/src/actions/entity-action-guard.ts b/lib/src/actions/entity-action-guard.ts index 0762d425..82f45bdd 100644 --- a/lib/src/actions/entity-action-guard.ts +++ b/lib/src/actions/entity-action-guard.ts @@ -1,99 +1,110 @@ import { EntityAction } from './entity-action'; import { IdSelector, Update } from '../utils/ngrx-entity-models'; +/** + * Guard methods that ensure EntityAction payload is as expected. + * Each method returns that payload if it passes the guard or + * throws an error. + */ export class EntityActionGuard { - constructor(private selectId: IdSelector) {} + constructor(private entityName: string, private selectId: IdSelector) {} /** Throw if the action payload is not an entity with a valid key */ - mustBeEntity(action: EntityAction) { - const { entityName, payload, op, type } = action; - if (!payload) { - this.makeError(action, `should have a single entity.`); + mustBeEntity(action: EntityAction): T { + const data = this.extractData(action); + if (!data) { + this.throwError(action, `should have a single entity.`); } - const id = this.selectId(payload); + const id = this.selectId(data); if (this.isNotKeyType(id)) { - this.makeError(action, `has a missing or invalid entity key (id)`); + this.throwError(action, `has a missing or invalid entity key (id)`); } + return data as T; } /** Throw if the action payload is not an array of entities with valid keys */ - mustBeEntities(action: EntityAction) { - const { entityName, payload, op, type } = action; - if (!Array.isArray(payload)) { - this.makeError(action, `should be an array of entities`); + mustBeEntities(action: EntityAction): T[] { + const data = this.extractData(action); + if (!Array.isArray(data)) { + this.throwError(action, `should be an array of entities`); } - payload.forEach((entity, i) => { + data.forEach((entity, i) => { const id = this.selectId(entity); if (this.isNotKeyType(id)) { const msg = `, item ${i + 1}, does not have a valid entity key (id)`; - this.makeError(action, msg); + this.throwError(action, msg); } }); + return data; } /** Throw if the action payload is not a single, valid key */ - mustBeKey(action: EntityAction) { - const { entityName, payload, op, type } = action; - if (!payload) { + mustBeKey(action: EntityAction): string | number { + const data = this.extractData(action); + if (!data) { throw new Error(`should be a single entity key`); } - if (this.isNotKeyType(payload)) { + if (this.isNotKeyType(data)) { throw new Error(`is not a valid key (id)`); } + return data; } /** Throw if the action payload is not an array of valid keys */ - mustBeKeys(action: EntityAction<(string | number)[]>) { - const { entityName, payload, op, type } = action; - if (!Array.isArray(payload)) { - this.makeError(action, `should be an array of entity keys (id)`); + mustBeKeys(action: EntityAction<(string | number)[]>): (string | number)[] { + const data = this.extractData(action); + if (!Array.isArray(data)) { + this.throwError(action, `should be an array of entity keys (id)`); } - payload.forEach((id, i) => { + data.forEach((id, i) => { if (this.isNotKeyType(id)) { - const msg = `${entityName} ', item ${i + - 1}, is not a valid entity key (id)`; - this.makeError(action, msg); + const msg = `${this.entityName} ', item ${i + 1}, is not a valid entity key (id)`; + this.throwError(action, msg); } }); + return data; } /** Throw if the action payload is not an update with a valid key (id) */ - mustBeUpdate(action: EntityAction>) { - const { entityName, payload, op, type } = action; - if (!payload) { - this.makeError(action, `should be a single entity update`); + mustBeUpdate(action: EntityAction>): Update { + const data = this.extractData(action); + if (!data) { + this.throwError(action, `should be a single entity update`); } - const { id, changes } = payload; + const { id, changes } = data; const id2 = this.selectId(changes); if (this.isNotKeyType(id) || this.isNotKeyType(id2)) { - this.makeError(action, `has a missing or invalid entity key (id)`); + this.throwError(action, `has a missing or invalid entity key (id)`); } + return data; } /** Throw if the action payload is not an array of updates with valid keys (ids) */ - mustBeUpdates(action: EntityAction[]>) { - const { entityName, payload, op, type } = action; - if (!Array.isArray(payload)) { - this.makeError(action, `should be an array of entity updates`); + mustBeUpdates(action: EntityAction[]>): Update[] { + const data = this.extractData(action); + if (!Array.isArray(data)) { + this.throwError(action, `should be an array of entity updates`); } - payload.forEach((item, i) => { + data.forEach((item, i) => { const { id, changes } = item; const id2 = this.selectId(changes); if (this.isNotKeyType(id) || this.isNotKeyType(id2)) { - this.makeError( - action, - `, item ${i + 1}, has a missing or invalid entity key (id)` - ); + this.throwError(action, `, item ${i + 1}, has a missing or invalid entity key (id)`); } }); + return data; + } + + private extractData(action: EntityAction) { + return action.payload && action.payload.data; } /** Return true if this key (id) is invalid */ - isNotKeyType(id: any) { + private isNotKeyType(id: any) { return typeof id !== 'string' && typeof id !== 'number'; } - makeError(action: EntityAction, msg: string) { - throw new Error(`Action "${action.type}" payload ${msg}`); + private throwError(action: EntityAction, msg: string): void { + throw new Error(`${this.entityName} EntityAction guard for "${action.type}": payload ${msg}`); } } diff --git a/lib/src/actions/entity-action-operators.spec.ts b/lib/src/actions/entity-action-operators.spec.ts index 3762475f..b54794d3 100644 --- a/lib/src/actions/entity-action-operators.spec.ts +++ b/lib/src/actions/entity-action-operators.spec.ts @@ -3,7 +3,8 @@ import { Actions } from '@ngrx/effects'; import { Subject } from 'rxjs'; -import { EntityAction, EntityActionFactory } from './entity-action'; +import { EntityAction } from './entity-action'; +import { EntityActionFactory } from './entity-action-factory'; import { EntityOp } from './entity-op'; import { ofEntityType, ofEntityOp } from './entity-action-operators'; @@ -24,15 +25,8 @@ describe('EntityAction Operators', () => { const testActions = { foo: { type: 'Foo' }, hero_query_all: entityActionFactory.create('Hero', EntityOp.QUERY_ALL), - villain_query_many: entityActionFactory.create( - 'Villain', - EntityOp.QUERY_MANY - ), - hero_delete: entityActionFactory.create( - 'Hero', - EntityOp.SAVE_DELETE_ONE, - 42 - ), + villain_query_many: entityActionFactory.create('Villain', EntityOp.QUERY_MANY), + hero_delete: entityActionFactory.create('Hero', EntityOp.SAVE_DELETE_ONE, 42), bar: ({ type: 'Bar', payload: 'bar' }) }; @@ -51,11 +45,7 @@ describe('EntityAction Operators', () => { // EntityActions of any kind actions.pipe(ofEntityType()).subscribe(ea => results.push(ea)); - const expectedActions = [ - testActions.hero_query_all, - testActions.villain_query_many, - testActions.hero_delete - ]; + const expectedActions = [testActions.hero_query_all, testActions.villain_query_many, testActions.hero_delete]; dispatchTestActions(); expect(results).toEqual(expectedActions); }); @@ -64,19 +54,14 @@ describe('EntityAction Operators', () => { // EntityActions of one type actions.pipe(ofEntityType('Hero')).subscribe(ea => results.push(ea)); - const expectedActions = [ - testActions.hero_query_all, - testActions.hero_delete - ]; + const expectedActions = [testActions.hero_query_all, testActions.hero_delete]; dispatchTestActions(); expect(results).toEqual(expectedActions); }); it(`#ofEntityType('Type1', 'Type2', 'Type3')`, () => { // n.b. 'Bar' is not an EntityType even though it is an action type - actions - .pipe(ofEntityType('Hero', 'Villain', 'Bar')) - .subscribe(ea => results.push(ea)); + actions.pipe(ofEntityType('Hero', 'Villain', 'Bar')).subscribe(ea => results.push(ea)); ofEntityTypeTest(); }); @@ -117,9 +102,7 @@ describe('EntityAction Operators', () => { /////////////// it('#ofEntityOp with string args', () => { - actions - .pipe(ofEntityOp(EntityOp.QUERY_ALL, EntityOp.QUERY_MANY)) - .subscribe(ea => results.push(ea)); + actions.pipe(ofEntityOp(EntityOp.QUERY_ALL, EntityOp.QUERY_MANY)).subscribe(ea => results.push(ea)); ofEntityOpTest(); }); @@ -142,20 +125,13 @@ describe('EntityAction Operators', () => { // EntityOps of any kind actions.pipe(ofEntityOp()).subscribe(ea => results.push(ea)); - const expectedActions = [ - testActions.hero_query_all, - testActions.villain_query_many, - testActions.hero_delete - ]; + const expectedActions = [testActions.hero_query_all, testActions.villain_query_many, testActions.hero_delete]; dispatchTestActions(); expect(results).toEqual(expectedActions); }); function ofEntityOpTest() { - const expectedActions = [ - testActions.hero_query_all, - testActions.villain_query_many - ]; + const expectedActions = [testActions.hero_query_all, testActions.villain_query_many]; dispatchTestActions(); expect(results).toEqual(expectedActions); } diff --git a/lib/src/actions/entity-action-operators.ts b/lib/src/actions/entity-action-operators.ts index 063698f2..c7501736 100644 --- a/lib/src/actions/entity-action-operators.ts +++ b/lib/src/actions/entity-action-operators.ts @@ -16,29 +16,24 @@ import { flattenArgs } from '../utils/utilities'; * this.actions.pipe(ofEntityOp(EntityOp.QUERY_ALL, EntityOp.QUERY_MANY), ...) * this.actions.pipe(ofEntityOp(...queryOps), ...) * this.actions.pipe(ofEntityOp(queryOps), ...) - * this.actions.pipe(ofEntityOp(), ...) // any action with a defined `op` property + * this.actions.pipe(ofEntityOp(), ...) // any action with a defined `entityOp` property * ``` */ -export function ofEntityOp( - allowedOps: string[] | EntityOp[] -): OperatorFunction; -export function ofEntityOp( - ...allowedOps: (string | EntityOp)[] -): OperatorFunction; -export function ofEntityOp( - ...allowedEntityOps: any[] -): OperatorFunction { +export function ofEntityOp(allowedOps: string[] | EntityOp[]): OperatorFunction; +export function ofEntityOp(...allowedOps: (string | EntityOp)[]): OperatorFunction; +export function ofEntityOp(...allowedEntityOps: any[]): OperatorFunction { const ops: string[] = flattenArgs(allowedEntityOps); switch (ops.length) { case 0: - return filter((action: EntityAction): action is T => !!action.op); + return filter((action: EntityAction): action is T => action.payload && action.payload.entityOp != null); case 1: const op = ops[0]; - return filter((action: EntityAction): action is T => op === action.op); + return filter((action: EntityAction): action is T => action.payload && op === action.payload.entityOp); default: - return filter((action: EntityAction): action is T => - ops.some(entityOp => entityOp === action.op) - ); + return filter((action: EntityAction): action is T => { + const entityOp = action.payload && action.payload.entityOp; + return entityOp && ops.some(o => o === entityOp); + }); } } @@ -54,27 +49,20 @@ export function ofEntityOp( * this.actions.pipe(ofEntityType(theChosen), ...) * ``` */ -export function ofEntityType( - allowedEntityNames?: string[] -): OperatorFunction; -export function ofEntityType( - ...allowedEntityNames: string[] -): OperatorFunction; -export function ofEntityType( - ...allowedEntityNames: any[] -): OperatorFunction { +export function ofEntityType(allowedEntityNames?: string[]): OperatorFunction; +export function ofEntityType(...allowedEntityNames: string[]): OperatorFunction; +export function ofEntityType(...allowedEntityNames: any[]): OperatorFunction { const names: string[] = flattenArgs(allowedEntityNames); switch (names.length) { case 0: - return filter((action: EntityAction): action is T => !!action.entityName); + return filter((action: EntityAction): action is T => action.payload && action.payload.entityName != null); case 1: const name = names[0]; - return filter( - (action: EntityAction): action is T => name === action.entityName - ); + return filter((action: EntityAction): action is T => action.payload && name === action.payload.entityName); default: - return filter((action: EntityAction): action is T => - names.some(entityName => entityName === action.entityName) - ); + return filter((action: EntityAction): action is T => { + const entityName = action.payload && action.payload.entityName; + return entityName && names.some(n => n === entityName); + }); } } diff --git a/lib/src/actions/entity-action.spec.ts b/lib/src/actions/entity-action.spec.ts deleted file mode 100644 index cc6e2a6e..00000000 --- a/lib/src/actions/entity-action.spec.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { Action } from '@ngrx/store'; - -import { EntityAction, EntityActionFactory } from './entity-action'; -import { EntityOp } from './entity-op'; - -class Hero { - id: number; - name: string; -} - -describe('EntityActionFactory', () => { - let factory: EntityActionFactory; - - beforeEach(() => { - factory = new EntityActionFactory(); - }); - - it('should create expected EntityAction for named entity', () => { - const hero: Hero = { id: 42, name: 'Francis' }; - const action = factory.create('Hero', EntityOp.ADD_ONE, hero); - expect(action.entityName).toBe('Hero'); - expect(action.op).toBe(EntityOp.ADD_ONE); - expect(action.payload).toBe(hero); - }); - - it('should create EntityAction from another EntityAction', () => { - const hero: Hero = { id: 42, name: 'Francis' }; - const action1 = factory.create('Hero', EntityOp.ADD_ONE, hero); - const action = factory.create(action1, EntityOp.SAVE_ADD_ONE); - expect(action.entityName).toBe('Hero'); - expect(action.op).toBe(EntityOp.SAVE_ADD_ONE); - // Forward's the payload to the new action. - expect(action.payload).toBe(hero); - }); - - it('can suppress the payload when create EntityAction from another EntityAction', () => { - const hero: Hero = { id: 42, name: 'Francis' }; - const action1 = factory.create('Hero', EntityOp.ADD_ONE, hero); - const action = factory.create(action1, EntityOp.SAVE_ADD_ONE, undefined); - expect(action.entityName).toBe('Hero'); - expect(action.op).toBe(EntityOp.SAVE_ADD_ONE); - expect(action.payload).toBeUndefined(); - }); - - it('should format type as expected with #formatActionTypeName()', () => { - const action = factory.create('Hero', EntityOp.QUERY_ALL); - const expectedFormat = factory.formatActionType(EntityOp.QUERY_ALL, 'Hero'); - expect(action.type).toBe(expectedFormat); - }); - - it('should format type with given label instead of the entity name', () => { - const label = 'Hero - Label Test'; - const action = factory.create('Hero', EntityOp.QUERY_ALL, null, label); - expect(action.type).toContain(label); - }); - - it('can re-format generated action.type with custom #formatActionType()', () => { - factory.formatActionType = (op, entityName) => - `${entityName}_${op}`.toUpperCase(); - - const expected = ('Hero_' + EntityOp.QUERY_ALL).toUpperCase(); - const action = factory.create('Hero', EntityOp.QUERY_ALL); - expect(action.type).toBe(expected); - }); - - it('should throw if do not specify entity name', () => { - expect(() => factory.create(null)).toThrow(); - }); - - it('should throw if do not specify EntityOp', () => { - expect(() => factory.create('Hero')).toThrow(); - }); -}); diff --git a/lib/src/actions/entity-action.ts b/lib/src/actions/entity-action.ts index f2a0c67d..f658e4f6 100644 --- a/lib/src/actions/entity-action.ts +++ b/lib/src/actions/entity-action.ts @@ -2,57 +2,44 @@ import { Injectable } from '@angular/core'; import { Action } from '@ngrx/store'; import { EntityOp } from './entity-op'; +import { MergeStrategy } from './merge-strategy'; +/** Action concerning an entity collection. */ export interface EntityAction

extends Action { readonly type: string; - readonly entityName: string; - readonly op: EntityOp; - readonly payload?: P; - /** The label to use in the action's type. The entityName if no label specified. */ - readonly label?: string; - // The only mutable property because - // it's the only way to stop downstream action processing - /** The action was determined (usually by a reducer) to be in error and should not be processed. */ - error?: Error; + readonly payload: EntityActionPayload

; } -@Injectable() -export class EntityActionFactory { - create

( - nameOrAction: string | EntityAction, - op?: EntityOp, - payload?: P, - label?: string, - error?: Error - ): EntityAction

{ - let entityName: string; +/** Options of an EntityAction */ +export interface EntityActionOptions { + /** Correlate related EntityActions, particularly related saves. Must be serializable. */ + readonly correlationId?: any; + /** True if should perform action optimistically (before server responds) */ + readonly isOptimistic?: boolean; + readonly mergeStrategy?: MergeStrategy; + /** The tag to use in the action's type. The entityName if no tag specified. */ + readonly tag?: string; - if (typeof nameOrAction === 'string') { - if (nameOrAction == null) { - throw new Error('Missing entity name for new action'); - } - if (op == null) { - throw new Error('Missing EntityOp for new action'); - } - entityName = nameOrAction.trim(); - } else { - // is an EntityAction - entityName = nameOrAction.entityName; - label = label || nameOrAction.label; - op = op || nameOrAction.op; - if (arguments.length < 3) { - payload = nameOrAction.payload; - } - } - label = (label || entityName).trim(); - const type = this.formatActionType(op, label); - return error - ? { type, entityName, op, payload, label, error } - : { type, entityName, op, payload, label }; - } + // Mutable actions are BAD. + // Unfortunately, these mutations are the only way to stop @ngrx/effects + // from processing these actions. + + /** + * The action was determined (usually by a reducer) to be in error. + * Downstream effects should not process but rather treat it as an error. + */ + error?: Error; - formatActionType(op: string, label: string) { - return `[${label}] ${op}`; - // return `${op} [${label}]`.toUpperCase(); // an alternative - } + /** + * Downstream effects should skip processing this action but should return + * an innocuous Observable of success. + */ + skip?: boolean; +} + +/** Payload of an EntityAction */ +export interface EntityActionPayload

extends EntityActionOptions { + readonly entityName: string; + readonly entityOp: EntityOp; + readonly data?: P; } diff --git a/lib/src/actions/entity-cache-action.ts b/lib/src/actions/entity-cache-action.ts new file mode 100644 index 00000000..7c2565fd --- /dev/null +++ b/lib/src/actions/entity-cache-action.ts @@ -0,0 +1,58 @@ +/* + * Actions dedicated to the EntityCache as a whole + */ +import { Action } from '@ngrx/store'; + +import { EntityCache } from '../reducers/entity-cache'; +import { EntityActionOptions } from '../actions/entity-action'; +import { MergeStrategy } from '../actions/merge-strategy'; + +export enum EntityCacheAction { + MERGE_QUERY_SET = 'ngrx-data/entity-cache/merge-query-set', + SET_ENTITY_CACHE = 'ngrx-data/entity-cache/set-cache' +} + +/** + * Hash of entities keyed by EntityCollection name, + * typically the result of a query that returned results from a multi-collection query + * that will be merged into an EntityCache via the `MergeQuerySet` action. + */ +export interface EntityCacheQuerySet { + [entityName: string]: any[]; +} + +/** + * Create entity cache action that merges entities from a query result + * that returned entities from multiple collections. + * Corresponding entity cache reducer should add and update all collections + * at the same time, before any selectors$ observables emit. + * @param querySet The result of the query in the form of a map of entity collections. + * These are the entity data to merge into the respective collections. + * @param mergeStrategy How to merge a queried entity when it is already in the collection. + * The default is MergeStrategy.PreserveChanges + */ +export class MergeQuerySet implements Action { + readonly payload: { + querySet: EntityCacheQuerySet; + mergeStrategy?: MergeStrategy; + }; + + readonly type = EntityCacheAction.MERGE_QUERY_SET; + + constructor(querySet: EntityCacheQuerySet, mergeStrategy?: MergeStrategy) { + this.payload = { + querySet, + mergeStrategy: mergeStrategy === null ? MergeStrategy.PreserveChanges : mergeStrategy + }; + } +} + +/** + * Create entity cache action for replacing the entire entity cache. + * Dangerous because brute force but useful as when re-hydrating an EntityCache + * from local browser storage when the application launches. + */ +export class SetEntityCache implements Action { + readonly type = EntityCacheAction.SET_ENTITY_CACHE; + constructor(public readonly payload: EntityCache) {} +} diff --git a/lib/src/actions/entity-cache-actions.ts b/lib/src/actions/entity-cache-actions.ts deleted file mode 100644 index ffc13d1d..00000000 --- a/lib/src/actions/entity-cache-actions.ts +++ /dev/null @@ -1,30 +0,0 @@ -/* - * Actions dedicated to the EntityCache as a whole - */ -import { Action } from '@ngrx/store'; - -import { EntityCache } from '../reducers/entity-cache'; - -export const MERGE_ENTITY_CACHE = 'ngrx-data/entity-cache/merge'; -export const SET_ENTITY_CACHE = 'ngrx-data/entity-cache/set'; - -/** - * Create cache merge operation that replaces the collections in cache with the - * collections in the payload. - * Dangerous but useful as when re-hydrating an EntityCache from local browser storage - * or rolling selected collections back to a previously known state. - */ -export class EntityCacheMerge implements Action { - readonly type = MERGE_ENTITY_CACHE; - constructor(public readonly payload: EntityCache) {} -} - -/** - * Create cache set operation that replaces the entire entity cache. - * Dangerous but useful as when re-hydrating an EntityCache from local browser storage - * when the application launches. - */ -export class EntityCacheSet implements Action { - readonly type = SET_ENTITY_CACHE; - constructor(public readonly payload: EntityCache) {} -} diff --git a/lib/src/actions/entity-op.ts b/lib/src/actions/entity-op.ts index 8aa4c6b6..695288d2 100644 --- a/lib/src/actions/entity-op.ts +++ b/lib/src/actions/entity-op.ts @@ -1,19 +1,19 @@ -/** "Success" suffix appended to EntityOps that are successful.*/ -export const OP_SUCCESS = '/success'; - -/** "Error" suffix appended to EntityOps that have failed.*/ -export const OP_ERROR = '/error'; - // Ensure that these suffix values and the EntityOp suffixes match // Cannot do that programmatically. /** General purpose entity action operations, good for any entity type */ export enum EntityOp { - // Persisting Actions (more to come) + // Persistance operations + CANCEL_PERSIST = 'ngrx-data/cancel-persist', + QUERY_ALL = 'ngrx-data/query-all', QUERY_ALL_SUCCESS = 'ngrx-data/query-all/success', QUERY_ALL_ERROR = 'ngrx-data/query-all/error', + QUERY_LOAD = 'ngrx-data/query-load', + QUERY_LOAD_SUCCESS = 'ngrx-data/query-load/success', + QUERY_LOAD_ERROR = 'ngrx-data/query-load/error', + QUERY_MANY = 'ngrx-data/query-many', QUERY_MANY_SUCCESS = 'ngrx-data/query-many/success', QUERY_MANY_ERROR = 'ngrx-data/query-many/error', @@ -34,31 +34,44 @@ export enum EntityOp { SAVE_UPDATE_ONE_SUCCESS = 'ngrx-data/save/update-one/success', SAVE_UPDATE_ONE_ERROR = 'ngrx-data/save/update-one/error', - SAVE_ADD_ONE_OPTIMISTIC = 'ngrx-data/save/add-one/optimistic', - SAVE_ADD_ONE_OPTIMISTIC_ERROR = 'ngrx-data/save/add-one/optimistic/error', - SAVE_ADD_ONE_OPTIMISTIC_SUCCESS = 'ngrx-data/save/add-one/optimistic/success', - - SAVE_DELETE_ONE_OPTIMISTIC = 'ngrx-data/save/delete-one/optimistic', - SAVE_DELETE_ONE_OPTIMISTIC_SUCCESS = 'ngrx-data/save/delete-one/optimistic/success', - SAVE_DELETE_ONE_OPTIMISTIC_ERROR = 'ngrx-data/save/delete-one/optimistic/error', - - SAVE_UPDATE_ONE_OPTIMISTIC = 'ngrx-data/save/update-one/optimistic', - SAVE_UPDATE_ONE_OPTIMISTIC_SUCCESS = 'ngrx-data/save/update-one/optimistic/success', - SAVE_UPDATE_ONE_OPTIMISTIC_ERROR = 'ngrx-data/save/update-one/optimistic/error', - - // Cache actions + // Cache operations ADD_ALL = 'ngrx-data/add-all', ADD_MANY = 'ngrx-data/add-many', ADD_ONE = 'ngrx-data/add-one', + REMOVE_ALL = 'ngrx-data/remove-all', REMOVE_MANY = 'ngrx-data/remove-many', REMOVE_ONE = 'ngrx-data/remove-one', - REMOVE_ALL = 'ngrx-data/remove-all', UPDATE_MANY = 'ngrx-data/update-many', UPDATE_ONE = 'ngrx-data/update-one', UPSERT_MANY = 'ngrx-data/upsert-many', UPSERT_ONE = 'ngrx-data/upsert-one', + COMMIT_ALL = 'ngrx-data/commit-all', + COMMIT_MANY = 'ngrx-data/commit-many', + COMMIT_ONE = 'ngrx-data/commit-one', + UNDO_ALL = 'ngrx-data/undo-all', + UNDO_MANY = 'ngrx-data/undo-many', + UNDO_ONE = 'ngrx-data/undo-one', + + SET_CHANGE_STATE = 'ngrx-data/set-change-state', + SET_COLLECTION = 'ngrx-data/set-collection', SET_FILTER = 'ngrx-data/set-filter', SET_LOADED = 'ngrx-data/set-loaded', SET_LOADING = 'ngrx-data/set-loading' } + +/** "Success" suffix appended to EntityOps that are successful.*/ +export const OP_SUCCESS = '/success'; + +/** "Error" suffix appended to EntityOps that have failed.*/ +export const OP_ERROR = '/error'; + +/** Make the error EntityOp corresponding to the given EntityOp */ +export function makeErrorOp(op: EntityOp): EntityOp { + return (op + OP_ERROR); +} + +/** Make the success EntityOp corresponding to the given EntityOp */ +export function makeSuccessOp(op: EntityOp): EntityOp { + return (op + OP_SUCCESS); +} diff --git a/lib/src/actions/merge-strategy.ts b/lib/src/actions/merge-strategy.ts new file mode 100644 index 00000000..3b9caed9 --- /dev/null +++ b/lib/src/actions/merge-strategy.ts @@ -0,0 +1,22 @@ +/** How to merge an entity, after query or save, when the corresponding entity in the collection has unsaved changes. */ +export enum MergeStrategy { + /** + * Update the collection entities and ignore all change tracking for this operation. + * ChangeState is untouched. + */ + IgnoreChanges, + /** + * Updates current values for unchanged entities. + * If entities are changed, preserves their current values and + * overwrites their originalValue with the merge entity. + * This is the query-success default. + */ + PreserveChanges, + /** + * Replace the current collection entities. + * Discards the ChangeState for the merged entities if set + * and their ChangeTypes becomes "unchanged". + * This is the save-success default. + */ + OverwriteChanges +} diff --git a/lib/src/dataservices/data-service-error.ts b/lib/src/dataservices/data-service-error.ts index 406c22c3..fab1a641 100644 --- a/lib/src/dataservices/data-service-error.ts +++ b/lib/src/dataservices/data-service-error.ts @@ -1,17 +1,24 @@ import { EntityAction } from '../actions/entity-action'; import { RequestData } from './interfaces'; +/** + * Error from a DataService + * The source error either comes from a failed HTTP response or was thrown within the service. + * @param error the HttpResponse error or the error thrown by the service + * @param requestData the HTTP request information such as the method and the url. + */ +// If extend from Error, `dse instanceof DataServiceError` returns false +// in some (all?) unit tests so don't bother trying. export class DataServiceError { - readonly message: string; + message: string; + constructor(public error: any, public requestData: RequestData) { - this.message = - (error.error && error.error.message) || - (error.message || (error.body && error.body.error) || error).toString(); + this.message = (error.error && error.error.message) || (error.message || (error.body && error.body.error) || error).toString(); } } /** Payload for an EntityAction data service error such as QUERY_ALL_ERROR */ export interface EntityActionDataServiceError { - originalAction: EntityAction; error: DataServiceError; + originalAction: EntityAction; } diff --git a/lib/src/dataservices/default-data.service.spec.ts b/lib/src/dataservices/default-data.service.spec.ts index 649a440e..5bb4aabf 100644 --- a/lib/src/dataservices/default-data.service.spec.ts +++ b/lib/src/dataservices/default-data.service.spec.ts @@ -1,24 +1,13 @@ import { TestBed } from '@angular/core/testing'; import { HttpClient, HttpResponse } from '@angular/common/http'; -import { - HttpClientTestingModule, - HttpTestingController -} from '@angular/common/http/testing'; +import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; import { of } from 'rxjs'; -import { - DefaultDataService, - DefaultDataServiceFactory, - DefaultDataServiceConfig -} from './default-data.service'; +import { DefaultDataService, DefaultDataServiceFactory, DefaultDataServiceConfig } from './default-data.service'; import { DataServiceError } from './data-service-error'; -import { - DefaultHttpUrlGenerator, - EntityHttpResourceUrls, - HttpUrlGenerator -} from './http-url-generator'; +import { DefaultHttpUrlGenerator, EntityHttpResourceUrls, HttpUrlGenerator } from './http-url-generator'; import { Update } from '../utils/ngrx-entity-models'; class Hero { @@ -102,16 +91,7 @@ describe('DefaultDataService', () => { }); it('should return expected heroes (called once)', () => { - service - .getAll() - .subscribe( - heroes => - expect(heroes).toEqual( - expectedHeroes, - 'should return expected heroes' - ), - fail - ); + service.getAll().subscribe(heroes => expect(heroes).toEqual(expectedHeroes, 'should return expected heroes'), fail); // HeroService should have made one request to GET heroes from expected URL const req = httpTestingController.expectOne(heroesUrl); @@ -122,13 +102,7 @@ describe('DefaultDataService', () => { }); it('should be OK returning no heroes', () => { - service - .getAll() - .subscribe( - heroes => - expect(heroes.length).toEqual(0, 'should have empty heroes array'), - fail - ); + service.getAll().subscribe(heroes => expect(heroes.length).toEqual(0, 'should have empty heroes array'), fail); const req = httpTestingController.expectOne(heroesUrl); req.flush([]); // Respond with no heroes @@ -137,16 +111,7 @@ describe('DefaultDataService', () => { it('should return expected heroes (called multiple times)', () => { service.getAll().subscribe(); service.getAll().subscribe(); - service - .getAll() - .subscribe( - heroes => - expect(heroes).toEqual( - expectedHeroes, - 'should return expected heroes' - ), - fail - ); + service.getAll().subscribe(heroes => expect(heroes).toEqual(expectedHeroes, 'should return expected heroes'), fail); const requests = httpTestingController.match(heroesUrl); expect(requests.length).toEqual(3, 'calls to getAll()'); @@ -164,10 +129,7 @@ describe('DefaultDataService', () => { heroes => fail('getAll succeeded when expected it to fail with a 404'), err => { expect(err).toBeDefined('request should have failed'); - expect(err instanceof DataServiceError).toBe( - true, - 'is DataServiceError' - ); + expect(err instanceof DataServiceError).toBe(true, 'is DataServiceError'); expect(err.error.status).toEqual(404, 'has 404 status'); expect(err.message).toEqual(msg, 'has expected error message'); } @@ -196,13 +158,7 @@ describe('DefaultDataService', () => { it('should return expected hero when id is found', () => { expectedHero = { id: 1, name: 'A' }; - service - .getById(1) - .subscribe( - hero => - expect(hero).toEqual(expectedHero, 'should return expected hero'), - fail - ); + service.getById(1).subscribe(hero => expect(hero).toEqual(expectedHero, 'should return expected hero'), fail); // One request to GET hero from expected URL const req = httpTestingController.expectOne(heroUrlId1); @@ -244,14 +200,7 @@ describe('DefaultDataService', () => { it('should return expected selected heroes w/ object params', () => { service .getWithQuery({ name: 'B' }) - .subscribe( - heroes => - expect(heroes).toEqual( - expectedHeroes, - 'should return expected heroes' - ), - fail - ); + .subscribe(heroes => expect(heroes).toEqual(expectedHeroes, 'should return expected heroes'), fail); // HeroService should have made one request to GET heroes // from expected URL with query params @@ -263,16 +212,7 @@ describe('DefaultDataService', () => { }); it('should return expected selected heroes w/ string params', () => { - service - .getWithQuery('name=B') - .subscribe( - heroes => - expect(heroes).toEqual( - expectedHeroes, - 'should return expected heroes' - ), - fail - ); + service.getWithQuery('name=B').subscribe(heroes => expect(heroes).toEqual(expectedHeroes, 'should return expected heroes'), fail); // HeroService should have made one request to GET heroes // from expected URL with query params @@ -284,13 +224,7 @@ describe('DefaultDataService', () => { }); it('should be OK returning no heroes', () => { - service - .getWithQuery({ name: 'B' }) - .subscribe( - heroes => - expect(heroes.length).toEqual(0, 'should have empty heroes array'), - fail - ); + service.getWithQuery({ name: 'B' }).subscribe(heroes => expect(heroes.length).toEqual(0, 'should have empty heroes array'), fail); const req = httpTestingController.expectOne(heroesUrl + '?name=B'); req.flush([]); // Respond with no heroes @@ -300,14 +234,10 @@ describe('DefaultDataService', () => { const msg = 'deliberate 404 error'; service.getWithQuery({ name: 'B' }).subscribe( - heroes => - fail('getWithQuery succeeded when expected it to fail with a 404'), + heroes => fail('getWithQuery succeeded when expected it to fail with a 404'), err => { expect(err).toBeDefined('request should have failed'); - expect(err instanceof DataServiceError).toBe( - true, - 'is DataServiceError' - ); + expect(err instanceof DataServiceError).toBe(true, 'is DataServiceError'); expect(err.error.status).toEqual(404, 'has 404 status'); expect(err.message).toEqual(msg, 'has expected error message'); } @@ -327,18 +257,10 @@ describe('DefaultDataService', () => { it('should return expected hero with id', () => { expectedHero = { id: 42, name: 'A' }; - service - .add({ name: 'A' } as Hero) - .subscribe( - hero => - expect(hero).toEqual(expectedHero, 'should return expected hero'), - fail - ); + service.add({ name: 'A' } as Hero).subscribe(hero => expect(hero).toEqual(expectedHero, 'should return expected hero'), fail); // One request to POST hero from expected URL - const req = httpTestingController.expectOne( - r => r.method === 'POST' && r.url === heroUrl - ); + const req = httpTestingController.expectOne(r => r.method === 'POST' && r.url === heroUrl); // Respond with the expected hero req.flush(expectedHero); @@ -358,36 +280,20 @@ describe('DefaultDataService', () => { const heroUrlId1 = heroUrl + '1'; it('should delete by hero id', () => { - service - .delete(1) - .subscribe( - result => - expect(result).toEqual(1, 'should return the deleted entity id'), - fail - ); + service.delete(1).subscribe(result => expect(result).toEqual(1, 'should return the deleted entity id'), fail); // One request to DELETE hero from expected URL - const req = httpTestingController.expectOne( - r => r.method === 'DELETE' && r.url === heroUrlId1 - ); + const req = httpTestingController.expectOne(r => r.method === 'DELETE' && r.url === heroUrlId1); // Respond with empty nonsense object req.flush({}); }); it('should return successfully when id not found and delete404OK is true (default)', () => { - service - .delete(1) - .subscribe( - result => - expect(result).toEqual(1, 'should return the deleted entity id'), - fail - ); + service.delete(1).subscribe(result => expect(result).toEqual(1, 'should return the deleted entity id'), fail); // One request to DELETE hero from expected URL - const req = httpTestingController.expectOne( - r => r.method === 'DELETE' && r.url === heroUrlId1 - ); + const req = httpTestingController.expectOne(r => r.method === 'DELETE' && r.url === heroUrlId1); // Respond with empty nonsense object req.flush({}); @@ -432,24 +338,10 @@ describe('DefaultDataService', () => { // The server makes the update AND updates the version concurrency property. const expectedHero: Hero = { id: 1, name: 'B', version: 2 }; - // Service must return an Update - const expectedUpdate: Update = { id: 1, changes: expectedHero }; - - service - .update(updateArg) - .subscribe( - updated => - expect(updated).toEqual( - expectedUpdate, - 'should return expected hero update' - ), - fail - ); + service.update(updateArg).subscribe(updated => expect(updated).toEqual(expectedHero, 'should return the expected hero'), fail); // One request to PUT hero from expected URL - const req = httpTestingController.expectOne( - r => r.method === 'PUT' && r.url === heroUrlId1 - ); + const req = httpTestingController.expectOne(r => r.method === 'PUT' && r.url === heroUrlId1); // Respond with the expected hero req.flush(expectedHero); @@ -516,11 +408,7 @@ describe('DefaultDataServiceFactory', () => { describe('(with config)', () => { it('can create factory', () => { const config: DefaultDataServiceConfig = { root: 'api' }; - const factory = new DefaultDataServiceFactory( - http, - httpUrlGenerator, - config - ); + const factory = new DefaultDataServiceFactory(http, httpUrlGenerator, config); const heroDS = factory.create('Hero'); expect(heroDS.name).toBe('Hero DefaultDataService'); }); @@ -536,11 +424,7 @@ describe('DefaultDataServiceFactory', () => { } } }; - const factory = new DefaultDataServiceFactory( - http, - httpUrlGenerator, - config - ); + const factory = new DefaultDataServiceFactory(http, httpUrlGenerator, config); const heroDS = factory.create('Hero'); heroDS.getAll(); expect(http.get).toHaveBeenCalledWith(newHeroesUrl, undefined); diff --git a/lib/src/dataservices/default-data.service.ts b/lib/src/dataservices/default-data.service.ts index abc0f4fc..bd13dec4 100644 --- a/lib/src/dataservices/default-data.service.ts +++ b/lib/src/dataservices/default-data.service.ts @@ -1,9 +1,5 @@ import { Injectable, Optional } from '@angular/core'; -import { - HttpClient, - HttpErrorResponse, - HttpParams -} from '@angular/common/http'; +import { HttpClient, HttpErrorResponse, HttpParams } from '@angular/common/http'; import { Observable, of, throwError } from 'rxjs'; import { catchError, delay, map, tap, timeout } from 'rxjs/operators'; @@ -67,14 +63,7 @@ export class DefaultDataService implements EntityCollectionDataService { ) { this._name = `${entityName} DefaultDataService`; this.entityName = entityName; - const { - root = 'api', - delete404OK = true, - getDelay = 0, - saveDelay = 0, - timeout: to = 0 - } = - config || {}; + const { root = 'api', delete404OK = true, getDelay = 0, saveDelay = 0, timeout: to = 0 } = config || {}; this.delete404OK = delete404OK; this.entityUrl = httpUrlGenerator.entityResource(entityName, root); this.entitiesUrl = httpUrlGenerator.collectionResource(entityName, root); @@ -84,19 +73,18 @@ export class DefaultDataService implements EntityCollectionDataService { } add(entity: T): Observable { - const entityOrError = - entity || new Error(`No "${this.entityName}" entity to add`); + const entityOrError = entity || new Error(`No "${this.entityName}" entity to add`); return this.execute('POST', this.entityUrl, entityOrError); } - delete(key: number | string): Observable { + delete(key: number | string): Observable { let err: Error; if (key == null) { err = new Error(`No "${this.entityName}" key to delete`); } return this.execute('DELETE', this.entityUrl + key, err).pipe( // forward the id of deleted entity as the result of the HTTP DELETE - map(result => key as any) + map(result => key as number | string) ); } @@ -113,20 +101,14 @@ export class DefaultDataService implements EntityCollectionDataService { } getWithQuery(queryParams: QueryParams | string): Observable { - const qParams = - typeof queryParams === 'string' - ? { fromString: queryParams } - : { fromObject: queryParams }; + const qParams = typeof queryParams === 'string' ? { fromString: queryParams } : { fromObject: queryParams }; const params = new HttpParams(qParams); return this.execute('GET', this.entitiesUrl, undefined, { params }); } - update(update: Update): Observable> { + update(update: Update): Observable { const id = update && update.id; - const updateOrError = - id == null - ? new Error(`No "${this.entityName}" update data or id`) - : update; + const updateOrError = id == null ? new Error(`No "${this.entityName}" update data or id`) : update; return this.execute('PUT', this.entityUrl + id, updateOrError); } @@ -136,7 +118,7 @@ export class DefaultDataService implements EntityCollectionDataService { data?: any, // data, error, or undefined/null options?: any ): Observable { - const req: RequestData = { method, url, options }; + const req: RequestData = { method, url, data, options }; if (data instanceof Error) { return this.handleError(req)(data); @@ -168,19 +150,7 @@ export class DefaultDataService implements EntityCollectionDataService { } // N.B.: It must return an Update case 'PUT': { - const { id, changes } = data; // data must be Update - result$ = this.http.put(url, changes, options).pipe( - map(updated => { - // Return Update with merged updated data (if any). - // If no data from server, - const noData = Object.keys(updated || {}).length === 0; - // assume the server made no additional changes of its own and - // append `unchanged: true` to the original payload. - return noData - ? { ...data, unchanged: true } - : { id, changes: { ...changes, ...updated } }; - }) - ); + result$ = this.http.put(url, data, options); if (this.saveDelay) { result$ = result$.pipe(delay(this.saveDelay)); } @@ -209,11 +179,7 @@ export class DefaultDataService implements EntityCollectionDataService { } private handleDelete404(error: HttpErrorResponse, reqData: RequestData) { - if ( - error.status === 404 && - reqData.method === 'DELETE' && - this.delete404OK - ) { + if (error.status === 404 && reqData.method === 'DELETE' && this.delete404OK) { return of({}); } return undefined; @@ -241,11 +207,6 @@ export class DefaultDataServiceFactory { * @param entityName {string} Name of the entity type for this data service */ create(entityName: string): EntityCollectionDataService { - return new DefaultDataService( - entityName, - this.http, - this.httpUrlGenerator, - this.config - ); + return new DefaultDataService(entityName, this.http, this.httpUrlGenerator, this.config); } } diff --git a/lib/src/dataservices/entity-data.service.spec.ts b/lib/src/dataservices/entity-data.service.spec.ts index 436163d1..67d2b27b 100644 --- a/lib/src/dataservices/entity-data.service.spec.ts +++ b/lib/src/dataservices/entity-data.service.spec.ts @@ -4,20 +4,10 @@ import { HttpClient } from '@angular/common/http'; import { Observable } from 'rxjs'; -import { - createEntityDefinition, - EntityDefinition -} from '../entity-metadata/entity-definition'; -import { - EntityMetadata, - EntityMetadataMap, - ENTITY_METADATA_TOKEN -} from '../entity-metadata/entity-metadata'; - -import { - DefaultDataService, - DefaultDataServiceFactory -} from './default-data.service'; +import { createEntityDefinition, EntityDefinition } from '../entity-metadata/entity-definition'; +import { EntityMetadata, EntityMetadataMap, ENTITY_METADATA_TOKEN } from '../entity-metadata/entity-metadata'; + +import { DefaultDataService, DefaultDataServiceFactory } from './default-data.service'; import { HttpUrlGenerator, EntityHttpResourceUrls } from './http-url-generator'; import { EntityDataService } from './entity-data.service'; @@ -40,8 +30,7 @@ export class Bazinga { wow: string; } -export class BazingaDataService - implements EntityCollectionDataService { +export class BazingaDataService implements EntityCollectionDataService { name: string; // TestBed bug requires `@Optional` even though http is always provided. @@ -67,7 +56,7 @@ export class BazingaDataService getWithQuery(params: string | QueryParams): Observable { return this.bazinga(); } - update(update: Update): Observable> { + update(update: Update): Observable { return this.bazinga(); } @@ -81,10 +70,7 @@ export class BazingaDataService providers: [BazingaDataService] }) export class CustomDataServiceModule { - constructor( - entityDataService: EntityDataService, - bazingaService: BazingaDataService - ) { + constructor(entityDataService: EntityDataService, bazingaService: BazingaDataService) { entityDataService.registerService('Bazinga', bazingaService); } } @@ -101,9 +87,7 @@ class TestHttpUrlGenerator implements HttpUrlGenerator { collectionResource(entityName: string, root: string): string { return 'api/heroes/'; } - registerHttpResourceUrls( - entityHttpResourceUrls: EntityHttpResourceUrls - ): void {} + registerHttpResourceUrls(entityHttpResourceUrls: EntityHttpResourceUrls): void {} } // endregion @@ -132,7 +116,7 @@ describe('EntityDataService', () => { expect(service).toBeDefined(); }); - it('can data service is a DefaultDataService by default', () => { + it('data service should be a DefaultDataService by default', () => { const service = entityDataService.getService('Hero'); expect(service instanceof DefaultDataService).toBe(true); }); diff --git a/lib/src/dataservices/entity-data.service.ts b/lib/src/dataservices/entity-data.service.ts index 3190f6a0..67e19376 100644 --- a/lib/src/dataservices/entity-data.service.ts +++ b/lib/src/dataservices/entity-data.service.ts @@ -12,11 +12,11 @@ import { Update } from '../utils/ngrx-entity-models'; export interface EntityCollectionDataService { readonly name: string; add(entity: T): Observable; - delete(id: any): Observable; + delete(id: number | string): Observable; getAll(): Observable; getById(id: any): Observable; getWithQuery(params: QueryParams | string): Observable; - update(update: Update): Observable>; + update(update: Update): Observable; } @Injectable() diff --git a/lib/src/dataservices/interfaces.ts b/lib/src/dataservices/interfaces.ts index ca0c006a..5b9bf0c3 100644 --- a/lib/src/dataservices/interfaces.ts +++ b/lib/src/dataservices/interfaces.ts @@ -3,6 +3,7 @@ export type HttpMethods = 'DELETE' | 'GET' | 'POST' | 'PUT'; export interface RequestData { method: HttpMethods; url: string; + data?: any; options?: any; } diff --git a/lib/src/dataservices/persistence-result-handler.service.ts b/lib/src/dataservices/persistence-result-handler.service.ts index 28170815..1429cb2b 100644 --- a/lib/src/dataservices/persistence-result-handler.service.ts +++ b/lib/src/dataservices/persistence-result-handler.service.ts @@ -3,12 +3,10 @@ import { Action } from '@ngrx/store'; import { Observable, of } from 'rxjs'; -import { - DataServiceError, - EntityActionDataServiceError -} from './data-service-error'; -import { EntityAction, EntityActionFactory } from '../actions/entity-action'; -import { EntityOp, OP_ERROR, OP_SUCCESS } from '../actions/entity-op'; +import { DataServiceError, EntityActionDataServiceError } from './data-service-error'; +import { EntityAction } from '../actions/entity-action'; +import { EntityActionFactory } from '../actions/entity-action-factory'; +import { EntityOp, makeErrorOp, makeSuccessOp } from '../actions/entity-op'; import { Logger } from '../utils/interfaces'; /** @@ -16,14 +14,10 @@ import { Logger } from '../utils/interfaces'; */ export abstract class PersistenceResultHandler { /** Handle successful result of persistence operation for an action */ - abstract handleSuccess(action: EntityAction): (data: any) => Action; + abstract handleSuccess(originalAction: EntityAction): (data: any) => Action; /** Handle error result of persistence operation for an action */ - abstract handleError( - action: EntityAction - ): ( - error: DataServiceError | Error - ) => EntityAction; + abstract handleError(originalAction: EntityAction): (error: DataServiceError | Error) => EntityAction; } /** @@ -31,39 +25,28 @@ export abstract class PersistenceResultHandler { * specifically an EntityDataService */ @Injectable() -export class DefaultPersistenceResultHandler - implements PersistenceResultHandler { - constructor( - private logger: Logger, - private entityActionFactory: EntityActionFactory - ) {} +export class DefaultPersistenceResultHandler implements PersistenceResultHandler { + constructor(private logger: Logger, private entityActionFactory: EntityActionFactory) {} /** Handle successful result of persistence operation on an EntityAction */ - handleSuccess(action: EntityAction): (data: any) => Action { - const successOp = (action.op + OP_SUCCESS); - return (data: any) => - this.entityActionFactory.create(action as EntityAction, successOp, data); + handleSuccess(originalAction: EntityAction): (data: any) => Action { + const successOp = makeSuccessOp(originalAction.payload.entityOp); + return (data: any) => this.entityActionFactory.createFromAction(originalAction, { entityOp: successOp, data }); } /** Handle error result of persistence operation on an EntityAction */ - handleError( - action: EntityAction - ): ( - error: DataServiceError | Error - ) => EntityAction { - const errorOp = (action.op + OP_ERROR); - return (error: DataServiceError | Error) => { - if (error instanceof Error) { - error = new DataServiceError(error, null); - } - this.logger.error(error); - const errorAction = this.entityActionFactory.create< - EntityActionDataServiceError - >(action as EntityAction, errorOp, { - error, - originalAction: action as EntityAction + handleError(originalAction: EntityAction): (error: DataServiceError | Error) => EntityAction { + const errorOp = makeErrorOp(originalAction.payload.entityOp); + + return (err: DataServiceError | Error) => { + const error = err instanceof DataServiceError ? err : new DataServiceError(err, null); + const errorData: EntityActionDataServiceError = { error, originalAction }; + this.logger.error(errorData); + const action = this.entityActionFactory.createFromAction(originalAction, { + entityOp: errorOp, + data: errorData }); - return errorAction; + return action; }; } } diff --git a/lib/src/dispatchers/entity-commands.ts b/lib/src/dispatchers/entity-commands.ts index 4c8e7ce3..9292a439 100644 --- a/lib/src/dispatchers/entity-commands.ts +++ b/lib/src/dispatchers/entity-commands.ts @@ -1,112 +1,143 @@ +import { Observable } from 'rxjs'; +import { EntityActionOptions } from '../actions/entity-action'; +import { MergeStrategy } from '../actions/merge-strategy'; import { QueryParams } from '../dataservices/interfaces'; -/*** Commands that update the remote server ***/ +/** Commands that update the remote server. */ export interface EntityServerCommands { /** - * Save a new entity to remote storage. - * Does not add to cache until save succeeds. - * Ignored by cache-add if the entity is already in cache. + * Dispatch action to save a new entity to remote storage. + * @param entity entity to add, which may omit its key if pessimistic and the server creates the key; + * must have a key if optimistic save. + * @returns A terminating Observable of the entity + * after server reports successful save or the save error. */ - add(entity: T, isOptimistic?: boolean): void; + add(entity: T, options?: EntityActionOptions): Observable; /** - * Removes entity from the cache by key (if it is in the cache). - * and deletes entity from remote storage by key. - * Does not restore to cache if the delete fails. - * @param key The primary key of the entity to remove + * Dispatch action to cancel the persistence operation (query or save) with the given correlationId. + * @param correlationId The correlation id for the corresponding EntityAction + * @param [reason] explains why canceled and by whom. */ - delete(key: number | string, isOptimistic?: boolean): void; + cancel(correlationId: any, reason?: string): void; /** - * Removes entity from the cache (if it is in the cache) - * and deletes entity from remote storage by key. - * Does not restore to cache if the delete fails. - * @param entity The entity to remove + * Dispatch action to delete entity from remote storage by key. + * @param key The entity to delete + * @returns A terminating Observable of the deleted key + * after server reports successful save or the save error. */ - delete(entity: T, isOptimistic?: boolean): void; + delete(entity: T, options?: EntityActionOptions): Observable; /** - * Query remote storage for all entities and - * completely replace the cached collection with the queried entities. + * Dispatch action to delete entity from remote storage by key. + * @param key The primary key of the entity to remove + * @returns Observable of the deleted key + * after server reports successful save or the save error. + */ + delete(key: number | string, options?: EntityActionOptions): Observable; + + /** + * Dispatch action to query remote storage for all entities and + * merge the queried entities into the cached collection. + * @returns A terminating Observable of the collection + * after server reports successful query or the query error. + * @see load() */ - getAll(): void; + getAll(options?: EntityActionOptions): Observable; /** - * Query remote storage for the entity with this primary key. + * Dispatch action to query remote storage for the entity with this primary key. * If the server returns an entity, * merge it into the cached collection. + * @returns A terminating Observable of the queried entities that are in the collection + * after server reports success or the query error. */ - getByKey(key: any): void; + getByKey(key: any, options?: EntityActionOptions): Observable; /** - * Query remote storage for the entities that satisfy a query expressed - * with either a query parameter map or an HTTP URL query string. + * Dispatch action to query remote storage for the entities that satisfy a query expressed + * with either a query parameter map or an HTTP URL query string, * and merge the results into the cached collection. + * @params queryParams the query in a form understood by the server + * @returns A terminating Observable of the queried entities + * after server reports successful query or the query error. */ - getWithQuery(queryParams: QueryParams | string): void; + getWithQuery(queryParams: QueryParams | string, options?: EntityActionOptions): Observable; /** - * Save the updated entity (or partial entity) to remote storage. - * Updates the cached entity after the save succeeds. - * Update in cache is ignored if the entity's key is not found in cache. + * Dispatch action to query remote storage for all entities and + * completely replace the cached collection with the queried entities. + * @returns A terminating Observable of the entities in the collection + * after server reports successful query or the query error. + * @see getAll + */ + load(options?: EntityActionOptions): Observable; + + /** + * Dispatch action to save the updated entity (or partial entity) in remote storage. * The update entity may be partial (but must have its key) * in which case it patches the existing entity. + * @param entity update entity, which might be a partial of T but must at least have its key. + * @returns A terminating Observable of the updated entity + * after server reports successful save or the save error. */ - update(entity: Partial, isOptimistic?: boolean): void; + update(entity: Partial, options?: EntityActionOptions): Observable; } -/*** Cache-only commands that do not update remote storage ***/ +/*** A collection's cache-only commands, which do not update remote storage ***/ + export interface EntityCacheCommands { /** * Replace all entities in the cached collection. * Does not save to remote storage. */ - addAllToCache(entities: T[]): void; + addAllToCache(entities: T[], options?: EntityActionOptions): void; /** * Add a new entity directly to the cache. * Does not save to remote storage. * Ignored if an entity with the same primary key is already in cache. */ - addOneToCache(entity: T): void; + addOneToCache(entity: T, options?: EntityActionOptions): void; /** * Add multiple new entities directly to the cache. * Does not save to remote storage. * Entities with primary keys already in cache are ignored. */ - addManyToCache(entities: T[]): void; + addManyToCache(entities: T[], options?: EntityActionOptions): void; /** Clear the cached entity collection */ - clearCache(): void; + clearCache(options?: EntityActionOptions): void; /** * Remove an entity directly from the cache. * Does not delete that entity from remote storage. * @param entity The entity to remove */ - removeOneFromCache(entity: T): void; + removeOneFromCache(entity: T, options?: EntityActionOptions): void; /** * Remove an entity directly from the cache. * Does not delete that entity from remote storage. * @param key The primary key of the entity to remove */ - removeOneFromCache(key: number | string): void; + removeOneFromCache(key: number | string, options?: EntityActionOptions): void; /** * Remove multiple entities directly from the cache. * Does not delete these entities from remote storage. * @param entity The entities to remove */ - removeManyFromCache(entities: T[]): void; + removeManyFromCache(entities: T[], options?: EntityActionOptions): void; /** * Remove multiple entities directly from the cache. * Does not delete these entities from remote storage. * @param keys The primary keys of the entities to remove */ - removeManyFromCache(keys: (number | string)[]): void; + removeManyFromCache(keys: (number | string)[], options?: EntityActionOptions): void; /** * Update a cached entity directly. @@ -115,7 +146,7 @@ export interface EntityCacheCommands { * The update entity may be partial (but must have its key) * in which case it patches the existing entity. */ - updateOneInCache(entity: Partial): void; + updateOneInCache(entity: Partial, options?: EntityActionOptions): void; /** * Update multiple cached entities directly. @@ -124,7 +155,7 @@ export interface EntityCacheCommands { * Update entities may be partial but must at least have their keys. * such partial entities patch their cached counterparts. */ - updateManyInCache(entities: Partial[]): void; + updateManyInCache(entities: Partial[], options?: EntityActionOptions): void; /** * Insert or update a cached entity directly. @@ -132,7 +163,7 @@ export interface EntityCacheCommands { * Upsert entity might be a partial of T but must at least have its key. * Pass the Update structure as the payload */ - upsertOneInCache(entity: Partial): void; + upsertOneInCache(entity: Partial, options?: EntityActionOptions): void; /** * Insert or update multiple cached entities directly. @@ -140,28 +171,20 @@ export interface EntityCacheCommands { * Upsert entities might be partial but must at least have their keys. * Pass an array of the Update structure as the payload */ - upsertManyInCache(entities: Partial[]): void; + upsertManyInCache(entities: Partial[], options?: EntityActionOptions): void; /** * Set the pattern that the collection's filter applies * when using the `filteredEntities` selector. */ - setFilter(pattern: any): void; + setFilter(pattern: any, options?: EntityActionOptions): void; /** Set the loaded flag */ - setLoaded(isLoaded: boolean): void; + setLoaded(isLoaded: boolean, options?: EntityActionOptions): void; /** Set the loading flag */ - setLoading(isLoading: boolean): void; + setLoading(isLoading: boolean, options?: EntityActionOptions): void; } -/** - * Interface for ngrx-data entity commands that - * dispatch entity actions to the ngrx store. - */ -export interface EntityCommands - extends EntityServerCommands, - EntityCacheCommands {} - -// TypeScript bug: have to export something real in JavaScript -export const __dummy__: any = undefined; +/** Commands that dispatch entity actions for a collection */ +export interface EntityCommands extends EntityServerCommands, EntityCacheCommands {} diff --git a/lib/src/dispatchers/entity-dispatcher-base.ts b/lib/src/dispatchers/entity-dispatcher-base.ts new file mode 100644 index 00000000..f3a2b788 --- /dev/null +++ b/lib/src/dispatchers/entity-dispatcher-base.ts @@ -0,0 +1,493 @@ +import { Action, createSelector, select, Store } from '@ngrx/store'; + +import { Observable, of, throwError } from 'rxjs'; +import { filter, first, map, mergeMap, shareReplay, withLatestFrom } from 'rxjs/operators'; + +import { CorrelationIdGenerator } from '../utils/correlation-id-generator'; +import { EntityDispatcherDefaultOptions } from './entity-dispatcher-default-options'; +import { defaultSelectId, toUpdateFactory } from '../utils/utilities'; +import { EntityAction, EntityActionOptions } from '../actions/entity-action'; +import { EntityActionFactory } from '../actions/entity-action-factory'; +import { EntityActionGuard } from '../actions/entity-action-guard'; +import { EntityCache } from '../reducers/entity-cache'; +import { EntityCacheSelector } from '../selectors/entity-cache-selector'; +import { EntityCollection } from '../reducers/entity-collection'; +import { EntityCommands } from './entity-commands'; +import { EntityDispatcher, PersistanceCanceled } from './entity-dispatcher'; +import { EntityOp, OP_ERROR, OP_SUCCESS } from '../actions/entity-op'; +import { IdSelector, Update, UpdateData } from '../utils/ngrx-entity-models'; +import { MergeStrategy } from '../actions/merge-strategy'; +import { QueryParams } from '../dataservices/interfaces'; + +export class EntityDispatcherBase implements EntityDispatcher { + /** Utility class with methods to validate EntityAction payloads.*/ + guard: EntityActionGuard; + + private entityCollection$: Observable>; + + /** + * Convert an entity (or partial entity) into the `Update` object + * `update...` and `upsert...` methods take `Update` args + */ + toUpdate: (entity: Partial) => Update; + + constructor( + /** Name of the entity type for which entities are dispatched */ + public entityName: string, + /** Creates an {EntityAction} */ + public entityActionFactory: EntityActionFactory, + /** The store, scoped to the EntityCache */ + public store: Store, + /** Returns the primary key (id) of this entity */ + public selectId: IdSelector = defaultSelectId, + /** + * Dispatcher options configure dispatcher behavior such as + * whether add is optimistic or pessimistic by default. + */ + public defaultDispatcherOptions: EntityDispatcherDefaultOptions, + /** Actions scanned by the store after it processed them with reducers. */ + private reducedActions$: Observable, + /** Store selector for the EntityCache */ + entityCacheSelector: EntityCacheSelector, + /** Generates correlation ids for query and save methods */ + private correlationIdGenerator: CorrelationIdGenerator + ) { + this.guard = new EntityActionGuard(entityName, selectId); + this.toUpdate = toUpdateFactory(selectId); + + const collectionSelector = createSelector(entityCacheSelector, cache => cache[entityName] as EntityCollection); + this.entityCollection$ = store.select(collectionSelector); + } + + /** + * Create an {EntityAction} for this entity type. + * @param entityOp {EntityOp} the entity operation + * @param [data] the action data + * @param [options] additional options + * @returns the EntityAction + */ + createEntityAction

(entityOp: EntityOp, data?: P, options?: EntityActionOptions): EntityAction

{ + return this.entityActionFactory.create({ + entityName: this.entityName, + entityOp, + data, + ...options + }); + } + + /** + * Create an {EntityAction} for this entity type and + * dispatch it immediately to the store. + * @param op {EntityOp} the entity operation + * @param [data] the action data + * @param [options] additional options + * @returns the dispatched EntityAction + */ + createAndDispatch

(op: EntityOp, data?: P, options?: EntityActionOptions): EntityAction

{ + const action = this.createEntityAction(op, data, options); + this.dispatch(action); + return action; + } + + /** + * Dispatch an Action to the store. + * @param action the Action + * @returns the dispatched Action + */ + dispatch(action: Action): Action { + this.store.dispatch(action); + return action; + } + + // #region Query and save operations + + /** + * Dispatch action to save a new entity to remote storage. + * @param entity entity to add, which may omit its key if pessimistic and the server creates the key; + * must have a key if optimistic save. + * @returns A terminating Observable of the entity + * after server reports successful save or the save error. + */ + add(entity: T, options?: EntityActionOptions): Observable { + options = this.setSaveEntityActionOptions(options, this.defaultDispatcherOptions.optimisticAdd); + const action = this.createEntityAction(EntityOp.SAVE_ADD_ONE, entity, options); + if (options.isOptimistic) { + this.guard.mustBeEntity(action); + } + this.dispatch(action); + return this.getResponseData$(options.correlationId).pipe( + // Use the returned entity data's id to get the entity from the collection + // as it might be different from the entity returned from the server. + withLatestFrom(this.entityCollection$), + map(([e, collection]) => collection.entities[this.selectId(e)]), + shareReplay(1) + ); + } + + /** + * Dispatch action to cancel the persistence operation (query or save) + * @param correlationId The correlation id for the corresponding EntityAction + * @param [reason] explains why canceled and by whom. + */ + cancel(correlationId: any, reason?: string): void { + this.createAndDispatch(EntityOp.CANCEL_PERSIST, reason, { correlationId }); + } + + /** + * Dispatch action to delete entity from remote storage by key. + * @param key The primary key of the entity to remove + * @returns A terminating Observable of the deleted key + * after server reports successful save or the save error. + */ + delete(entity: T, options?: EntityActionOptions): Observable; + + /** + * Dispatch action to delete entity from remote storage by key. + * @param key The entity to delete + * @returns A terminating Observable of the deleted key + * after server reports successful save or the save error. + */ + delete(key: number | string, options?: EntityActionOptions): Observable; + delete(arg: number | string | T, options?: EntityActionOptions): Observable { + options = this.setSaveEntityActionOptions(options, this.defaultDispatcherOptions.optimisticDelete); + const key = this.getKey(arg); + const action = this.createEntityAction(EntityOp.SAVE_DELETE_ONE, key, options); + this.guard.mustBeKey(action); + this.dispatch(action); + return this.getResponseData$(options.correlationId).pipe(map(() => key), shareReplay(1)); + } + + /** + * Dispatch action to query remote storage for all entities and + * merge the queried entities into the cached collection. + * @returns A terminating Observable of the queried entities that are in the collection + * after server reports success query or the query error. + * @see load() + */ + getAll(options?: EntityActionOptions): Observable { + options = this.setQueryEntityActionOptions(options); + const action = this.createEntityAction(EntityOp.QUERY_ALL, null, options); + this.dispatch(action); + return this.getResponseData$(options.correlationId).pipe( + // Use the returned entity ids to get the entities from the collection + // as they might be different from the entities returned from the server + // because of unsaved changes (deletes or updates). + withLatestFrom(this.entityCollection$), + map(([entities, collection]) => + entities.reduce( + (acc, e) => { + const entity = collection.entities[this.selectId(e)]; + if (entity) { + acc.push(entity); // only return an entity found in the collection + } + return acc; + }, + [] as T[] + ) + ), + shareReplay(1) + ); + } + + /** + * Dispatch action to query remote storage for the entity with this primary key. + * If the server returns an entity, + * merge it into the cached collection. + * @returns A terminating Observable of the collection + * after server reports successful query or the query error. + */ + getByKey(key: any, options?: EntityActionOptions): Observable { + options = this.setQueryEntityActionOptions(options); + const action = this.createEntityAction(EntityOp.QUERY_BY_KEY, key, options); + this.dispatch(action); + return this.getResponseData$(options.correlationId).pipe( + // Use the returned entity data's id to get the entity from the collection + // as it might be different from the entity returned from the server. + withLatestFrom(this.entityCollection$), + map(([entity, collection]) => collection.entities[this.selectId(entity)]), + shareReplay(1) + ); + } + + /** + * Dispatch action to query remote storage for the entities that satisfy a query expressed + * with either a query parameter map or an HTTP URL query string, + * and merge the results into the cached collection. + * @params queryParams the query in a form understood by the server + * @returns A terminating Observable of the queried entities + * after server reports successful query or the query error. + */ + getWithQuery(queryParams: QueryParams | string, options?: EntityActionOptions): Observable { + options = this.setQueryEntityActionOptions(options); + const action = this.createEntityAction(EntityOp.QUERY_MANY, queryParams, options); + this.dispatch(action); + return this.getResponseData$(options.correlationId).pipe( + // Use the returned entity ids to get the entities from the collection + // as they might be different from the entities returned from the server + // because of unsaved changes (deletes or updates). + withLatestFrom(this.entityCollection$), + map(([entities, collection]) => + entities.reduce( + (acc, e) => { + const entity = collection.entities[this.selectId(e)]; + if (entity) { + acc.push(entity); // only return an entity found in the collection + } + return acc; + }, + [] as T[] + ) + ), + shareReplay(1) + ); + } + + /** + * Dispatch action to query remote storage for all entities and + * completely replace the cached collection with the queried entities. + * @returns A terminating Observable of the entities in the collection + * after server reports successful query or the query error. + * @see getAll + */ + load(options?: EntityActionOptions): Observable { + options = this.setQueryEntityActionOptions(options); + const action = this.createEntityAction(EntityOp.QUERY_LOAD, null, options); + this.dispatch(action); + return this.getResponseData$(options.correlationId).pipe(shareReplay(1)); + } + + /** + * Dispatch action to save the updated entity (or partial entity) in remote storage. + * The update entity may be partial (but must have its key) + * in which case it patches the existing entity. + * @param entity update entity, which might be a partial of T but must at least have its key. + * @returns A terminating Observable of the updated entity + * after server reports successful save or the save error. + */ + update(entity: Partial, options?: EntityActionOptions): Observable { + // update entity might be a partial of T but must at least have its key. + // pass the Update structure as the payload + const update: Update = this.toUpdate(entity); + options = this.setSaveEntityActionOptions(options, this.defaultDispatcherOptions.optimisticUpdate); + const action = this.createEntityAction(EntityOp.SAVE_UPDATE_ONE, update, options); + if (options.isOptimistic) { + this.guard.mustBeEntity(action); + } + this.dispatch(action); + return this.getResponseData$>(options.correlationId).pipe( + // Use the update entity data id to get the entity from the collection + // as might be different from the entity returned from the server + // because the id changed or there are unsaved changes. + map(updateData => updateData.changes), + withLatestFrom(this.entityCollection$), + map(([e, collection]) => collection.entities[this.selectId(e)]), + shareReplay(1) + ); + } + // #endregion Query and save operations + + // #region Cache-only operations that do not update remote storage + + // Unguarded for performance. + // EntityCollectionReducer runs a guard (which throws) + // Developer should understand cache-only methods well enough + // to call them with the proper entities. + // May reconsider and add guards in future. + + /** + * Replace all entities in the cached collection. + * Does not save to remote storage. + */ + addAllToCache(entities: T[], options?: EntityActionOptions): void { + this.createAndDispatch(EntityOp.ADD_ALL, entities, options); + } + + /** + * Add a new entity directly to the cache. + * Does not save to remote storage. + * Ignored if an entity with the same primary key is already in cache. + */ + addOneToCache(entity: T, options?: EntityActionOptions): void { + this.createAndDispatch(EntityOp.ADD_ONE, entity, options); + } + + /** + * Add multiple new entities directly to the cache. + * Does not save to remote storage. + * Entities with primary keys already in cache are ignored. + */ + addManyToCache(entities: T[], options?: EntityActionOptions): void { + this.createAndDispatch(EntityOp.ADD_MANY, entities, options); + } + + /** Clear the cached entity collection */ + clearCache(options?: EntityActionOptions): void { + this.createAndDispatch(EntityOp.REMOVE_ALL, undefined, options); + } + + /** + * Remove an entity directly from the cache. + * Does not delete that entity from remote storage. + * @param entity The entity to remove + */ + removeOneFromCache(entity: T, options?: EntityActionOptions): void; + + /** + * Remove an entity directly from the cache. + * Does not delete that entity from remote storage. + * @param key The primary key of the entity to remove + */ + removeOneFromCache(key: number | string, options?: EntityActionOptions): void; + removeOneFromCache(arg: (number | string) | T, options?: EntityActionOptions): void { + this.createAndDispatch(EntityOp.REMOVE_ONE, this.getKey(arg), options); + } + + /** + * Remove multiple entities directly from the cache. + * Does not delete these entities from remote storage. + * @param entity The entities to remove + */ + removeManyFromCache(entities: T[], options?: EntityActionOptions): void; + + /** + * Remove multiple entities directly from the cache. + * Does not delete these entities from remote storage. + * @param keys The primary keys of the entities to remove + */ + removeManyFromCache(keys: (number | string)[], options?: EntityActionOptions): void; + removeManyFromCache(args: (number | string)[] | T[], options?: EntityActionOptions): void { + if (!args || args.length === 0) { + return; + } + const keys = + typeof args[0] === 'object' + ? // if array[0] is a key, assume they're all keys + (args).map(arg => this.getKey(arg)) + : args; + this.createAndDispatch(EntityOp.REMOVE_MANY, keys, options); + } + + /** + * Update a cached entity directly. + * Does not update that entity in remote storage. + * Ignored if an entity with matching primary key is not in cache. + * The update entity may be partial (but must have its key) + * in which case it patches the existing entity. + */ + updateOneInCache(entity: Partial, options?: EntityActionOptions): void { + // update entity might be a partial of T but must at least have its key. + // pass the Update structure as the payload + const update: Update = this.toUpdate(entity); + this.createAndDispatch(EntityOp.UPDATE_ONE, update, options); + } + + /** + * Update multiple cached entities directly. + * Does not update these entities in remote storage. + * Entities whose primary keys are not in cache are ignored. + * Update entities may be partial but must at least have their keys. + * such partial entities patch their cached counterparts. + */ + updateManyInCache(entities: Partial[], options?: EntityActionOptions): void { + if (!entities || entities.length === 0) { + return; + } + const updates: Update[] = entities.map(entity => this.toUpdate(entity)); + this.createAndDispatch(EntityOp.UPDATE_MANY, updates, options); + } + + /** + * Add or update a new entity directly to the cache. + * Does not save to remote storage. + * Upsert entity might be a partial of T but must at least have its key. + * Pass the Update structure as the payload + */ + upsertOneInCache(entity: Partial, options?: EntityActionOptions): void { + this.createAndDispatch(EntityOp.UPSERT_ONE, entity, options); + } + + /** + * Add or update multiple cached entities directly. + * Does not save to remote storage. + */ + upsertManyInCache(entities: Partial[], options?: EntityActionOptions): void { + if (!entities || entities.length === 0) { + return; + } + this.createAndDispatch(EntityOp.UPSERT_MANY, entities, options); + } + + /** + * Set the pattern that the collection's filter applies + * when using the `filteredEntities` selector. + */ + setFilter(pattern: any): void { + this.createAndDispatch(EntityOp.SET_FILTER, pattern); + } + + /** Set the loaded flag */ + setLoaded(isLoaded: boolean): void { + this.createAndDispatch(EntityOp.SET_LOADED, !!isLoaded); + } + + /** Set the loading flag */ + setLoading(isLoading: boolean): void { + this.createAndDispatch(EntityOp.SET_LOADING, !!isLoading); + } + // #endregion Cache-only operations that do not update remote storage + + // #region private helpers + + /** Get key from entity (unless arg is already a key) */ + private getKey(arg: number | string | T) { + return typeof arg === 'object' ? this.selectId(arg) : arg; + } + + /** + * Return Observable of data from the server-success EntityAction with + * the given Correlation Id, after that action was processed by the ngrx store. + * or else put the server error on the Observable error channel. + * @param crid The correlationId for both the save and response actions. + */ + private getResponseData$(crid: any): Observable { + /** + * reducedActions$ must be replay observable of the most recent action reduced by the store. + * because the response action might have been dispatched to the store + * before caller had a chance to subscribe. + */ + return this.reducedActions$.pipe( + filter((act: any) => !!act.payload), + filter((act: EntityAction) => { + const { correlationId, entityName, entityOp } = act.payload; + return ( + entityName === this.entityName && + correlationId === crid && + (entityOp.endsWith(OP_SUCCESS) || entityOp.endsWith(OP_ERROR) || entityOp === EntityOp.CANCEL_PERSIST) + ); + }), + first(), + mergeMap(act => { + const { entityOp } = act.payload; + return entityOp === EntityOp.CANCEL_PERSIST + ? throwError(new PersistanceCanceled(act.payload.data)) + : entityOp.endsWith(OP_SUCCESS) + ? of(act.payload.data as D) + : throwError(act.payload.data.error); + }) + ); + } + + private setQueryEntityActionOptions(options: EntityActionOptions): EntityActionOptions { + options = options || {}; + const correlationId = options.correlationId == null ? this.correlationIdGenerator.next() : options.correlationId; + return { ...options, correlationId }; + } + + private setSaveEntityActionOptions(options: EntityActionOptions, defaultOptimism: boolean): EntityActionOptions { + options = options || {}; + const correlationId = options.correlationId == null ? this.correlationIdGenerator.next() : options.correlationId; + const isOptimistic = options.isOptimistic == null ? defaultOptimism || false : options.isOptimistic === true; + return { ...options, correlationId, isOptimistic }; + } + // #endregion private helpers +} diff --git a/lib/src/dispatchers/entity-dispatcher-default-options.ts b/lib/src/dispatchers/entity-dispatcher-default-options.ts new file mode 100644 index 00000000..1690c904 --- /dev/null +++ b/lib/src/dispatchers/entity-dispatcher-default-options.ts @@ -0,0 +1,18 @@ +import { Injectable } from '@angular/core'; +/** + * Default options for EntityDispatcher behavior + * such as whether `add()` is optimistic or pessimistic by default. + * An optimistic save modifies the collection immediately and before saving to the server. + * A pessimistic save modifies the collection after the server confirms the save was successful. + * This class initializes the defaults to the safest values. + * Provide an alternative to change the defaults for all entity collections. + */ +@Injectable() +export class EntityDispatcherDefaultOptions { + /** True if added entities are saved optimistically; False if saved pessimistically. */ + optimisticAdd = false; + /** True if deleted entities are saved optimistically; False if saved pessimistically. */ + optimisticDelete = true; + /** True if updated entities are saved optimistically; False if saved pessimistically. */ + optimisticUpdate = false; +} diff --git a/lib/src/dispatchers/entity-dispatcher-factory.ts b/lib/src/dispatchers/entity-dispatcher-factory.ts index 97406b2b..3ddd63d9 100644 --- a/lib/src/dispatchers/entity-dispatcher-factory.ts +++ b/lib/src/dispatchers/entity-dispatcher-factory.ts @@ -1,36 +1,46 @@ -import { Injectable } from '@angular/core'; -import { Store } from '@ngrx/store'; +import { Inject, Injectable, OnDestroy } from '@angular/core'; +import { Action, Store, ScannedActionsSubject } from '@ngrx/store'; +import { Observable, Subscription } from 'rxjs'; +import { shareReplay } from 'rxjs/operators'; -import { EntityAction, EntityActionFactory } from '../actions/entity-action'; -import { EntityActionGuard } from '../actions/entity-action-guard'; -import { EntityOp } from '../actions/entity-op'; -import { QueryParams } from '../dataservices/interfaces'; -import { EntityCommands } from './entity-commands'; +import { CorrelationIdGenerator } from '../utils/correlation-id-generator'; +import { EntityDispatcherDefaultOptions } from './entity-dispatcher-default-options'; +import { defaultSelectId, toUpdateFactory } from '../utils/utilities'; +import { EntityAction } from '../actions/entity-action'; +import { EntityActionFactory } from '../actions/entity-action-factory'; import { EntityCache } from '../reducers/entity-cache'; -import { - EntityDispatcher, - EntityDispatcherBase, - EntityDispatcherOptions -} from './entity-dispatcher'; +import { EntityCacheSelector, ENTITY_CACHE_SELECTOR_TOKEN, createEntityCacheSelector } from '../selectors/entity-cache-selector'; +import { EntityDispatcher } from './entity-dispatcher'; +import { EntityDispatcherBase } from './entity-dispatcher-base'; +import { EntityOp } from '../actions/entity-op'; import { IdSelector, Update } from '../utils/ngrx-entity-models'; -import { defaultSelectId, toUpdateFactory } from '../utils/utilities'; +import { QueryParams } from '../dataservices/interfaces'; +/** Creates EntityDispatchers for entity collections */ @Injectable() -export class EntityDispatcherFactory { +export class EntityDispatcherFactory implements OnDestroy { /** - * Default dispatcher options. - * These defaults are the safest values. + * Actions scanned by the store after it processed them with reducers. + * A replay observable of the most recent action reduced by the store. */ - defaultDispatcherOptions = { - optimisticAdd: false, - optimisticDelete: true, - optimisticUpdate: false - }; + reducedActions$: Observable; + private raSubscription: Subscription; constructor( private entityActionFactory: EntityActionFactory, - private store: Store - ) {} + private store: Store, + private entityDispatcherDefaultOptions: EntityDispatcherDefaultOptions, + @Inject(ScannedActionsSubject) scannedActions$: Observable, + @Inject(ENTITY_CACHE_SELECTOR_TOKEN) private entityCacheSelector: EntityCacheSelector, + private correlationIdGenerator: CorrelationIdGenerator + ) { + // Replay because sometimes in tests will fake data service with synchronous observable + // which makes subscriber miss the dispatched actions. + // Sure that's a testing mistake. But easy to forget, leading to painful debugging. + this.reducedActions$ = scannedActions$.pipe(shareReplay(1)); + // Start listening so late subscriber won't miss the most recent action. + this.raSubscription = this.reducedActions$.subscribe(); + } /** * Create an `EntityDispatcher` for an entity type `T` and store. @@ -43,23 +53,26 @@ export class EntityDispatcherFactory { * Usually acquired from `EntityDefinition` metadata. */ selectId: IdSelector = defaultSelectId, - /** Options that influence dispatcher behavior such as whether + /** Defaults for options that influence dispatcher behavior such as whether * `add()` is optimistic or pessimistic; */ - dispatcherOptions: Partial = {} + defaultOptions: Partial = {} ): EntityDispatcher { - // merge w/ dispatcher options with defaults - const options: EntityDispatcherOptions = Object.assign( - {}, - this.defaultDispatcherOptions, - dispatcherOptions - ); + // merge w/ defaultOptions with injected defaults + const options: EntityDispatcherDefaultOptions = { ...this.entityDispatcherDefaultOptions, ...defaultOptions }; return new EntityDispatcherBase( entityName, this.entityActionFactory, this.store, selectId, - options + options, + this.reducedActions$, + this.entityCacheSelector, + this.correlationIdGenerator ); } + + ngOnDestroy() { + this.raSubscription.unsubscribe(); + } } diff --git a/lib/src/dispatchers/entity-dispatcher.spec.ts b/lib/src/dispatchers/entity-dispatcher.spec.ts index 8fa92bf3..eddfeab2 100644 --- a/lib/src/dispatchers/entity-dispatcher.spec.ts +++ b/lib/src/dispatchers/entity-dispatcher.spec.ts @@ -1,10 +1,16 @@ +import { Action } from '@ngrx/store'; +import { Subject } from 'rxjs'; + +import { CorrelationIdGenerator } from '../utils/correlation-id-generator'; +import { EntityDispatcherDefaultOptions } from './entity-dispatcher-default-options'; import { defaultSelectId } from '../utils/utilities'; -import { EntityAction, EntityActionFactory } from '../actions/entity-action'; +import { EntityAction } from '../actions/entity-action'; +import { EntityActionFactory } from '../actions/entity-action-factory'; +import { createEntityCacheSelector } from '../selectors/entity-cache-selector'; +import { EntityDispatcher } from './entity-dispatcher'; +import { EntityDispatcherBase } from './entity-dispatcher-base'; import { EntityOp } from '../actions/entity-op'; -import { EntityDispatcher, EntityDispatcherBase } from './entity-dispatcher'; -import { EntityDispatcherFactory } from './entity-dispatcher-factory'; -import { EntityCommands } from './entity-commands'; -import { EntityDispatcherOptions } from './entity-dispatcher'; +import { MergeStrategy } from '../actions/merge-strategy'; import { Update } from '../utils/ngrx-entity-models'; class Hero { @@ -13,35 +19,47 @@ class Hero { saying?: string; } -const defaultDispatcherOptions = new EntityDispatcherFactory(null, null) - .defaultDispatcherOptions; +/** Store stub */ +class TestStore { + // only interested in calls to store.dispatch() + dispatch() {} + select() {} +} -describe('EntityDispatcher', () => { - commandDispatchTest(entityDispatcherTestSetup); +const defaultDispatcherOptions = new EntityDispatcherDefaultOptions(); - function entityDispatcherTestSetup() { - // only interested in calls to store.dispatch() - const testStore = jasmine.createSpyObj('store', ['dispatch']); +describe('EntityDispatcher', () => { + commandDispatchTest(entityDispatcherSetup); - const selectId = defaultSelectId; + function entityDispatcherSetup() { + const correlationIdGenerator = new CorrelationIdGenerator(); const entityActionFactory = new EntityActionFactory(); + const entityCacheSelector = createEntityCacheSelector(); + const scannedActions$ = new Subject(); + const selectId = defaultSelectId; + const store: any = new TestStore(); + const dispatcher = new EntityDispatcherBase( 'Hero', entityActionFactory, - testStore, + store, selectId, - defaultDispatcherOptions + defaultDispatcherOptions, + scannedActions$, // scannedActions$ not used in these tests + entityCacheSelector, // entityCacheSelector not used in these tests + correlationIdGenerator ); - return { dispatcher, testStore }; + return { dispatcher, store }; } }); ///// Tests ///// -/** Test that implementer of EntityCommands dispatches properly */ -export function commandDispatchTest( - setup: () => { dispatcher: EntityDispatcher; testStore: any } -) { +/** + * Test that implementer of EntityCommands dispatches properly + * @param setup Function that sets up the EntityDispatcher before each test (called in a BeforeEach()). + */ +export function commandDispatchTest(setup: () => { dispatcher: EntityDispatcher; store: any }) { let dispatcher: EntityDispatcher; let testStore: { dispatch: jasmine.Spy }; @@ -51,79 +69,94 @@ export function commandDispatchTest( beforeEach(() => { const s = setup(); + spyOn(s.store, 'dispatch').and.callThrough(); dispatcher = s.dispatcher; - testStore = s.testStore; + testStore = s.store; }); it('#entityName is the expected name of the entity type', () => { expect(dispatcher.entityName).toBe('Hero'); }); + it('#cancel(correlationId) can dispatch CANCEL_PERSIST', () => { + dispatcher.cancel('CRID007', 'Test cancel'); + const { entityOp, correlationId, data } = dispatchedAction().payload; + expect(entityOp).toBe(EntityOp.CANCEL_PERSIST); + expect(correlationId).toBe('CRID007'); + expect(data).toBe('Test cancel'); + }); + describe('Save actions', () => { // By default add and update are pessimistic and delete is optimistic. // Tests override in the dispatcher method calls as necessary. describe('(optimistic)', () => { - it('#add(hero) dispatches SAVE_ADD_ONE_OPTIMISTIC', () => { + it('#add(hero) can dispatch SAVE_ADD_ONE optimistically', () => { const hero: Hero = { id: 42, name: 'test' }; - dispatcher.add(hero, /* isOptimistic */ true); - - expect(dispatchedAction().op).toBe(EntityOp.SAVE_ADD_ONE_OPTIMISTIC); - expect(dispatchedAction().payload).toBe(hero); + dispatcher.add(hero, { isOptimistic: true }); + const { entityOp, isOptimistic, data } = dispatchedAction().payload; + expect(entityOp).toBe(EntityOp.SAVE_ADD_ONE); + expect(isOptimistic).toBe(true); + expect(data).toBe(hero); }); - it('#delete(42) dispatches SAVE_DELETE_ONE_OPTIMISTIC for the id:42', () => { + it('#delete(42) dispatches SAVE_DELETE_ONE optimistically for the id:42', () => { dispatcher.delete(42); // optimistic by default - - expect(dispatchedAction().op).toBe(EntityOp.SAVE_DELETE_ONE_OPTIMISTIC); - expect(dispatchedAction().payload).toBe(42); + const { entityOp, isOptimistic, data } = dispatchedAction().payload; + expect(entityOp).toBe(EntityOp.SAVE_DELETE_ONE); + expect(isOptimistic).toBe(true); + expect(data).toBe(42); }); - it('#delete(hero) dispatches SAVE_DELETE_ONE_OPTIMISTIC for the hero.id', () => { + it('#delete(hero) dispatches SAVE_DELETE_ONE optimistically for the hero.id', () => { const id = 42; const hero: Hero = { id, name: 'test' }; - dispatcher.delete(hero); // optimistic by default - - expect(dispatchedAction().op).toBe(EntityOp.SAVE_DELETE_ONE_OPTIMISTIC); - expect(dispatchedAction().payload).toBe(id); + const { entityOp, isOptimistic, data } = dispatchedAction().payload; + expect(entityOp).toBe(EntityOp.SAVE_DELETE_ONE); + expect(isOptimistic).toBe(true); + expect(data).toBe(42); }); - it('#update(hero) dispatches SAVE_UPDATE_ONE_OPTIMISTIC with an update payload', () => { + it('#update(hero) can dispatch SAVE_UPDATE_ONE optimistically with an update payload', () => { const hero: Hero = { id: 42, name: 'test' }; const expectedUpdate: Update = { id: 42, changes: hero }; - dispatcher.update(hero, /* isOptimistic */ true); - - expect(dispatchedAction().op).toBe(EntityOp.SAVE_UPDATE_ONE_OPTIMISTIC); - expect(dispatchedAction().payload).toEqual(expectedUpdate); + dispatcher.update(hero, { isOptimistic: true }); + const { entityOp, isOptimistic, data } = dispatchedAction().payload; + expect(entityOp).toBe(EntityOp.SAVE_UPDATE_ONE); + expect(isOptimistic).toBe(true); + expect(data).toEqual(expectedUpdate); }); }); describe('(pessimistic)', () => { - it('#add(hero) dispatches SAVE_ADD', () => { + it('#add(hero) dispatches SAVE_ADD pessimistically', () => { const hero: Hero = { id: 42, name: 'test' }; dispatcher.add(hero); // pessimistic by default - - expect(dispatchedAction().op).toBe(EntityOp.SAVE_ADD_ONE); - expect(dispatchedAction().payload).toBe(hero); + const { entityOp, isOptimistic, data } = dispatchedAction().payload; + expect(entityOp).toBe(EntityOp.SAVE_ADD_ONE); + expect(isOptimistic).toBe(false); + expect(data).toBe(hero); }); - it('#delete(42) dispatches SAVE_DELETE for the id:42', () => { - dispatcher.delete(42, /* isOptimistic */ false); // optimistic by default - - expect(dispatchedAction().op).toBe(EntityOp.SAVE_DELETE_ONE); - expect(dispatchedAction().payload).toBe(42); + it('#delete(42) can dispatch SAVE_DELETE pessimistically for the id:42', () => { + dispatcher.delete(42, { isOptimistic: false }); // optimistic by default + const { entityOp, isOptimistic, data } = dispatchedAction().payload; + expect(entityOp).toBe(EntityOp.SAVE_DELETE_ONE); + expect(isOptimistic).toBe(false); + expect(data).toBe(42); }); - it('#delete(hero) dispatches SAVE_DELETE for the hero.id', () => { + it('#delete(hero) can dispatch SAVE_DELETE pessimistically for the hero.id', () => { const id = 42; const hero: Hero = { id, name: 'test' }; - dispatcher.delete(hero, /* isOptimistic */ false); // optimistic by default - - expect(dispatchedAction().op).toBe(EntityOp.SAVE_DELETE_ONE); - expect(dispatchedAction().payload).toBe(id); + dispatcher.delete(hero, { isOptimistic: false }); // optimistic by default + const { entityOp, isOptimistic, data } = dispatchedAction().payload; + expect(entityOp).toBe(EntityOp.SAVE_DELETE_ONE); + expect(isOptimistic).toBe(false); + expect(data).toBe(42); }); it('#update(hero) dispatches SAVE_UPDATE with an update payload', () => { @@ -131,119 +164,187 @@ export function commandDispatchTest( const expectedUpdate: Update = { id: 42, changes: hero }; dispatcher.update(hero); // pessimistic by default - - expect(dispatchedAction().op).toBe(EntityOp.SAVE_UPDATE_ONE); - expect(dispatchedAction().payload).toEqual(expectedUpdate); + const { entityOp, isOptimistic, data } = dispatchedAction().payload; + expect(entityOp).toBe(EntityOp.SAVE_UPDATE_ONE); + expect(isOptimistic).toBe(false); + expect(data).toEqual(expectedUpdate); }); }); }); describe('Query actions', () => { - it('#getAll() dispatches QUERY_ALL for the Hero collection', () => { + it('#getAll() dispatches QUERY_ALL', () => { dispatcher.getAll(); - expect(dispatchedAction().op).toBe(EntityOp.QUERY_ALL); - expect(dispatchedAction().entityName).toBe('Hero'); + const { entityOp, entityName, mergeStrategy } = dispatchedAction().payload; + expect(entityOp).toBe(EntityOp.QUERY_ALL); + expect(entityName).toBe('Hero'); + expect(mergeStrategy).toBeUndefined('no MergeStrategy'); + }); + + it('#getAll({mergeStrategy}) dispatches QUERY_ALL with a MergeStrategy', () => { + dispatcher.getAll({ mergeStrategy: MergeStrategy.PreserveChanges }); + + const { entityOp, entityName, mergeStrategy } = dispatchedAction().payload; + expect(entityOp).toBe(EntityOp.QUERY_ALL); + expect(entityName).toBe('Hero'); + expect(mergeStrategy).toBe(MergeStrategy.PreserveChanges); }); it('#getByKey(42) dispatches QUERY_BY_KEY for the id:42', () => { dispatcher.getByKey(42); - expect(dispatchedAction().op).toBe(EntityOp.QUERY_BY_KEY); - expect(dispatchedAction().payload).toBe(42); + const { entityOp, data, mergeStrategy } = dispatchedAction().payload; + expect(entityOp).toBe(EntityOp.QUERY_BY_KEY); + expect(data).toBe(42); + expect(mergeStrategy).toBeUndefined('no MergeStrategy'); + }); + + it('#getByKey(42, {mergeStrategy}) dispatches QUERY_BY_KEY with a MergeStrategy', () => { + dispatcher.getByKey(42, { mergeStrategy: MergeStrategy.OverwriteChanges }); + + const { entityOp, data, mergeStrategy } = dispatchedAction().payload; + expect(entityOp).toBe(EntityOp.QUERY_BY_KEY); + expect(data).toBe(42); + expect(mergeStrategy).toBe(MergeStrategy.OverwriteChanges); }); it('#getWithQuery(QueryParams) dispatches QUERY_MANY', () => { dispatcher.getWithQuery({ name: 'B' }); - expect(dispatchedAction().op).toBe(EntityOp.QUERY_MANY); - expect(dispatchedAction().entityName).toBe('Hero'); - expect(dispatchedAction().payload).toEqual({ name: 'B' }, 'params'); + const { entityOp, data, entityName, mergeStrategy } = dispatchedAction().payload; + expect(entityOp).toBe(EntityOp.QUERY_MANY); + expect(entityName).toBe('Hero'); + expect(data).toEqual({ name: 'B' }, 'params'); + expect(mergeStrategy).toBeUndefined('no MergeStrategy'); }); it('#getWithQuery(string) dispatches QUERY_MANY', () => { dispatcher.getWithQuery('name=B'); - expect(dispatchedAction().op).toBe(EntityOp.QUERY_MANY); - expect(dispatchedAction().entityName).toBe('Hero'); - expect(dispatchedAction().payload).toEqual('name=B', 'params'); + const { entityOp, data, entityName, mergeStrategy } = dispatchedAction().payload; + expect(entityOp).toBe(EntityOp.QUERY_MANY); + expect(entityName).toBe('Hero'); + expect(data).toEqual('name=B', 'params'); + expect(mergeStrategy).toBeUndefined('no MergeStrategy'); + }); + + it('#getWithQuery(string) dispatches QUERY_MANY with a MergeStrategy', () => { + dispatcher.getWithQuery('name=B', { mergeStrategy: MergeStrategy.PreserveChanges }); + + const { entityOp, data, entityName, mergeStrategy } = dispatchedAction().payload; + expect(entityOp).toBe(EntityOp.QUERY_MANY); + expect(entityName).toBe('Hero'); + expect(data).toEqual('name=B', 'params'); + expect(mergeStrategy).toBe(MergeStrategy.PreserveChanges); + }); + + it('#load() dispatches QUERY_LOAD', () => { + dispatcher.load(); + + const { entityOp, entityName, mergeStrategy } = dispatchedAction().payload; + expect(entityOp).toBe(EntityOp.QUERY_LOAD); + expect(entityName).toBe('Hero'); + expect(mergeStrategy).toBeUndefined('no MergeStrategy'); }); }); /*** Cache-only operations ***/ describe('Cache-only actions', () => { it('#addAllToCache dispatches ADD_ALL', () => { - const heroes: Hero[] = [ - { id: 42, name: 'test 42' }, - { id: 84, name: 'test 84', saying: 'howdy' } - ]; + const heroes: Hero[] = [{ id: 42, name: 'test 42' }, { id: 84, name: 'test 84', saying: 'howdy' }]; dispatcher.addAllToCache(heroes); - - expect(dispatchedAction().op).toBe(EntityOp.ADD_ALL); - expect(dispatchedAction().payload).toBe(heroes); + const { entityOp, data } = dispatchedAction().payload; + expect(entityOp).toBe(EntityOp.ADD_ALL); + expect(data).toBe(heroes); }); it('#addOneToCache dispatches ADD_ONE', () => { const hero: Hero = { id: 42, name: 'test' }; dispatcher.addOneToCache(hero); + const { entityOp, data, mergeStrategy } = dispatchedAction().payload; + expect(entityOp).toBe(EntityOp.ADD_ONE); + expect(data).toBe(hero); + expect(mergeStrategy).toBeUndefined('no MergeStrategy'); + }); - expect(dispatchedAction().op).toBe(EntityOp.ADD_ONE); - expect(dispatchedAction().payload).toBe(hero); + it('#addOneToCache can dispatch ADD_ONE and MergeStrategy.IgnoreChanges', () => { + const hero: Hero = { id: 42, name: 'test' }; + dispatcher.addOneToCache(hero, { mergeStrategy: MergeStrategy.IgnoreChanges }); + const { entityOp, mergeStrategy } = dispatchedAction().payload; + expect(entityOp).toBe(EntityOp.ADD_ONE); + expect(mergeStrategy).toBe(MergeStrategy.IgnoreChanges); }); it('#addManyToCache dispatches ADD_MANY', () => { - const heroes: Hero[] = [ - { id: 42, name: 'test 42' }, - { id: 84, name: 'test 84', saying: 'howdy' } - ]; + const heroes: Hero[] = [{ id: 42, name: 'test 42' }, { id: 84, name: 'test 84', saying: 'howdy' }]; dispatcher.addManyToCache(heroes); + const { entityOp, data } = dispatchedAction().payload; + expect(entityOp).toBe(EntityOp.ADD_MANY); + expect(data).toBe(heroes); + }); - expect(dispatchedAction().op).toBe(EntityOp.ADD_MANY); - expect(dispatchedAction().payload).toBe(heroes); + it('#addManyToCache can dispatch ADD_MANY and MergeStrategy.IgnoreChanges', () => { + const heroes: Hero[] = [{ id: 42, name: 'test 42' }, { id: 84, name: 'test 84', saying: 'howdy' }]; + dispatcher.addManyToCache(heroes, { mergeStrategy: MergeStrategy.IgnoreChanges }); + const { entityOp, mergeStrategy } = dispatchedAction().payload; + expect(entityOp).toBe(EntityOp.ADD_MANY); + expect(mergeStrategy).toBe(MergeStrategy.IgnoreChanges); }); it('#clearCache() dispatches REMOVE_ALL for the Hero collection', () => { dispatcher.clearCache(); + const { entityOp, entityName } = dispatchedAction().payload; + expect(entityOp).toBe(EntityOp.REMOVE_ALL); + expect(entityName).toBe('Hero'); + }); - expect(dispatchedAction().op).toBe(EntityOp.REMOVE_ALL); - expect(dispatchedAction().entityName).toBe('Hero'); + it('#clearCache() can dispatch REMOVE_ALL with options', () => { + dispatcher.clearCache({ mergeStrategy: MergeStrategy.IgnoreChanges }); + const { entityOp, mergeStrategy } = dispatchedAction().payload; + expect(entityOp).toBe(EntityOp.REMOVE_ALL); + expect(mergeStrategy).toBe(MergeStrategy.IgnoreChanges); }); it('#removeOneFromCache(key) dispatches REMOVE_ONE', () => { const id = 42; dispatcher.removeOneFromCache(id); - - expect(dispatchedAction().op).toBe(EntityOp.REMOVE_ONE); - expect(dispatchedAction().payload).toBe(id); + const { entityOp, data } = dispatchedAction().payload; + expect(entityOp).toBe(EntityOp.REMOVE_ONE); + expect(data).toBe(id); }); - it('#removeOneFromCache(entity) dispatches REMOVE_ONE', () => { + it('#removeOneFromCache(key) can dispatch REMOVE_ONE and MergeStrategy.IgnoreChanges', () => { const id = 42; - const hero: Hero = { id, name: 'test' }; - dispatcher.removeOneFromCache(hero); - - expect(dispatchedAction().op).toBe(EntityOp.REMOVE_ONE); - expect(dispatchedAction().payload).toBe(id); + dispatcher.removeOneFromCache(id, { mergeStrategy: MergeStrategy.IgnoreChanges }); + const { entityOp, mergeStrategy } = dispatchedAction().payload; + expect(entityOp).toBe(EntityOp.REMOVE_ONE); + expect(mergeStrategy).toBe(MergeStrategy.IgnoreChanges); }); it('#removeManyFromCache(keys) dispatches REMOVE_MANY', () => { const keys = [42, 84]; dispatcher.removeManyFromCache(keys); + const { entityOp, data } = dispatchedAction().payload; + expect(entityOp).toBe(EntityOp.REMOVE_MANY); + expect(data).toBe(keys); + }); - expect(dispatchedAction().op).toBe(EntityOp.REMOVE_MANY); - expect(dispatchedAction().payload).toBe(keys); + it('#removeManyFromCache(keys) can dispatch REMOVE_MANY and MergeStrategy.IgnoreChanges', () => { + const keys = [42, 84]; + dispatcher.removeManyFromCache(keys, { mergeStrategy: MergeStrategy.IgnoreChanges }); + const { entityOp, mergeStrategy } = dispatchedAction().payload; + expect(entityOp).toBe(EntityOp.REMOVE_MANY); + expect(mergeStrategy).toBe(MergeStrategy.IgnoreChanges); }); it('#removeManyFromCache(entities) dispatches REMOVE_MANY', () => { - const heroes: Hero[] = [ - { id: 42, name: 'test 42' }, - { id: 84, name: 'test 84', saying: 'howdy' } - ]; + const heroes: Hero[] = [{ id: 42, name: 'test 42' }, { id: 84, name: 'test 84', saying: 'howdy' }]; const keys = heroes.map(h => h.id); dispatcher.removeManyFromCache(heroes); - - expect(dispatchedAction().op).toBe(EntityOp.REMOVE_MANY); - expect(dispatchedAction().payload).toEqual(keys); + const { entityOp, data } = dispatchedAction().payload; + expect(entityOp).toBe(EntityOp.REMOVE_MANY); + expect(data).toEqual(keys); }); it('#toUpdate() helper method creates Update', () => { @@ -257,48 +358,68 @@ export function commandDispatchTest( const hero: Partial = { id: 42, name: 'test' }; const update = { id: 42, changes: hero }; dispatcher.updateOneInCache(hero); + const { entityOp, data } = dispatchedAction().payload; + expect(entityOp).toBe(EntityOp.UPDATE_ONE); + expect(data).toEqual(update); + }); - expect(dispatchedAction().op).toBe(EntityOp.UPDATE_ONE); - expect(dispatchedAction().payload).toEqual(update); + it('#updateOneInCache can dispatch UPDATE_ONE and MergeStrategy.IgnoreChanges', () => { + const hero: Partial = { id: 42, name: 'test' }; + const update = { id: 42, changes: hero }; + dispatcher.updateOneInCache(hero, { mergeStrategy: MergeStrategy.IgnoreChanges }); + const { entityOp, mergeStrategy } = dispatchedAction().payload; + expect(entityOp).toBe(EntityOp.UPDATE_ONE); + expect(mergeStrategy).toBe(MergeStrategy.IgnoreChanges); }); it('#updateManyInCache dispatches UPDATE_MANY', () => { - const heroes: Partial[] = [ - { id: 42, name: 'test 42' }, - { id: 84, saying: 'ho ho ho' } - ]; - const updates = [ - { id: 42, changes: heroes[0] }, - { id: 84, changes: heroes[1] } - ]; + const heroes: Partial[] = [{ id: 42, name: 'test 42' }, { id: 84, saying: 'ho ho ho' }]; + const updates = [{ id: 42, changes: heroes[0] }, { id: 84, changes: heroes[1] }]; dispatcher.updateManyInCache(heroes); + const { entityOp, data } = dispatchedAction().payload; + expect(entityOp).toBe(EntityOp.UPDATE_MANY); + expect(data).toEqual(updates); + }); - expect(dispatchedAction().op).toBe(EntityOp.UPDATE_MANY); - expect(dispatchedAction().payload).toEqual(updates); + it('#updateManyInCache can dispatch UPDATE_MANY and MergeStrategy.IgnoreChanges', () => { + const heroes: Partial[] = [{ id: 42, name: 'test 42' }, { id: 84, saying: 'ho ho ho' }]; + const updates = [{ id: 42, changes: heroes[0] }, { id: 84, changes: heroes[1] }]; + dispatcher.updateManyInCache(heroes, { mergeStrategy: MergeStrategy.IgnoreChanges }); + const { entityOp, mergeStrategy } = dispatchedAction().payload; + expect(entityOp).toBe(EntityOp.UPDATE_MANY); + expect(mergeStrategy).toBe(MergeStrategy.IgnoreChanges); }); it('#upsertOneInCache dispatches UPSERT_ONE', () => { - const hero: Partial = { id: 42, name: 'test' }; - const upsert = { id: 42, changes: hero }; + const hero = { id: 42, name: 'test' }; dispatcher.upsertOneInCache(hero); + const { entityOp, data } = dispatchedAction().payload; + expect(entityOp).toBe(EntityOp.UPSERT_ONE); + expect(data).toEqual(hero); + }); - expect(dispatchedAction().op).toBe(EntityOp.UPSERT_ONE); - expect(dispatchedAction().payload).toEqual(upsert); + it('#upsertOneInCache can dispatch UPSERT_ONE and MergeStrategy.IgnoreChanges', () => { + const hero = { id: 42, name: 'test' }; + dispatcher.upsertOneInCache(hero, { mergeStrategy: MergeStrategy.IgnoreChanges }); + const { entityOp, mergeStrategy } = dispatchedAction().payload; + expect(entityOp).toBe(EntityOp.UPSERT_ONE); + expect(mergeStrategy).toBe(MergeStrategy.IgnoreChanges); }); it('#upsertManyInCache dispatches UPSERT_MANY', () => { - const heroes: Partial[] = [ - { id: 42, name: 'test 42' }, - { id: 84, saying: 'ho ho ho' } - ]; - const upserts = [ - { id: 42, changes: heroes[0] }, - { id: 84, changes: heroes[1] } - ]; + const heroes = [{ id: 42, name: 'test 42' }, { id: 84, saying: 'ho ho ho' }]; dispatcher.upsertManyInCache(heroes); + const { entityOp, data } = dispatchedAction().payload; + expect(entityOp).toBe(EntityOp.UPSERT_MANY); + expect(data).toEqual(heroes); + }); - expect(dispatchedAction().op).toBe(EntityOp.UPSERT_MANY); - expect(dispatchedAction().payload).toEqual(upserts); + it('#upsertManyInCache can dispatch UPSERT_MANY and MergeStrategy.IgnoreChanges', () => { + const heroes = [{ id: 42, name: 'test 42' }, { id: 84, saying: 'ho ho ho' }]; + dispatcher.upsertManyInCache(heroes, { mergeStrategy: MergeStrategy.IgnoreChanges }); + const { entityOp, mergeStrategy } = dispatchedAction().payload; + expect(entityOp).toBe(EntityOp.UPSERT_MANY); + expect(mergeStrategy).toBe(MergeStrategy.IgnoreChanges); }); }); } diff --git a/lib/src/dispatchers/entity-dispatcher.ts b/lib/src/dispatchers/entity-dispatcher.ts index c9fb48a3..e43760b0 100644 --- a/lib/src/dispatchers/entity-dispatcher.ts +++ b/lib/src/dispatchers/entity-dispatcher.ts @@ -1,24 +1,11 @@ -import { Injectable } from '@angular/core'; import { Action, Store } from '@ngrx/store'; -import { EntityAction, EntityActionFactory } from '../actions/entity-action'; +import { EntityAction, EntityActionOptions } from '../actions/entity-action'; import { EntityActionGuard } from '../actions/entity-action-guard'; -import { EntityOp } from '../actions/entity-op'; -import { QueryParams } from '../dataservices/interfaces'; import { EntityCommands } from './entity-commands'; import { EntityCache } from '../reducers/entity-cache'; +import { EntityOp } from '../actions/entity-op'; import { IdSelector, Update } from '../utils/ngrx-entity-models'; -import { defaultSelectId, toUpdateFactory } from '../utils/utilities'; - -/** - * Options controlling EntityDispatcher behavior - * such as whether `add()` is optimistic or pessimistic - */ -export interface EntityDispatcherOptions { - optimisticAdd: boolean; - optimisticDelete: boolean; - optimisticUpdate: boolean; -} /** * Dispatches Entity-related commands to effects and reducers @@ -41,23 +28,28 @@ export interface EntityDispatcher extends EntityCommands { /** * Create an {EntityAction} for this entity type. * @param op {EntityOp} the entity operation - * @param payload the action payload + * @param [data] the action data + * @param [options] additional options + * @returns the EntityAction */ - createEntityAction(op: EntityOp, payload?: any): EntityAction; + createEntityAction

(op: EntityOp, data?: P, options?: EntityActionOptions): EntityAction

; /** * Create an {EntityAction} for this entity type and * dispatch it immediately to the store. * @param op {EntityOp} the entity operation - * @param payload the action payload + * @param [data] the action data + * @param [options] additional options + * @returns the dispatched EntityAction */ - createAndDispatch(op: EntityOp, payload?: any): void; + createAndDispatch

(op: EntityOp, data?: P, options?: EntityActionOptions): EntityAction

; /** - * Dispatch action to the store. + * Dispatch an Action to the store. * @param action the Action + * @returns the dispatched Action */ - dispatch(action: Action): void; + dispatch(action: Action): Action; /** * Convert an entity (or partial entity) into the `Update` object @@ -66,312 +58,11 @@ export interface EntityDispatcher extends EntityCommands { toUpdate(entity: Partial): Update; } -export class EntityDispatcherBase implements EntityDispatcher { - /** - * Utility class with methods to validate EntityAction payloads. - */ - guard: EntityActionGuard; - - /** - * Convert an entity (or partial entity) into the `Update` object - * `update...` and `upsert...` methods take `Update` args - */ - toUpdate: (entity: Partial) => Update; - - constructor( - /** Name of the entity type for which entities are dispatched */ - public entityName: string, - /** Creates an {EntityAction} */ - public entityActionFactory: EntityActionFactory, - /** The store, scoped to the EntityCache */ - public store: Store, - /** Returns the primary key (id) of this entity */ - public selectId: IdSelector = defaultSelectId, - /** - * Dispatcher options configure dispatcher behavior such as - * whether add is optimistic or pessimistic. - */ - public options: EntityDispatcherOptions - ) { - this.guard = new EntityActionGuard(selectId); - this.toUpdate = toUpdateFactory(selectId); - } - - /** - * Create an {EntityAction} for this entity type. - * @param op {EntityOp} the entity operation - * @param payload the action payload - */ - createEntityAction(op: EntityOp, payload?: any): EntityAction { - return this.entityActionFactory.create(this.entityName, op, payload); - } - - /** - * Create an {EntityAction} for this entity type and - * dispatch it immediately to the store. - * @param op {EntityOp} the entity operation - * @param payload the action payload - */ - createAndDispatch(op: EntityOp, payload?: any): void { - const action = this.createEntityAction(op, payload); - this.dispatch(action); - } - - /** - * Dispatch {EntityAction} to the store. - * @param action the EntityAction - */ - dispatch(action: Action): void { - this.store.dispatch(action); - } - - /** - * Save a new entity to remote storage. - * Does not add to cache until save succeeds. - * Ignored by cache-add if the entity is already in cache. - */ - add(entity: T, isOptimistic?: boolean): void { - isOptimistic = - isOptimistic != null ? isOptimistic : this.options.optimisticAdd; - const op = isOptimistic - ? EntityOp.SAVE_ADD_ONE_OPTIMISTIC - : EntityOp.SAVE_ADD_ONE; - - const action = this.createEntityAction(op, entity); - if (isOptimistic) { - this.guard.mustBeEntity(action); - } - this.dispatch(action); - } - - /** - * Removes entity from the cache (if it is in the cache) - * and deletes entity from remote storage by key. - * Does not restore to cache if the delete fails. - * @param entity The entity to remove - */ - delete(entity: T, isOptimistic?: boolean): void; - - /** - * Removes entity from the cache by key (if it is in the cache) - * and deletes entity from remote storage by key. - * Does not restore to cache if the delete fails. - * @param key The primary key of the entity to remove - */ - delete(key: number | string, isOptimistic?: boolean): void; - delete(arg: (number | string) | T, isOptimistic?: boolean): void { - const op = (isOptimistic != null - ? isOptimistic - : this.options.optimisticDelete) - ? EntityOp.SAVE_DELETE_ONE_OPTIMISTIC - : EntityOp.SAVE_DELETE_ONE; - const key = this.getKey(arg); - const action = this.createEntityAction(op, key); - this.guard.mustBeKey(action); - this.dispatch(action); - } - - /** - * Query remote storage for all entities and - * completely replace the cached collection with the queried entities. - */ - getAll(): void { - this.createAndDispatch(EntityOp.QUERY_ALL); - } - - /** - * Query remote storage for the entity with this primary key. - * If the server returns an entity, - * merge it into the cached collection. - */ - getByKey(key: any): void { - this.createAndDispatch(EntityOp.QUERY_BY_KEY, key); - } - - /** - * Query remote storage for the entities that satisfy a query expressed - * with either a query parameter map or an HTTP URL query string. - * and merge the results into the cached collection. - */ - getWithQuery(queryParams: QueryParams | string): void { - this.createAndDispatch(EntityOp.QUERY_MANY, queryParams); - } - - /** - * Save the updated entity (or partial entity) to remote storage. - * Updates the cached entity after the save succeeds. - * Update in cache is ignored if the entity's key is not found in cache. - * The update entity may be partial (but must have its key) - * in which case it patches the existing entity. - */ - update(entity: Partial, isOptimistic?: boolean): void { - // update entity might be a partial of T but must at least have its key. - // pass the Update structure as the payload - const update: Update = this.toUpdate(entity); - const op = (isOptimistic != null - ? isOptimistic - : this.options.optimisticUpdate) - ? EntityOp.SAVE_UPDATE_ONE_OPTIMISTIC - : EntityOp.SAVE_UPDATE_ONE; - - const action = this.createEntityAction(op, update); - this.guard.mustBeUpdate(action); - this.dispatch(action); - } - - /*** Cache-only operations that do not update remote storage ***/ - - // Unguarded for performance. - // EntityCollectionReducer runs a guard (which throws) - // Developer should understand cache-only methods well enough - // to call them with the proper entities. - // May reconsider and add guards in future. - - /** - * Replace all entities in the cached collection. - * Does not save to remote storage. - */ - addAllToCache(entities: T[]): void { - this.createAndDispatch(EntityOp.ADD_ALL, entities); - } - - /** - * Add a new entity directly to the cache. - * Does not save to remote storage. - * Ignored if an entity with the same primary key is already in cache. - */ - addOneToCache(entity: T): void { - this.createAndDispatch(EntityOp.ADD_ONE, entity); - } - - /** - * Add multiple new entities directly to the cache. - * Does not save to remote storage. - * Entities with primary keys already in cache are ignored. - */ - addManyToCache(entities: T[]): void { - this.createAndDispatch(EntityOp.ADD_MANY, entities); - } - - /** Clear the cached entity collection */ - clearCache(): void { - this.createAndDispatch(EntityOp.REMOVE_ALL); - } - - /** - * Remove an entity directly from the cache. - * Does not delete that entity from remote storage. - * @param entity The entity to remove - */ - removeOneFromCache(entity: T): void; - - /** - * Remove an entity directly from the cache. - * Does not delete that entity from remote storage. - * @param key The primary key of the entity to remove - */ - removeOneFromCache(key: number | string): void; - removeOneFromCache(arg: (number | string) | T): void { - this.createAndDispatch(EntityOp.REMOVE_ONE, this.getKey(arg)); - } - - /** - * Remove multiple entities directly from the cache. - * Does not delete these entities from remote storage. - * @param entity The entities to remove - */ - removeManyFromCache(entities: T[]): void; - - /** - * Remove multiple entities directly from the cache. - * Does not delete these entities from remote storage. - * @param keys The primary keys of the entities to remove - */ - removeManyFromCache(keys: (number | string)[]): void; - removeManyFromCache(args: (number | string)[] | T[]): void { - if (!args || args.length === 0) { - return; - } - const keys = - typeof args[0] === 'object' - ? // if array[0] is a key, assume they're all keys - (args).map(arg => this.getKey(arg)) - : args; - this.createAndDispatch(EntityOp.REMOVE_MANY, keys); - } - - /** - * Update a cached entity directly. - * Does not update that entity in remote storage. - * Ignored if an entity with matching primary key is not in cache. - * The update entity may be partial (but must have its key) - * in which case it patches the existing entity. - */ - updateOneInCache(entity: Partial): void { - // update entity might be a partial of T but must at least have its key. - // pass the Update structure as the payload - const update: Update = this.toUpdate(entity); - this.createAndDispatch(EntityOp.UPDATE_ONE, update); - } - - /** - * Update multiple cached entities directly. - * Does not update these entities in remote storage. - * Entities whose primary keys are not in cache are ignored. - * Update entities may be partial but must at least have their keys. - * such partial entities patch their cached counterparts. - */ - updateManyInCache(entities: Partial[]): void { - if (!entities || entities.length === 0) { - return; - } - const updates: Update[] = entities.map(entity => this.toUpdate(entity)); - this.createAndDispatch(EntityOp.UPDATE_MANY, updates); - } - - /** - * Add or update a new entity directly to the cache. - * Does not save to remote storage. - * Upsert entity might be a partial of T but must at least have its key. - * Pass the Update structure as the payload - */ - upsertOneInCache(entity: Partial): void { - const upsert: Update = this.toUpdate(entity); - this.createAndDispatch(EntityOp.UPSERT_ONE, upsert); - } - - /** - * Add or update multiple cached entities directly. - * Does not save to remote storage. - */ - upsertManyInCache(entities: Partial[]): void { - if (!entities || entities.length === 0) { - return; - } - const upserts: Update[] = entities.map(entity => this.toUpdate(entity)); - this.createAndDispatch(EntityOp.UPSERT_MANY, upserts); - } - - /** - * Set the pattern that the collection's filter applies - * when using the `filteredEntities` selector. - */ - setFilter(pattern: any): void { - this.createAndDispatch(EntityOp.SET_FILTER, pattern); - } - - /** Set the loaded flag */ - setLoaded(isLoaded: boolean): void { - this.createAndDispatch(EntityOp.SET_LOADED, !!isLoaded); - } - - /** Set the loading flag */ - setLoading(isLoading: boolean): void { - this.createAndDispatch(EntityOp.SET_LOADED, !!isLoading); - } - - /** Get key from entity (unless arg is already a key) */ - private getKey(arg: number | string | T) { - return typeof arg === 'object' ? this.selectId(arg) : arg; +/** + * Persistence operation canceled + */ +export class PersistanceCanceled { + constructor(public readonly message?: string) { + this.message = message || 'Canceled by user'; } } diff --git a/lib/src/effects/entity-effects.marbles.spec.ts b/lib/src/effects/entity-effects.marbles.spec.ts index 23f46909..45aae87c 100644 --- a/lib/src/effects/entity-effects.marbles.spec.ts +++ b/lib/src/effects/entity-effects.marbles.spec.ts @@ -1,65 +1,33 @@ // Using marble testing import { TestBed } from '@angular/core/testing'; -import { cold, hot } from 'jasmine-marbles'; -import { Observable, Subject } from 'rxjs'; +import { cold, hot, getTestScheduler } from 'jasmine-marbles'; +import { Observable, of, Subject } from 'rxjs'; import { Actions } from '@ngrx/effects'; import { provideMockActions } from '@ngrx/effects/testing'; -import { EntityAction, EntityActionFactory } from '../actions/entity-action'; -import { EntityOp, OP_ERROR } from '../actions/entity-op'; - -import { - EntityCollectionDataService, - EntityDataService -} from '../dataservices/entity-data.service'; -import { - DataServiceError, - EntityActionDataServiceError -} from '../dataservices/data-service-error'; -import { - PersistenceResultHandler, - DefaultPersistenceResultHandler -} from '../dataservices/persistence-result-handler.service'; +import { EntityAction } from '../actions/entity-action'; +import { EntityActionFactory } from '../actions/entity-action-factory'; +import { EntityOp, makeErrorOp } from '../actions/entity-op'; +import { MergeStrategy } from '../actions/merge-strategy'; + +import { EntityCollectionDataService, EntityDataService } from '../dataservices/entity-data.service'; +import { DataServiceError, EntityActionDataServiceError } from '../dataservices/data-service-error'; +import { PersistenceResultHandler, DefaultPersistenceResultHandler } from '../dataservices/persistence-result-handler.service'; import { HttpMethods } from '../dataservices/interfaces'; -import { EntityEffects } from './entity-effects'; +import { EntityEffects, ENTITY_EFFECTS_SCHEDULER } from './entity-effects'; import { Logger } from '../utils/interfaces'; import { Update } from '../utils/ngrx-entity-models'; import { TestHotObservable } from 'jasmine-marbles/src/test-observables'; -export class TestEntityDataService { - dataServiceSpy: any; - - constructor() { - this.dataServiceSpy = jasmine.createSpyObj( - 'EntityCollectionDataService', - ['add', 'delete', 'getAll', 'getById', 'getWithQuery', 'update'] - ); - } - - getService() { - return this.dataServiceSpy; - } -} - -// For AOT -export function getDataService() { - return new TestEntityDataService(); -} - -export class Hero { - id: number; - name: string; -} - //////// Tests begin //////// describe('EntityEffects (marble testing)', () => { let effects: EntityEffects; let entityActionFactory: EntityActionFactory; - let testEntityDataService: TestEntityDataService; + let dataService: TestDataService; let actions: Observable; let logger: Logger; @@ -71,7 +39,10 @@ describe('EntityEffects (marble testing)', () => { EntityEffects, provideMockActions(() => actions), EntityActionFactory, - { provide: EntityDataService, useFactory: getDataService }, + // See https://github.com/ReactiveX/rxjs/blob/master/doc/marble-testing.md + { provide: ENTITY_EFFECTS_SCHEDULER, useFactory: getTestScheduler }, + /* tslint:disable-next-line:no-use-before-declare */ + { provide: EntityDataService, useClass: TestDataService }, { provide: Logger, useValue: logger }, { provide: PersistenceResultHandler, @@ -79,10 +50,10 @@ describe('EntityEffects (marble testing)', () => { } ] }); + actions = TestBed.get(Actions); + dataService = TestBed.get(EntityDataService); entityActionFactory = TestBed.get(EntityActionFactory); effects = TestBed.get(EntityEffects); - testEntityDataService = TestBed.get(EntityDataService); - actions = TestBed.get(Actions); }); it('should return a QUERY_ALL_SUCCESS with the heroes on success', () => { @@ -91,71 +62,57 @@ describe('EntityEffects (marble testing)', () => { const heroes = [hero1, hero2]; const action = entityActionFactory.create('Hero', EntityOp.QUERY_ALL); - const completion = entityActionFactory.create( - 'Hero', - EntityOp.QUERY_ALL_SUCCESS, - heroes - ); + const completion = entityActionFactory.create('Hero', EntityOp.QUERY_ALL_SUCCESS, heroes); const x = hot('-a---', { a: action }); actions = hot('-a---', { a: action }); - // delay the response 3 ticks + // delay the response 3 frames const response = cold('---a|', { a: heroes }); const expected = cold('----b', { b: completion }); - testEntityDataService.dataServiceSpy.getAll.and.returnValue(response); + dataService.getAll.and.returnValue(response); expect(effects.persist$).toBeObservable(expected); }); - it('should return a QUERY_ALL_ERROR when service fails', () => { + it('should return a QUERY_ALL_ERROR when data service fails', () => { const action = entityActionFactory.create('Hero', EntityOp.QUERY_ALL); const httpError = { error: new Error('Test Failure'), status: 501 }; - const completion = makeEntityErrorCompletion(action, 'GET', httpError); - const error = completion.payload.error; + const error = makeDataServiceError('GET', httpError); + const completion = makeEntityErrorCompletion(action, error); actions = hot('-a---', { a: action }); const response = cold('----#|', {}, error); - const expected = cold('-----b', { b: completion }); - testEntityDataService.dataServiceSpy.getAll.and.returnValue(response); + const expected = cold('------b', { b: completion }); + dataService.getAll.and.returnValue(response); expect(effects.persist$).toBeObservable(expected); - expect(completion.op).toEqual(EntityOp.QUERY_ALL_ERROR); + expect(completion.payload.entityOp).toEqual(EntityOp.QUERY_ALL_ERROR); }); it('should return a QUERY_BY_KEY_SUCCESS with a hero on success', () => { - const action = entityActionFactory.create( - 'Hero', - EntityOp.QUERY_BY_KEY, - 42 - ); - const completion = entityActionFactory.create( - 'Hero', - EntityOp.QUERY_BY_KEY_SUCCESS - ); + const hero = { id: 1, name: 'A' } as Hero; + const action = entityActionFactory.create('Hero', EntityOp.QUERY_BY_KEY, 1); + const completion = entityActionFactory.create('Hero', EntityOp.QUERY_BY_KEY_SUCCESS, hero); actions = hot('-a---', { a: action }); - // delay the response 3 ticks - const response = cold('---a|', { a: undefined }); + // delay the response 3 frames + const response = cold('---a|', { a: hero }); const expected = cold('----b', { b: completion }); - testEntityDataService.dataServiceSpy.getById.and.returnValue(response); + dataService.getById.and.returnValue(response); expect(effects.persist$).toBeObservable(expected); }); - it('should return a QUERY_BY_KEY_ERROR when service fails', () => { - const action = entityActionFactory.create( - 'Hero', - EntityOp.QUERY_BY_KEY, - 42 - ); + it('should return a QUERY_BY_KEY_ERROR when data service fails', () => { + const action = entityActionFactory.create('Hero', EntityOp.QUERY_BY_KEY, 42); const httpError = { error: new Error('Entity not found'), status: 404 }; - const completion = makeEntityErrorCompletion(action, 'DELETE', httpError); - const error = completion.payload.error; + const error = makeDataServiceError('GET', httpError); + const completion = makeEntityErrorCompletion(action, error); actions = hot('-a---', { a: action }); const response = cold('----#|', {}, error); - const expected = cold('-----b', { b: completion }); - testEntityDataService.dataServiceSpy.getById.and.returnValue(response); + const expected = cold('------b', { b: completion }); + dataService.getById.and.returnValue(response); expect(effects.persist$).toBeObservable(expected); }); @@ -168,281 +125,164 @@ describe('EntityEffects (marble testing)', () => { const action = entityActionFactory.create('Hero', EntityOp.QUERY_MANY, { name: 'B' }); - const completion = entityActionFactory.create( - 'Hero', - EntityOp.QUERY_MANY_SUCCESS, - heroes - ); + const completion = entityActionFactory.create('Hero', EntityOp.QUERY_MANY_SUCCESS, heroes); actions = hot('-a---', { a: action }); - // delay the response 3 ticks + // delay the response 3 frames const response = cold('---a|', { a: heroes }); const expected = cold('----b', { b: completion }); - testEntityDataService.dataServiceSpy.getWithQuery.and.returnValue(response); + dataService.getWithQuery.and.returnValue(response); expect(effects.persist$).toBeObservable(expected); }); - it('should return a QUERY_MANY_ERROR when service fails', () => { + it('should return a QUERY_MANY_ERROR when data service fails', () => { const action = entityActionFactory.create('Hero', EntityOp.QUERY_MANY, { name: 'B' }); const httpError = { error: new Error('Resource not found'), status: 404 }; - const completion = makeEntityErrorCompletion(action, 'GET', httpError, { + const error = makeDataServiceError('GET', httpError, { name: 'B' }); - const error = completion.payload.error; + const completion = makeEntityErrorCompletion(action, error); actions = hot('-a---', { a: action }); const response = cold('----#|', {}, error); - const expected = cold('-----b', { b: completion }); - testEntityDataService.dataServiceSpy.getWithQuery.and.returnValue(response); + const expected = cold('------b', { b: completion }); + dataService.getWithQuery.and.returnValue(response); expect(effects.persist$).toBeObservable(expected); - expect(completion.op).toEqual(EntityOp.QUERY_MANY_ERROR); + expect(completion.payload.entityOp).toEqual(EntityOp.QUERY_MANY_ERROR); }); - it('should return a SAVE_ADD_SUCCESS with the hero on success', () => { + it('should return a SAVE_ADD_ONE_SUCCESS (optimistic) with the hero on success', () => { const hero = { id: 1, name: 'A' } as Hero; - const action = entityActionFactory.create( - 'Hero', - EntityOp.SAVE_ADD_ONE, - hero - ); - const completion = entityActionFactory.create( - 'Hero', - EntityOp.SAVE_ADD_ONE_SUCCESS, - hero - ); + const action = entityActionFactory.create('Hero', EntityOp.SAVE_ADD_ONE, hero, { isOptimistic: true }); + const completion = entityActionFactory.create('Hero', EntityOp.SAVE_ADD_ONE_SUCCESS, hero, { isOptimistic: true }); actions = hot('-a---', { a: action }); - // delay the response 3 ticks + // delay the response 3 frames const response = cold('---a|', { a: hero }); const expected = cold('----b', { b: completion }); - testEntityDataService.dataServiceSpy.add.and.returnValue(response); + dataService.add.and.returnValue(response); expect(effects.persist$).toBeObservable(expected); }); - it('should return a SAVE_ADD_ERROR when service fails', () => { + it('should return a SAVE_ADD_ONE_SUCCESS (pessimistic) with the hero on success', () => { const hero = { id: 1, name: 'A' } as Hero; - const action = entityActionFactory.create( - 'Hero', - EntityOp.SAVE_ADD_ONE, - hero - ); - const httpError = { error: new Error('Test Failure'), status: 501 }; - const completion = makeEntityErrorCompletion(action, 'PUT', httpError); - const error = completion.payload.error; - - actions = hot('-a---', { a: action }); - const response = cold('----#|', {}, error); - const expected = cold('-----b', { b: completion }); - testEntityDataService.dataServiceSpy.add.and.returnValue(response); - - expect(effects.persist$).toBeObservable(expected); - }); - it('should return a SAVE_DELETE_SUCCESS on success', () => { - const action = entityActionFactory.create( - 'Hero', - EntityOp.SAVE_DELETE_ONE, - 42 - ); - const completion = entityActionFactory.create( - 'Hero', - EntityOp.SAVE_DELETE_ONE_SUCCESS, - 42 - ); + const action = entityActionFactory.create('Hero', EntityOp.SAVE_ADD_ONE, hero); + const completion = entityActionFactory.create('Hero', EntityOp.SAVE_ADD_ONE_SUCCESS, hero); actions = hot('-a---', { a: action }); - // delay the response 3 ticks - const response = cold('---a|', { a: 42 }); + // delay the response 3 frames + const response = cold('---a|', { a: hero }); const expected = cold('----b', { b: completion }); - testEntityDataService.dataServiceSpy.delete.and.returnValue(response); + dataService.add.and.returnValue(response); expect(effects.persist$).toBeObservable(expected); }); - it('should return a SAVE_DELETE_ERROR when service fails', () => { - const action = entityActionFactory.create( - 'Hero', - EntityOp.SAVE_DELETE_ONE, - 42 - ); + it('should return a SAVE_ADD_ONE_ERROR when data service fails', () => { + const hero = { id: 1, name: 'A' } as Hero; + const action = entityActionFactory.create('Hero', EntityOp.SAVE_ADD_ONE, hero); const httpError = { error: new Error('Test Failure'), status: 501 }; - const completion = makeEntityErrorCompletion(action, 'DELETE', httpError); - const error = completion.payload.error; + const error = makeDataServiceError('PUT', httpError); + const completion = makeEntityErrorCompletion(action, error); actions = hot('-a---', { a: action }); const response = cold('----#|', {}, error); - const expected = cold('-----b', { b: completion }); - testEntityDataService.dataServiceSpy.delete.and.returnValue(response); + const expected = cold('------b', { b: completion }); + dataService.add.and.returnValue(response); expect(effects.persist$).toBeObservable(expected); }); - it('should return a SAVE_UPDATE_SUCCESS with the hero on success', () => { - const update = { id: 1, changes: { id: 1, name: 'A' } } as Update; - - const action = entityActionFactory.create( - 'Hero', - EntityOp.SAVE_UPDATE_ONE, - update - ); - const completion = entityActionFactory.create( - 'Hero', - EntityOp.SAVE_UPDATE_ONE_SUCCESS, - update - ); + it('should return a SAVE_DELETE_ONE_SUCCESS (Optimistic) with the delete id on success', () => { + const action = entityActionFactory.create('Hero', EntityOp.SAVE_DELETE_ONE, 42, { isOptimistic: true }); + const completion = entityActionFactory.create('Hero', EntityOp.SAVE_DELETE_ONE_SUCCESS, 42, { isOptimistic: true }); actions = hot('-a---', { a: action }); - // delay the response 3 ticks - const response = cold('---a|', { a: update }); + // delay the response 3 frames + const response = cold('---a|', { a: 42 }); const expected = cold('----b', { b: completion }); - testEntityDataService.dataServiceSpy.update.and.returnValue(response); - - expect(effects.persist$).toBeObservable(expected); - }); - - it('should return a SAVE_UPDATE_ERROR when service fails', () => { - const update = { id: 1, changes: { id: 1, name: 'A' } } as Update; - const action = entityActionFactory.create( - 'Hero', - EntityOp.SAVE_UPDATE_ONE, - update - ); - const httpError = { error: new Error('Test Failure'), status: 501 }; - const completion = makeEntityErrorCompletion(action, 'PUT', httpError); - const error = completion.payload.error; - - actions = hot('-a---', { a: action }); - const response = cold('----#|', {}, error); - const expected = cold('-----b', { b: completion }); - testEntityDataService.dataServiceSpy.update.and.returnValue(response); + dataService.delete.and.returnValue(response); expect(effects.persist$).toBeObservable(expected); }); - it('should return a SAVE_ADD_ONE_OPTIMISTIC_SUCCESS with the hero on success', () => { - const hero = { id: 1, name: 'A' } as Hero; - - const action = entityActionFactory.create( - 'Hero', - EntityOp.SAVE_ADD_ONE_OPTIMISTIC, - hero - ); - const completion = entityActionFactory.create( - 'Hero', - EntityOp.SAVE_ADD_ONE_OPTIMISTIC_SUCCESS, - hero - ); + it('should return a SAVE_DELETE_ONE_SUCCESS (Pessimistic) on success', () => { + const action = entityActionFactory.create('Hero', EntityOp.SAVE_DELETE_ONE, 42); + const completion = entityActionFactory.create('Hero', EntityOp.SAVE_DELETE_ONE_SUCCESS, 42); actions = hot('-a---', { a: action }); - // delay the response 3 ticks - const response = cold('---a|', { a: hero }); + // delay the response 3 frames + const response = cold('---a|', { a: 42 }); const expected = cold('----b', { b: completion }); - testEntityDataService.dataServiceSpy.add.and.returnValue(response); + dataService.delete.and.returnValue(response); expect(effects.persist$).toBeObservable(expected); }); - it('should return a SAVE_ADD_ONE_OPTIMISTIC_ERROR when service fails', () => { - const hero = { id: 1, name: 'A' } as Hero; - const action = entityActionFactory.create( - 'Hero', - EntityOp.SAVE_ADD_ONE_OPTIMISTIC, - hero - ); + it('should return a SAVE_DELETE_ONE_ERROR when data service fails', () => { + const action = entityActionFactory.create('Hero', EntityOp.SAVE_DELETE_ONE, 42); const httpError = { error: new Error('Test Failure'), status: 501 }; - const completion = makeEntityErrorCompletion(action, 'PUT', httpError); - const error = completion.payload.error; + const error = makeDataServiceError('DELETE', httpError); + const completion = makeEntityErrorCompletion(action, error); actions = hot('-a---', { a: action }); const response = cold('----#|', {}, error); - const expected = cold('-----b', { b: completion }); - testEntityDataService.dataServiceSpy.add.and.returnValue(response); + const expected = cold('------b', { b: completion }); + dataService.delete.and.returnValue(response); expect(effects.persist$).toBeObservable(expected); }); - it('should return a SAVE_DELETE_ONE_OPTIMISTIC_SUCCESS on success', () => { - const action = entityActionFactory.create( - 'Hero', - EntityOp.SAVE_DELETE_ONE_OPTIMISTIC, - 42 - ); - const completion = entityActionFactory.create( - 'Hero', - EntityOp.SAVE_DELETE_ONE_OPTIMISTIC_SUCCESS - ); - - actions = hot('-a---', { a: action }); - // delay the response 3 ticks - const response = cold('---a|', { a: undefined }); - const expected = cold('----b', { b: completion }); - testEntityDataService.dataServiceSpy.delete.and.returnValue(response); - - expect(effects.persist$).toBeObservable(expected); - }); + it('should return a SAVE_UPDATE_ONE_SUCCESS (Optimistic) with the hero on success', () => { + const updateEntity = { id: 1, name: 'A' }; + const update = { id: 1, changes: updateEntity } as Update; - it('should return a SAVE_DELETE_ONE_OPTIMISTIC_ERROR when service fails', () => { - const action = entityActionFactory.create( - 'Hero', - EntityOp.SAVE_DELETE_ONE_OPTIMISTIC, - 42 - ); - const httpError = { error: new Error('Test Failure'), status: 501 }; - const completion = makeEntityErrorCompletion(action, 'DELETE', httpError); - const error = completion.payload.error; + const action = entityActionFactory.create('Hero', EntityOp.SAVE_UPDATE_ONE, update, { isOptimistic: true }); + const completion = entityActionFactory.create('Hero', EntityOp.SAVE_UPDATE_ONE_SUCCESS, update, { isOptimistic: true }); actions = hot('-a---', { a: action }); - const response = cold('----#|', {}, error); - const expected = cold('-----b', { b: completion }); - testEntityDataService.dataServiceSpy.delete.and.returnValue(response); + // delay the response 3 frames + const response = cold('---a|', { a: updateEntity }); + const expected = cold('----b', { b: completion }); + dataService.update.and.returnValue(response); expect(effects.persist$).toBeObservable(expected); }); - it('should return a SAVE_UPDATE_ONE_OPTIMISTIC_SUCCESS with the hero on success', () => { - const update = { id: 1, changes: { id: 1, name: 'A' } } as Update; + it('should return a SAVE_UPDATE_ONE_SUCCESS (Pessimistic) with the hero on success', () => { + const updateEntity = { id: 1, name: 'A' }; + const update = { id: 1, changes: updateEntity } as Update; - const action = entityActionFactory.create( - 'Hero', - EntityOp.SAVE_UPDATE_ONE_OPTIMISTIC, - update - ); - const completion = entityActionFactory.create( - 'Hero', - EntityOp.SAVE_UPDATE_ONE_OPTIMISTIC_SUCCESS, - update - ); + const action = entityActionFactory.create('Hero', EntityOp.SAVE_UPDATE_ONE, update); + const completion = entityActionFactory.create('Hero', EntityOp.SAVE_UPDATE_ONE_SUCCESS, update); actions = hot('-a---', { a: action }); - // delay the response 3 ticks - const response = cold('---a|', { a: update }); + // delay the response 3 frames + const response = cold('---a|', { a: updateEntity }); const expected = cold('----b', { b: completion }); - testEntityDataService.dataServiceSpy.update.and.returnValue(response); + dataService.update.and.returnValue(response); expect(effects.persist$).toBeObservable(expected); }); - it('should return a SAVE_UPDATE_ONE_OPTIMISTIC_ERROR when service fails', () => { + it('should return a SAVE_UPDATE_ONE_ERROR when data service fails', () => { const update = { id: 1, changes: { id: 1, name: 'A' } } as Update; - const action = entityActionFactory.create( - 'Hero', - EntityOp.SAVE_UPDATE_ONE_OPTIMISTIC, - update - ); + const action = entityActionFactory.create('Hero', EntityOp.SAVE_UPDATE_ONE, update); const httpError = { error: new Error('Test Failure'), status: 501 }; - const completion = makeEntityErrorCompletion(action, 'PUT', httpError); - const error = completion.payload.error; + const error = makeDataServiceError('PUT', httpError); + const completion = makeEntityErrorCompletion(action, error); actions = hot('-a---', { a: action }); const response = cold('----#|', {}, error); - const expected = cold('-----b', { b: completion }); - testEntityDataService.dataServiceSpy.update.and.returnValue(response); + const expected = cold('------b', { b: completion }); + dataService.update.and.returnValue(response); expect(effects.persist$).toBeObservable(expected); }); @@ -458,10 +298,14 @@ describe('EntityEffects (marble testing)', () => { }); }); -/** Make an EntityDataService error */ -function makeEntityErrorCompletion( - /** The action that initiated the data service call */ - originalAction: EntityAction, +// #region test helpers +export class Hero { + id: number; + name: string; +} + +/** make error produced by the EntityDataService */ +function makeDataServiceError( /** Http method for that action */ method: HttpMethods, /** Http error from the web api */ @@ -470,18 +314,22 @@ function makeEntityErrorCompletion( options?: any ) { let url = 'api/heroes'; - - // Error from the web api if (httpError) { url = httpError.url || url; } else { httpError = { error: new Error('Test error'), status: 500, url }; } + return new DataServiceError(httpError, { method, url, options }); +} - // Error produced by the EntityDataService - const error = new DataServiceError(httpError, { method, url, options }); - - const errOp = (originalAction.op + OP_ERROR); +/** Make an EntityDataService error */ +function makeEntityErrorCompletion( + /** The action that initiated the data service call */ + originalAction: EntityAction, + /** error produced by the EntityDataService */ + error: DataServiceError +) { + const errOp = makeErrorOp(originalAction.payload.entityOp); // Entity Error Action const eaFactory = new EntityActionFactory(); @@ -490,3 +338,30 @@ function makeEntityErrorCompletion( error }); } + +export interface TestDataServiceMethod { + add: jasmine.Spy; + delete: jasmine.Spy; + getAll: jasmine.Spy; + getById: jasmine.Spy; + getWithQuery: jasmine.Spy; + update: jasmine.Spy; +} + +export class TestDataService { + add = jasmine.createSpy('add'); + delete = jasmine.createSpy('delete'); + getAll = jasmine.createSpy('getAll'); + getById = jasmine.createSpy('getById'); + getWithQuery = jasmine.createSpy('getWithQuery'); + update = jasmine.createSpy('update'); + + getService(): TestDataServiceMethod { + return this; + } + + setResponse(methodName: keyof TestDataServiceMethod, data$: Observable) { + this[methodName].and.returnValue(data$); + } +} +// #endregion test helpers diff --git a/lib/src/effects/entity-effects.spec.ts b/lib/src/effects/entity-effects.spec.ts index 8a359f9e..3d55c40e 100644 --- a/lib/src/effects/entity-effects.spec.ts +++ b/lib/src/effects/entity-effects.spec.ts @@ -3,24 +3,16 @@ import { TestBed } from '@angular/core/testing'; import { Action } from '@ngrx/store'; import { Actions } from '@ngrx/effects'; -import { Observable, of, merge, Subject, throwError } from 'rxjs'; -import { delay, first } from 'rxjs/operators'; - -import { EntityAction, EntityActionFactory } from '../actions/entity-action'; -import { EntityOp, OP_ERROR } from '../actions/entity-op'; - -import { - EntityCollectionDataService, - EntityDataService -} from '../dataservices/entity-data.service'; -import { - DataServiceError, - EntityActionDataServiceError -} from '../dataservices/data-service-error'; -import { - PersistenceResultHandler, - DefaultPersistenceResultHandler -} from '../dataservices/persistence-result-handler.service'; +import { Observable, of, merge, ReplaySubject, throwError, timer } from 'rxjs'; +import { delay, first, mergeMap } from 'rxjs/operators'; + +import { EntityAction } from '../actions/entity-action'; +import { EntityActionFactory } from '../actions/entity-action-factory'; +import { EntityOp, makeErrorOp } from '../actions/entity-op'; + +import { EntityCollectionDataService, EntityDataService } from '../dataservices/entity-data.service'; +import { DataServiceError, EntityActionDataServiceError } from '../dataservices/data-service-error'; +import { PersistenceResultHandler, DefaultPersistenceResultHandler } from '../dataservices/persistence-result-handler.service'; import { HttpMethods } from '../dataservices/interfaces'; import { EntityEffects } from './entity-effects'; @@ -28,46 +20,20 @@ import { EntityEffects } from './entity-effects'; import { Logger } from '../utils/interfaces'; import { Update } from '../utils/ngrx-entity-models'; -export class TestEntityDataService { - dataServiceSpy: any; - - constructor() { - this.dataServiceSpy = jasmine.createSpyObj( - 'EntityCollectionDataService', - ['add', 'delete', 'getAll', 'getById', 'getWithQuery', 'update'] - ); - } - - getService() { - return this.dataServiceSpy; - } -} - -// For AOT -export function getDataService() { - return new TestEntityDataService(); -} - -export class Hero { - id: number; - name: string; -} - -//////// Tests begin //////// - describe('EntityEffects (normal testing)', () => { // factory never changes in these tests const entityActionFactory = new EntityActionFactory(); - let actions$: Subject; + let actions$: ReplaySubject; let effects: EntityEffects; let logger: Logger; - let testEntityDataService: TestEntityDataService; + let dataService: TestDataService; - function expectCompletion(completion: EntityAction) { + function expectCompletion(completion: EntityAction, done: DoneFn) { effects.persist$.subscribe( result => { expect(result).toEqual(completion); + done(); }, error => { fail(error); @@ -77,14 +43,15 @@ describe('EntityEffects (normal testing)', () => { beforeEach(() => { logger = jasmine.createSpyObj('Logger', ['error', 'log', 'warn']); - actions$ = new Subject(); + actions$ = new ReplaySubject(1); TestBed.configureTestingModule({ providers: [ EntityEffects, { provide: Actions, useValue: actions$ }, { provide: EntityActionFactory, useValue: entityActionFactory }, - { provide: EntityDataService, useFactory: getDataService }, + /* tslint:disable-next-line:no-use-before-declare */ + { provide: EntityDataService, useClass: TestDataService }, { provide: Logger, useValue: logger }, { provide: PersistenceResultHandler, @@ -95,127 +62,106 @@ describe('EntityEffects (normal testing)', () => { actions$ = TestBed.get(Actions); effects = TestBed.get(EntityEffects); - testEntityDataService = TestBed.get(EntityDataService); + dataService = TestBed.get(EntityDataService); }); - it('should return a QUERY_ALL_SUCCESS, with the heroes, on success', () => { + it('cancel$ should emit correlation id for CANCEL_PERSIST', (done: DoneFn) => { + const action = entityActionFactory.create('Hero', EntityOp.CANCEL_PERSIST, undefined, { correlationId: 42 }); + effects.cancel$.subscribe(crid => { + expect(crid).toBe(42); + done(); + }); + actions$.next(action); + }); + + it('should return a QUERY_ALL_SUCCESS with the heroes on success', (done: DoneFn) => { const hero1 = { id: 1, name: 'A' } as Hero; const hero2 = { id: 2, name: 'B' } as Hero; const heroes = [hero1, hero2]; - - const response = of(heroes); - testEntityDataService.dataServiceSpy.getAll.and.returnValue(response); + dataService.setResponse('getAll', heroes); const action = entityActionFactory.create('Hero', EntityOp.QUERY_ALL); - const completion = entityActionFactory.create( - 'Hero', - EntityOp.QUERY_ALL_SUCCESS, - heroes - ); + const completion = entityActionFactory.create('Hero', EntityOp.QUERY_ALL_SUCCESS, heroes); actions$.next(action); - expectCompletion(completion); + expectCompletion(completion, done); }); - it('should perform QUERY_ALL when dispatch custom labeled action', () => { + it('should perform QUERY_ALL when dispatch custom tagged action', (done: DoneFn) => { const hero1 = { id: 1, name: 'A' } as Hero; const hero2 = { id: 2, name: 'B' } as Hero; const heroes = [hero1, hero2]; + dataService.setResponse('getAll', heroes); - const response = of(heroes); - testEntityDataService.dataServiceSpy.getAll.and.returnValue(response); - - const action = entityActionFactory.create( - 'Hero', - EntityOp.QUERY_ALL, - null, - 'Custom Hero Label' - ); + const action = entityActionFactory.create({ + entityName: 'Hero', + entityOp: EntityOp.QUERY_ALL, + tag: 'Custom Hero Tag' + }); - const completion = entityActionFactory.create( - action, - EntityOp.QUERY_ALL_SUCCESS, - heroes - ); + const completion = entityActionFactory.createFromAction(action, { entityOp: EntityOp.QUERY_ALL_SUCCESS, data: heroes }); actions$.next(action); - expectCompletion(completion); + expectCompletion(completion, done); }); - it('should perform QUERY_ALL when dispatch properly marked, custom action', () => { + it('should perform QUERY_ALL when dispatch custom action w/ that entityOp', (done: DoneFn) => { const hero1 = { id: 1, name: 'A' } as Hero; const hero2 = { id: 2, name: 'B' } as Hero; const heroes = [hero1, hero2]; - - const response = of(heroes); - testEntityDataService.dataServiceSpy.getAll.and.returnValue(response); + dataService.setResponse('getAll', heroes); const action = { type: 'some/arbitrary/type/text', - entityName: 'Hero', - op: EntityOp.QUERY_ALL + payload: { + entityName: 'Hero', + entityOp: EntityOp.QUERY_ALL + } }; - const completion = entityActionFactory.create( - action, - EntityOp.QUERY_ALL_SUCCESS, - heroes - ); + const completion = entityActionFactory.createFromAction(action, { entityOp: EntityOp.QUERY_ALL_SUCCESS, data: heroes }); actions$.next(action); - expectCompletion(completion); + expectCompletion(completion, done); }); - it('should return a QUERY_ALL_ERROR when service fails', () => { + it('should return a QUERY_ALL_ERROR when data service fails', (done: DoneFn) => { const action = entityActionFactory.create('Hero', EntityOp.QUERY_ALL); const httpError = { error: new Error('Test Failure'), status: 501 }; - const completion = makeEntityErrorCompletion(action, 'GET', httpError); - const error = completion.payload.error; + const error = makeDataServiceError('GET', httpError); + const completion = makeEntityErrorCompletion(action, error); actions$.next(action); - const response = throwError(error); - testEntityDataService.dataServiceSpy.getAll.and.returnValue(response); + dataService.setErrorResponse('getAll', error); - expectCompletion(completion); - expect(completion.op).toEqual(EntityOp.QUERY_ALL_ERROR); + expectCompletion(completion, done); + expect(completion.payload.entityOp).toEqual(EntityOp.QUERY_ALL_ERROR); }); - it('should return a QUERY_BY_KEY_SUCCESS with a hero on success', () => { - const action = entityActionFactory.create( - 'Hero', - EntityOp.QUERY_BY_KEY, - 42 - ); - const completion = entityActionFactory.create( - 'Hero', - EntityOp.QUERY_BY_KEY_SUCCESS - ); + it('should return a QUERY_BY_KEY_SUCCESS with a hero on success', (done: DoneFn) => { + const hero = { id: 1, name: 'A' } as Hero; + const action = entityActionFactory.create('Hero', EntityOp.QUERY_BY_KEY, 1); + const completion = entityActionFactory.create('Hero', EntityOp.QUERY_BY_KEY_SUCCESS, hero); actions$.next(action); - const response = of(undefined); - testEntityDataService.dataServiceSpy.getById.and.returnValue(response); + dataService.setResponse('getById', hero); - expectCompletion(completion); + expectCompletion(completion, done); }); - it('should return a QUERY_BY_KEY_ERROR when service fails', () => { - const action = entityActionFactory.create( - 'Hero', - EntityOp.QUERY_BY_KEY, - 42 - ); + it('should return a QUERY_BY_KEY_ERROR when data service fails', (done: DoneFn) => { + const action = entityActionFactory.create('Hero', EntityOp.QUERY_BY_KEY, 42); const httpError = { error: new Error('Entity not found'), status: 404 }; - const completion = makeEntityErrorCompletion(action, 'DELETE', httpError); - const error = completion.payload.error; + const error = makeDataServiceError('GET', httpError); + const completion = makeEntityErrorCompletion(action, error); actions$.next(action); - const response = throwError(error); - testEntityDataService.dataServiceSpy.getById.and.returnValue(response); + dataService.setErrorResponse('getById', error); - expectCompletion(completion); + expectCompletion(completion, done); }); - it('should return a QUERY_MANY_SUCCESS with selected heroes on success', () => { + it('should return a QUERY_MANY_SUCCESS with selected heroes on success', (done: DoneFn) => { const hero1 = { id: 1, name: 'BA' } as Hero; const hero2 = { id: 2, name: 'BB' } as Hero; const heroes = [hero1, hero2]; @@ -223,261 +169,136 @@ describe('EntityEffects (normal testing)', () => { const action = entityActionFactory.create('Hero', EntityOp.QUERY_MANY, { name: 'B' }); - const completion = entityActionFactory.create( - 'Hero', - EntityOp.QUERY_MANY_SUCCESS, - heroes - ); + const completion = entityActionFactory.create('Hero', EntityOp.QUERY_MANY_SUCCESS, heroes); actions$.next(action); - const response = of(heroes); - testEntityDataService.dataServiceSpy.getWithQuery.and.returnValue(response); + dataService.setResponse('getWithQuery', heroes); - expectCompletion(completion); + expectCompletion(completion, done); }); - it('should return a QUERY_MANY_ERROR when service fails', () => { + it('should return a QUERY_MANY_ERROR when data service fails', (done: DoneFn) => { const action = entityActionFactory.create('Hero', EntityOp.QUERY_MANY, { name: 'B' }); const httpError = { error: new Error('Resource not found'), status: 404 }; - const completion = makeEntityErrorCompletion(action, 'GET', httpError, { + const error = makeDataServiceError('GET', httpError, { name: 'B' }); - const error = completion.payload.error; + const completion = makeEntityErrorCompletion(action, error); actions$.next(action); - const response = throwError(error); - testEntityDataService.dataServiceSpy.getWithQuery.and.returnValue(response); + dataService.setErrorResponse('getWithQuery', error); - expectCompletion(completion); + expectCompletion(completion, done); }); - it('should return a SAVE_ADD_SUCCESS with the hero on success', () => { + it('should return a SAVE_ADD_ONE_SUCCESS (Optimistic) with the hero on success', (done: DoneFn) => { const hero = { id: 1, name: 'A' } as Hero; - const action = entityActionFactory.create( - 'Hero', - EntityOp.SAVE_ADD_ONE, - hero - ); - const completion = entityActionFactory.create( - 'Hero', - EntityOp.SAVE_ADD_ONE_SUCCESS, - hero - ); + const action = entityActionFactory.create('Hero', EntityOp.SAVE_ADD_ONE, hero, { isOptimistic: true }); + const completion = entityActionFactory.create('Hero', EntityOp.SAVE_ADD_ONE_SUCCESS, hero, { isOptimistic: true }); actions$.next(action); - const response = of(hero); - testEntityDataService.dataServiceSpy.add.and.returnValue(response); + dataService.setResponse('add', hero); - expectCompletion(completion); + expectCompletion(completion, done); }); - it('should return a SAVE_ADD_ERROR when service fails', () => { + it('should return a SAVE_ADD_ONE_SUCCESS (Pessimistic) with the hero on success', (done: DoneFn) => { const hero = { id: 1, name: 'A' } as Hero; - const action = entityActionFactory.create( - 'Hero', - EntityOp.SAVE_ADD_ONE, - hero - ); - const httpError = { error: new Error('Test Failure'), status: 501 }; - const completion = makeEntityErrorCompletion(action, 'PUT', httpError); - const error = completion.payload.error; - actions$.next(action); - const response = throwError(error); - testEntityDataService.dataServiceSpy.add.and.returnValue(response); - - expectCompletion(completion); - }); - - it('should return a SAVE_DELETE_SUCCESS on success', () => { - const action = entityActionFactory.create( - 'Hero', - EntityOp.SAVE_DELETE_ONE, - 42 - ); - const completion = entityActionFactory.create( - 'Hero', - EntityOp.SAVE_DELETE_ONE_SUCCESS, - 42 - ); + const action = entityActionFactory.create('Hero', EntityOp.SAVE_ADD_ONE, hero); + const completion = entityActionFactory.create('Hero', EntityOp.SAVE_ADD_ONE_SUCCESS, hero); actions$.next(action); - const response = of(42); // dataservice successful delete returns the deleted entity id - testEntityDataService.dataServiceSpy.delete.and.returnValue(response); + dataService.setResponse('add', hero); - expectCompletion(completion); + expectCompletion(completion, done); }); - it('should return a SAVE_DELETE_ERROR when service fails', () => { - const action = entityActionFactory.create( - 'Hero', - EntityOp.SAVE_DELETE_ONE, - 42 - ); + it('should return a SAVE_ADD_ONE_ERROR when data service fails', (done: DoneFn) => { + const hero = { id: 1, name: 'A' } as Hero; + const action = entityActionFactory.create('Hero', EntityOp.SAVE_ADD_ONE, hero); const httpError = { error: new Error('Test Failure'), status: 501 }; - const completion = makeEntityErrorCompletion(action, 'DELETE', httpError); - const error = completion.payload.error; - - actions$.next(action); - const response = throwError(error); - testEntityDataService.dataServiceSpy.delete.and.returnValue(response); - - expectCompletion(completion); - }); - - it('should return a SAVE_UPDATE_SUCCESS with the hero on success', () => { - const update = { id: 1, changes: { id: 1, name: 'A' } } as Update; - - const action = entityActionFactory.create( - 'Hero', - EntityOp.SAVE_UPDATE_ONE, - update - ); - const completion = entityActionFactory.create( - 'Hero', - EntityOp.SAVE_UPDATE_ONE_SUCCESS, - update - ); + const error = makeDataServiceError('PUT', httpError); + const completion = makeEntityErrorCompletion(action, error); actions$.next(action); - const response = of(update); - testEntityDataService.dataServiceSpy.update.and.returnValue(response); + dataService.setErrorResponse('add', error); - expectCompletion(completion); + expectCompletion(completion, done); }); - it('should return a SAVE_UPDATE_ERROR when service fails', () => { - const update = { id: 1, changes: { id: 1, name: 'A' } } as Update; - const action = entityActionFactory.create( - 'Hero', - EntityOp.SAVE_UPDATE_ONE, - update - ); - const httpError = { error: new Error('Test Failure'), status: 501 }; - const completion = makeEntityErrorCompletion(action, 'PUT', httpError); - const error = completion.payload.error; + it('should return a SAVE_DELETE_ONE_SUCCESS (Optimistic) on success with delete id', (done: DoneFn) => { + const action = entityActionFactory.create('Hero', EntityOp.SAVE_DELETE_ONE, 42, { isOptimistic: true }); + const completion = entityActionFactory.create('Hero', EntityOp.SAVE_DELETE_ONE_SUCCESS, 42, { isOptimistic: true }); actions$.next(action); - const response = throwError(error); - testEntityDataService.dataServiceSpy.update.and.returnValue(response); + dataService.setResponse('delete', 42); - expectCompletion(completion); + expectCompletion(completion, done); }); - it('should return a SAVE_ADD_ONE_OPTIMISTIC_SUCCESS with the hero on success', () => { - const hero = { id: 1, name: 'A' } as Hero; - - const action = entityActionFactory.create( - 'Hero', - EntityOp.SAVE_ADD_ONE_OPTIMISTIC, - hero - ); - const completion = entityActionFactory.create( - 'Hero', - EntityOp.SAVE_ADD_ONE_OPTIMISTIC_SUCCESS, - hero - ); + it('should return a SAVE_DELETE_ONE_SUCCESS (Pessimistic) on success', (done: DoneFn) => { + const action = entityActionFactory.create('Hero', EntityOp.SAVE_DELETE_ONE, 42); + const completion = entityActionFactory.create('Hero', EntityOp.SAVE_DELETE_ONE_SUCCESS, 42); actions$.next(action); - const response = of(hero); - testEntityDataService.dataServiceSpy.add.and.returnValue(response); + dataService.setResponse('delete', 42); - expectCompletion(completion); + expectCompletion(completion, done); }); - it('should return a SAVE_ADD_ONE_OPTIMISTIC_ERROR when service fails', () => { - const hero = { id: 1, name: 'A' } as Hero; - const action = entityActionFactory.create( - 'Hero', - EntityOp.SAVE_ADD_ONE_OPTIMISTIC, - hero - ); + it('should return a SAVE_DELETE_ONE_ERROR when data service fails', (done: DoneFn) => { + const action = entityActionFactory.create('Hero', EntityOp.SAVE_DELETE_ONE, 42); const httpError = { error: new Error('Test Failure'), status: 501 }; - const completion = makeEntityErrorCompletion(action, 'PUT', httpError); - const error = completion.payload.error; + const error = makeDataServiceError('DELETE', httpError); + const completion = makeEntityErrorCompletion(action, error); actions$.next(action); - const response = throwError(error); - testEntityDataService.dataServiceSpy.add.and.returnValue(response); + dataService.setErrorResponse('delete', error); - expectCompletion(completion); + expectCompletion(completion, done); }); - it('should return a SAVE_DELETE_ONE_OPTIMISTIC_SUCCESS on success', () => { - const action = entityActionFactory.create( - 'Hero', - EntityOp.SAVE_DELETE_ONE_OPTIMISTIC, - 42 - ); - const completion = entityActionFactory.create( - 'Hero', - EntityOp.SAVE_DELETE_ONE_OPTIMISTIC_SUCCESS - ); + it('should return a SAVE_UPDATE_ONE_SUCCESS (Optimistic) with the hero on success', (done: DoneFn) => { + const updateEntity = { id: 1, name: 'A' }; + const update = { id: 1, changes: updateEntity } as Update; - actions$.next(action); - const response = of(undefined); - testEntityDataService.dataServiceSpy.delete.and.returnValue(response); - - expectCompletion(completion); - }); - - it('should return a SAVE_DELETE_ONE_OPTIMISTIC_ERROR when service fails', () => { - const action = entityActionFactory.create( - 'Hero', - EntityOp.SAVE_DELETE_ONE_OPTIMISTIC, - 42 - ); - const httpError = { error: new Error('Test Failure'), status: 501 }; - const completion = makeEntityErrorCompletion(action, 'DELETE', httpError); - const error = completion.payload.error; + const action = entityActionFactory.create('Hero', EntityOp.SAVE_UPDATE_ONE, update, { isOptimistic: true }); + const completion = entityActionFactory.create('Hero', EntityOp.SAVE_UPDATE_ONE_SUCCESS, update, { isOptimistic: true }); actions$.next(action); - const response = throwError(error); - testEntityDataService.dataServiceSpy.delete.and.returnValue(response); + dataService.setResponse('update', updateEntity); - expectCompletion(completion); + expectCompletion(completion, done); }); - it('should return a SAVE_UPDATE_ONE_OPTIMISTIC_SUCCESS with the hero on success', () => { - const update = { id: 1, changes: { id: 1, name: 'A' } } as Update; + it('should return a SAVE_UPDATE_ONE_SUCCESS (Pessimistic) with the hero on success', (done: DoneFn) => { + const updateEntity = { id: 1, name: 'A' }; + const update = { id: 1, changes: updateEntity } as Update; - const action = entityActionFactory.create( - 'Hero', - EntityOp.SAVE_UPDATE_ONE_OPTIMISTIC, - update - ); - const completion = entityActionFactory.create( - 'Hero', - EntityOp.SAVE_UPDATE_ONE_OPTIMISTIC_SUCCESS, - update - ); + const action = entityActionFactory.create('Hero', EntityOp.SAVE_UPDATE_ONE, update); + const completion = entityActionFactory.create('Hero', EntityOp.SAVE_UPDATE_ONE_SUCCESS, update); actions$.next(action); - const response = of(update); - testEntityDataService.dataServiceSpy.update.and.returnValue(response); + dataService.setResponse('update', updateEntity); - expectCompletion(completion); + expectCompletion(completion, done); }); - it('should return a SAVE_UPDATE_ONE_OPTIMISTIC_ERROR when service fails', () => { + it('should return a SAVE_UPDATE_ONE_ERROR when data service fails', (done: DoneFn) => { const update = { id: 1, changes: { id: 1, name: 'A' } } as Update; - const action = entityActionFactory.create( - 'Hero', - EntityOp.SAVE_UPDATE_ONE_OPTIMISTIC, - update - ); + const action = entityActionFactory.create('Hero', EntityOp.SAVE_UPDATE_ONE, update); const httpError = { error: new Error('Test Failure'), status: 501 }; - const completion = makeEntityErrorCompletion(action, 'PUT', httpError); - const error = completion.payload.error; + const error = makeDataServiceError('PUT', httpError); + const completion = makeEntityErrorCompletion(action, error); actions$.next(action); - const response = throwError(error); - testEntityDataService.dataServiceSpy.update.and.returnValue(response); + dataService.setErrorResponse('update', error); - expectCompletion(completion); + expectCompletion(completion, done); }); it(`should not do anything with an irrelevant action`, (done: DoneFn) => { @@ -504,10 +325,14 @@ describe('EntityEffects (normal testing)', () => { }); }); -/** Make an EntityDataService error */ -function makeEntityErrorCompletion( - /** The action that initiated the data service call */ - originalAction: EntityAction, +// #region test helpers +export class Hero { + id: number; + name: string; +} + +/** make error produced by the EntityDataService */ +function makeDataServiceError( /** Http method for that action */ method: HttpMethods, /** Http error from the web api */ @@ -516,18 +341,22 @@ function makeEntityErrorCompletion( options?: any ) { let url = 'api/heroes'; - - // Error from the web api if (httpError) { url = httpError.url || url; } else { httpError = { error: new Error('Test error'), status: 500, url }; } + return new DataServiceError(httpError, { method, url, options }); +} - // Error produced by the EntityDataService - const error = new DataServiceError(httpError, { method, url, options }); - - const errOp = (originalAction.op + OP_ERROR); +/** Make an EntityDataService error */ +function makeEntityErrorCompletion( + /** The action that initiated the data service call */ + originalAction: EntityAction, + /** error produced by the EntityDataService */ + error: DataServiceError +) { + const errOp = makeErrorOp(originalAction.payload.entityOp); // Entity Error Action const eaFactory = new EntityActionFactory(); @@ -536,3 +365,36 @@ function makeEntityErrorCompletion( error }); } + +export interface TestDataServiceMethod { + add: jasmine.Spy; + delete: jasmine.Spy; + getAll: jasmine.Spy; + getById: jasmine.Spy; + getWithQuery: jasmine.Spy; + update: jasmine.Spy; +} +export class TestDataService { + add = jasmine.createSpy('add'); + delete = jasmine.createSpy('delete'); + getAll = jasmine.createSpy('getAll'); + getById = jasmine.createSpy('getById'); + getWithQuery = jasmine.createSpy('getWithQuery'); + update = jasmine.createSpy('update'); + + getService(): TestDataServiceMethod { + return this; + } + + setResponse(methodName: keyof TestDataServiceMethod, data: any) { + this[methodName].and.returnValue(of(data).pipe(delay(1))); + } + + setErrorResponse(methodName: keyof TestDataServiceMethod, error: any) { + // Following won't quite work because delay does not appear to delay an error + // this[methodName].and.returnValue(throwError(error).pipe(delay(1))); + // Use timer instead + this[methodName].and.returnValue(timer(1).pipe(mergeMap(() => throwError(error)))); + } +} +// #endregion test helpers diff --git a/lib/src/effects/entity-effects.ts b/lib/src/effects/entity-effects.ts index 32a87ef3..2e8345e6 100644 --- a/lib/src/effects/entity-effects.ts +++ b/lib/src/effects/entity-effects.ts @@ -1,57 +1,86 @@ -import { Injectable } from '@angular/core'; +import { Inject, Injectable, InjectionToken, Optional } from '@angular/core'; import { Action } from '@ngrx/store'; import { Effect, Actions } from '@ngrx/effects'; -import { Observable, of } from 'rxjs'; -import { concatMap, catchError, map } from 'rxjs/operators'; +import { asyncScheduler, Observable, of, SchedulerLike } from 'rxjs'; +import { concatMap, catchError, delay, filter, map, mergeMap, takeUntil, tap } from 'rxjs/operators'; -import { EntityAction, EntityActionFactory } from '../actions/entity-action'; -import { EntityOp } from '../actions/entity-op'; +import { EntityAction } from '../actions/entity-action'; +import { EntityActionFactory } from '../actions/entity-action-factory'; +import { EntityOp, makeSuccessOp } from '../actions/entity-op'; import { ofEntityOp } from '../actions/entity-action-operators'; +import { Update } from '../utils/ngrx-entity-models'; import { EntityDataService } from '../dataservices/entity-data.service'; import { PersistenceResultHandler } from '../dataservices/persistence-result-handler.service'; export const persistOps: EntityOp[] = [ EntityOp.QUERY_ALL, + EntityOp.QUERY_LOAD, EntityOp.QUERY_BY_KEY, EntityOp.QUERY_MANY, EntityOp.SAVE_ADD_ONE, EntityOp.SAVE_DELETE_ONE, - EntityOp.SAVE_UPDATE_ONE, - EntityOp.SAVE_ADD_ONE_OPTIMISTIC, - EntityOp.SAVE_DELETE_ONE_OPTIMISTIC, - EntityOp.SAVE_UPDATE_ONE_OPTIMISTIC + EntityOp.SAVE_UPDATE_ONE ]; +/** Token to inject a special RxJS Scheduler during marble tests. */ +export const ENTITY_EFFECTS_SCHEDULER = new InjectionToken('EntityEffects Scheduler'); + @Injectable() export class EntityEffects { + // See https://github.com/ReactiveX/rxjs/blob/master/doc/marble-testing.md + /** Delay for error and skip observables. Must be multiple of 10 for marble testing. */ + private responseDelay = 10; + + @Effect({ dispatch: false }) + cancel$: Observable = this.actions.pipe( + ofEntityOp(EntityOp.CANCEL_PERSIST), + map((action: EntityAction) => action.payload.correlationId), + filter(id => id !== null) + ); + @Effect() // Concurrent persistence requests considered unsafe. - // `concatMap` ensures each request must complete-or-fail before making the next request. - persist$: Observable = this.actions.pipe( - ofEntityOp(persistOps), - concatMap(action => this.persist(action)) - ); + // `mergeMap` allows for concurrent requests which may return in any order + persist$: Observable = this.actions.pipe(ofEntityOp(persistOps), mergeMap(action => this.persist(action))); constructor( private actions: Actions, private dataService: EntityDataService, private entityActionFactory: EntityActionFactory, - private resultHandler: PersistenceResultHandler + private resultHandler: PersistenceResultHandler, + /** + * Injecting an optional Scheduler that will be undefined + * in normal application usage, but its injected here so that you can mock out + * during testing using the RxJS TestScheduler for simulating passages of time. + */ + @Optional() + @Inject(ENTITY_EFFECTS_SCHEDULER) + private scheduler: SchedulerLike ) {} /** - * Perform the requested persistence operation and return a completing Observable + * Perform the requested persistence operation and return a scalar Observable * that the effect should dispatch to the store after the server responds. * @param action A persistence operation EntityAction */ persist(action: EntityAction): Observable { - if (action.error) { - return this.handleError$(action)(action.error); + if (action.payload.skip) { + // Should not persist. Pretend it succeeded. + return this.handleSkipSuccess$(action); + } + if (action.payload.error) { + return this.handleError$(action)(action.payload.error); } try { return this.callDataService(action).pipe( + takeUntil( + this.cancel$.pipe( + // terminate this observable if canceled with this action's correlationId + filter(id => action.payload.correlationId === id) + ) + ), map(this.resultHandler.handleSuccess(action)), catchError(this.handleError$(action)) ); @@ -61,44 +90,65 @@ export class EntityEffects { } private callDataService(action: EntityAction) { - const service = this.dataService.getService(action.entityName); - switch (action.op) { - case EntityOp.QUERY_ALL: { + const { entityName, entityOp, data } = action.payload; + const service = this.dataService.getService(entityName); + switch (entityOp) { + case EntityOp.QUERY_ALL: + case EntityOp.QUERY_LOAD: return service.getAll(); - } - case EntityOp.QUERY_BY_KEY: { - return service.getById(action.payload); - } - case EntityOp.QUERY_MANY: { - return service.getWithQuery(action.payload); - } - case EntityOp.SAVE_ADD_ONE_OPTIMISTIC: - case EntityOp.SAVE_ADD_ONE: { - return service.add(action.payload); - } - case EntityOp.SAVE_DELETE_ONE_OPTIMISTIC: - case EntityOp.SAVE_DELETE_ONE: { - return service.delete(action.payload); - } - case EntityOp.SAVE_UPDATE_ONE_OPTIMISTIC: - case EntityOp.SAVE_UPDATE_ONE: { - return service.update(action.payload); - } - default: { - throw new Error( - `Persistence action "${action.op}" is not implemented.` + + case EntityOp.QUERY_BY_KEY: + return service.getById(data); + + case EntityOp.QUERY_MANY: + return service.getWithQuery(data); + + case EntityOp.SAVE_ADD_ONE: + return service.add(data); + + case EntityOp.SAVE_DELETE_ONE: + return service.delete(data); + + case EntityOp.SAVE_UPDATE_ONE: + const { id, changes } = data as Update; // data must be Update + return service.update(data).pipe( + map(updatedEntity => { + // Return an Update with merged updated entity data. + // If no update data from the server, + // assume the server made no additional changes of its own and + // append `unchanged: true` to the original payload. + const hasData = updatedEntity && Object.keys(updatedEntity).length > 0; + return hasData ? { id, changes: { ...changes, ...updatedEntity } } : { id, changes, unchanged: true }; + }) ); - } + + default: + throw new Error(`Persistence action "${entityOp}" is not implemented.`); } } /** * Handle error result of persistence operation on an EntityAction, - * returning observable of error action + * returning a scalar observable of error action + */ + private handleError$(action: EntityAction): (error: Error) => Observable { + // Although error may return immediately, + // ensure observable takes some time, + // as app likely assumes asynchronous response. + return (error: Error) => + of(this.resultHandler.handleError(action)(error)).pipe(delay(this.responseDelay, this.scheduler || asyncScheduler)); + } + + /** + * Because EntityAction.payload.skip is true, skip the persistence step and + * return a scalar success action that looks like the operation succeeded. */ - private handleError$( - action: EntityAction | EntityAction - ): (error: Error) => Observable { - return (error: Error) => of(this.resultHandler.handleError(action)(error)); + private handleSkipSuccess$(originalAction: EntityAction): Observable { + const successOp = makeSuccessOp(originalAction.payload.entityOp); + const successAction = this.entityActionFactory.createFromAction(originalAction, { entityOp: successOp }); + // Although returns immediately, + // ensure observable takes one tick (by using a promise), + // as app likely assumes asynchronous response. + return of(successAction).pipe(delay(this.responseDelay, this.scheduler || asyncScheduler)); } } diff --git a/lib/src/entity-metadata/entity-definition.service.ts b/lib/src/entity-metadata/entity-definition.service.ts index 5ffbbe9e..94392788 100644 --- a/lib/src/entity-metadata/entity-definition.service.ts +++ b/lib/src/entity-metadata/entity-definition.service.ts @@ -1,17 +1,14 @@ import { Inject, Injectable, InjectionToken, Optional } from '@angular/core'; import { createEntityDefinition, EntityDefinition } from './entity-definition'; -import { - EntityMetadata, - EntityMetadataMap, - ENTITY_METADATA_TOKEN -} from './entity-metadata'; +import { EntityMetadata, EntityMetadataMap, ENTITY_METADATA_TOKEN } from './entity-metadata'; import { ENTITY_CACHE_NAME } from '../reducers/constants'; export interface EntityDefinitions { [entityName: string]: EntityDefinition; } +/** Registry of EntityDefinitions for all cached entity types */ @Injectable() export class EntityDefinitionService { /** {EntityDefinition} for all cached entity types */ @@ -35,10 +32,7 @@ export class EntityDefinitionService { * getDefinition('Hero'); // definition for Heroes, untyped * getDefinition(`Hero`); // definition for Heroes, typed with Hero interface */ - getDefinition( - entityName: string, - shouldThrow = true - ): EntityDefinition { + getDefinition(entityName: string, shouldThrow = true): EntityDefinition { entityName = entityName.trim(); const definition = this.definitions[entityName]; if (!definition && shouldThrow) { @@ -76,9 +70,7 @@ export class EntityDefinitionService { */ registerMetadataMap(metadataMap: EntityMetadataMap = {}) { // The entity type name should be the same as the map key - Object.keys(metadataMap || {}).forEach(entityName => - this.registerMetadata({ entityName, ...metadataMap[entityName] }) - ); + Object.keys(metadataMap || {}).forEach(entityName => this.registerMetadata({ entityName, ...metadataMap[entityName] })); } /** diff --git a/lib/src/entity-metadata/entity-definition.spec.ts b/lib/src/entity-metadata/entity-definition.spec.ts index 86183626..cfbcc876 100644 --- a/lib/src/entity-metadata/entity-definition.spec.ts +++ b/lib/src/entity-metadata/entity-definition.spec.ts @@ -39,12 +39,13 @@ describe('EntityDefinition', () => { const def = createEntityDefinition(heroMetadata); const initialState = def.initialState; expect(initialState).toEqual({ + entityName: 'Hero', ids: [], entities: {}, filter: '', loaded: false, loading: false, - originalValues: {} + changeState: {} }); }); @@ -57,12 +58,13 @@ describe('EntityDefinition', () => { const def = createEntityDefinition(metadata); const initialState = def.initialState; expect(initialState).toEqual({ + entityName: 'Hero', ids: [], entities: {}, filter: '', loaded: false, loading: false, - originalValues: {}, + changeState: {}, foo: 'foo' }); }); diff --git a/lib/src/entity-metadata/entity-definition.ts b/lib/src/entity-metadata/entity-definition.ts index 831ddfa1..ad55fb40 100644 --- a/lib/src/entity-metadata/entity-definition.ts +++ b/lib/src/entity-metadata/entity-definition.ts @@ -1,35 +1,25 @@ import { EntityState, EntityAdapter, createEntityAdapter } from '@ngrx/entity'; -import { - EntitySelectors, - EntitySelectorsFactory -} from '../selectors/entity-selectors'; -import { - Comparer, - Dictionary, - IdSelector, - Update -} from '../utils/ngrx-entity-models'; +import { EntitySelectors, EntitySelectorsFactory } from '../selectors/entity-selectors'; +import { Comparer, Dictionary, IdSelector, Update } from '../utils/ngrx-entity-models'; +import { EntityDispatcherDefaultOptions } from '../dispatchers/entity-dispatcher-default-options'; import { defaultSelectId } from '../utils/utilities'; import { EntityCollection } from '../reducers/entity-collection'; -import { EntityDispatcherOptions } from '../dispatchers/entity-dispatcher'; import { EntityFilterFn } from './entity-filters'; import { EntityMetadata } from './entity-metadata'; export interface EntityDefinition { entityName: string; entityAdapter: EntityAdapter; - entityDispatcherOptions?: Partial; + entityDispatcherOptions?: Partial; initialState: EntityCollection; metadata: EntityMetadata; + noChangeTracking: boolean; selectId: IdSelector; sortComparer: false | Comparer; } -export function createEntityDefinition( - metadata: EntityMetadata -): EntityDefinition { - // extract known essential properties driving entity definition. +export function createEntityDefinition(metadata: EntityMetadata): EntityDefinition { let entityName = metadata.entityName; if (!entityName) { throw new Error('Missing required entityName'); @@ -40,23 +30,26 @@ export function createEntityDefinition( const entityAdapter = createEntityAdapter({ selectId, sortComparer }); - const entityDispatcherOptions: Partial = - metadata.entityDispatcherOptions || {}; + const entityDispatcherOptions: Partial = metadata.entityDispatcherOptions || {}; const initialState: EntityCollection = entityAdapter.getInitialState({ + entityName, filter: '', loaded: false, loading: false, - originalValues: {}, + changeState: {}, ...(metadata.additionalCollectionState || {}) }); + const noChangeTracking = metadata.noChangeTracking === true; // false by default + return { entityName, entityAdapter, entityDispatcherOptions, initialState, metadata, + noChangeTracking, selectId, sortComparer }; diff --git a/lib/src/entity-metadata/entity-metadata.ts b/lib/src/entity-metadata/entity-metadata.ts index 15adc981..7f986386 100644 --- a/lib/src/entity-metadata/entity-metadata.ts +++ b/lib/src/entity-metadata/entity-metadata.ts @@ -1,17 +1,16 @@ import { InjectionToken } from '@angular/core'; -import { EntityDispatcherOptions } from '../dispatchers/entity-dispatcher'; +import { EntityDispatcherDefaultOptions } from '../dispatchers/entity-dispatcher-default-options'; import { EntityFilterFn } from './entity-filters'; import { IdSelector, Comparer } from '../utils/ngrx-entity-models'; -export const ENTITY_METADATA_TOKEN = new InjectionToken( - 'ngrx-data/entity-metadata' -); +export const ENTITY_METADATA_TOKEN = new InjectionToken('ngrx-data/entity-metadata'); /** Metadata that describe an entity type and its collection to ngrx-data */ export interface EntityMetadata { entityName: string; - entityDispatcherOptions?: Partial; + entityDispatcherOptions?: Partial; filterFn?: EntityFilterFn; + noChangeTracking?: boolean; selectId?: IdSelector; sortComparer?: false | Comparer; additionalCollectionState?: S; diff --git a/lib/src/entity-services/default-entity-collection-service-factory.ts b/lib/src/entity-services/default-entity-collection-service-factory.ts deleted file mode 100644 index db14494f..00000000 --- a/lib/src/entity-services/default-entity-collection-service-factory.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { Injectable } from '@angular/core'; -import { Store } from '@ngrx/store'; - -import { Observable } from 'rxjs'; - -import { EntityCache } from '../reducers/entity-cache'; -import { EntityDefinitionService } from '../entity-metadata/entity-definition.service'; -import { EntityDispatcherFactory } from '../dispatchers/entity-dispatcher-factory'; -import { EntitySelectorsFactory } from '../selectors/entity-selectors'; -import { - EntitySelectors$, - EntitySelectors$Factory -} from '../selectors/entity-selectors$'; -import { - EntityCollectionService, - EntityCollectionServiceElements, - EntityCollectionServiceFactory -} from './entity-services-interfaces'; -import { EntityCollectionServiceBase } from './entity-collection-service-base'; - -/** - * Creates EntityCollectionService instances for - * a cached collection of T entities in the ngrx store. - */ -@Injectable() -export class DefaultEntityCollectionServiceFactory - implements EntityCollectionServiceFactory { - entityCache$: Observable | Store; - - constructor( - private entityDispatcherFactory: EntityDispatcherFactory, - private entityDefinitionService: EntityDefinitionService, - private entitySelectorsFactory: EntitySelectorsFactory, - private entitySelectors$Factory: EntitySelectors$Factory - ) { - this.entityCache$ = entitySelectors$Factory.entityCache$; - } - - getEntityCollectionServiceElements< - T, - S$ extends EntitySelectors$ = EntitySelectors$ - >(entityName: string): EntityCollectionServiceElements { - entityName = entityName.trim(); - const definition = this.entityDefinitionService.getDefinition( - entityName - ); - const dispatcher = this.entityDispatcherFactory.create( - entityName, - definition.selectId, - definition.entityDispatcherOptions - ); - const selectors = this.entitySelectorsFactory.create( - definition.metadata - ); - const selectors$ = this.entitySelectors$Factory.create( - entityName, - selectors - ); - - return { - entityName, - dispatcher, - selectors, - selectors$ - }; - } - /** - * Create an EntityCollectionService for an entity type - * @param entityName - name of the entity type - */ - create = EntitySelectors$>( - entityName: string - ): EntityCollectionService { - const service = new EntityCollectionServiceBase(entityName, this); - return service; - } -} diff --git a/lib/src/entity-services/entity-collection-service-base.ts b/lib/src/entity-services/entity-collection-service-base.ts index 092ddd54..5c0a6198 100644 --- a/lib/src/entity-services/entity-collection-service-base.ts +++ b/lib/src/entity-services/entity-collection-service-base.ts @@ -5,33 +5,28 @@ import { Actions } from '@ngrx/effects'; import { Observable } from 'rxjs'; import { Dictionary, IdSelector, Update } from '../utils/ngrx-entity-models'; -import { EntityAction } from '../actions/entity-action'; -import { EntityOp } from '../actions/entity-op'; +import { EntityAction, EntityActionOptions } from '../actions/entity-action'; import { EntityActionGuard } from '../actions/entity-action-guard'; import { EntityCache } from '../reducers/entity-cache'; -import { EntityCollection } from '../reducers/entity-collection'; +import { EntityCollection, ChangeStateMap } from '../reducers/entity-collection'; import { EntityDispatcher } from '../dispatchers/entity-dispatcher'; +import { EntityCollectionService } from './entity-collection-service'; +import { EntityCollectionServiceElementsFactory } from './entity-collection-service-elements-factory'; +import { EntityOp } from '../actions/entity-op'; import { EntitySelectors } from '../selectors/entity-selectors'; import { EntitySelectors$ } from '../selectors/entity-selectors$'; -import { - EntityCollectionService, - EntityCollectionServiceFactory -} from './entity-services-interfaces'; import { QueryParams } from '../dataservices/interfaces'; // tslint:disable:member-ordering + /** * Base class for a concrete EntityCollectionService. - * Can be instantiated. Cannot be injected. - * @param entityName Entity type name - * @param EntityCollectionServiceFactory A creator of an EntityCollectionService which here serves + * Can be instantiated. Cannot be injected. Use EntityCollectionServiceFactory to create. + * @param EntityCollectionServiceElements The ingredients for this service * as a source of supporting services for creating an EntityCollectionService instance. */ -export class EntityCollectionServiceBase< - T, - S$ extends EntitySelectors$ = EntitySelectors$ -> implements EntityCollectionService { - /** Dispatch entity actions for this entity collection */ +export class EntityCollectionServiceBase = EntitySelectors$> implements EntityCollectionService { + /** Dispatcher of EntityCommands (EntityActions) */ readonly dispatcher: EntityDispatcher; /** All selectors of entity collection properties */ @@ -41,18 +36,13 @@ export class EntityCollectionServiceBase< readonly selectors$: S$; constructor( + /** Name of the entity type of this collection service */ public readonly entityName: string, - entityCollectionServiceFactory: EntityCollectionServiceFactory + /** Creates the core elements of the EntityCollectionService for this entity type */ + serviceElementsFactory: EntityCollectionServiceElementsFactory ) { entityName = entityName.trim(); - const { - dispatcher, - selectors, - selectors$ - } = entityCollectionServiceFactory.getEntityCollectionServiceElements< - T, - S$ - >(entityName); + const { dispatcher, selectors, selectors$ } = serviceElementsFactory.getServiceElements(entityName); this.entityName = entityName; this.dispatcher = dispatcher; @@ -73,34 +63,39 @@ export class EntityCollectionServiceBase< this.keys$ = selectors$.keys$; this.loaded$ = selectors$.loaded$; this.loading$ = selectors$.loading$; - this.originalValues$ = selectors$.originalValues$; + this.changeState$ = selectors$.changeState$; } /** * Create an {EntityAction} for this entity type. * @param op {EntityOp} the entity operation - * @param payload the action payload + * @param [data] the action data + * @param [options] additional options + * @returns the EntityAction */ - createEntityAction(op: EntityOp, payload?: any): EntityAction { - return this.dispatcher.createEntityAction(op, payload); + createEntityAction

(op: EntityOp, data?: P, options?: EntityActionOptions): EntityAction

{ + return this.dispatcher.createEntityAction(op, data, options); } /** * Create an {EntityAction} for this entity type and * dispatch it immediately to the store. * @param op {EntityOp} the entity operation - * @param payload the action payload + * @param [data] the action data + * @param [options] additional options + * @returns the dispatched EntityAction */ - createAndDispatch(op: EntityOp, payload?: any): void { - this.dispatcher.createAndDispatch(op, payload); + createAndDispatch

(op: EntityOp, data?: P, options?: EntityActionOptions): EntityAction

{ + return this.dispatcher.createAndDispatch(op, data, options); } /** - * Dispatch action to the store. - * @param action the EntityAction + * Dispatch an action of any type to the ngrx store. + * @param action the Action + * @returns the dispatched Action */ - dispatch(action: Action): void { - this.dispatcher.dispatch(action); + dispatch(action: Action): Action { + return this.dispatcher.dispatch(action); } /** The NgRx Store for the {EntityCache} */ @@ -125,68 +120,99 @@ export class EntityCollectionServiceBase< // region Dispatch commands /** - * Save a new entity to remote storage. - * Does not add to cache until save succeeds. - * Ignored by cache-add if the entity is already in cache. + * Dispatch action to save a new entity to remote storage. + * @param entity entity to add, which may omit its key if pessimistic and the server creates the key; + * must have a key if optimistic save. + * @returns Observable of the entity + * after server reports successful save or the save error. */ - add(entity: T, isOptimistic?: boolean): void { - this.dispatcher.add(entity, isOptimistic); + add(entity: T, options?: EntityActionOptions): Observable { + return this.dispatcher.add(entity, options); } /** - * Removes entity from the cache (if it is in the cache) - * and deletes entity from remote storage by key. - * Does not restore to cache if the delete fails. - * @param entity The entity to remove + * Dispatch action to cancel the persistence operation (query or save) with the given correlationId. + * @param correlationId The correlation id for the corresponding EntityAction + * @param [reason] explains why canceled and by whom. + */ + cancel(correlationId: any, reason?: string): void { + this.dispatcher.cancel(correlationId, reason); + } + + /** + * Dispatch action to delete entity from remote storage by key. + * @param key The entity to delete + * @returns Observable of the deleted key + * after server reports successful save or the save error. */ - delete(entity: T, isOptimistic?: boolean): void; + delete(entity: T, options?: EntityActionOptions): Observable; /** - * Removes entity from the cache by key (if it is in the cache) - * and deletes entity from remote storage by key. - * Does not restore to cache if the delete fails. + * Dispatch action to delete entity from remote storage by key. * @param key The primary key of the entity to remove + * @returns Observable of the deleted key + * after server reports successful save or the save error. */ - delete(key: number | string, isOptimistic?: boolean): void; - delete(arg: (number | string) | T, isOptimistic?: boolean): void { - this.dispatcher.delete(arg as any, isOptimistic); + delete(key: number | string, options?: EntityActionOptions): Observable; + delete(arg: number | string | T, options?: EntityActionOptions): Observable { + return this.dispatcher.delete(arg as any, options); } /** - * Query remote storage for all entities and - * completely replace the cached collection with the queried entities. + * Dispatch action to query remote storage for all entities and + * merge the queried entities into the cached collection. + * @returns Observable of the collection + * after server reports successful query or the query error. + * @see load() */ - getAll(): void { - this.dispatcher.getAll(); + getAll(options?: EntityActionOptions): Observable { + return this.dispatcher.getAll(options); } /** - * Query remote storage for the entity with this primary key. + * Dispatch action to query remote storage for the entity with this primary key. * If the server returns an entity, * merge it into the cached collection. + * @returns Observable of the queried entities that are in the collection + * after server reports success or the query error. */ - getByKey(key: any): void { - this.dispatcher.getByKey(key); + getByKey(key: any, options?: EntityActionOptions): Observable { + return this.dispatcher.getByKey(key, options); } /** - * Query remote storage for the entities that satisfy a query expressed - * with either a query parameter map or an HTTP URL query string. + * Dispatch action to query remote storage for the entities that satisfy a query expressed + * with either a query parameter map or an HTTP URL query string, * and merge the results into the cached collection. + * @params queryParams the query in a form understood by the server + * @returns Observable of the queried entities + * after server reports successful query or the query error. + */ + getWithQuery(queryParams: QueryParams | string, options?: EntityActionOptions): Observable { + return this.dispatcher.getWithQuery(queryParams, options); + } + + /** + * Dispatch action to query remote storage for all entities and + * completely replace the cached collection with the queried entities. + * @returns Observable of the collection + * after server reports successful query or the query error. + * @see getAll */ - getWithQuery(queryParams: QueryParams | string): void { - this.dispatcher.getWithQuery(queryParams); + load(options?: EntityActionOptions): Observable { + return this.dispatcher.load(options); } /** - * Save the updated entity (or partial entity) to remote storage. - * Updates the cached entity after the save succeeds. - * Update in cache is ignored if the entity's key is not found in cache. + * Dispatch action to save the updated entity (or partial entity) in remote storage. * The update entity may be partial (but must have its key) * in which case it patches the existing entity. + * @param entity update entity, which might be a partial of T but must at least have its key. + * @returns Observable of the updated entity + * after server reports successful save or the save error. */ - update(entity: Partial, isOptimistic?: boolean): void { - this.dispatcher.update(entity, isOptimistic); + update(entity: Partial, options?: EntityActionOptions): Observable { + return this.dispatcher.update(entity, options); } /*** Cache-only operations that do not update remote storage ***/ @@ -353,7 +379,7 @@ export class EntityCollectionServiceBase< loading$: Observable | Store; /** Original entity values for entities with unsaved changes */ - originalValues$: Observable> | Store>; + changeState$: Observable> | Store>; // endregion Selectors$ } diff --git a/lib/src/entity-services/entity-collection-service-elements-factory.ts b/lib/src/entity-services/entity-collection-service-elements-factory.ts new file mode 100644 index 00000000..1e2116f0 --- /dev/null +++ b/lib/src/entity-services/entity-collection-service-elements-factory.ts @@ -0,0 +1,45 @@ +import { Injectable } from '@angular/core'; +import { EntityCollectionService } from './entity-collection-service'; +import { EntityCollectionServiceBase } from './entity-collection-service-base'; +import { EntityDispatcher } from '../dispatchers/entity-dispatcher'; +import { EntityDispatcherFactory } from '../dispatchers/entity-dispatcher-factory'; +import { EntityDefinitionService } from '../entity-metadata/entity-definition.service'; +import { EntitySelectors, EntitySelectorsFactory } from '../selectors/entity-selectors'; +import { EntitySelectors$, EntitySelectors$Factory } from '../selectors/entity-selectors$'; + +/** Core ingredients of an EntityCollectionService */ +export interface EntityCollectionServiceElements = EntitySelectors$> { + readonly dispatcher: EntityDispatcher; + readonly entityName: string; + readonly selectors: EntitySelectors; + readonly selectors$: S$; +} + +/** Creates the core elements of the EntityCollectionService for an entity type. */ +@Injectable() +export class EntityCollectionServiceElementsFactory { + constructor( + private entityDispatcherFactory: EntityDispatcherFactory, + private entityDefinitionService: EntityDefinitionService, + private entitySelectorsFactory: EntitySelectorsFactory, + private entitySelectors$Factory: EntitySelectors$Factory + ) {} + + /** + * Get the ingredients for making an EntityCollectionService for this entity type + * @param entityName - name of the entity type + */ + getServiceElements = EntitySelectors$>(entityName: string): EntityCollectionServiceElements { + entityName = entityName.trim(); + const definition = this.entityDefinitionService.getDefinition(entityName); + const dispatcher = this.entityDispatcherFactory.create(entityName, definition.selectId, definition.entityDispatcherOptions); + const selectors = this.entitySelectorsFactory.create(definition.metadata); + const selectors$ = this.entitySelectors$Factory.create(entityName, selectors); + return { + dispatcher, + entityName, + selectors, + selectors$ + }; + } +} diff --git a/lib/src/entity-services/entity-collection-service-factory.ts b/lib/src/entity-services/entity-collection-service-factory.ts new file mode 100644 index 00000000..892ecf6b --- /dev/null +++ b/lib/src/entity-services/entity-collection-service-factory.ts @@ -0,0 +1,25 @@ +import { Injectable } from '@angular/core'; +import { EntityCollectionService } from './entity-collection-service'; +import { EntityCollectionServiceBase } from './entity-collection-service-base'; +import { EntityCollectionServiceElementsFactory } from './entity-collection-service-elements-factory'; +import { EntitySelectors$ } from '../selectors/entity-selectors$'; + +/** + * Creates EntityCollectionService instances for + * a cached collection of T entities in the ngrx store. + */ +@Injectable() +export class EntityCollectionServiceFactory { + constructor( + /** Creates the core elements of the EntityCollectionService for an entity type. */ + public entityCollectionServiceElementsFactory: EntityCollectionServiceElementsFactory + ) {} + + /** + * Create an EntityCollectionService for an entity type + * @param entityName - name of the entity type + */ + create = EntitySelectors$>(entityName: string): EntityCollectionService { + return new EntityCollectionServiceBase(entityName, this.entityCollectionServiceElementsFactory); + } +} diff --git a/lib/src/entity-services/entity-collection-service.spec.ts b/lib/src/entity-services/entity-collection-service.spec.ts deleted file mode 100644 index 66f3cf29..00000000 --- a/lib/src/entity-services/entity-collection-service.spec.ts +++ /dev/null @@ -1,141 +0,0 @@ -/** TODO: much more testing */ -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { Action, StoreModule, Store } from '@ngrx/store'; -import { Actions } from '@ngrx/effects'; - -import { Subject } from 'rxjs'; - -import { EntityAction, EntityActionFactory } from '../actions/entity-action'; -import { EntityOp } from '../actions/entity-op'; - -import { EntityCache } from '../reducers/entity-cache'; -import { EntityCollection } from '../reducers/entity-collection'; -import { ENTITY_METADATA_TOKEN } from '../entity-metadata/entity-metadata'; -import { - EntityCollectionService, - EntityCollectionServiceFactory -} from './entity-services-interfaces'; - -import { NgrxDataModuleWithoutEffects } from '../ngrx-data-without-effects.module'; - -import { commandDispatchTest } from '../dispatchers/entity-dispatcher.spec'; - -class Hero { - id: number; - name: string; - saying?: string; -} - -describe('EntityCollectionService', () => { - describe('Commands', () => { - commandDispatchTest(heroDispatcherSetup); - }); - - describe('Selectors$', () => { - let entityCollectionServiceFactory: EntityCollectionServiceFactory; - let heroService: EntityCollectionService; - let store: Store; - let createAction: ( - entityName: string, - op: EntityOp, - payload: any - ) => EntityAction; - - function dispatchedAction() { - return (store.dispatch).calls.argsFor(0)[0]; - } - - beforeEach(() => { - // Note: bug in linter is responsible for this tortured syntax. - const factorySetup = entityServiceFactorySetup(); - const { entityActionFactory, testStore } = factorySetup; - entityCollectionServiceFactory = - factorySetup.entityCollectionServiceFactory; - heroService = entityCollectionServiceFactory.create('Hero'); - store = testStore; - createAction = entityActionFactory.create.bind(entityActionFactory); - }); - - it('can get collection from collection$', () => { - let collection: EntityCollection; - const action = createAction('Hero', EntityOp.ADD_ALL, [ - { id: 1, name: 'A' } - ]); - store.dispatch(action); - heroService.collection$.subscribe(c => { - collection = c; - }); - - expect(collection.ids).toEqual([1]); - }); - - it('`EntityCollectionServiceFactory.entityCache$` observes the entire entity cache', () => { - const entityCacheValues: any = []; - - entityCollectionServiceFactory.entityCache$.subscribe(ec => - entityCacheValues.push(ec) - ); - - // An action that goes through the Hero's EntityCollectionReducer - // creates the collection in the store as a side-effect - const heroAction = createAction('Hero', EntityOp.SET_FILTER, 'test'); - store.dispatch(heroAction); - - expect(entityCacheValues.length).toEqual(2, 'set the cache twice'); - expect(entityCacheValues[0]).toEqual({}, 'empty at first'); - expect(entityCacheValues[1].Hero).toBeDefined('has Hero collection'); - }); - }); -}); - -// region test helpers -const heroMetadata = { - entityName: 'Hero' -}; - -function entityServiceFactorySetup() { - const actions$ = new Subject(); - - TestBed.configureTestingModule({ - imports: [StoreModule.forRoot({}), NgrxDataModuleWithoutEffects], - providers: [ - { provide: Actions, useValue: actions$ }, - { - provide: ENTITY_METADATA_TOKEN, - multi: true, - useValue: { - Hero: heroMetadata - } - } - ] - }); - - const testStore: Store = TestBed.get(Store); - spyOn(testStore, 'dispatch').and.callThrough(); - - const entityActionFactory: EntityActionFactory = TestBed.get( - EntityActionFactory - ); - const entityCollectionServiceFactory: EntityCollectionServiceFactory = TestBed.get( - EntityCollectionServiceFactory - ); - - return { - actions$, - entityActionFactory, - entityCollectionServiceFactory, - testStore - }; -} - -function heroDispatcherSetup() { - const { - entityCollectionServiceFactory, - testStore - } = entityServiceFactorySetup(); - const dispatcher: EntityCollectionService< - Hero - > = entityCollectionServiceFactory.create('Hero'); - return { dispatcher, testStore }; -} -// endregion test helpers diff --git a/lib/src/entity-services/entity-collection-service.ts b/lib/src/entity-services/entity-collection-service.ts new file mode 100644 index 00000000..a816c168 --- /dev/null +++ b/lib/src/entity-services/entity-collection-service.ts @@ -0,0 +1,45 @@ +import { EntityAction, EntityActionOptions } from '../actions/entity-action'; +import { EntityCommands } from '../dispatchers/entity-commands'; +import { EntityDispatcher } from '../dispatchers/entity-dispatcher'; +import { EntityOp } from '../actions/entity-op'; +import { EntitySelectors$ } from '../selectors/entity-selectors$'; +import { EntitySelectors } from '../selectors/entity-selectors'; + +// tslint:disable:member-ordering + +/** + * A facade for managing + * a cached collection of T entities in the ngrx store. + */ +export interface EntityCollectionService extends EntityCommands, EntitySelectors$ { + /** + * Create an {EntityAction} for this entity type. + * @param op {EntityOp} the entity operation + * @param [data] the action data + * @param [options] additional options + * @returns the EntityAction + */ + createEntityAction(op: EntityOp, payload?: any, options?: EntityActionOptions): EntityAction; + + /** + * Create an {EntityAction} for this entity type and + * dispatch it immediately to the store. + * @param op {EntityOp} the entity operation + * @param [data] the action data + * @param [options] additional options + * @returns the dispatched EntityAction + */ + createAndDispatch

(op: EntityOp, data?: P, options?: EntityActionOptions): EntityAction

; + + /** Dispatcher of EntityCommands (EntityActions) */ + readonly dispatcher: EntityDispatcher; + + /** Name of the entity type for this collection service */ + readonly entityName: string; + + /** All selector functions of the entity collection */ + readonly selectors: EntitySelectors; + + /** All selectors$ (observables of the selectors of entity collection properties) */ + readonly selectors$: EntitySelectors$; +} diff --git a/lib/src/entity-services/entity-services-base.ts b/lib/src/entity-services/entity-services-base.ts index 43c4e7d2..ce46de57 100644 --- a/lib/src/entity-services/entity-services-base.ts +++ b/lib/src/entity-services/entity-services-base.ts @@ -3,34 +3,31 @@ import { Action, Store } from '@ngrx/store'; import { Observable } from 'rxjs'; +import { EntityAction } from '../actions/entity-action'; import { EntityCache } from '../reducers/entity-cache'; -import { - EntityCollectionService, - EntityCollectionServiceFactory, - EntityCollectionServiceMap, - EntityServices -} from './entity-services-interfaces'; +import { EntityCollectionService } from './entity-collection-service'; import { EntityCollectionServiceBase } from './entity-collection-service-base'; +import { EntityCollectionServiceFactory } from './entity-collection-service-factory'; +import { EntityCollectionServiceMap, EntityServices } from './entity-services'; import { EntitySelectorsFactory } from '../selectors/entity-selectors'; +import { EntitySelectors$, EntitySelectors$Factory } from '../selectors/entity-selectors$'; +import { EntityServicesElements } from './entity-services-elements'; + +// tslint:disable:member-ordering /** * Base/default class of a central registry of EntityCollectionServices for all entity types. - * Creates a new default EntityCollectionService for any entity type not in the registry. - * Optionally register specialized EntityCollectionServices for individual types - * * Create your own subclass to add app-specific members for an improved developer experience. * * @example * export class EntityServices extends EntityServicesBase { - * constructor( - * store: Store, - * entityCollectionServiceFactory: EntityCollectionServiceFactory) { - * super(store, entityCollectionServiceFactory); + * constructor(entityServicesElements: EntityServicesElements) { + * super(entityServicesElements); * } * // Extend with well-known, app entity collection services * // Convenience property to return a typed custom entity collection service * get companyService() { - * return this.getService('Company') as CompanyService; + * return this.getEntityCollectionService('Company') as CompanyService; * } * // Convenience dispatch methods * clearCompany(companyId: string) { @@ -40,22 +37,15 @@ import { EntitySelectorsFactory } from '../selectors/entity-selectors'; */ @Injectable() export class EntityServicesBase implements EntityServices { - /** Observable of the entire entity cache */ - readonly entityCache$: Observable | Store; - - private readonly EntityCollectionServices: { - [entityName: string]: EntityCollectionService; - } = {}; - - // Dear ngrx-data developer: think hard before changing the constructor - // as this will break many apps that derive from this base class as they are expected to do. - constructor( - /** The ngrx store, scoped to the EntityCache */ - public readonly store: Store, - /** Factory to create a default instance of an EntityCollectionService */ - public readonly entityCollectionServiceFactory: EntityCollectionServiceFactory - ) { - this.entityCache$ = entityCollectionServiceFactory.entityCache$; + // Dear ngrx-data developer: think hard before changing the constructor signature. + // Doing so will break apps that derive from this base class, + // and many apps will derive from this class. + constructor(entityServicesElements: EntityServicesElements) { + this.entityActionErrors$ = entityServicesElements.entitySelectors$Factory.entityActionErrors$; + this.entityCache$ = entityServicesElements.entitySelectors$Factory.entityCache$; + this.entityCollectionServiceFactory = entityServicesElements.entityCollectionServiceFactory; + this.reducedActions$ = entityServicesElements.entityDispatcherFactory.reducedActions$; + this.store = entityServicesElements.store; } /** Dispatch any action to the store */ @@ -63,28 +53,46 @@ export class EntityServicesBase implements EntityServices { this.store.dispatch(action); } + /** Observable of error EntityActions (e.g. QUERY_ALL_ERROR) for all entity types */ + readonly entityActionErrors$: Observable; + + /** Observable of the entire entity cache */ + readonly entityCache$: Observable | Store; + + /** Factory to create a default instance of an EntityCollectionService */ + private readonly entityCollectionServiceFactory: EntityCollectionServiceFactory; + + /** Registry of EntityCollectionService instances */ + private readonly EntityCollectionServices: EntityCollectionServiceMap = {}; + /** - * Create a default instance of an EntityCollectionService + * Actions scanned by the store after it processed them with reducers. + * A replay observable of the most recent action reduced by the store. + */ + readonly reducedActions$: Observable; + + /** The ngrx store, scoped to the EntityCache */ + protected readonly store: Store; + + /** + * Create a new default instance of an EntityCollectionService. + * Prefer getEntityCollectionService() unless you really want a new default instance. + * This one will NOT be registered with EntityServices! * @param entityName {string} Name of the entity type of the service */ - protected createEntityCollectionService( + protected createEntityCollectionService = EntitySelectors$>( entityName: string ): EntityCollectionService { - return new EntityCollectionServiceBase( - entityName, - this.entityCollectionServiceFactory - ); + return this.entityCollectionServiceFactory.create(entityName); } /** Get (or create) the singleton instance of an EntityCollectionService * @param entityName {string} Name of the entity type of the service */ - getEntityCollectionService( - entityName: string - ): EntityCollectionService { + getEntityCollectionService = EntitySelectors$>(entityName: string): EntityCollectionService { let service = this.EntityCollectionServices[entityName]; if (!service) { - service = this.createEntityCollectionService(entityName); + service = this.createEntityCollectionService(entityName); this.EntityCollectionServices[entityName] = service; } return service; @@ -95,10 +103,7 @@ export class EntityServicesBase implements EntityServices { * @param service {EntityCollectionService} The entity service * @param serviceName {string} optional service name to use instead of the service's entityName */ - registerEntityCollectionService( - service: EntityCollectionService, - serviceName?: string - ) { + registerEntityCollectionService(service: EntityCollectionService, serviceName?: string) { this.EntityCollectionServices[serviceName || service.entityName] = service; } @@ -108,21 +113,12 @@ export class EntityServicesBase implements EntityServices { * @param entityCollectionServices {EntityCollectionServiceMap | EntityCollectionService[]} * EntityCollectionServices to register, either as a map or an array */ - registerEntityCollectionServices( - entityCollectionServices: - | EntityCollectionServiceMap - | EntityCollectionService[] - ): void { + registerEntityCollectionServices(entityCollectionServices: EntityCollectionServiceMap | EntityCollectionService[]): void { if (Array.isArray(entityCollectionServices)) { - entityCollectionServices.forEach(service => - this.registerEntityCollectionService(service) - ); + entityCollectionServices.forEach(service => this.registerEntityCollectionService(service)); } else { Object.keys(entityCollectionServices || {}).forEach(serviceName => { - this.registerEntityCollectionService( - entityCollectionServices[serviceName], - serviceName - ); + this.registerEntityCollectionService(entityCollectionServices[serviceName], serviceName); }); } } diff --git a/lib/src/entity-services/entity-services-elements.ts b/lib/src/entity-services/entity-services-elements.ts new file mode 100644 index 00000000..d51b198f --- /dev/null +++ b/lib/src/entity-services/entity-services-elements.ts @@ -0,0 +1,25 @@ +import { Injectable } from '@angular/core'; +import { Store } from '@ngrx/store'; + +import { EntityCache } from '../reducers/entity-cache'; +import { EntityDispatcherFactory } from '../dispatchers/entity-dispatcher-factory'; +import { EntitySelectors$Factory } from '../selectors/entity-selectors$'; +import { EntityCollectionServiceFactory } from './entity-collection-service-factory'; + +/** Core ingredients of an EntityServices class */ +@Injectable() +export class EntityServicesElements { + constructor( + /** + * Creates EntityCollectionService instances for + * a cached collection of T entities in the ngrx store. + */ + public readonly entityCollectionServiceFactory: EntityCollectionServiceFactory, + /** Creates EntityDispatchers for entity collections */ + public readonly entityDispatcherFactory: EntityDispatcherFactory, + /** Creates observable EntitySelectors$ for entity collections. */ + public readonly entitySelectors$Factory: EntitySelectors$Factory, + /** The ngrx store, scoped to the EntityCache */ + public readonly store: Store + ) {} +} diff --git a/lib/src/entity-services/entity-services-interfaces.ts b/lib/src/entity-services/entity-services-interfaces.ts deleted file mode 100644 index 33f477df..00000000 --- a/lib/src/entity-services/entity-services-interfaces.ts +++ /dev/null @@ -1,145 +0,0 @@ -import { Action, Store } from '@ngrx/store'; - -import { Observable } from 'rxjs'; - -import { EntityAction } from '../actions/entity-action'; -import { EntityOp } from '../actions/entity-op'; -import { EntityCache } from '../reducers/entity-cache'; -import { EntityDispatcher } from '../dispatchers/entity-dispatcher'; -import { EntitySelectors } from '../selectors/entity-selectors'; -import { EntitySelectors$ } from '../selectors/entity-selectors$'; - -// tslint:disable:member-ordering - -/** - * Class-Interface for a central registry of EntityCollectionServices for all entity types, - * suitable as an Angular provider token. - * Creates a new default EntityCollectionService for any entity type not in the registry. - * Optionally register specialized EntityCollectionServices for individual types - */ -export abstract class EntityServices { - /** Observable of the entire entity cache */ - abstract readonly entityCache$: Observable | Store; - - /** The ngrx store, scoped to the EntityCache */ - abstract readonly store: Store; - - /** Dispatch any action to the store */ - abstract dispatch(action: Action): void; - - /** Get (or create) the singleton instance of an EntityCollectionService - * @param entityName {string} Name of the entity type of the service - */ - abstract getEntityCollectionService( - entityName: string - ): EntityCollectionService; - - //// EntityCollectionService creation and registration API ////// - - /** - * Factory to create a default instance of an EntityCollectionService - * Often called within constructor of a custom collection service - * @example - * constructor(private entityServices: EntityServices) { - * super('Hero', entityServices.entityCollectionServiceFactory); - * - * // Register self as THE hero service in EntityServices - * this.entityServices.registerEntityCollectionService('Hero', this); - * } - */ - abstract readonly entityCollectionServiceFactory: EntityCollectionServiceFactory; - - /** Register an EntityCollectionService under its entity type name. - * Will replace a pre-existing service for that type. - * @param service {EntityCollectionService} The entity service - */ - abstract registerEntityCollectionService( - service: EntityCollectionService - ): void; - - /** Register entity services for several entity types at once. - * Will replace a pre-existing service for that type. - * @param entityCollectionServices Array of EntityCollectionServices to register - */ - abstract registerEntityCollectionServices( - entityCollectionServices: EntityCollectionService[] - ): void; - - /** Register entity services for several entity types at once. - * Will replace a pre-existing service for that type. - * @param entityCollectionServiceMap Map of service-name to entity-collection-service - */ - abstract registerEntityCollectionServices( - // tslint:disable-next-line:unified-signatures - entityCollectionServiceMap: EntityCollectionServiceMap - ): void; -} - -/** - * A facade for managing - * a cached collection of T entities in the ngrx store. - */ -export interface EntityCollectionService - extends EntityDispatcher, - EntitySelectors$ { - /** Create an EntityAction for this collection */ - createEntityAction(op: EntityOp, payload?: any): EntityAction; - - /** - * Dispatch an action to the ngrx store. - * @param action the Action - */ - dispatch(action: Action): void; - - /** Name of the entity for this collection service */ - readonly entityName: string; - - /** All selector functions of the entity collection */ - readonly selectors: EntitySelectors; - - /** All selectors$ (observables of the selectors of entity collection properties) */ - readonly selectors$: EntitySelectors$; - - /** The Ngrx Store for the EntityCache */ - readonly store: Store; -} - -/** The API members of an EntityCollectionService */ -export interface EntityCollectionServiceElements< - T, - S$ extends EntitySelectors$ = EntitySelectors$ -> { - readonly entityName: string; - readonly dispatcher: EntityDispatcher; - readonly selectors: EntitySelectors; - readonly selectors$: S$; -} - -export abstract class EntityCollectionServiceFactory { - /** - * Create an EntityCollectionService for an entity type - * @param entityName - name of the entity type - */ - abstract create = EntitySelectors$>( - entityName: string - ): EntityCollectionService; - - /** Observable of the entire entity cache */ - readonly entityCache$: Observable | Store; - - /** - * Get the core sub-service elements. - * A helper method for EntityCollectionServiceFactory implementors. - */ - abstract getEntityCollectionServiceElements< - T, - S$ extends EntitySelectors$ = EntitySelectors$ - >(entityName: string): EntityCollectionServiceElements; -} - -/** - * A map of service or entity names to their corresponding EntityCollectionServices. - */ -export interface EntityCollectionServiceMap { - [entityName: string]: EntityCollectionService; -} diff --git a/lib/src/entity-services/entity-services.spec.ts b/lib/src/entity-services/entity-services.spec.ts new file mode 100644 index 00000000..60603c6e --- /dev/null +++ b/lib/src/entity-services/entity-services.spec.ts @@ -0,0 +1,616 @@ +import { Injectable } from '@angular/core'; +import { ComponentFixture, inject, TestBed } from '@angular/core/testing'; +import { HttpErrorResponse } from '@angular/common/http'; +import { Action, StoreModule, Store } from '@ngrx/store'; +import { Actions, EffectsModule } from '@ngrx/effects'; + +import { Observable, of, ReplaySubject, throwError, timer } from 'rxjs'; +import { delay, filter, first, mergeMap, skip, tap, withLatestFrom } from 'rxjs/operators'; + +import { DataServiceError, EntityActionDataServiceError } from '../dataservices/data-service-error'; +import { EntityAction, EntityActionOptions } from '../actions/entity-action'; +import { EntityActionFactory } from '../actions/entity-action-factory'; +import { EntityOp, makeErrorOp, OP_SUCCESS } from '../actions/entity-op'; +import { EntityCache } from '../reducers/entity-cache'; +import { EntityCacheQuerySet, MergeQuerySet } from '../actions/entity-cache-action'; +import { EntityCacheReducerFactory } from '../reducers/entity-cache-reducer-factory'; +import { EntityCollection } from '../reducers/entity-collection'; +import { EntityCollectionService } from './entity-collection-service'; +import { EntityCollectionDataService, EntityDataService } from '../dataservices/entity-data.service'; +import { EntityDispatcherDefaultOptions } from '../dispatchers/entity-dispatcher-default-options'; +import { EntityDispatcherFactory } from '../dispatchers/entity-dispatcher-factory'; +import { EntityMetadataMap } from '../entity-metadata/entity-metadata'; +import { EntityServices } from './entity-services'; +import { NgrxDataModule } from '../ngrx-data.module'; +import { HttpMethods } from '../dataservices/interfaces'; +import { Logger } from '../utils/interfaces'; +import { PersistanceCanceled } from '../dispatchers/entity-dispatcher'; + +import { commandDispatchTest } from '../dispatchers/entity-dispatcher.spec'; + +describe('EntityServices', () => { + describe('entityActionErrors$', () => { + it('should emit EntityAction errors for multiple entity types', () => { + const errors: EntityAction[] = []; + const { entityActionFactory, entityServices } = entityServicesSetup(); + entityServices.entityActionErrors$.subscribe(error => errors.push(error)); + + entityServices.dispatch({ type: 'not-an-entity-action' }); + entityServices.dispatch(entityActionFactory.create('Hero', EntityOp.QUERY_ALL)); // not an error + entityServices.dispatch( + entityActionFactory.create('Hero', EntityOp.QUERY_ALL_ERROR, makeDataServiceError('GET', new Error('Bad hero news'))) + ); + entityServices.dispatch(entityActionFactory.create('Villain', EntityOp.QUERY_ALL)); // not an error + entityServices.dispatch( + entityActionFactory.create('Villain', EntityOp.SAVE_ADD_ONE_ERROR, makeDataServiceError('PUT', new Error('Bad villain news'))) + ); + + expect(errors.length).toBe(2); + }); + }); + + describe('entityCache$', () => { + it('should observe the entire entity cache', () => { + const entityCacheValues: any = []; + + const { entityActionFactory, entityServices, store } = entityServicesSetup(); + + // entityCache$.subscribe() callback invoked immediately. The cache is empty at first. + entityServices.entityCache$.subscribe(ec => entityCacheValues.push(ec)); + + // This first action to go through the Hero's EntityCollectionReducer + // creates the collection in the EntityCache as a side-effect, + // triggering the second entityCache$.subscribe() callback + const heroAction = entityActionFactory.create('Hero', EntityOp.SET_FILTER, 'test'); + store.dispatch(heroAction); + + expect(entityCacheValues.length).toEqual(2, 'entityCache$ callback twice'); + expect(entityCacheValues[0]).toEqual({}, 'empty at first'); + expect(entityCacheValues[1].Hero).toBeDefined('has Hero collection'); + }); + }); + + describe('dispatch(MergeQuerySet)', () => { + // using async test to guard against false test pass. + it('should update entityCache$ twice after merging two individual collections', (done: DoneFn) => { + const hero1 = { id: 1, name: 'A' } as Hero; + const hero2 = { id: 2, name: 'B' } as Hero; + const heroes = [hero1, hero2]; + + const villain = { key: 'DE', name: 'Dr. Evil' } as Villain; + + const { entityServices, heroCollectionService } = entityServicesSetup(); + const villainCollectionService = entityServices.getEntityCollectionService('Villain'); + + const entityCacheValues: any = []; + entityServices.entityCache$.subscribe(cache => { + entityCacheValues.push(cache); + if (entityCacheValues.length === 3) { + expect(entityCacheValues[0]).toEqual({}, '#1 empty at first'); + expect(entityCacheValues[1]['Hero'].ids).toEqual([1, 2], '#2 has heroes'); + expect(entityCacheValues[1]['Villain']).toBeUndefined('#2 does not have Villain collection'); + expect(entityCacheValues[2]['Villain'].entities['DE']).toEqual(villain, '#3 has villain'); + done(); + } + }); + + heroCollectionService.createAndDispatch(EntityOp.ADD_MANY, heroes); + villainCollectionService.createAndDispatch(EntityOp.ADD_ONE, villain); + }); + + // using async test to guard against false test pass. + it('should update entityCache$ once when MergeQuerySet multiple collections', (done: DoneFn) => { + const hero1 = { id: 1, name: 'A' } as Hero; + const hero2 = { id: 2, name: 'B' } as Hero; + const heroes = [hero1, hero2]; + const villain = { key: 'DE', name: 'Dr. Evil' } as Villain; + const querySet: EntityCacheQuerySet = { + Hero: heroes, + Villain: [villain] + }; + const action = new MergeQuerySet(querySet); + + const { entityServices } = entityServicesSetup(); + + // Skip initial value. Want the first one after merge is dispatched + entityServices.entityCache$.pipe(skip(1), first()).subscribe(cache => { + expect(cache['Hero'].ids).toEqual([1, 2], 'has merged heroes'); + expect(cache['Villain'].entities['DE']).toEqual(villain, 'has merged villain'); + done(); + }); + entityServices.dispatch(action); + }); + }); + + describe('EntityCollectionService', () => { + describe('Command dispatching', () => { + // Borrowing the dispatcher tests from entity-dispatcher.spec. + // The critical difference: those test didn't invoke the reducers; they do when run here. + commandDispatchTest(getDispatcher); + + function getDispatcher() { + const { heroCollectionService, store } = entityServicesSetup(); + const dispatcher = heroCollectionService.dispatcher; + return { dispatcher, store }; + } + }); + + // TODO: test the effect of MergeStrategy when there are entities in cache with changes + // This concern is largely met by EntityChangeTracker tests but integration tests would be reassuring. + describe('queries', () => { + let heroCollectionService: EntityCollectionService; + let dataService: TestDataService; + let reducedActions$Snoop: () => void; + + beforeEach(() => { + ({ heroCollectionService, reducedActions$Snoop, dataService } = entityServicesSetup()); + }); + + // Compare to next test which subscribes to getAll() result + it('can use loading$ to learn when getAll() succeeds', (done: DoneFn) => { + const hero1 = { id: 1, name: 'A' } as Hero; + const hero2 = { id: 2, name: 'B' } as Hero; + const heroes = [hero1, hero2]; + dataService.setResponse('getAll', heroes); + heroCollectionService.getAll(); + + // N.B.: This technique does not detect errors + heroCollectionService.loading$ + .pipe(filter(loading => !loading), withLatestFrom(heroCollectionService.entities$)) + .subscribe(([loading, data]) => { + expect(data).toEqual(heroes); + done(); + }); + }); + + // Compare to previous test the waits for loading$ flag to flip + it('getAll observable should emit heroes on success', (done: DoneFn) => { + const hero1 = { id: 1, name: 'A' } as Hero; + const hero2 = { id: 2, name: 'B' } as Hero; + const heroes = [hero1, hero2]; + dataService.setResponse('getAll', heroes); + heroCollectionService.getAll().subscribe(expectDataToBe(heroes, done)); + + // reducedActions$Snoop(); // diagnostic + }); + + it('getAll observable should emit expected error when data service fails', (done: DoneFn) => { + const httpError = { error: new Error('Test Failure'), status: 501 }; + const error = makeDataServiceError('GET', httpError); + dataService.setErrorResponse('getAll', error); + heroCollectionService.getAll().subscribe(expectErrorToBe(error, done)); + }); + + it('getByKey observable should emit a hero on success', (done: DoneFn) => { + const hero = { id: 1, name: 'A' } as Hero; + dataService.setResponse('getById', hero); + heroCollectionService.getByKey(1).subscribe(expectDataToBe(hero, done)); + }); + + it('getByKey observable should emit expected error when data service fails', (done: DoneFn) => { + // Simulate HTTP 'Not Found' response + const httpError = new HttpErrorResponse({ + error: 'Entity not found', + status: 404, + statusText: 'Not Found', + url: 'bad/location' + }); + + // For test purposes, the following would have been effectively the same thing + // const httpError = { error: new Error('Entity not found'), status: 404 }; + + const error = makeDataServiceError('GET', httpError); + dataService.setErrorResponse('getById', error); + heroCollectionService.getByKey(42).subscribe(expectErrorToBe(error, done)); + }); + + it('getWithQuery observable should emit heroes on success', (done: DoneFn) => { + const hero1 = { id: 1, name: 'A' } as Hero; + const hero2 = { id: 2, name: 'B' } as Hero; + const heroes = [hero1, hero2]; + dataService.setResponse('getWithQuery', heroes); + heroCollectionService.getWithQuery({ name: 'foo' }).subscribe(expectDataToBe(heroes, done)); + + // reducedActions$Snoop(); // diagnostic + }); + + it('getWithQuery observable should emit expected error when data service fails', (done: DoneFn) => { + const httpError = { error: new Error('Test Failure'), status: 501 }; + const error = makeDataServiceError('GET', httpError); + dataService.setErrorResponse('getWithQuery', error); + heroCollectionService.getWithQuery({ name: 'foo' }).subscribe(expectErrorToBe(error, done)); + }); + + it('load observable should emit heroes on success', (done: DoneFn) => { + const hero1 = { id: 1, name: 'A' } as Hero; + const hero2 = { id: 2, name: 'B' } as Hero; + const heroes = [hero1, hero2]; + dataService.setResponse('getAll', heroes); + heroCollectionService.load().subscribe(expectDataToBe(heroes, done)); + }); + + it('load observable should emit expected error when data service fails', (done: DoneFn) => { + const httpError = { error: new Error('Test Failure'), status: 501 }; + const error = makeDataServiceError('GET', httpError); + dataService.setErrorResponse('getAll', error); + heroCollectionService.load().subscribe(expectErrorToBe(error, done)); + }); + }); + + describe('cancel', () => { + const hero1 = { id: 1, name: 'A' } as Hero; + const hero2 = { id: 2, name: 'B' } as Hero; + const heroes = [hero1, hero2]; + + let heroCollectionService: EntityCollectionService; + let dataService: TestDataService; + let reducedActions$Snoop: () => void; + + beforeEach(() => { + ({ dataService, heroCollectionService, reducedActions$Snoop } = entityServicesSetup()); + }); + + it('can cancel a long running query', (done: DoneFn) => { + const responseDelay = 4; + dataService['getAll'].and.returnValue(of(heroes).pipe(delay(responseDelay))); + + // Create the correlation id yourself to know which action to cancel. + const correlationId = 'CRID007'; + const options: EntityActionOptions = { correlationId }; + heroCollectionService.getAll(options).subscribe( + data => fail('should not have data but got data'), + error => { + expect(error instanceof PersistanceCanceled).toBe(true, 'is PersistanceCanceled'); + expect(error.message).toBe('Test cancel'); + done(); + } + ); + + heroCollectionService.cancel(correlationId, 'Test cancel'); + }); + + it('has no effect on action with different correlationId', (done: DoneFn) => { + const responseDelay = 4; + dataService['getAll'].and.returnValue(of(heroes).pipe(delay(responseDelay))); + + const correlationId = 'CRID007'; + const options: EntityActionOptions = { correlationId }; + heroCollectionService.getAll(options).subscribe(data => { + expect(data).toEqual(heroes); + done(); + }, fail); + + heroCollectionService.cancel('not-the-crid'); + }); + + it('has no effect when too late', (done: DoneFn) => { + const responseDelay = 4; + dataService['getAll'].and.returnValue(of(heroes).pipe(delay(responseDelay))); + + const correlationId = 'CRID007'; + const options: EntityActionOptions = { correlationId }; + heroCollectionService.getAll(options).subscribe(data => expect(data).toEqual(heroes), fail); + + setTimeout(() => heroCollectionService.cancel(correlationId), responseDelay + 2); + setTimeout(done, responseDelay + 4); // wait for all to complete + }); + }); + + describe('saves (optimistic)', () => { + beforeEach(() => { + TestBed.configureTestingModule({ + /* tslint:disable-next-line:no-use-before-declare */ + providers: [{ provide: EntityDispatcherDefaultOptions, useClass: OptimisticDispatcherDefaultOptions }] + }); + }); + + combinedSaveTests(true); + }); + + describe('saves (pessimistic)', () => { + beforeEach(() => { + TestBed.configureTestingModule({ + /* tslint:disable-next-line:no-use-before-declare */ + providers: [{ provide: EntityDispatcherDefaultOptions, useClass: PessimisticDispatcherDefaultOptions }] + }); + }); + + combinedSaveTests(false); + }); + + /** Save tests to be run both optimistically and pessimistically */ + function combinedSaveTests(isOptimistic: boolean) { + let heroCollectionService: EntityCollectionService; + let dataService: TestDataService; + let expectOptimisticSuccess: (expect: boolean) => () => void; + let reducedActions$Snoop: () => void; + let successActions$: Observable; + + beforeEach(() => { + ({ dataService, expectOptimisticSuccess, heroCollectionService, reducedActions$Snoop, successActions$ } = entityServicesSetup()); + }); + + it('add() should save a new entity and return it', (done: DoneFn) => { + const extra = expectOptimisticSuccess(isOptimistic); + const hero = { id: 1, name: 'A' } as Hero; + dataService.setResponse('add', hero); + heroCollectionService.add(hero).subscribe(expectDataToBe(hero, done, undefined, extra)); + }); + + it('add() observable should emit expected error when data service fails', (done: DoneFn) => { + const hero = { id: 1, name: 'A' } as Hero; + const httpError = { error: new Error('Test Failure'), status: 501 }; + const error = makeDataServiceError('PUT', httpError); + dataService.setErrorResponse('add', error); + heroCollectionService.add(hero).subscribe(expectErrorToBe(error, done)); + }); + + it('delete() should send delete for entity not in cache and return its id', (done: DoneFn) => { + const extra = expectOptimisticSuccess(isOptimistic); + dataService.setResponse('delete', 42); + heroCollectionService.delete(42).subscribe(expectDataToBe(42, done, undefined, extra)); + }); + + it('delete() should skip delete for added entity cache', (done: DoneFn) => { + // reducedActions$Snoop(); + let wasSkipped: boolean; + successActions$.subscribe((act: EntityAction) => (wasSkipped = act.payload.skip === true)); + const extra = () => expect(wasSkipped).toBe(true, 'expected to be skipped'); + + const hero = { id: 1, name: 'A' } as Hero; + heroCollectionService.addOneToCache(hero); + dataService.setResponse('delete', 1); + heroCollectionService.delete(1).subscribe(expectDataToBe(1, done, undefined, extra)); + }); + + it('delete() observable should emit expected error when data service fails', (done: DoneFn) => { + const httpError = { error: new Error('Test Failure'), status: 501 }; + const error = makeDataServiceError('DELETE', httpError); + dataService.setErrorResponse('delete', error); + heroCollectionService.delete(42).subscribe(expectErrorToBe(error, done)); + }); + + it('update() should save updated entity and return it', (done: DoneFn) => { + const extra = expectOptimisticSuccess(isOptimistic); + const preUpdate = { id: 1, name: 'A' } as Hero; + heroCollectionService.addAllToCache([preUpdate]); // populate cache + const update = { ...preUpdate, name: 'Updated A' }; + dataService.setResponse('update', null); // server returns nothing after update + heroCollectionService.update(update).subscribe(expectDataToBe(update, done, undefined, extra)); + }); + + it('update() observable should emit expected error when data service fails', (done: DoneFn) => { + const preUpdate = { id: 1, name: 'A' } as Hero; + heroCollectionService.addAllToCache([preUpdate]); // populate cache + const update = { ...preUpdate, name: 'Updated A' }; + const httpError = { error: new Error('Test Failure'), status: 501 }; + const error = makeDataServiceError('PUT', httpError); + dataService.setErrorResponse('update', error); + heroCollectionService.update(update).subscribe(expectErrorToBe(error, done)); + }); + + it('can handle out-of-order save results', (done: DoneFn) => { + const hero1 = { id: 1, name: 'A' } as Hero; + const hero2 = { id: 2, name: 'B' } as Hero; + let successActionCount = 0; + const delayMs = 5; + let responseDelay = delayMs; + const savedHeroes: Hero[] = []; + + successActions$.pipe(delay(1)).subscribe(act => { + successActionCount += 1; + if (successActionCount === 2) { + // Confirm hero2 actually saved before hero1 + expect(savedHeroes).toEqual([hero2, hero1], 'savedHeroes'); + done(); + } + }); + + // dataService.add returns odd responses later than even responses + // so add of hero2 should complete before add of hero1 + dataService['add'].and.callFake((data: Hero) => { + const result = of(data).pipe(delay(responseDelay), tap(h => savedHeroes.push(h))); + responseDelay = delayMs === responseDelay ? 1 : responseDelay; + return result; + }); + + // Save hero1 before hero2 + // Confirm that each add returns with its own hero + heroCollectionService.add(hero1).subscribe(data => expect(data).toEqual(hero1, 'first hero')); + + heroCollectionService.add(hero2).subscribe(data => expect(data).toEqual(hero2, 'second hero')); + }); + } + + describe('selectors$', () => { + let entityActionFactory: EntityActionFactory; + let heroCollectionService: EntityCollectionService; + let store: Store; + + function dispatchedAction() { + return (store.dispatch).calls.argsFor(0)[0]; + } + + beforeEach(() => { + const setup = entityServicesSetup(); + ({ entityActionFactory, heroCollectionService, store } = setup); + spyOn(store, 'dispatch').and.callThrough(); + }); + + it('can get collection from collection$', () => { + let collection: EntityCollection; + const action = entityActionFactory.create('Hero', EntityOp.ADD_ALL, [{ id: 1, name: 'A' }]); + store.dispatch(action); + heroCollectionService.collection$.subscribe(c => { + collection = c; + }); + + expect(collection.ids).toEqual([1]); + }); + }); + }); +}); + +// #region test helpers +class Hero { + id: number; + name: string; + saying?: string; +} +class Villain { + key: string; + name: string; +} + +const entityMetadata: EntityMetadataMap = { + Hero: {}, + Villain: { selectId: (villain: Villain) => villain.key } +}; + +function entityServicesSetup() { + const logger = jasmine.createSpyObj('Logger', ['error', 'log', 'warn']); + + TestBed.configureTestingModule({ + imports: [ + StoreModule.forRoot({}), + EffectsModule.forRoot([]), + NgrxDataModule.forRoot({ + entityMetadata: entityMetadata + }) + ], + /* tslint:disable-next-line:no-use-before-declare */ + providers: [{ provide: EntityDataService, useClass: TestDataService }, { provide: Logger, useValue: logger }] + }); + + const actions$: Observable = TestBed.get(Actions); + const dataService: TestDataService = TestBed.get(EntityDataService); + const entityActionFactory: EntityActionFactory = TestBed.get(EntityActionFactory); + const entityDispatcherFactory: EntityDispatcherFactory = TestBed.get(EntityDispatcherFactory); + const entityServices: EntityServices = TestBed.get(EntityServices); + const heroCollectionService = entityServices.getEntityCollectionService('Hero'); + const reducedActions$: Observable = entityDispatcherFactory.reducedActions$; + const store: Store = TestBed.get(Store); + const successActions$: Observable = reducedActions$.pipe( + filter((act: any) => act.payload && act.payload.entityOp.endsWith(OP_SUCCESS)) + ); + + /** Returns fn that confirms EntityAction was (or was not Optimistic) after success */ + function expectOptimisticSuccess(expected: boolean) { + let wasOptimistic: boolean; + const msg = `${expected ? 'Optimistic' : 'Pessimistic'} save `; + successActions$.subscribe((act: EntityAction) => (wasOptimistic = act.payload.isOptimistic === true)); + return () => expect(wasOptimistic).toBe(expected, msg); + } + + /** Snoop on reducedActions$ while debugging a test */ + function reducedActions$Snoop() { + reducedActions$.subscribe(act => { + console.log('scannedActions$', act); + }); + } + + return { + actions$, + dataService, + entityActionFactory, + entityServices, + expectOptimisticSuccess, + heroCollectionService, + reducedActions$, + reducedActions$Snoop, + store, + successActions$ + }; +} + +function expectDataToBe(expected: any, done: DoneFn, message?: string, extra?: () => void) { + return { + next: (data: any) => { + expect(data).toEqual(expected, message); + if (extra) { + extra(); // extra expectations before done + } + done(); + }, + error: fail + }; +} + +function expectErrorToBe(expected: any, done: DoneFn, message?: string) { + return { + next: (data: any) => { + fail(`Expected error response but got data: '${JSON.stringify(data)}'`); + done(); + }, + error: (error: any) => { + expect(error).toEqual(expected, message); + done(); + } + }; +} + +/** make error produced by the EntityDataService */ +function makeDataServiceError( + /** Http method for that action */ + method: HttpMethods, + /** Http error from the web api */ + httpError?: any, + /** Options sent with the request */ + options?: any +) { + let url = 'api/heroes'; + if (httpError) { + url = httpError.url || url; + } else { + httpError = { error: new Error('Test error'), status: 500, url }; + } + return new DataServiceError(httpError, { method, url, options }); +} + +@Injectable() +export class OptimisticDispatcherDefaultOptions { + optimisticAdd = true; + optimisticDelete = true; + optimisticUpdate = true; +} + +@Injectable() +export class PessimisticDispatcherDefaultOptions { + optimisticAdd = false; + optimisticDelete = false; + optimisticUpdate = false; +} + +export interface TestDataServiceMethod { + add: jasmine.Spy; + delete: jasmine.Spy; + getAll: jasmine.Spy; + getById: jasmine.Spy; + getWithQuery: jasmine.Spy; + update: jasmine.Spy; +} + +export class TestDataService { + add = jasmine.createSpy('add'); + delete = jasmine.createSpy('delete'); + getAll = jasmine.createSpy('getAll'); + getById = jasmine.createSpy('getById'); + getWithQuery = jasmine.createSpy('getWithQuery'); + update = jasmine.createSpy('update'); + + getService(): TestDataServiceMethod { + return this; + } + + setResponse(methodName: keyof TestDataServiceMethod, data: any) { + this[methodName].and.returnValue(of(data).pipe(delay(1))); + } + + setErrorResponse(methodName: keyof TestDataServiceMethod, error: any) { + // Following won't quite work because delay does not appear to delay an error + // this[methodName].and.returnValue(throwError(error).pipe(delay(1))); + // Use timer instead + this[methodName].and.returnValue(timer(1).pipe(mergeMap(() => throwError(error)))); + } +} +// #endregion test helpers diff --git a/lib/src/entity-services/entity-services.ts b/lib/src/entity-services/entity-services.ts new file mode 100644 index 00000000..882a63d0 --- /dev/null +++ b/lib/src/entity-services/entity-services.ts @@ -0,0 +1,69 @@ +import { Action, Store } from '@ngrx/store'; +import { Observable } from 'rxjs'; + +import { EntityAction } from '../actions/entity-action'; +import { EntityCache } from '../reducers/entity-cache'; +import { EntityCollectionService } from './entity-collection-service'; +import { EntityCollectionServiceFactory } from './entity-collection-service-factory'; + +// tslint:disable:member-ordering + +/** + * Class-Interface for EntityCache and EntityCollection services. + * Serves as an Angular provider token for this service class. + * Includes a registry of EntityCollectionServices for all entity types. + * Creates a new default EntityCollectionService for any entity type not in the registry. + * Optionally register specialized EntityCollectionServices for individual types + */ +export abstract class EntityServices { + /** Dispatch any action to the store */ + abstract dispatch(action: Action): void; + + /** Observable of error EntityActions (e.g. QUERY_ALL_ERROR) for all entity types */ + abstract readonly entityActionErrors$: Observable; + + /** Observable of the entire entity cache */ + abstract readonly entityCache$: Observable | Store; + + /** Get (or create) the singleton instance of an EntityCollectionService + * @param entityName {string} Name of the entity type of the service + */ + abstract getEntityCollectionService(entityName: string): EntityCollectionService; + + /** + * Actions scanned by the store after it processed them with reducers. + * A replay observable of the most recent Action (not just EntityAction) reduced by the store. + */ + abstract readonly reducedActions$: Observable; + + // #region EntityCollectionService creation and registration API + + /** Register an EntityCollectionService under its entity type name. + * Will replace a pre-existing service for that type. + * @param service {EntityCollectionService} The entity service + */ + abstract registerEntityCollectionService(service: EntityCollectionService): void; + + /** Register entity services for several entity types at once. + * Will replace a pre-existing service for that type. + * @param entityCollectionServices Array of EntityCollectionServices to register + */ + abstract registerEntityCollectionServices(entityCollectionServices: EntityCollectionService[]): void; + + /** Register entity services for several entity types at once. + * Will replace a pre-existing service for that type. + * @param entityCollectionServiceMap Map of service-name to entity-collection-service + */ + abstract registerEntityCollectionServices( + // tslint:disable-next-line:unified-signatures + entityCollectionServiceMap: EntityCollectionServiceMap + ): void; + // #endregion EntityCollectionService creation and registration API +} + +/** + * A map of service or entity names to their corresponding EntityCollectionServices. + */ +export interface EntityCollectionServiceMap { + [entityName: string]: EntityCollectionService; +} diff --git a/lib/src/index.ts b/lib/src/index.ts index fd025a57..c3508b03 100644 --- a/lib/src/index.ts +++ b/lib/src/index.ts @@ -2,10 +2,12 @@ // NO BARRELS or else `ng build --aot` of any app using ngrx-data produces strange errors // actions export * from './actions/entity-action'; -export * from './actions/entity-action-operators'; +export * from './actions/entity-action-factory'; export * from './actions/entity-action-guard'; -export * from './actions/entity-cache-actions'; +export * from './actions/entity-action-operators'; +export * from './actions/entity-cache-action'; export * from './actions/entity-op'; +export * from './actions/merge-strategy'; // dataservices export * from './dataservices/data-service-error'; @@ -16,8 +18,10 @@ export * from './dataservices/interfaces'; export * from './dataservices/persistence-result-handler.service'; // dispatchers +export * from './dispatchers/entity-dispatcher-default-options'; export * from './dispatchers/entity-commands'; export * from './dispatchers/entity-dispatcher'; +export * from './dispatchers/entity-dispatcher-base'; export * from './dispatchers/entity-dispatcher-factory'; // effects @@ -30,20 +34,25 @@ export * from './entity-metadata/entity-filters'; export * from './entity-metadata/entity-metadata'; // entity-services -export * from './entity-services/default-entity-collection-service-factory'; -export * from './entity-services/entity-services-interfaces'; +export * from './entity-services/entity-collection-service'; export * from './entity-services/entity-collection-service-base'; +export * from './entity-services/entity-collection-service-elements-factory'; +export * from './entity-services/entity-collection-service-factory'; +export * from './entity-services/entity-services'; export * from './entity-services/entity-services-base'; +export * from './entity-services/entity-services-elements'; // reducers export * from './reducers/constants'; -export * from './reducers/default-entity-collection-reducer-methods'; +export * from './reducers/entity-change-tracker-base'; +export * from './reducers/entity-collection-reducer-methods'; export * from './reducers/entity-cache'; +export * from './reducers/entity-cache-reducer-factory'; export * from './reducers/entity-change-tracker'; export * from './reducers/entity-collection'; -export * from './reducers/entity-collection-reducer'; export * from './reducers/entity-collection-creator'; -export * from './reducers/entity-reducer'; +export * from './reducers/entity-collection-reducer'; +export * from './reducers/entity-collection-reducer-registry'; // selectors export * from './selectors/entity-selectors'; @@ -51,15 +60,14 @@ export * from './selectors/entity-selectors$'; export * from './selectors/entity-cache-selector'; // Utils -export * from './utils/ngrx-entity-models'; // should be exported by @ngrx/entity -export * from './utils/interfaces'; +export * from './utils/correlation-id-generator'; export * from './utils/default-logger'; export * from './utils/default-pluralizer'; +export * from './utils/guid-fns'; +export * from './utils/interfaces'; +export * from './utils/ngrx-entity-models'; // should be exported by @ngrx/entity export * from './utils/utilities'; // NgrxDataModule export { NgrxDataModule } from './ngrx-data.module'; -export { - NgrxDataModuleWithoutEffects, - NgrxDataModuleConfig -} from './ngrx-data-without-effects.module'; +export { NgrxDataModuleWithoutEffects, NgrxDataModuleConfig } from './ngrx-data-without-effects.module'; diff --git a/lib/src/ngrx-data-without-effects.module.ts b/lib/src/ngrx-data-without-effects.module.ts index 51705fd8..b7baf78e 100644 --- a/lib/src/ngrx-data-without-effects.module.ts +++ b/lib/src/ngrx-data-without-effects.module.ts @@ -1,74 +1,48 @@ -import { - ModuleWithProviders, - NgModule, - Inject, - Injector, - InjectionToken, - Optional, - OnDestroy -} from '@angular/core'; - -import { - Action, - ActionReducer, - combineReducers, - MetaReducer, - ReducerManager, - StoreModule -} from '@ngrx/store'; - -import { EntityAction, EntityActionFactory } from './actions/entity-action'; - -import { EntityDispatcherFactory } from './dispatchers/entity-dispatcher-factory'; +import { ModuleWithProviders, NgModule, Inject, Injector, InjectionToken, Optional, OnDestroy } from '@angular/core'; -import { EntityDefinitionService } from './entity-metadata/entity-definition.service'; -import { - EntityMetadataMap, - ENTITY_METADATA_TOKEN -} from './entity-metadata/entity-metadata'; +import { Action, ActionReducer, combineReducers, MetaReducer, ReducerManager, StoreModule } from '@ngrx/store'; +import { CorrelationIdGenerator } from './utils/correlation-id-generator'; +import { EntityDispatcherDefaultOptions } from './dispatchers/entity-dispatcher-default-options'; +import { EntityAction } from './actions/entity-action'; +import { EntityActionFactory } from './actions/entity-action-factory'; import { EntityCache } from './reducers/entity-cache'; import { entityCacheSelectorProvider } from './selectors/entity-cache-selector'; -import { EntityCollectionServiceFactory } from './entity-services/entity-services-interfaces'; -import { DefaultEntityCollectionServiceFactory } from './entity-services/default-entity-collection-service-factory'; +import { EntityCollectionService } from './entity-services/entity-collection-service'; +import { EntityCollectionServiceElementsFactory } from './entity-services/entity-collection-service-elements-factory'; +import { EntityCollectionServiceFactory } from './entity-services/entity-collection-service-factory'; +import { EntityCollectionServiceMap, EntityServices } from './entity-services/entity-services'; import { EntityCollection } from './reducers/entity-collection'; import { EntityCollectionCreator } from './reducers/entity-collection-creator'; -import { - EntityCollectionReducerFactory, - EntityCollectionReducerMethodsFactory -} from './reducers/entity-collection-reducer'; +import { EntityCollectionReducerFactory } from './reducers/entity-collection-reducer'; +import { EntityCollectionReducerMethodsFactory } from './reducers/entity-collection-reducer-methods'; +import { EntityCollectionReducerRegistry } from './reducers/entity-collection-reducer-registry'; +import { EntityDispatcherFactory } from './dispatchers/entity-dispatcher-factory'; +import { EntityDefinitionService } from './entity-metadata/entity-definition.service'; import { EntityEffects } from './effects/entity-effects'; +import { EntityMetadataMap, ENTITY_METADATA_TOKEN } from './entity-metadata/entity-metadata'; -import { DefaultEntityCollectionReducerMethodsFactory } from './reducers/default-entity-collection-reducer-methods'; -import { - createEntityReducer, - EntityReducerFactory -} from './reducers/entity-reducer'; +import { EntityCacheReducerFactory } from './reducers/entity-cache-reducer-factory'; import { ENTITY_CACHE_NAME, ENTITY_CACHE_NAME_TOKEN, ENTITY_CACHE_META_REDUCERS, ENTITY_COLLECTION_META_REDUCERS, - ENTITY_CACHE_REDUCER, INITIAL_ENTITY_CACHE_STATE } from './reducers/constants'; -import { Logger, Pluralizer, PLURAL_NAMES_TOKEN } from './utils/interfaces'; - +import { DefaultLogger } from './utils/default-logger'; +import { DefaultPluralizer } from './utils/default-pluralizer'; import { EntitySelectors } from './selectors/entity-selectors'; import { EntitySelectorsFactory } from './selectors/entity-selectors'; import { EntitySelectors$Factory } from './selectors/entity-selectors$'; -import { EntityServices } from './entity-services/entity-services-interfaces'; import { EntityServicesBase } from './entity-services/entity-services-base'; - -import { DefaultLogger } from './utils/default-logger'; -import { DefaultPluralizer } from './utils/default-pluralizer'; +import { EntityServicesElements } from './entity-services/entity-services-elements'; +import { Logger, Pluralizer, PLURAL_NAMES_TOKEN } from './utils/interfaces'; export interface NgrxDataModuleConfig { entityMetadata?: EntityMetadataMap; - entityCacheMetaReducers?: ( - | MetaReducer - | InjectionToken>)[]; + entityCacheMetaReducers?: (MetaReducer | InjectionToken>)[]; entityCollectionMetaReducers?: MetaReducer[]; // Initial EntityCache state or a function that returns that state initialEntityCacheState?: EntityCache | (() => EntityCache); @@ -86,33 +60,24 @@ export interface NgrxDataModuleConfig { StoreModule // rely on Store feature providers rather than Store.forFeature() ], providers: [ + CorrelationIdGenerator, + EntityDispatcherDefaultOptions, EntityActionFactory, + EntityCacheReducerFactory, entityCacheSelectorProvider, EntityCollectionCreator, EntityCollectionReducerFactory, + EntityCollectionReducerMethodsFactory, + EntityCollectionReducerRegistry, + EntityCollectionServiceElementsFactory, + EntityCollectionServiceFactory, EntityDefinitionService, EntityDispatcherFactory, - EntityReducerFactory, EntitySelectorsFactory, EntitySelectors$Factory, - { - provide: EntityCollectionReducerMethodsFactory, - useClass: DefaultEntityCollectionReducerMethodsFactory - }, + EntityServicesElements, { provide: ENTITY_CACHE_NAME_TOKEN, useValue: ENTITY_CACHE_NAME }, - { - provide: ENTITY_CACHE_REDUCER, - deps: [EntityReducerFactory], - useFactory: createEntityReducer - }, - { - provide: EntityCollectionServiceFactory, - useClass: DefaultEntityCollectionServiceFactory - }, - { - provide: EntityServices, - useClass: EntityServicesBase - }, + { provide: EntityServices, useClass: EntityServicesBase }, { provide: Logger, useClass: DefaultLogger } ] }) @@ -125,15 +90,11 @@ export class NgrxDataModuleWithoutEffects implements OnDestroy { providers: [ { provide: ENTITY_CACHE_META_REDUCERS, - useValue: config.entityCacheMetaReducers - ? config.entityCacheMetaReducers - : [] + useValue: config.entityCacheMetaReducers ? config.entityCacheMetaReducers : [] }, { provide: ENTITY_COLLECTION_META_REDUCERS, - useValue: config.entityCollectionMetaReducers - ? config.entityCollectionMetaReducers - : [] + useValue: config.entityCollectionMetaReducers ? config.entityCollectionMetaReducers : [] }, { provide: PLURAL_NAMES_TOKEN, @@ -146,8 +107,7 @@ export class NgrxDataModuleWithoutEffects implements OnDestroy { constructor( private reducerManager: ReducerManager, - @Inject(ENTITY_CACHE_REDUCER) - private entityCacheReducer: ActionReducer, + entityCacheReducerFactory: EntityCacheReducerFactory, private injector: Injector, // optional params @Optional() @@ -158,26 +118,21 @@ export class NgrxDataModuleWithoutEffects implements OnDestroy { private initialState: any, @Optional() @Inject(ENTITY_CACHE_META_REDUCERS) - private metaReducers: ( - | MetaReducer - | InjectionToken>)[] + private metaReducers: (MetaReducer | InjectionToken>)[] ) { // Add the ngrx-data feature to the Store's features // as Store.forFeature does for StoreFeatureModule const key = entityCacheName || ENTITY_CACHE_NAME; - initialState = - typeof initialState === 'function' ? initialState() : initialState; + initialState = typeof initialState === 'function' ? initialState() : initialState; - const reducers: MetaReducer[] = ( - metaReducers || [] - ).map(mr => { + const reducers: MetaReducer[] = (metaReducers || []).map(mr => { return mr instanceof InjectionToken ? injector.get(mr) : mr; }); this.entityCacheFeature = { key, - reducers: entityCacheReducer, + reducers: entityCacheReducerFactory.create(), reducerFactory: combineReducers, initialState: initialState || {}, metaReducers: reducers diff --git a/lib/src/ngrx-data.module.spec.ts b/lib/src/ngrx-data.module.spec.ts index 958ca352..6c9e4954 100644 --- a/lib/src/ngrx-data.module.spec.ts +++ b/lib/src/ngrx-data.module.spec.ts @@ -1,11 +1,5 @@ import { Injectable, InjectionToken } from '@angular/core'; -import { - Action, - ActionReducer, - MetaReducer, - Store, - StoreModule -} from '@ngrx/store'; +import { Action, ActionReducer, MetaReducer, Store, StoreModule } from '@ngrx/store'; import { Actions, Effect, EffectsModule } from '@ngrx/effects'; // Not using marble testing @@ -14,7 +8,8 @@ import { TestBed } from '@angular/core/testing'; import { Observable, of, Subject } from 'rxjs'; import { map, skip, tap } from 'rxjs/operators'; -import { EntityAction, EntityActionFactory } from './actions/entity-action'; +import { EntityAction } from './actions/entity-action'; +import { EntityActionFactory } from './actions/entity-action-factory'; import { EntityOp, OP_ERROR } from './actions/entity-op'; import { ofEntityOp } from './actions/entity-action-operators'; @@ -26,17 +21,13 @@ import { EntityEffects, persistOps } from './effects/entity-effects'; import { NgrxDataModule } from './ngrx-data.module'; const TEST_ACTION = 'test/get-everything-succeeded'; -const EC_METAREDUCER_TOKEN = new InjectionToken< - MetaReducer ->('EC MetaReducer'); +const EC_METAREDUCER_TOKEN = new InjectionToken>('EC MetaReducer'); @Injectable() class TestEntityEffects { @Effect() test$: Observable = this.actions.pipe( - // tap(action => { - // console.log('test$ effect', action); - // }), + // tap(action => console.log('test$ effect', action)), ofEntityOp(persistOps), map(this.testHook) ); @@ -45,7 +36,7 @@ class TestEntityEffects { return { type: 'test-action', payload: action, // the incoming action - entityName: action.entityName + entityName: action.payload.entityName }; } @@ -132,9 +123,7 @@ describe('NgrxDataModule', () => { let metaReducerLog: string[]; let store: Store<{ entityCache: EntityCache }>; - function loggingEntityCacheMetaReducer( - reducer: ActionReducer - ): ActionReducer { + function loggingEntityCacheMetaReducer(reducer: ActionReducer): ActionReducer { return (state: EntityCache, action: Action) => { metaReducerLog.push(`MetaReducer saw "${action.type}"`); return reducer(state, action); @@ -150,10 +139,7 @@ describe('NgrxDataModule', () => { EffectsModule.forRoot([]), NgrxDataModule.forRoot({ entityMetadata: entityMetadata, - entityCacheMetaReducers: [ - loggingEntityCacheMetaReducer, - EC_METAREDUCER_TOKEN - ] + entityCacheMetaReducers: [loggingEntityCacheMetaReducer, EC_METAREDUCER_TOKEN] }) ], providers: [ @@ -175,18 +161,12 @@ describe('NgrxDataModule', () => { it('should log an ordinary entity action', () => { const action = eaFactory.create('Hero', EntityOp.SET_LOADING); store.dispatch(action); - expect(metaReducerLog.join('|')).toContain( - EntityOp.SET_LOADING, - 'logged entity action' - ); + expect(metaReducerLog.join('|')).toContain(EntityOp.SET_LOADING, 'logged entity action'); }); it('should respond to action handled by custom EntityCacheMetaReducer', () => { const data = { - Hero: [ - { id: 2, name: 'B', power: 'Fast' }, - { id: 1, name: 'A', power: 'invisible' } - ], + Hero: [{ id: 2, name: 'B', power: 'Fast' }, { id: 1, name: 'A', power: 'invisible' }], Villain: [{ id: 30, name: 'Dr. Evil' }] }; const action = { @@ -196,18 +176,9 @@ describe('NgrxDataModule', () => { store.dispatch(action); cacheSelector$.subscribe(cache => { try { - expect(cache.Hero.entities[1]).toEqual( - data.Hero[1], - 'has expected hero' - ); - expect(cache.Villain.entities[30]).toEqual( - data.Villain[0], - 'has expected hero' - ); - expect(metaReducerLog.join('|')).toContain( - TEST_ACTION, - 'logged test action' - ); + expect(cache.Hero.entities[1]).toEqual(data.Hero[1], 'has expected hero'); + expect(cache.Villain.entities[30]).toEqual(data.Villain[0], 'has expected hero'); + expect(metaReducerLog.join('|')).toContain(TEST_ACTION, 'logged test action'); } catch (error) { fail(error); } @@ -219,19 +190,14 @@ describe('NgrxDataModule', () => { // #region helpers /** Create the test entityCacheMetaReducer, injected in tests */ -function entityCacheMetaReducerFactory( - collectionCreator: EntityCollectionCreator -) { +function entityCacheMetaReducerFactory(collectionCreator: EntityCollectionCreator) { return (reducer: ActionReducer) => { return (state: EntityCache, action: { type: string; payload?: any }) => { switch (action.type) { case TEST_ACTION: { const mergeState = { Hero: createCollection('Hero', action.payload['Hero'] || []), - Villain: createCollection( - 'Villain', - action.payload['Villain'] || [] - ) + Villain: createCollection('Villain', action.payload['Villain'] || []) }; return { ...state, ...mergeState }; } @@ -240,10 +206,7 @@ function entityCacheMetaReducerFactory( }; }; - function createCollection( - entityName: string, - data: T[] - ) { + function createCollection(entityName: string, data: T[]) { return { ...collectionCreator.create(entityName), ids: data.map(e => e.id), diff --git a/lib/src/reducers/constants.ts b/lib/src/reducers/constants.ts index 779768b4..261404ac 100644 --- a/lib/src/reducers/constants.ts +++ b/lib/src/reducers/constants.ts @@ -3,20 +3,9 @@ import { Action, ActionReducer, MetaReducer } from '@ngrx/store'; import { EntityCache } from './entity-cache'; export const ENTITY_CACHE_NAME = 'entityCache'; -export const ENTITY_CACHE_NAME_TOKEN = new InjectionToken( - 'ngrx-data/entity-cache-name' -); +export const ENTITY_CACHE_NAME_TOKEN = new InjectionToken('ngrx-data/entity-cache-name'); -export const ENTITY_CACHE_META_REDUCERS = new InjectionToken< - MetaReducer[] ->('ngrx-data/entity-cache-meta-reducers'); -export const ENTITY_COLLECTION_META_REDUCERS = new InjectionToken< - MetaReducer[] ->('ngrx-data/entity-collection-meta-reducers'); -export const ENTITY_CACHE_REDUCER = new InjectionToken< - ActionReducer ->('ngrx-data/entity-reducer'); +export const ENTITY_CACHE_META_REDUCERS = new InjectionToken[]>('ngrx-data/entity-cache-meta-reducers'); +export const ENTITY_COLLECTION_META_REDUCERS = new InjectionToken[]>('ngrx-data/entity-collection-meta-reducers'); -export const INITIAL_ENTITY_CACHE_STATE = new InjectionToken< - EntityCache | (() => EntityCache) ->('ngrx-data/initial-entity-cache-state'); +export const INITIAL_ENTITY_CACHE_STATE = new InjectionToken EntityCache)>('ngrx-data/initial-entity-cache-state'); diff --git a/lib/src/reducers/default-entity-collection-reducer-methods.ts b/lib/src/reducers/default-entity-collection-reducer-methods.ts deleted file mode 100644 index ebe00391..00000000 --- a/lib/src/reducers/default-entity-collection-reducer-methods.ts +++ /dev/null @@ -1,580 +0,0 @@ -import { Injectable } from '@angular/core'; - -import { Action } from '@ngrx/store'; -import { EntityAdapter } from '@ngrx/entity'; - -import { IdSelector, Update } from '../utils/ngrx-entity-models'; -import { defaultSelectId, toUpdateFactory } from '../utils/utilities'; - -import { EntityAction } from '../actions/entity-action'; -import { EntityActionGuard } from '../actions/entity-action-guard'; -import { EntityOp } from '../actions/entity-op'; - -import { EntityChangeTracker } from './entity-change-tracker'; -import { EntityCollection } from './entity-collection'; -import { - EntityCollectionReducerMethods, - EntityCollectionReducerMethodsFactory -} from './entity-collection-reducer'; -import { EntityDefinition } from '../entity-metadata/entity-definition'; -import { EntityDefinitionService } from '../entity-metadata/entity-definition.service'; - -/** - * {EntityCollectionReducerMethods} for a given entity type. - */ -export class DefaultEntityCollectionReducerMethods { - protected adapter: EntityAdapter; - protected guard: EntityActionGuard; - - /** Extract the primary key (id); default to `id` */ - selectId: IdSelector; - - /** - * Convert an entity (or partial entity) into the `Update` object - * `id`: the primary key and - * `changes`: the entity (or partial entity of changes). - */ - protected toUpdate: (entity: Partial) => Update; - - /** - * Dictionary of the {EntityCollectionReducerMethods} for this entity type, - * keyed by the {EntityOp} - */ - readonly methods: EntityCollectionReducerMethods = { - [EntityOp.QUERY_ALL]: this.queryAll.bind(this), - [EntityOp.QUERY_ALL_ERROR]: this.queryAllError.bind(this), - [EntityOp.QUERY_ALL_SUCCESS]: this.queryAllSuccess.bind(this), - - [EntityOp.QUERY_BY_KEY]: this.queryByKey.bind(this), - [EntityOp.QUERY_BY_KEY_ERROR]: this.queryByKeyError.bind(this), - [EntityOp.QUERY_BY_KEY_SUCCESS]: this.queryByKeySuccess.bind(this), - - [EntityOp.QUERY_MANY]: this.queryMany.bind(this), - [EntityOp.QUERY_MANY_ERROR]: this.queryManyError.bind(this), - [EntityOp.QUERY_MANY_SUCCESS]: this.queryManySuccess.bind(this), - - [EntityOp.SAVE_ADD_ONE]: this.saveAddOne.bind(this), - [EntityOp.SAVE_ADD_ONE_ERROR]: this.saveAddOneError.bind(this), - [EntityOp.SAVE_ADD_ONE_SUCCESS]: this.saveAddOneSuccess.bind(this), - - [EntityOp.SAVE_ADD_ONE_OPTIMISTIC]: this.saveAddOneOptimistic.bind(this), - [EntityOp.SAVE_ADD_ONE_OPTIMISTIC_ERROR]: this.saveAddOneOptimisticError.bind( - this - ), - [EntityOp.SAVE_ADD_ONE_OPTIMISTIC_SUCCESS]: this.saveAddOneOptimisticSuccess.bind( - this - ), - - [EntityOp.SAVE_DELETE_ONE]: this.saveDeleteOne.bind(this), - [EntityOp.SAVE_DELETE_ONE_ERROR]: this.saveDeleteOneError.bind(this), - [EntityOp.SAVE_DELETE_ONE_SUCCESS]: this.saveDeleteOneSuccess.bind(this), - - [EntityOp.SAVE_DELETE_ONE_OPTIMISTIC]: this.saveDeleteOneOptimistic.bind( - this - ), - [EntityOp.SAVE_DELETE_ONE_OPTIMISTIC_ERROR]: this.saveDeleteOneOptimisticError.bind( - this - ), - [EntityOp.SAVE_DELETE_ONE_OPTIMISTIC_SUCCESS]: this.saveDeleteOneOptimisticSuccess.bind( - this - ), - - [EntityOp.SAVE_UPDATE_ONE]: this.saveUpdateOne.bind(this), - [EntityOp.SAVE_UPDATE_ONE_ERROR]: this.saveUpdateOneError.bind(this), - [EntityOp.SAVE_UPDATE_ONE_SUCCESS]: this.saveUpdateOneSuccess.bind(this), - - [EntityOp.SAVE_UPDATE_ONE_OPTIMISTIC]: this.saveUpdateOneOptimistic.bind( - this - ), - [EntityOp.SAVE_UPDATE_ONE_OPTIMISTIC_ERROR]: this.saveUpdateOneOptimisticError.bind( - this - ), - [EntityOp.SAVE_UPDATE_ONE_OPTIMISTIC_SUCCESS]: this.saveUpdateOneOptimisticSuccess.bind( - this - ), - - // Do nothing on save errors except turn the loading flag off. - // See the ChangeTrackerMetaReducers - // Or the app could listen for those errors and do something - - /// cache only operations /// - - [EntityOp.ADD_ALL]: this.addAll.bind(this), - [EntityOp.ADD_MANY]: this.addMany.bind(this), - [EntityOp.ADD_ONE]: this.addOne.bind(this), - - [EntityOp.REMOVE_ALL]: this.removeAll.bind(this), - [EntityOp.REMOVE_MANY]: this.removeMany.bind(this), - [EntityOp.REMOVE_ONE]: this.removeOne.bind(this), - - [EntityOp.UPDATE_MANY]: this.updateMany.bind(this), - [EntityOp.UPDATE_ONE]: this.updateOne.bind(this), - - [EntityOp.UPSERT_MANY]: this.upsertMany.bind(this), - [EntityOp.UPSERT_ONE]: this.upsertOne.bind(this), - - [EntityOp.SET_FILTER]: this.setFilter.bind(this), - [EntityOp.SET_LOADED]: this.setLoaded.bind(this), - [EntityOp.SET_LOADING]: this.setLoading.bind(this) - }; - - /** @deprecated() in favor of the reducerMethods property - * Get the reducer methods. - */ - getMethods() { - return this.methods; - } - - constructor( - public entityName: string, - public definition: EntityDefinition, - /* - * Track changes to entities since the last query or save - * Can revert some or all of those changes - * Required for optimistic saves - * TODO: consider using for all cache updates. - */ - public entityChangeTracker?: EntityChangeTracker - ) { - this.adapter = definition.entityAdapter; - this.selectId = definition.selectId; - - if (!entityChangeTracker) { - this.entityChangeTracker = new EntityChangeTracker( - entityName, - this.adapter, - this.selectId - ); - } - - this.guard = new EntityActionGuard(this.selectId); - this.toUpdate = toUpdateFactory(this.selectId); - } - - protected queryAll(collection: EntityCollection): EntityCollection { - return this.setLoadingTrue(collection); - } - - protected queryAllError( - collection: EntityCollection - ): EntityCollection { - return this.setLoadingFalse(collection); - } - - protected queryAllSuccess( - collection: EntityCollection, - action: EntityAction - ): EntityCollection { - return { - ...this.adapter.addAll(action.payload, collection), - loaded: true, // only QUERY_ALL_SUCCESS and ADD_ALL sets loaded to true - loading: false, - originalValues: {} - }; - } - - protected queryByKey( - collection: EntityCollection, - action: EntityAction - ): EntityCollection { - return this.setLoadingTrue(collection); - } - - protected queryByKeyError( - collection: EntityCollection, - action: EntityAction - ): EntityCollection { - return this.setLoadingFalse(collection); - } - - protected queryByKeySuccess( - collection: EntityCollection, - action: EntityAction - ): EntityCollection { - collection = this.setLoadingFalse(collection); - const upsert = action.payload; - return upsert == null - ? collection - : this.adapter.upsertOne(upsert, collection); - } - - protected queryMany( - collection: EntityCollection, - action: EntityAction - ): EntityCollection { - return this.setLoadingTrue(collection); - } - - protected queryManyError( - collection: EntityCollection, - action: EntityAction - ): EntityCollection { - return this.setLoadingFalse(collection); - } - - protected queryManySuccess( - collection: EntityCollection, - action: EntityAction - ): EntityCollection { - collection = this.setLoadingFalse(collection); - const upserts = action.payload as T[]; - return upserts == null || upserts.length === 0 - ? collection - : this.adapter.upsertMany(upserts, collection); - } - - /** pessimistic add upon success */ - protected saveAddOne( - collection: EntityCollection, - action: EntityAction - ): EntityCollection { - return this.setLoadingTrue(collection); - } - - protected saveAddOneError( - collection: EntityCollection, - action: EntityAction - ): EntityCollection { - return this.setLoadingFalse(collection); - } - - protected saveAddOneSuccess( - collection: EntityCollection, - action: EntityAction - ) { - // Ensure the server generated the primary key if the client didn't send one. - this.guard.mustBeEntity(action); - collection = this.setLoadingFalse(collection); - return this.adapter.addOne(action.payload, collection); - } - - /** optimistic add; add entity immediately - * Must have pkey to add optimistically - */ - protected saveAddOneOptimistic( - collection: EntityCollection, - action: EntityAction - ): EntityCollection { - // Ensure the server generated the primary key if the client didn't send one. - this.guard.mustBeEntity(action); - collection = this.setLoadingTrue(collection); - return this.adapter.addOne(action.payload, collection); - } - - /** optimistic add error; item already added to collection. - * TODO: consider compensation to undo. - */ - protected saveAddOneOptimisticError( - collection: EntityCollection, - action: EntityAction - ): EntityCollection { - return this.setLoadingFalse(collection); - } - - // Although already added to collection - // the server might have added other fields (e.g, concurrency field) - // Therefore, update with returned value - // Caution: in a race, this update could overwrite unsaved user changes. - // Use pessimistic add to avoid this risk. - /** optimistic add succeeded. */ - protected saveAddOneOptimisticSuccess( - collection: EntityCollection, - action: EntityAction - ): EntityCollection { - this.guard.mustBeEntity(action); - collection = this.setLoadingFalse(collection); - const update = this.toUpdate(action.payload); - return this.adapter.updateOne(update, collection); - } - - /** pessimistic delete, after success */ - protected saveDeleteOne( - collection: EntityCollection, - action: EntityAction - ): EntityCollection { - return this.setLoadingTrue(collection); - } - - protected saveDeleteOneError( - collection: EntityCollection, - action: EntityAction - ): EntityCollection { - return this.setLoadingFalse(collection); - } - - protected saveDeleteOneSuccess( - collection: EntityCollection, - action: EntityAction - ): EntityCollection { - collection = this.setLoadingFalse(collection); - const toDelete = action.payload; - const deleteId = - typeof toDelete === 'object' ? this.selectId(toDelete) : toDelete; - return this.adapter.removeOne(deleteId as string, collection); - } - - /** optimistic delete by entity key immediately */ - protected saveDeleteOneOptimistic( - collection: EntityCollection, - action: EntityAction - ): EntityCollection { - collection = this.setLoadingTrue(collection); - const toDelete = action.payload; - const deleteId = - typeof toDelete === 'object' ? this.selectId(toDelete) : toDelete; - return this.adapter.removeOne(deleteId as string, collection); - } - - /** optimistic delete error; item already removed from collection.. - * TODO: consider compensation to undo. - */ - protected saveDeleteOneOptimisticError( - collection: EntityCollection, - action: EntityAction - ): EntityCollection { - return this.setLoadingFalse(collection); - } - - protected saveDeleteOneOptimisticSuccess( - collection: EntityCollection, - action: EntityAction - ): EntityCollection { - return this.setLoadingFalse(collection); - } - - /** - * pessimistic update; update entity only upon success - * payload must be an {Update} - */ - protected saveUpdateOne( - collection: EntityCollection, - action: EntityAction> - ): EntityCollection { - this.guard.mustBeUpdate(action); - return this.setLoadingTrue(collection); - } - - protected saveUpdateOneError( - collection: EntityCollection, - action: EntityAction> - ): EntityCollection { - return this.setLoadingFalse(collection); - } - - /** pessimistic update upon success */ - protected saveUpdateOneSuccess( - collection: EntityCollection, - action: EntityAction> - ): EntityCollection { - collection = this.setLoadingFalse(collection); - return this.adapter.updateOne(action.payload, collection); - } - - /** - * optimistic update; update entity immediately - * payload must be an {Update} - */ - protected saveUpdateOneOptimistic( - collection: EntityCollection, - action: EntityAction> - ): EntityCollection { - this.guard.mustBeUpdate(action); - collection = this.setLoadingTrue(collection); - return this.adapter.updateOne(action.payload, collection); - } - - /** optimistic update error; collection already updated. - * TODO: consider compensation to undo. - */ - protected saveUpdateOneOptimisticError( - collection: EntityCollection, - action: EntityAction> - ): EntityCollection { - return this.setLoadingFalse(collection); - } - - /** optimistic update success; collection already updated. - * Server may have touched other fields - * so update the collection again if the server sent different data. - * payload must be an {Update} - */ - protected saveUpdateOneOptimisticSuccess( - collection: EntityCollection, - action: EntityAction> - ): EntityCollection { - collection = this.setLoadingFalse(collection); - const result = action.payload || { unchanged: true }; - // A data service like `DefaultDataService` will add `unchanged:true` - // if the server responded without data, meaning there is nothing to update. - return (result).unchanged - ? collection - : this.adapter.updateOne(action.payload, collection); - } - - ///// Cache-only operations ///// - - protected addAll( - collection: EntityCollection, - action: EntityAction - ): EntityCollection { - this.guard.mustBeEntities(action); - return { - ...this.adapter.addAll(action.payload, collection), - loaded: true, // only QUERY_ALL_SUCCESS and ADD_ALL sets loaded to true - originalValues: {} - }; - } - - protected addMany( - collection: EntityCollection, - action: EntityAction - ): EntityCollection { - this.guard.mustBeEntities(action); - return this.adapter.addMany(action.payload, collection); - } - - protected addOne( - collection: EntityCollection, - action: EntityAction - ): EntityCollection { - this.guard.mustBeEntity(action); - return this.adapter.addOne(action.payload, collection); - } - - protected removeAll( - collection: EntityCollection, - action: EntityAction - ): EntityCollection { - return { - ...this.adapter.removeAll(collection), - loaded: false, // Only REMOVE_ALL sets loaded to false - loading: false, - originalValues: {} - }; - } - - protected removeMany( - collection: EntityCollection, - action: EntityAction - ): EntityCollection { - // payload must be entity keys - return this.adapter.removeMany(action.payload as string[], collection); - } - - protected removeOne( - collection: EntityCollection, - action: EntityAction - ): EntityCollection { - // payload must be entity key - return this.adapter.removeOne(action.payload as string, collection); - } - - protected updateMany( - collection: EntityCollection, - action: EntityAction[]> - ): EntityCollection { - // payload must be an array of `Updates`, not entities - this.guard.mustBeUpdates(action); - return this.adapter.updateMany(action.payload, collection); - } - - protected updateOne( - collection: EntityCollection, - action: EntityAction> - ): EntityCollection { - // payload must be an `Update`, not an entity - this.guard.mustBeUpdate(action); - return this.adapter.updateOne(action.payload, collection); - } - - protected upsertMany( - collection: EntityCollection, - action: EntityAction - ): EntityCollection { - // `, not entities - // this.guard.mustBeUpdates(action); - // v6+: payload must be an array of T - this.guard.mustBeEntities(action); - return this.adapter.upsertMany(action.payload, collection); - } - - protected upsertOne( - collection: EntityCollection, - action: EntityAction - ): EntityCollection { - // `, not an entity - // this.guard.mustBeUpdate(action); - // v6+: payload must be a T - this.guard.mustBeEntity(action); - return this.adapter.upsertOne(action.payload, collection); - } - - protected setFilter( - collection: EntityCollection, - action: EntityAction - ): EntityCollection { - const filter = action.payload; - return collection.filter === filter - ? collection - : { ...collection, filter }; - } - - protected setLoaded( - collection: EntityCollection, - action: EntityAction - ): EntityCollection { - const loaded = action.payload === true || false; - return collection.loaded === loaded - ? collection - : { ...collection, loaded }; - } - - protected setLoading( - collection: EntityCollection, - action: EntityAction - ): EntityCollection { - return this.setLoadingFlag(collection, action.payload); - } - - protected setLoadingFalse( - collection: EntityCollection - ): EntityCollection { - return this.setLoadingFlag(collection, false); - } - - protected setLoadingTrue( - collection: EntityCollection - ): EntityCollection { - return this.setLoadingFlag(collection, true); - } - - /** Set the collection's loading flag */ - protected setLoadingFlag(collection: EntityCollection, loading: boolean) { - loading = loading === true ? true : false; - return collection.loading === loading - ? collection - : { ...collection, loading }; - } -} - -/** - * Creates default {EntityCollectionReducerMethods} for a given entity type. - */ -@Injectable() -export class DefaultEntityCollectionReducerMethodsFactory - implements EntityCollectionReducerMethodsFactory { - constructor(protected entityDefinitionService: EntityDefinitionService) {} - - /** Create the {EntityCollectionReducerMethods} for the named entity type */ - create(entityName: string): EntityCollectionReducerMethods { - const definition = this.entityDefinitionService.getDefinition( - entityName - ); - const methodsClass = new DefaultEntityCollectionReducerMethods( - entityName, - definition - ); - - return methodsClass.methods; - } -} diff --git a/lib/src/reducers/entity-cache-reducer-factory.ts b/lib/src/reducers/entity-cache-reducer-factory.ts new file mode 100644 index 00000000..ac611488 --- /dev/null +++ b/lib/src/reducers/entity-cache-reducer-factory.ts @@ -0,0 +1,102 @@ +import { Injectable } from '@angular/core'; +import { Action, ActionReducer } from '@ngrx/store'; + +import { EntityAction } from '../actions/entity-action'; +import { EntityCache } from './entity-cache'; +import { EntityCacheAction, MergeQuerySet } from '../actions/entity-cache-action'; +import { EntityCollection } from './entity-collection'; +import { EntityCollectionCreator } from './entity-collection-creator'; +import { EntityCollectionReducerRegistry } from './entity-collection-reducer-registry'; +import { EntityOp } from '../actions/entity-op'; +import { Logger } from '../utils/interfaces'; +import { MergeStrategy } from '../actions/merge-strategy'; + +@Injectable() +export class EntityCacheReducerFactory { + constructor( + private entityCollectionCreator: EntityCollectionCreator, + private entityCollectionReducerRegistry: EntityCollectionReducerRegistry, + private logger: Logger + ) {} + + /** + * Create the ngrx-data entity cache reducer which either responds to entity cache level actions + * or (more commonly) delegates to an EntityCollectionReducer based on the action.payload.entityName. + */ + create(): ActionReducer { + // This technique ensures a named function appears in the debugger + return entityCacheReducer.bind(this); + + function entityCacheReducer( + this: EntityCacheReducerFactory, + entityCache: EntityCache = {}, + action: { type: string; payload?: any } + ): EntityCache { + // EntityCache actions + switch (action.type) { + case EntityCacheAction.SET_ENTITY_CACHE: { + // Completely replace the EntityCache. Be careful! + return action.payload; + } + + // Merge entities from each collection in the QuerySet + // using collection reducer's upsert operation + case EntityCacheAction.MERGE_QUERY_SET: { + return this.mergeQuerySetReducer(entityCache, action as MergeQuerySet); + } + } + + // Apply collection reducer if this is a valid EntityAction for a collection + const payload = action.payload; + if (payload && payload.entityName && payload.entityOp && !payload.error) { + return this.applyCollectionReducer(entityCache, action as EntityAction); + } + + // Not a valid EntityAction + return entityCache; + } + } + + /** Apply reducer for the action's EntityCollection (if the action targets a collection) */ + private applyCollectionReducer(cache: EntityCache = {}, action: EntityAction) { + const entityName = action.payload.entityName; + const collection = cache[entityName]; + const reducer = this.entityCollectionReducerRegistry.getOrCreateReducer(entityName); + + let newCollection: EntityCollection; + try { + newCollection = collection ? reducer(collection, action) : reducer(this.entityCollectionCreator.create(entityName), action); + } catch (error) { + this.logger.error(error); + action.payload.error = error; + } + + return action.payload.error || collection === newCollection ? cache : { ...cache, [entityName]: newCollection }; + } + + /** + * Reducer to merge query sets in the form of a hash of entity data for multiple collections. + * @param entityCache the entity cache + * @param action a MergeQuerySet action with the query set and a MergeStrategy + */ + protected mergeQuerySetReducer(entityCache: EntityCache, action: MergeQuerySet) { + // tslint:disable-next-line:prefer-const + let { mergeStrategy, querySet } = action.payload; + mergeStrategy = mergeStrategy === null ? MergeStrategy.PreserveChanges : mergeStrategy; + const entityOp = EntityOp.UPSERT_MANY; + + const entityNames = Object.keys(querySet); + entityCache = entityNames.reduce((newCache, entityName) => { + const payload = { + entityName, + entityOp, + data: querySet[entityName], + mergeStrategy + }; + const act: EntityAction = { type: `[${entityName}] ${action.type}`, payload }; + newCache = this.applyCollectionReducer(newCache, act); + return newCache; + }, entityCache); + return entityCache; + } +} diff --git a/lib/src/reducers/entity-cache-reducer.spec.ts b/lib/src/reducers/entity-cache-reducer.spec.ts new file mode 100644 index 00000000..7a45ef9d --- /dev/null +++ b/lib/src/reducers/entity-cache-reducer.spec.ts @@ -0,0 +1,222 @@ +import { TestBed } from '@angular/core/testing'; +import { Action, ActionReducer } from '@ngrx/store'; + +import { EntityAction } from '../actions/entity-action'; +import { EntityActionFactory } from '../actions/entity-action-factory'; +import { EntityCache } from './entity-cache'; +import { EntityCacheReducerFactory } from './entity-cache-reducer-factory'; +import { EntityCacheQuerySet, MergeQuerySet, SetEntityCache } from '../actions/entity-cache-action'; +import { EntityCollection } from './entity-collection'; +import { EntityCollectionCreator } from './entity-collection-creator'; +import { EntityCollectionReducerFactory } from './entity-collection-reducer'; +import { EntityCollectionReducerMethodsFactory } from './entity-collection-reducer-methods'; +import { EntityCollectionReducerRegistry } from './entity-collection-reducer-registry'; +import { EntityDefinitionService } from '../entity-metadata/entity-definition.service'; +import { EntityMetadataMap, ENTITY_METADATA_TOKEN } from '../entity-metadata/entity-metadata'; +import { EntityOp } from '../actions/entity-op'; +import { IdSelector } from '../utils/ngrx-entity-models'; +import { Logger } from '../utils/interfaces'; + +class Hero { + id: number; + name: string; + power?: string; +} +class Villain { + key: string; + name: string; +} + +const metadata: EntityMetadataMap = { + Hero: {}, + Villain: { selectId: (villain: Villain) => villain.key } +}; + +describe('EntityCacheReducer', () => { + let collectionCreator: EntityCollectionCreator; + let entityActionFactory: EntityActionFactory; + let entityCacheReducer: ActionReducer; + + beforeEach(() => { + entityActionFactory = new EntityActionFactory(); + const logger = jasmine.createSpyObj('Logger', ['error', 'log', 'warn']); + + TestBed.configureTestingModule({ + providers: [ + EntityCacheReducerFactory, + EntityCollectionCreator, + { + provide: EntityCollectionReducerMethodsFactory, + useClass: EntityCollectionReducerMethodsFactory + }, + EntityCollectionReducerFactory, + EntityCollectionReducerRegistry, + EntityDefinitionService, + { provide: ENTITY_METADATA_TOKEN, multi: true, useValue: metadata }, + { provide: Logger, useValue: logger } + ] + }); + + collectionCreator = TestBed.get(EntityCollectionCreator); + const entityCacheReducerFactory = TestBed.get(EntityCacheReducerFactory) as EntityCacheReducerFactory; + entityCacheReducer = entityCacheReducerFactory.create(); + }); + + describe('#create', () => { + it('creates a default hero reducer when QUERY_ALL for hero', () => { + const hero: Hero = { id: 42, name: 'Bobby' }; + const action = entityActionFactory.create('Hero', EntityOp.ADD_ONE, hero); + + const state = entityCacheReducer({}, action); + const collection = state['Hero']; + expect(collection.ids.length).toBe(1, 'should have added one'); + expect(collection.entities[42]).toEqual(hero, 'should be added hero'); + }); + + it('throws when ask for reducer of unknown entity type', () => { + const action = entityActionFactory.create('Foo', EntityOp.QUERY_ALL); + expect(() => entityCacheReducer({}, action)).toThrowError(/no EntityDefinition/i); + }); + }); + + /** + * Test the EntityCache-level actions, SET and MERGE, which can + * be used to restore the entity cache from a know state such as + * re-hydrating from browser storage. + * Useful for an offline-capable app. + */ + describe('EntityCache-level actions', () => { + let initialHeroes: Hero[]; + let initialCache: EntityCache; + + beforeEach(() => { + initialHeroes = [{ id: 2, name: 'B', power: 'Fast' }, { id: 1, name: 'A', power: 'invisible' }]; + initialCache = createInitialCache({ Hero: initialHeroes }); + }); + + describe('ENTITY_CACHE_SET', () => { + it('should initialize cache', () => { + const cache = createInitialCache({ + Hero: initialHeroes, + Villain: [{ key: 'DE', name: 'Dr. Evil' }] + }); + + const action = new SetEntityCache(cache); + // const action = { // equivalent + // type: SET_ENTITY_CACHE, + // payload: cache + // }; + + const state = entityCacheReducer(cache, action); + expect(state['Hero'].ids).toEqual([2, 1], 'Hero ids'); + expect(state['Hero'].entities).toEqual({ + 1: initialHeroes[1], + 2: initialHeroes[0] + }); + expect(state['Villain'].ids).toEqual(['DE'], 'Villain ids'); + }); + + it('should clear the cache when set with empty object', () => { + const action = new SetEntityCache({}); + const state = entityCacheReducer(initialCache, action); + expect(Object.keys(state)).toEqual([]); + }); + + it('should replace prior cache with new cache', () => { + const priorCache = createInitialCache({ + Hero: initialHeroes, + Villain: [{ key: 'DE', name: 'Dr. Evil' }] + }); + + const newHeroes = [{ id: 42, name: 'Bobby' }]; + const newCache = createInitialCache({ Hero: newHeroes }); + + const action = new SetEntityCache(newCache); + const state = entityCacheReducer(priorCache, action); + expect(state['Villain']).toBeUndefined('No villains'); + + const heroCollection = state['Hero']; + expect(heroCollection.ids).toEqual([42], 'hero ids'); + expect(heroCollection.entities[42]).toEqual(newHeroes[0], 'heroes'); + }); + }); + + describe('ENTITY_CACHE_QUERY_SET_MERGE', () => { + function shouldHaveExpectedHeroes(entityCache: EntityCache) { + expect(entityCache['Hero'].ids).toEqual([2, 1], 'Hero ids'); + expect(entityCache['Hero'].entities).toEqual({ + 1: initialHeroes[1], + 2: initialHeroes[0] + }); + } + + it('should initialize an empty cache with query set', () => { + const querySet: EntityCacheQuerySet = { + Hero: initialHeroes, + Villain: [{ key: 'DE', name: 'Dr. Evil' }] + }; + + const action = new MergeQuerySet(querySet); + + const state = entityCacheReducer({}, action); + shouldHaveExpectedHeroes(state); + expect(state['Villain'].ids).toEqual(['DE'], 'Villain ids'); + }); + + it('should return cache matching existing cache when query set is empty', () => { + const action = new MergeQuerySet({}); + const state = entityCacheReducer(initialCache, action); + shouldHaveExpectedHeroes(state); + }); + + it('should add a new collection to existing cache', () => { + const querySet: EntityCacheQuerySet = { + Villain: [{ key: 'DE', name: 'Dr. Evil' }] + }; + const action = new MergeQuerySet(querySet); + const state = entityCacheReducer(initialCache, action); + shouldHaveExpectedHeroes(state); + expect(state['Villain'].ids).toEqual(['DE'], 'Villain ids'); + }); + + it('should merge into an existing cached collection', () => { + const querySet: EntityCacheQuerySet = { + Hero: [{ id: 42, name: 'Bobby' }] + }; + const action = new MergeQuerySet(querySet); + const state = entityCacheReducer(initialCache, action); + const heroCollection = state['Hero']; + const expectedIds = initialHeroes.map(h => h.id).concat(42); + expect(heroCollection.ids).toEqual(expectedIds, 'merged ids'); + expect(heroCollection.entities[42]).toEqual({ id: 42, name: 'Bobby' }, 'merged hero'); + }); + }); + }); + + // #region helpers + function createCollection(entityName: string, data: T[], selectId: IdSelector) { + return { + ...collectionCreator.create(entityName), + ids: data.map(e => selectId(e)) as string[] | number[], + entities: data.reduce( + (acc, e) => { + acc[selectId(e)] = e; + return acc; + }, + {} as any + ) + } as EntityCollection; + } + + function createInitialCache(entityMap: { [entityName: string]: any[] }) { + const cache: EntityCache = {}; + // tslint:disable-next-line:forin + for (const entityName in entityMap) { + const selectId = metadata[entityName].selectId || ((entity: any) => entity.id); + cache[entityName] = createCollection(entityName, entityMap[entityName], selectId); + } + + return cache; + } + // #endregion helpers +}); diff --git a/lib/src/reducers/entity-change-tracker-base.spec.ts b/lib/src/reducers/entity-change-tracker-base.spec.ts new file mode 100644 index 00000000..9bb9958b --- /dev/null +++ b/lib/src/reducers/entity-change-tracker-base.spec.ts @@ -0,0 +1,555 @@ +import { defaultSelectId } from '../utils/utilities'; +import { EntityAdapter, createEntityAdapter } from '@ngrx/entity'; +import { EntityCollection, ChangeState, ChangeStateMap, ChangeType } from './entity-collection'; +import { createEmptyEntityCollection } from './entity-collection-creator'; +import { IdSelector, Update } from '../utils/ngrx-entity-models'; +import { MergeStrategy } from '../actions/merge-strategy'; + +import { EntityChangeTracker } from './entity-change-tracker'; +import { EntityChangeTrackerBase } from './entity-change-tracker-base'; + +interface Hero { + id: number; + name: string; + power?: string; +} + +function sortByName(a: { name: string }, b: { name: string }): number { + return a.name.localeCompare(b.name); +} + +/** Test version of toUpdate that assumes entity has key named 'id' */ +function toUpdate(entity: any) { + return { id: entity.id, changes: entity }; +} + +const adapter: EntityAdapter = createEntityAdapter({ + sortComparer: sortByName +}); + +describe('EntityChangeTrackerBase', () => { + let origCollection: EntityCollection; + let tracker: EntityChangeTracker; + + beforeEach(() => { + origCollection = createEmptyEntityCollection('Hero'); + origCollection.entities = { + 1: { id: 1, name: 'Alice', power: 'Strong' }, + 2: { id: 2, name: 'Gail', power: 'Loud' }, + 7: { id: 7, name: 'Bob', power: 'Swift' } + }; + origCollection.ids = [1, 7, 2]; + tracker = new EntityChangeTrackerBase(adapter, defaultSelectId); + }); + + describe('#commitAll', () => { + it('should clear all tracked changes', () => { + let { collection } = createTestTrackedEntities(); + expect(Object.keys(collection.changeState).length).toBe(3, 'tracking 3 entities'); + + collection = tracker.commitAll(collection); + expect(Object.keys(collection.changeState).length).toBe(0, 'tracking zero entities'); + }); + }); + + describe('#commitOne', () => { + it('should clear current tracking of the given entity', () => { + // tslint:disable-next-line:prefer-const + let { collection, deletedEntity, addedEntity, updatedEntity } = createTestTrackedEntities(); + collection = tracker.commitMany([updatedEntity], collection); + expect(collection.changeState[updatedEntity.id]).toBeUndefined('no changes tracked for updated entity'); + expect(collection.changeState[deletedEntity.id]).toBeDefined('still tracking deleted entity'); + expect(collection.changeState[addedEntity.id]).toBeDefined('still tracking added entity'); + }); + }); + + describe('#commitMany', () => { + it('should clear current tracking of the given entities', () => { + // tslint:disable-next-line:prefer-const + let { collection, deletedEntity, addedEntity, updatedEntity } = createTestTrackedEntities(); + collection = tracker.commitMany([addedEntity, updatedEntity], collection); + expect(collection.changeState[addedEntity.id]).toBeUndefined('no changes tracked for added entity'); + expect(collection.changeState[updatedEntity.id]).toBeUndefined('no changes tracked for updated entity'); + expect(collection.changeState[deletedEntity.id]).toBeDefined('still tracking deleted entity'); + }); + }); + + describe('#trackAddOne', () => { + it('should return a new collection with tracked new entity', () => { + const addedEntity = { id: 42, name: 'Ted', power: 'Chatty' }; + const collection = tracker.trackAddOne(addedEntity, origCollection); + + expect(collection).not.toBe(origCollection); + const change = collection.changeState[addedEntity.id]; + expect(change).toBeDefined('tracking the entity'); + expectChangeType(change, ChangeType.Added); + expect(change.originalValue).toBeUndefined('no original value for a new entity'); + }); + + it('should leave added entity tracked as added when entity is updated', () => { + const addedEntity = { id: 42, name: 'Ted', power: 'Chatty' }; + let collection = tracker.trackAddOne(addedEntity, origCollection); + + const updatedEntity = { ...addedEntity, name: 'Double Test' }; + collection = tracker.trackUpdateOne(toUpdate(updatedEntity), collection); + // simulate the collection update + collection.entities[addedEntity.id] = updatedEntity; + + const change = collection.changeState[updatedEntity.id]; + expect(change).toBeDefined('is still tracked as an added entity'); + expectChangeType(change, ChangeType.Added); + expect(change.originalValue).toBeUndefined('still no original value for added entity'); + }); + + it('should return same collection if called with null entity', () => { + const collection = tracker.trackAddOne(null, origCollection); + expect(collection).toBe(origCollection); + }); + + it('should return the same collection if MergeStrategy.IgnoreChanges', () => { + const addedEntity = { id: 42, name: 'Ted', power: 'Chatty' }; + const collection = tracker.trackAddOne(addedEntity, origCollection, MergeStrategy.IgnoreChanges); + + expect(collection).toBe(origCollection); + const change = collection.changeState[addedEntity.id]; + expect(change).toBeUndefined('not tracking the entity'); + }); + }); + + describe('#trackAddMany', () => { + const newEntities = [{ id: 42, name: 'Ted', power: 'Chatty' }, { id: 84, name: 'Sally', power: 'Laughter' }]; + + it('should return a new collection with tracked new entities', () => { + const collection = tracker.trackAddMany(newEntities, origCollection); + expect(collection).not.toBe(origCollection); + const trackKeys = Object.keys(collection.changeState); + expect(trackKeys).toEqual(['42', '84'], 'tracking new entities'); + + trackKeys.forEach((key, ix) => { + const change = collection.changeState[key]; + expect(change).toBeDefined(`tracking the entity ${key}`); + expectChangeType(change, ChangeType.Added, `tracking ${key} as a new entity`); + expect(change.originalValue).toBeUndefined(`no original value for new entity ${key}`); + }); + }); + + it('should return same collection if called with empty array', () => { + const collection = tracker.trackAddMany([] as any, origCollection); + expect(collection).toBe(origCollection); + }); + }); + + describe('#trackDeleteOne', () => { + it('should return a new collection with tracked "deleted" entity', () => { + const existingEntity = getFirstExistingEntity(); + const collection = tracker.trackDeleteOne(existingEntity.id, origCollection); + expect(collection).not.toBe(origCollection); + const change = collection.changeState[existingEntity.id]; + expect(change).toBeDefined('tracking the entity'); + expectChangeType(change, ChangeType.Deleted); + expect(change.originalValue).toBe(existingEntity, 'originalValue is the existing entity'); + }); + + it('should return a new collection with tracked "deleted" entity, deleted by key', () => { + const existingEntity = getFirstExistingEntity(); + const collection = tracker.trackDeleteOne(existingEntity.id, origCollection); + expect(collection).not.toBe(origCollection); + const change = collection.changeState[existingEntity.id]; + expect(change).toBeDefined('tracking the entity'); + expectChangeType(change, ChangeType.Deleted); + expect(change.originalValue).toBe(existingEntity, 'originalValue is the existing entity'); + }); + + it('should untrack (commit) an added entity when it is removed', () => { + const addedEntity = { id: 42, name: 'Ted', power: 'Chatty' }; + let collection = tracker.trackAddOne(addedEntity, origCollection); + + // Add it to the collection as the reducer would + collection = { + ...collection, + entities: { ...collection.entities, 42: addedEntity }, + ids: (collection.ids as number[]).concat(42) + }; + + let change = collection.changeState[addedEntity.id]; + expect(change).toBeDefined('tracking the new entity'); + + collection = tracker.trackDeleteOne(addedEntity.id, collection); + change = collection.changeState[addedEntity.id]; + expect(change).not.toBeDefined('is no longer tracking the new entity'); + }); + + it('should switch an updated entity to a deleted entity when it is removed', () => { + const existingEntity = getFirstExistingEntity(); + const updatedEntity = toUpdate({ ...existingEntity, name: 'test update' }); + + let collection = tracker.trackUpdateOne(toUpdate(updatedEntity), origCollection); + + let change = collection.changeState[updatedEntity.id]; + expect(change).toBeDefined('tracking the updated existing entity'); + expectChangeType(change, ChangeType.Updated, 'updated at first'); + + collection = tracker.trackDeleteOne(updatedEntity.id, collection); + change = collection.changeState[updatedEntity.id]; + expect(change).toBeDefined('tracking the deleted, updated entity'); + expectChangeType(change, ChangeType.Deleted, 'after delete'); + expect(change.originalValue).toEqual(existingEntity, 'tracking original value'); + }); + + it('should leave deleted entity tracked as deleted when try to update', () => { + const existingEntity = getFirstExistingEntity(); + let collection = tracker.trackDeleteOne(existingEntity.id, origCollection); + + let change = collection.changeState[existingEntity.id]; + expect(change).toBeDefined('tracking the deleted entity'); + expectChangeType(change, ChangeType.Deleted); + + // This shouldn't be possible but let's try it. + const updatedEntity = { ...existingEntity, name: 'Double Test' }; + collection.entities[existingEntity.id] = updatedEntity; + + collection = tracker.trackUpdateOne(toUpdate(updatedEntity), collection); + change = collection.changeState[updatedEntity.id]; + expect(change).toBeDefined('is still tracked as a deleted entity'); + expectChangeType(change, ChangeType.Deleted); + expect(change.originalValue).toEqual(existingEntity, 'still tracking original value'); + }); + + it('should return same collection if called with null entity', () => { + const collection = tracker.trackDeleteOne(null, origCollection); + expect(collection).toBe(origCollection); + }); + + it('should return same collection if called with a key not found', () => { + const collection = tracker.trackDeleteOne('1234', origCollection); + expect(collection).toBe(origCollection); + }); + + it('should return same collection if MergeStrategy.IgnoreChanges', () => { + const existingEntity = getFirstExistingEntity(); + const collection = tracker.trackDeleteOne(existingEntity.id, origCollection, MergeStrategy.IgnoreChanges); + expect(collection).toBe(origCollection); + const change = collection.changeState[existingEntity.id]; + expect(change).toBeUndefined('not tracking the entity'); + }); + }); + + describe('#trackDeleteMany', () => { + it('should return a new collection with tracked "deleted" entities', () => { + const existingEntities = getSomeExistingEntities(2); + const collection = tracker.trackDeleteMany(existingEntities.map(e => e.id), origCollection); + expect(collection).not.toBe(origCollection); + existingEntities.forEach((entity, ix) => { + const change = collection.changeState[existingEntities[ix].id]; + expect(change).toBeDefined(`tracking entity #${ix}`); + expectChangeType(change, ChangeType.Deleted, `entity #${ix}`); + expect(change.originalValue).toBe(existingEntities[ix], `entity #${ix} originalValue`); + }); + }); + + it('should return same collection if called with empty array', () => { + const collection = tracker.trackDeleteMany([], origCollection); + expect(collection).toBe(origCollection); + }); + + it('should return same collection if called with a key not found', () => { + const collection = tracker.trackDeleteMany(['1234', 456], origCollection); + expect(collection).toBe(origCollection); + }); + }); + + describe('#trackUpdateOne', () => { + it('should return a new collection with tracked updated entity', () => { + const existingEntity = getFirstExistingEntity(); + const updatedEntity = toUpdate({ ...existingEntity, name: 'test update' }); + const collection = tracker.trackUpdateOne(updatedEntity, origCollection); + expect(collection).not.toBe(origCollection); + const change = collection.changeState[existingEntity.id]; + expect(change).toBeDefined('tracking the entity'); + expectChangeType(change, ChangeType.Updated); + expect(change.originalValue).toBe(existingEntity, 'originalValue is the existing entity'); + }); + + it('should return a new collection with tracked updated entity, updated by key', () => { + const existingEntity = getFirstExistingEntity(); + const updatedEntity = toUpdate({ ...existingEntity, name: 'test update' }); + const collection = tracker.trackUpdateOne(updatedEntity, origCollection); + expect(collection).not.toBe(origCollection); + const change = collection.changeState[existingEntity.id]; + expect(change).toBeDefined('tracking the entity'); + expectChangeType(change, ChangeType.Updated); + expect(change.originalValue).toBe(existingEntity, 'originalValue is the existing entity'); + }); + + it('should leave updated entity tracked as updated if try to add', () => { + const existingEntity = getFirstExistingEntity(); + const updatedEntity = toUpdate({ ...existingEntity, name: 'test update' }); + let collection = tracker.trackUpdateOne(updatedEntity, origCollection); + + let change = collection.changeState[existingEntity.id]; + expect(change).toBeDefined('tracking the updated entity'); + expectChangeType(change, ChangeType.Updated); + + // This shouldn't be possible but let's try it. + const addedEntity = { ...existingEntity, name: 'Double Test' }; + collection.entities[existingEntity.id] = addedEntity; + + collection = tracker.trackAddOne(addedEntity, collection); + change = collection.changeState[addedEntity.id]; + expect(change).toBeDefined('is still tracked as an updated entity'); + expectChangeType(change, ChangeType.Updated); + expect(change.originalValue).toEqual(existingEntity, 'still tracking original value'); + }); + + it('should return same collection if called with null entity', () => { + const collection = tracker.trackUpdateOne(null, origCollection); + expect(collection).toBe(origCollection); + }); + + it('should return same collection if called with a key not found', () => { + const updateEntity = toUpdate({ id: '1234', name: 'Mr. 404' }); + const collection = tracker.trackUpdateOne(updateEntity, origCollection); + expect(collection).toBe(origCollection); + }); + + it('should return same collection if MergeStrategy.IgnoreChanges', () => { + const existingEntity = getFirstExistingEntity(); + const updatedEntity = toUpdate({ ...existingEntity, name: 'test update' }); + const collection = tracker.trackUpdateOne(updatedEntity, origCollection, MergeStrategy.IgnoreChanges); + expect(collection).toBe(origCollection); + const change = collection.changeState[existingEntity.id]; + expect(change).toBeUndefined('not tracking the entity'); + }); + }); + + describe('#trackUpdateMany', () => { + it('should return a new collection with tracked updated entities', () => { + const existingEntities = getSomeExistingEntities(2); + const updateEntities = existingEntities.map(e => toUpdate({ ...e, name: e.name + ' updated' })); + const collection = tracker.trackUpdateMany(updateEntities, origCollection); + expect(collection).not.toBe(origCollection); + existingEntities.forEach((entity, ix) => { + const change = collection.changeState[existingEntities[ix].id]; + expect(change).toBeDefined(`tracking entity #${ix}`); + expectChangeType(change, ChangeType.Updated, `entity #${ix}`); + expect(change.originalValue).toBe(existingEntities[ix], `entity #${ix} originalValue`); + }); + }); + + it('should return same collection if called with empty array', () => { + const collection = tracker.trackUpdateMany([], origCollection); + expect(collection).toBe(origCollection); + }); + + it('should return same collection if called with entities whose keys are not found', () => { + const updateEntities = [toUpdate({ id: '1234', name: 'Mr. 404' }), toUpdate({ id: 456, name: 'Ms. 404' })]; + const collection = tracker.trackUpdateMany(updateEntities, origCollection); + expect(collection).toBe(origCollection); + }); + }); + + describe('#trackUpsertOne', () => { + it('should return a new collection with tracked added entity', () => { + const addedEntity = { id: 42, name: 'Ted', power: 'Chatty' }; + const collection = tracker.trackUpsertOne(addedEntity, origCollection); + expect(collection).not.toBe(origCollection); + const change = collection.changeState[addedEntity.id]; + expect(change).toBeDefined('tracking the entity'); + expectChangeType(change, ChangeType.Added); + expect(change.originalValue).toBeUndefined('no originalValue for added entity'); + }); + + it('should return a new collection with tracked updated entity', () => { + const existingEntity = getFirstExistingEntity(); + const collection = tracker.trackUpsertOne(existingEntity, origCollection); + expect(collection).not.toBe(origCollection); + const change = collection.changeState[existingEntity.id]; + expect(change).toBeDefined('tracking the entity'); + expectChangeType(change, ChangeType.Updated); + expect(change.originalValue).toBe(existingEntity, 'originalValue is the existing entity'); + }); + + it('should not change orig value of updated entity that is updated again', () => { + const existingEntity = getFirstExistingEntity(); + let collection = tracker.trackUpsertOne(existingEntity, origCollection); + + let change = collection.changeState[existingEntity.id]; + expect(change).toBeDefined('tracking the updated entity'); + expectChangeType(change, ChangeType.Updated, 'first updated'); + + const updatedAgainEntity = { ...existingEntity, name: 'Double Test' }; + + collection = tracker.trackUpsertOne(updatedAgainEntity, collection); + change = collection.changeState[updatedAgainEntity.id]; + expect(change).toBeDefined('is still tracked as an updated entity'); + expectChangeType(change, ChangeType.Updated, 'still updated after attempted add'); + expect(change.originalValue).toEqual(existingEntity, 'still tracking original value'); + }); + + it('should return same collection if called with null entity', () => { + const collection = tracker.trackUpsertOne(null, origCollection); + expect(collection).toBe(origCollection); + }); + + it('should return same collection if MergeStrategy.IgnoreChanges', () => { + const existingEntity = getFirstExistingEntity(); + const updatedEntity = { ...existingEntity, name: 'test update' }; + const collection = tracker.trackUpsertOne(updatedEntity, origCollection, MergeStrategy.IgnoreChanges); + expect(collection).toBe(origCollection); + const change = collection.changeState[existingEntity.id]; + expect(change).toBeUndefined('not tracking the entity'); + }); + }); + + describe('#trackUpsertMany', () => { + it('should return a new collection with tracked upserted entities', () => { + const addedEntity = { id: 42, name: 'Ted', power: 'Chatty' }; + const exitingEntities = getSomeExistingEntities(2); + const updatedEntities = exitingEntities.map(e => ({ ...e, name: e.name + 'test' })); + const upsertEntities = updatedEntities.concat(addedEntity); + const collection = tracker.trackUpsertMany(upsertEntities, origCollection); + expect(collection).not.toBe(origCollection); + updatedEntities.forEach((entity, ix) => { + const change = collection.changeState[updatedEntities[ix].id]; + expect(change).toBeDefined(`tracking entity #${ix}`); + // first two should be updated, the 3rd is added + expectChangeType(change, ix === 2 ? ChangeType.Added : ChangeType.Updated, `entity #${ix}`); + if (change.changeType === ChangeType.Updated) { + expect(change.originalValue).toBe(exitingEntities[ix], `entity #${ix} originalValue`); + } else { + expect(change.originalValue).toBeUndefined(`no originalValue for added entity #${ix}`); + } + }); + }); + + it('should return same collection if called with empty array', () => { + const collection = tracker.trackUpsertMany([], origCollection); + expect(collection).toBe(origCollection); + }); + }); + + describe('#undoAll', () => { + it('should clear all tracked changes', () => { + let { collection } = createTestTrackedEntities(); + expect(Object.keys(collection.changeState).length).toBe(3, 'tracking 3 entities'); + + collection = tracker.undoAll(collection); + expect(Object.keys(collection.changeState).length).toBe(0, 'tracking zero entities'); + }); + + it('should restore the collection to the pre-change state', () => { + // tslint:disable-next-line:prefer-const + let { collection, addedEntity, deletedEntity, preUpdatedEntity, updatedEntity } = createTestTrackedEntities(); + + // Before undo + expect(collection.entities[addedEntity.id]).toBeDefined('added entity should be present'); + expect(collection.entities[deletedEntity.id]).toBeUndefined('deleted entity should be missing'); + expect(updatedEntity.name).not.toEqual(preUpdatedEntity.name, 'updated entity should be changed'); + + collection = tracker.undoAll(collection); + + // After undo + expect(collection.entities[addedEntity.id]).toBeUndefined('added entity should be removed'); + expect(collection.entities[deletedEntity.id]).toBeDefined('deleted entity should be restored'); + const revertedUpdate = collection.entities[updatedEntity.id]; + expect(revertedUpdate.name).toEqual(preUpdatedEntity.name, 'updated entity should be restored'); + }); + }); + + describe('#undoOne', () => { + it('should restore the collection to the pre-change state for the given entity', () => { + // tslint:disable-next-line:prefer-const + let { collection, addedEntity, deletedEntity, preUpdatedEntity, updatedEntity } = createTestTrackedEntities(); + + collection = tracker.undoOne(deletedEntity, collection); + + expect(collection.entities[deletedEntity.id]).toBeDefined('deleted entity should be restored'); + expect(collection.entities[addedEntity.id]).toBeDefined('added entity should still be present'); + expect(updatedEntity.name).not.toEqual(preUpdatedEntity.name, 'updated entity should be changed'); + }); + + it('should do nothing when the given entity is null', () => { + // tslint:disable-next-line:prefer-const + let { collection, addedEntity, deletedEntity, preUpdatedEntity, updatedEntity } = createTestTrackedEntities(); + + collection = tracker.undoOne(null, collection); + expect(collection.entities[addedEntity.id]).toBeDefined('added entity should be present'); + expect(collection.entities[deletedEntity.id]).toBeUndefined('deleted entity should be missing'); + expect(updatedEntity.name).not.toEqual(preUpdatedEntity.name, 'updated entity should be changed'); + }); + }); + + describe('#undoMany', () => { + it('should restore the collection to the pre-change state for the given entities', () => { + // tslint:disable-next-line:prefer-const + let { collection, addedEntity, deletedEntity, preUpdatedEntity, updatedEntity } = createTestTrackedEntities(); + + collection = tracker.undoMany([addedEntity, deletedEntity, updatedEntity], collection); + expect(collection.entities[addedEntity.id]).toBeUndefined('added entity should be removed'); + expect(collection.entities[deletedEntity.id]).toBeDefined('deleted entity should be restored'); + const revertedUpdate = collection.entities[updatedEntity.id]; + expect(revertedUpdate.name).toEqual(preUpdatedEntity.name, 'updated entity should be restored'); + }); + + it('should do nothing when there are no entities to undo', () => { + // tslint:disable-next-line:prefer-const + let { collection, addedEntity, deletedEntity, preUpdatedEntity, updatedEntity } = createTestTrackedEntities(); + + collection = tracker.undoMany([], collection); + expect(collection.entities[addedEntity.id]).toBeDefined('added entity should be present'); + expect(collection.entities[deletedEntity.id]).toBeUndefined('deleted entity should be missing'); + expect(updatedEntity.name).not.toEqual(preUpdatedEntity.name, 'updated entity should be changed'); + }); + }); + + /// helpers /// + + /** Simulate the state of the collection after some test changes */ + function createTestTrackedEntities() { + const addedEntity = { id: 42, name: 'Ted', power: 'Chatty' }; + const [deletedEntity, preUpdatedEntity] = getSomeExistingEntities(2); + const updatedEntity = { ...preUpdatedEntity, name: 'Test Me' }; + + let collection = tracker.trackAddOne(addedEntity, origCollection); + collection = tracker.trackDeleteOne(deletedEntity.id, collection); + collection = tracker.trackUpdateOne(toUpdate(updatedEntity), collection); + + // Make the collection match these changes + collection.ids = (collection.ids.slice(1, collection.ids.length) as number[]).concat(42); + const entities: { [id: number]: Hero } = { + ...collection.entities, + 42: addedEntity, + [updatedEntity.id]: updatedEntity + }; + delete entities[deletedEntity.id]; + collection.entities = entities; + return { collection, addedEntity, deletedEntity, preUpdatedEntity, updatedEntity }; + } + + /** Test for ChangeState with expected ChangeType */ + function expectChangeType(change: ChangeState, expectedChangeType: ChangeType, msg?: string) { + expect(ChangeType[change.changeType]).toEqual(ChangeType[expectedChangeType], msg); + } + + /** Get the first entity in `originalCollection` */ + function getFirstExistingEntity() { + return getExistingEntityById(origCollection.ids[0]); + } + + /** + * Get the first 'n' existing entities from `originalCollection` + * @param n Number of them to get + */ + function getSomeExistingEntities(n: number) { + const ids = (origCollection.ids as string[]).slice(0, n); + return getExistingEntitiesById(ids); + } + + function getExistingEntityById(id: number | string) { + return getExistingEntitiesById([id as string])[0]; + } + + function getExistingEntitiesById(ids: string[]) { + return ids.map(id => origCollection.entities[id]); + } +}); diff --git a/lib/src/reducers/entity-change-tracker-base.ts b/lib/src/reducers/entity-change-tracker-base.ts new file mode 100644 index 00000000..fd9d7ac5 --- /dev/null +++ b/lib/src/reducers/entity-change-tracker-base.ts @@ -0,0 +1,592 @@ +import { EntityAdapter, EntityState } from '@ngrx/entity'; + +import { ChangeState, ChangeStateMap, ChangeType, EntityCollection } from './entity-collection'; +import { defaultSelectId } from '../utils/utilities'; +import { Dictionary, IdSelector, Update, UpdateData } from '../utils/ngrx-entity-models'; +import { EntityAction, EntityActionOptions } from '../actions/entity-action'; +import { EntityChangeTracker } from './entity-change-tracker'; +import { MergeStrategy } from '../actions/merge-strategy'; + +/** + * The default implementation of EntityChangeTracker with + * methods for tracking, committing, and reverting/undoing unsaved entity changes. + * Used by EntityCollectionReducerMethods which should call tracker methods BEFORE modifying the collection. + * See EntityChangeTracker docs. + */ +export class EntityChangeTrackerBase implements EntityChangeTracker { + constructor(private adapter: EntityAdapter, private selectId: IdSelector) { + /** Extract the primary key (id); default to `id` */ + this.selectId = selectId || defaultSelectId; + } + + // #region commit methods + /** + * Commit all changes as when the collection has been completely reloaded from the server. + * Harmless when there are no entity changes to commit. + * @param collection The entity collection + */ + commitAll(collection: EntityCollection): EntityCollection { + return Object.keys(collection.changeState).length === 0 ? collection : { ...collection, changeState: {} }; + } + + /** + * Commit changes for the given entities as when they have been refreshed from the server. + * Harmless when there are no entity changes to commit. + * @param entityOrIdList The entities to clear tracking or their ids. + * @param collection The entity collection + */ + commitMany(entityOrIdList: (number | string | T)[], collection: EntityCollection): EntityCollection { + if (entityOrIdList == null || entityOrIdList.length === 0) { + return collection; // nothing to commit + } + let didMutate = false; + const changeState = entityOrIdList.reduce((chgState, entityOrId) => { + const id = typeof entityOrId === 'object' ? this.selectId(entityOrId) : entityOrId; + if (chgState[id]) { + if (!didMutate) { + chgState = { ...chgState }; + didMutate = true; + } + delete chgState[id]; + } + return chgState; + }, collection.changeState); + + return didMutate ? { ...collection, changeState } : collection; + } + + /** + * Commit changes for the given entity as when it have been refreshed from the server. + * Harmless when no entity changes to commit. + * @param entityOrId The entity to clear tracking or its id. + * @param collection The entity collection + */ + commitOne(entityOrId: number | string | T, collection: EntityCollection): EntityCollection { + return entityOrId == null ? collection : this.commitMany([entityOrId], collection); + } + + // #endregion commit methods + + // #region merge query + /** + * Merge query results into the collection, adjusting the ChangeState per the mergeStrategy. + * @param entities Entities returned from querying the server. + * @param collection The entity collection + * @param [mergeStrategy] How to merge a queried entity when the corresponding entity in the collection has an unsaved change. + * Defaults to MergeStrategy.PreserveChanges. + * @returns The merged EntityCollection. + */ + mergeQueryResults(entities: T[], collection: EntityCollection, mergeStrategy?: MergeStrategy): EntityCollection { + return this.mergeServerUpserts(entities, collection, MergeStrategy.PreserveChanges, mergeStrategy); + } + // #endregion merge query results + + // #region merge save results + /** + * Merge result of saving new entities into the collection, adjusting the ChangeState per the mergeStrategy. + * The default is MergeStrategy.OverwriteChanges. + * @param entities Entities returned from saving new entities to the server. + * @param collection The entity collection + * @param [mergeStrategy] How to merge a saved entity when the corresponding entity in the collection has an unsaved change. + * Defaults to MergeStrategy.OverwriteChanges. + * @returns The merged EntityCollection. + */ + mergeSaveAdds(entities: T[], collection: EntityCollection, mergeStrategy?: MergeStrategy): EntityCollection { + return this.mergeServerUpserts(entities, collection, MergeStrategy.OverwriteChanges, mergeStrategy); + } + + /** + * Merge successful result of deleting entities on the server that have the given primary keys + * Clears the entity changeState for those keys unless the MergeStrategy is ignoreChanges. + * @param entities keys primary keys of the entities to remove/delete. + * @param collection The entity collection + * @param [mergeStrategy] How to adjust change tracking when the corresponding entity in the collection has an unsaved change. + * Defaults to MergeStrategy.OverwriteChanges. + * @returns The merged EntityCollection. + */ + mergeSaveDeletes(keys: (number | string)[], collection: EntityCollection, mergeStrategy?: MergeStrategy): EntityCollection { + mergeStrategy = mergeStrategy == null ? MergeStrategy.OverwriteChanges : mergeStrategy; + // same logic for all non-ignore merge strategies: always clear (commit) the changes + const deleteIds = keys as string[]; // make TypeScript happy + collection = mergeStrategy === MergeStrategy.IgnoreChanges ? collection : this.commitMany(deleteIds, collection); + return this.adapter.removeMany(deleteIds, collection); + } + + /** + * Merge result of saving upserted entities into the collection, adjusting the ChangeState per the mergeStrategy. + * The default is MergeStrategy.OverwriteChanges. + * @param entities Entities returned from saving upserts to the server. + * @param collection The entity collection + * @param [mergeStrategy] How to merge a saved entity when the corresponding entity in the collection has an unsaved change. + * Defaults to MergeStrategy.OverwriteChanges. + * @returns The merged EntityCollection. + */ + mergeSaveUpserts(entities: T[], collection: EntityCollection, mergeStrategy?: MergeStrategy): EntityCollection { + return this.mergeServerUpserts(entities, collection, MergeStrategy.OverwriteChanges, mergeStrategy); + } + + /** + * Merge result of saving updated entities into the collection, adjusting the ChangeState per the mergeStrategy. + * The default is MergeStrategy.OverwriteChanges. + * @param entities Entities returned from saving updated entities to the server. + * @param collection The entity collection + * @param [mergeStrategy] How to merge a saved entity when the corresponding entity in the collection has an unsaved change. + * Defaults to MergeStrategy.OverwriteChanges. + * @param [skipUnchanged] True if should skip update when unchanged (for optimistic updates). False by default. + * @returns The merged EntityCollection. + */ + mergeSaveUpdates( + updates: UpdateData[], + collection: EntityCollection, + mergeStrategy?: MergeStrategy, + skipUnchanged?: boolean + ): EntityCollection { + if (updates == null || updates.length === 0) { + return collection; // nothing to merge. + } + + let didMutate = false; + let changeState = collection.changeState; + mergeStrategy = mergeStrategy == null ? MergeStrategy.OverwriteChanges : mergeStrategy; + + switch (mergeStrategy) { + case MergeStrategy.IgnoreChanges: + updates = purgeUnchanged(updates); + return this.adapter.updateMany(updates, collection); + + case MergeStrategy.OverwriteChanges: + changeState = updates.reduce((chgState, update) => { + const oldId = update.id; + const change = chgState[oldId]; + if (change) { + if (!didMutate) { + chgState = { ...chgState }; + didMutate = true; + } + delete chgState[oldId]; + } + return chgState; + }, collection.changeState); + + collection = didMutate ? { ...collection, changeState } : collection; + + updates = purgeUnchanged(updates); + return this.adapter.updateMany(updates, collection); + + case MergeStrategy.PreserveChanges: { + let updateEntities = [] as UpdateData[]; + changeState = updates.reduce((chgState, update) => { + const oldId = update.id; + const change = chgState[oldId]; + if (change) { + // Tracking a change so update original value but not the current value + if (!didMutate) { + chgState = { ...chgState }; + didMutate = true; + } + const newId = this.selectId(update.changes); + const oldChangeState = chgState[oldId]; + // If the server changed the id, register the new "originalValue" under the new id + // and remove the change tracked under the old id. + if (newId !== oldId) { + delete chgState[oldId]; + } + const newOrigValue = { ...(oldChangeState.originalValue as any), ...(update.changes as any) }; + chgState[newId] = { ...oldChangeState, originalValue: newOrigValue }; + } else { + updateEntities.push(update); + } + return chgState; + }, collection.changeState); + collection = didMutate ? { ...collection, changeState } : collection; + + updateEntities = purgeUnchanged(updateEntities); + return this.adapter.updateMany(updateEntities, collection); + } + } + + /** Exclude the unchanged updates for optimistic saves and strip off the `unchanged` property */ + function purgeUnchanged(ups: UpdateData[]): UpdateData[] { + if (skipUnchanged === true) { + ups = ups.filter(u => !u.unchanged); + } + return ups.map(up => { + const { unchanged, ...update } = up; + return update; + }); + } + } + // #endregion merge save results + + // #region query & save helpers + /** + * + * @param entities Entities to merge + * @param collection Collection into which entities are merged + * @param defaultMergeStrategy How to merge when action's MergeStrategy is unspecified + * @param [mergeStrategy] The action's MergeStrategy + */ + private mergeServerUpserts( + entities: T[], + collection: EntityCollection, + defaultMergeStrategy: MergeStrategy, + mergeStrategy?: MergeStrategy + ): EntityCollection { + if (entities == null || entities.length === 0) { + return collection; // nothing to merge. + } + + let didMutate = false; + let changeState = collection.changeState; + mergeStrategy = mergeStrategy == null ? defaultMergeStrategy : mergeStrategy; + + switch (mergeStrategy) { + case MergeStrategy.IgnoreChanges: + return this.adapter.upsertMany(entities, collection); + + case MergeStrategy.OverwriteChanges: + collection = this.adapter.upsertMany(entities, collection); + + changeState = entities.reduce((chgState, entity) => { + const id = this.selectId(entity); + const change = chgState[id]; + if (change) { + if (!didMutate) { + chgState = { ...chgState }; + didMutate = true; + } + delete chgState[id]; + } + return chgState; + }, collection.changeState); + + return didMutate ? { ...collection, changeState } : collection; + + case MergeStrategy.PreserveChanges: { + const upsertEntities = [] as T[]; + changeState = entities.reduce((chgState, entity) => { + const id = this.selectId(entity); + const change = chgState[id]; + if (change) { + if (!didMutate) { + chgState = { ...chgState }; + didMutate = true; + } + chgState[id].originalValue = entity; + } else { + upsertEntities.push(entity); + } + return chgState; + }, collection.changeState); + + collection = this.adapter.upsertMany(upsertEntities, collection); + return didMutate ? { ...collection, changeState } : collection; + } + } + } + // #endregion query & save helpers + + // #region track methods + /** + * Track multiple entities before adding them to the collection. + * Does NOT add to the collection (the reducer's job). + * @param entities The entities to add. They must all have their ids. + * @param collection The entity collection + * @param [mergeStrategy] Track by default. Don't track if is MergeStrategy.IgnoreChanges. + */ + trackAddMany(entities: T[], collection: EntityCollection, mergeStrategy?: MergeStrategy): EntityCollection { + if (mergeStrategy === MergeStrategy.IgnoreChanges || entities == null || entities.length === 0) { + return collection; // nothing to track + } + let didMutate = false; + const changeState = entities.reduce((chgState, entity) => { + const id = this.selectId(entity); + if (id == null || id === '') { + throw new Error(`${collection.entityName} entity add requires a key to be tracked`); + } + const trackedChange = chgState[id]; + + if (!trackedChange) { + if (!didMutate) { + didMutate = true; + chgState = { ...chgState }; + } + chgState[id] = { changeType: ChangeType.Added }; + } + return chgState; + }, collection.changeState); + return didMutate ? { ...collection, changeState } : collection; + } + + /** + * Track an entity before adding it to the collection. + * Does NOT add to the collection (the reducer's job). + * @param entity The entity to add. It must have an id. + * @param collection The entity collection + * @param [mergeStrategy] Track by default. Don't track if is MergeStrategy.IgnoreChanges. + * If not specified, implementation supplies a default strategy. + */ + trackAddOne(entity: T, collection: EntityCollection, mergeStrategy?: MergeStrategy): EntityCollection { + return entity == null ? collection : this.trackAddMany([entity], collection, mergeStrategy); + } + + /** + * Track multiple entities before removing them with the intention of deleting them on the server. + * Does NOT remove from the collection (the reducer's job). + * @param keys The primary keys of the entities to delete. + * @param collection The entity collection + * @param [mergeStrategy] Track by default. Don't track if is MergeStrategy.IgnoreChanges. + */ + trackDeleteMany(keys: (number | string)[], collection: EntityCollection, mergeStrategy?: MergeStrategy): EntityCollection { + if (mergeStrategy === MergeStrategy.IgnoreChanges || keys == null || keys.length === 0) { + return collection; // nothing to track + } + let didMutate = false; + const entityMap = collection.entities; + const changeState = keys.reduce((chgState, id) => { + const originalValue = entityMap[id]; + if (originalValue) { + const trackedChange = chgState[id]; + if (trackedChange) { + if (trackedChange.changeType === ChangeType.Added) { + // Special case: stop tracking an added entity that you delete + // The caller must also detect this, remove it immediately from the collection + // and skip attempt to delete on the server. + cloneChgStateOnce(); + delete chgState[id]; + } else if (trackedChange.changeType === ChangeType.Updated) { + // Special case: switch change type from Updated to Deleted. + cloneChgStateOnce(); + chgState[id].changeType = ChangeType.Deleted; + } + } else { + // Start tracking this entity + cloneChgStateOnce(); + chgState[id] = { changeType: ChangeType.Deleted, originalValue }; + } + } + return chgState; + + function cloneChgStateOnce() { + if (!didMutate) { + didMutate = true; + chgState = { ...chgState }; + } + } + }, collection.changeState); + + return didMutate ? { ...collection, changeState } : collection; + } + + /** + * Track an entity before it is removed with the intention of deleting it on the server. + * Does NOT remove from the collection (the reducer's job). + * @param key The primary key of the entity to delete. + * @param collection The entity collection + * @param [mergeStrategy] Track by default. Don't track if is MergeStrategy.IgnoreChanges. + */ + trackDeleteOne(key: number | string, collection: EntityCollection, mergeStrategy?: MergeStrategy): EntityCollection { + return key == null ? collection : this.trackDeleteMany([key], collection, mergeStrategy); + } + + /** + * Track multiple entities before updating them in the collection. + * Does NOT update the collection (the reducer's job). + * @param updates The entities to update. + * @param collection The entity collection + * @param [mergeStrategy] Track by default. Don't track if is MergeStrategy.IgnoreChanges. + */ + trackUpdateMany(updates: Update[], collection: EntityCollection, mergeStrategy?: MergeStrategy): EntityCollection { + if (mergeStrategy === MergeStrategy.IgnoreChanges || updates == null || updates.length === 0) { + return collection; // nothing to track + } + let didMutate = false; + const entityMap = collection.entities; + const changeState = updates.reduce((chgState, update) => { + const { id, changes: entity } = update; + if (id == null || id === '') { + throw new Error(`${collection.entityName} entity update requires a key to be tracked`); + } + const originalValue = entityMap[id]; + // Only track if it is in the collection. Silently ignore if it is not. + // @ngrx/entity adapter would also silently ignore. + // Todo: should missing update entity really be reported as an error? + if (originalValue) { + const trackedChange = chgState[id]; + if (!trackedChange) { + if (!didMutate) { + didMutate = true; + chgState = { ...chgState }; + } + chgState[id] = { changeType: ChangeType.Updated, originalValue }; + } + } + return chgState; + }, collection.changeState); + return didMutate ? { ...collection, changeState } : collection; + } + + /** + * Track an entity before updating it in the collection. + * Does NOT update the collection (the reducer's job). + * @param update The entity to update. + * @param collection The entity collection + * @param [mergeStrategy] Track by default. Don't track if is MergeStrategy.IgnoreChanges. + */ + trackUpdateOne(update: Update, collection: EntityCollection, mergeStrategy?: MergeStrategy): EntityCollection { + return update == null ? collection : this.trackUpdateMany([update], collection, mergeStrategy); + } + + /** + * Track multiple entities before upserting (adding and updating) them to the collection. + * Does NOT update the collection (the reducer's job). + * @param entities The entities to add or update. They must be complete entities with ids. + * @param collection The entity collection + * @param [mergeStrategy] Track by default. Don't track if is MergeStrategy.IgnoreChanges. + */ + trackUpsertMany(entities: T[], collection: EntityCollection, mergeStrategy?: MergeStrategy): EntityCollection { + if (mergeStrategy === MergeStrategy.IgnoreChanges || entities == null || entities.length === 0) { + return collection; // nothing to track + } + let didMutate = false; + const entityMap = collection.entities; + const changeState = entities.reduce((chgState, entity) => { + const id = this.selectId(entity); + if (id == null || id === '') { + throw new Error(`${collection.entityName} entity upsert requires a key to be tracked`); + } + const trackedChange = chgState[id]; + + if (!trackedChange) { + if (!didMutate) { + didMutate = true; + chgState = { ...chgState }; + } + + const originalValue = entityMap[id]; + chgState[id] = originalValue == null ? { changeType: ChangeType.Added } : { changeType: ChangeType.Updated, originalValue }; + } + return chgState; + }, collection.changeState); + return didMutate ? { ...collection, changeState } : collection; + } + + /** + * Track an entity before upsert (adding and updating) it to the collection. + * Does NOT update the collection (the reducer's job). + * @param entities The entity to add or update. It must be a complete entity with its id. + * @param collection The entity collection + * @param [mergeStrategy] Track by default. Don't track if is MergeStrategy.IgnoreChanges. + */ + trackUpsertOne(entity: T, collection: EntityCollection, mergeStrategy?: MergeStrategy): EntityCollection { + return entity == null ? collection : this.trackUpsertMany([entity], collection, mergeStrategy); + } + // #endregion track methods + + // #region undo methods + /** + * Revert the unsaved changes for all collection. + * Harmless when there are no entity changes to undo. + * @param collection The entity collection + */ + undoAll(collection: EntityCollection): EntityCollection { + const ids = Object.keys(collection.changeState); + + const { remove, upsert } = ids.reduce( + (acc, id) => { + const changeState = acc.chgState[id]; + switch (changeState.changeType) { + case ChangeType.Added: + acc.remove.push(id); + break; + case ChangeType.Deleted: + const removed = changeState.originalValue; + if (removed) { + acc.upsert.push(removed); + } + break; + case ChangeType.Updated: + acc.upsert.push(changeState.originalValue); + break; + } + return acc; + }, + // entitiesToUndo + { + remove: [] as (number | string)[], + upsert: [] as T[], + chgState: collection.changeState + } + ); + + collection = this.adapter.removeMany(remove as string[], collection); + collection = this.adapter.upsertMany(upsert, collection); + + return { ...collection, changeState: {} }; + } + + /** + * Revert the unsaved changes for the given entities. + * Harmless when there are no entity changes to undo. + * @param entityOrIdList The entities to revert or their ids. + * @param collection The entity collection + */ + undoMany(entityOrIdList: (number | string | T)[], collection: EntityCollection): EntityCollection { + if (entityOrIdList == null || entityOrIdList.length === 0) { + return collection; // nothing to undo + } + let didMutate = false; + + const { changeState, remove, upsert } = entityOrIdList.reduce( + (acc, entityOrId) => { + let chgState = acc.changeState; + const id = typeof entityOrId === 'object' ? this.selectId(entityOrId) : entityOrId; + if (chgState[id]) { + if (!didMutate) { + chgState = { ...chgState }; + didMutate = true; + } + const change = chgState[id]; + delete chgState[id]; // clear tracking of this entity + + switch (change.changeType) { + case ChangeType.Added: + acc.remove.push(id); + break; + case ChangeType.Deleted: + const removed = change.originalValue; + if (removed) { + acc.upsert.push(removed); + } + break; + case ChangeType.Updated: + acc.upsert.push(change.originalValue); + break; + } + } + return acc; + }, + // entitiesToUndo + { + remove: [] as (number | string)[], + upsert: [] as T[], + changeState: collection.changeState + } + ); + + collection = this.adapter.removeMany(remove as string[], collection); + collection = this.adapter.upsertMany(upsert, collection); + return didMutate ? collection : { ...collection, changeState }; + } + + /** + * Revert the unsaved changes for the given entity. + * Harmless when there are no entity changes to undo. + * @param entityOrId The entity to revert or its id. + * @param collection The entity collection + */ + undoOne(entityOrId: number | string | T, collection: EntityCollection): EntityCollection { + return entityOrId == null ? collection : this.undoMany([entityOrId], collection); + } + // #endregion undo methods +} diff --git a/lib/src/reducers/entity-change-tracker.spec.ts b/lib/src/reducers/entity-change-tracker.spec.ts deleted file mode 100644 index 2034141a..00000000 --- a/lib/src/reducers/entity-change-tracker.spec.ts +++ /dev/null @@ -1,314 +0,0 @@ -import { EntityAdapter, createEntityAdapter } from '@ngrx/entity'; - -import { EntityCollection } from './entity-collection'; -import { createEmptyEntityCollection } from './entity-collection-creator'; -import { IdSelector, Update } from '../utils/ngrx-entity-models'; - -import { EntityChangeTracker } from './entity-change-tracker'; - -interface Hero { - id: number; - name: string; - power?: string; -} - -function sortByName(a: { name: string }, b: { name: string }): number { - return a.name.localeCompare(b.name); -} - -const adapter: EntityAdapter = createEntityAdapter({ - sortComparer: sortByName -}); - -describe('EntityChangeTracker', () => { - let origCollection: EntityCollection; - let collection: EntityCollection; - - let tracker: EntityChangeTracker; - beforeEach(() => { - tracker = new EntityChangeTracker('Hero', adapter); - - origCollection = createEmptyEntityCollection(); - origCollection.entities = { - 1: { id: 1, name: 'Alice', power: 'Strong' }, - 2: { id: 2, name: 'Gail', power: 'Loud' }, - 7: { id: 7, name: 'Bob', power: 'Swift' } - }; - origCollection.ids = [1, 7, 2]; - }); - - describe('#addToTracker', () => { - it('should return a new collection after adding an entity', () => { - collection = tracker.addToTracker(origCollection, [1]); - expect(collection).not.toBe(origCollection); - }); - - it('can add tracking of existing entity by id', () => { - collection = tracker.addToTracker(origCollection, [1]); - expect(collection.originalValues[1]).toBe(collection.entities[1]); - }); - - it('can add tracking of existing entity by entity', () => { - const entity = origCollection.entities[1]; - collection = tracker.addToTracker(origCollection, [entity]); - expect(collection.originalValues[1]).toBe(entity); - }); - - it('can add tracking of existing entity by Update', () => { - const entity = origCollection.entities[1]; - const update = { id: 1, changes: entity }; - collection = tracker.addToTracker(origCollection, [update]); - expect(collection.originalValues[1]).toBe(entity); - }); - - it('can add tracking of several existing entities', () => { - const entity = origCollection.entities[1]; - const update = { id: 2, changes: origCollection.entities[2] }; - - collection = tracker.addToTracker(origCollection, [7, entity, update]); - - const originals = collection.originalValues; - - expect(Object.keys(originals)).toEqual(['1', '2', '7']); - expect(originals[1]).toBe(entity); - expect(originals[2]).toBe(update.changes); - expect(originals[7]).toBe(origCollection.entities[7]); - }); - - it('should return a new collection when it updates originalValues', () => { - collection = tracker.addToTracker(origCollection, [1]); - expect(collection).not.toBe(origCollection); - }); - - it('tracked entity should be undefined when not in cache (indicates revertable "add")', () => { - collection = tracker.addToTracker(origCollection, [42]); - expectIsTracked(42); - }); - - it('should preserve first tracked original when tracked a second time', () => { - const origHero = origCollection.entities[1]; - collection = tracker.addToTracker(origCollection, [1]); - - // change the cached hero id:1 - collection = adapter.updateOne( - { id: 1, changes: { name: 'xxx' } }, - collection - ); - expect(collection.entities[1].name).toBe('xxx', 'name changed in cache'); - - // second tracking of id:1 which has changed; should be ignored. - collection = tracker.addToTracker(origCollection, [1]); - expect(collection.originalValues[1]).toBe(origHero, 'original preserved'); - }); - }); - - describe('#revert', () => { - it('should revert an added entity', () => { - // track entities 1 & 7 - collection = tracker.addToTracker(origCollection, [7, 1]); - - // Track it BEFORE adding to the collection - const entity = { id: 42, name: 'Smokey', power: 'Invisible' }; - collection = tracker.addToTracker(collection, [entity]); - expectIsTracked(42); - expect(collection.originalValues[42]).toBeUndefined( - 'id:42 represented by undefined' - ); - - // Now add id:42 - collection = adapter.addMany([entity], collection); - expect(collection.ids).toEqual([1, 7, 2, 42], 'added id:42'); - - collection = tracker.revert(collection, [42]); - - expect(collection.ids).toEqual([1, 7, 2], 'restored ids, in sort order'); - expectIsNotTracked(42); - expectIsTracked(1); - }); - - it('should NOT revert added entity when tracked after add', () => { - // track entities 1 & 7 - collection = tracker.addToTracker(origCollection, [7, 1]); - - // Add before track. Probably not what you intended! - const entity = { id: 42, name: 'Smokey', power: 'Invisible' }; - collection = adapter.addMany([entity], collection); - - // Add to tracker AFTER adding to the collection - collection = tracker.addToTracker(collection, [entity]); - expect(collection.originalValues[42]).toBe( - entity, - 'tracking added entity' - ); - - expect(collection.ids).toEqual([1, 7, 2, 42], 'added id:42'); - - collection = tracker.revert(collection, [42]); - - // Not what you intended - expect(collection.ids).toEqual([1, 7, 2, 42], 'ids are the same'); - expect(collection.entities[42]).toBe(entity, 'added entity still there'); - expectIsNotTracked(42); // does stop tracking it - expectIsTracked(1); - }); - - it('should revert a deleted entity', () => { - // track entities 1 & 7 - collection = tracker.addToTracker(origCollection, [7, 1]); - const entity = origCollection.entities[7]; - - // "delete" id:7 - collection = adapter.removeMany([7], collection); - expect(collection.ids).toEqual([1, 2], 'removed id:7'); - - collection = tracker.revert(collection, [7]); - - expect(collection.ids).toEqual([1, 7, 2], 'restored ids, in sort order'); - expect(collection.entities[7]).toBe(entity, 'id:7 entity was restored'); - expectIsNotTracked(7); - expectIsTracked(1); - }); - - it('should revert an updated entity', () => { - // track entities 1 & 7 - collection = tracker.addToTracker(origCollection, [7, 1]); - - // update id:7 - const entity = origCollection.entities[7]; - const update = { id: 7, changes: { power: 'test-power' } }; - collection = adapter.updateOne(update, collection); - - expect(collection.entities[7].power).toBe('test-power', 'updated power'); - - collection = tracker.revert(collection, [7]); - - expect(collection.entities[7]).toBe(entity, 'id:7 entity was restored'); - expect(collection.entities[7].power).toBe( - entity.power, - 'id:7 has same power' - ); - expectIsNotTracked(7); - expectIsTracked(1); - }); - - it('should not revert an untracked, updated entity', () => { - const update = { id: 7, changes: { id: 7, power: 'test-power' } }; - - // not tracked before update - collection = adapter.updateOne(update, origCollection); - expect(collection.entities[7].power).toBe('test-power', 'updated power'); - - collection = tracker.revert(collection, [7]); - expect(collection.entities[7].power).toBe( - 'test-power', - 'did not revert power' - ); - expectIsNotTracked(7); - }); - - it('should revert several entities', () => { - collection = tracker.addToTracker(origCollection, [1, 2, 7, 42]); - - const entity = { id: 42, name: 'Smokey', power: 'Invisible' }; - const entity7 = collection.entities[7]; - const update = { id: 7, changes: { id: 7, power: 'test-power' } }; - - collection = adapter.addMany([entity], collection); - collection = adapter.removeMany([1, 2], collection); - collection = adapter.updateOne(update, collection); - - // revert all but id:2 - collection = tracker.revert(collection, [1, 7, 42]); - - // restored deleted id:1 but not id:2; removed added id:42 - expect(collection.ids).toEqual([1, 7]); - expect(collection.entities[7]).toBe(entity7); - expect(Object.keys(collection.originalValues)).toEqual( - ['2'], - 'only id:2 still tracked' - ); - }); - - it('should return original collection when none of the entities are tracked', () => { - collection = tracker.revert(origCollection, [1, 2, 7]); - expect(collection).toBe(origCollection); - }); - - it('should return original collection when asked to revert no entities', () => { - const trackingCollection = tracker.addToTracker(origCollection, [ - 1, - 7, - 42 - ]); - collection = tracker.revert(trackingCollection, []); - expect(collection).toBe(trackingCollection); - }); - - it('cannot (yet) revert an update that changes the primary key', () => { - collection = tracker.addToTracker(origCollection, [1]); - const entity = collection.entities[1]; - const update = { id: 1, changes: { id: 42, name: 'test-name' } }; - - collection = adapter.updateOne(update, collection); - expect(collection.ids).toEqual([7, 2, 42], 'ids after update'); - - // won't do it properly, even if you provide both ids - collection = tracker.revert(collection, [1, 42]); - - // Both 1 and 42 are in cache! - expect(collection.ids).toEqual([1, 7, 2, 42], 'ids improperly restored'); - expect(collection.ids).not.toEqual( - [1, 7, 2], - 'ids if reverted correctly' - ); - - expectIsNotTracked(1); - expectIsNotTracked(42); - }); - }); - - describe('#revertAll', () => { - it('should revert all tracked entity changes', () => { - collection = tracker.addToTracker(origCollection, [1, 2, 7, 42]); - - const entity = { id: 42, name: 'Smokey', power: 'Invisible' }; - const entity7 = collection.entities[7]; - const update = { id: 7, changes: { id: 7, power: 'test-power' } }; - - collection = adapter.addMany([entity], collection); - collection = adapter.removeMany([1, 2], collection); - collection = adapter.updateOne(update, collection); - - // revert all - collection = tracker.revertAll(collection); - - expect(collection.ids).toEqual([1, 7, 2], 'ids after revert'); - expect(collection.entities[7]).toBe(entity7); - expect(collection.originalValues).toEqual({}); - }); - - it('should return original collection when there are no tracked entities', () => { - collection = tracker.revertAll(origCollection); - expect(collection).toBe(origCollection); - }); - }); - - describe('#removeFromTracker', () => { - // TBD - }); - - /// test helpers /// - function expectIsTracked(id: number | string) { - expect(collection.originalValues.hasOwnProperty(id)).toBe( - true, - `hero ${id} is in originalValues` - ); - } - - function expectIsNotTracked(id: number | string) { - expect(collection.originalValues.hasOwnProperty(id)).toBe( - false, - `hero ${id} is in originalValues` - ); - } -}); diff --git a/lib/src/reducers/entity-change-tracker.ts b/lib/src/reducers/entity-change-tracker.ts index ffb92733..77c38cc5 100644 --- a/lib/src/reducers/entity-change-tracker.ts +++ b/lib/src/reducers/entity-change-tracker.ts @@ -1,122 +1,199 @@ -import { EntityAdapter, EntityState } from '@ngrx/entity'; +import { ChangeState, ChangeStateMap, ChangeType, EntityCollection } from './entity-collection'; +import { MergeStrategy } from '../actions/merge-strategy'; +import { Update } from '../utils/ngrx-entity-models'; -import { IdSelector, Update } from '../utils/ngrx-entity-models'; -import { defaultSelectId } from '../utils/utilities'; -import { EntityCollection } from './entity-collection'; +/** + * Methods for tracking, committing, and reverting/undoing unsaved entity changes. + * Used by EntityCollectionReducerMethods which should call tracker methods BEFORE modifying the collection. + * See EntityChangeTracker docs. + */ +export interface EntityChangeTracker { + // #region commit + /** + * Commit all changes as when the collection has been completely reloaded from the server. + * Harmless when there are no entity changes to commit. + * @param collection The entity collection + */ + commitAll(collection: EntityCollection): EntityCollection; -// Methods needed by EntityChangeTracker to mutate the collection -// The minimum subset of the @ngrx/entity EntityAdapter methods. -export interface CollectionMutator { - addMany>(entities: T[], state: S): S; - removeMany>(keys: string[], state: S): S; -} + /** + * Commit changes for the given entities as when they have been refreshed from the server. + * Harmless when there are no entity changes to commit. + * @param entityOrIdList The entities to clear tracking or their ids. + * @param collection The entity collection + */ + commitMany(entityOrIdList: (number | string | T)[], collection: EntityCollection): EntityCollection; + + /** + * Commit changes for the given entity as when it have been refreshed from the server. + * Harmless when no entity changes to commit. + * @param entityOrId The entity to clear tracking or its id. + * @param collection The entity collection + */ + commitOne(entityOrId: number | string | T, collection: EntityCollection): EntityCollection; + // #endregion commit -export class EntityChangeTracker { - constructor( - public name: string, - private mutator: CollectionMutator, - private selectId?: IdSelector - ) { - /** Extract the primary key (id); default to `id` */ - this.selectId = selectId || defaultSelectId; - } + // #region mergeQuery + /** + * Merge query results into the collection, adjusting the ChangeState per the mergeStrategy. + * @param entities Entities returned from querying the server. + * @param collection The entity collection + * @param [mergeStrategy] How to merge a queried entity when the corresponding entity in the collection has an unsaved change. + * If not specified, implementation supplies a default strategy. + * @returns The merged EntityCollection. + */ + mergeQueryResults(entities: T[], collection: EntityCollection, mergeStrategy?: MergeStrategy): EntityCollection; + // #endregion mergeQuery + // #region mergeSave /** - * Add entities to tracker by adding them to the collection's originalValues. - * @param collection Source entity collection - * @param idsSource Array of id sources which could be an id, - * an entity or an entity update + * Merge result of saving new entities into the collection, adjusting the ChangeState per the mergeStrategy. + * The default is MergeStrategy.OverwriteChanges. + * @param entities Entities returned from saving new entities to the server. + * @param collection The entity collection + * @param [mergeStrategy] How to merge a saved entity when the corresponding entity in the collection has an unsaved change. + * If not specified, implementation supplies a default strategy. + * @returns The merged EntityCollection. */ - addToTracker( - collection: EntityCollection, - idsSource: (number | string | T | Update)[] - ): EntityCollection { - let ids: (number | string)[] = (idsSource || []).map( - (source: any) => - typeof source === 'object' - ? source.id && source.changes ? source.id : this.selectId(source) - : source - ); - - let originalValues = collection.originalValues; - - // add only the ids that aren't currently tracked - ids = ids.filter(id => !originalValues.hasOwnProperty(id)); - if (ids.length === 0) { - return collection; - } - - const entities = collection.entities; - originalValues = { ...originalValues }; // clone it - - // when entities[id] === undefined, it's a revertable "add" - ids.forEach(id => (originalValues[id] = entities[id])); - return { ...collection, originalValues }; - } - - /** - * Remove given entities from tracker by removing them from original values. - * Those entities can no longer be reverted to their original values. - * @param collection Entity collection with originalValues - * @param ids Ids of entities whose original values should be removed. - */ - removeFromTracker( - collection: EntityCollection, - ids?: (number | string)[] - ): EntityCollection { - let originalValues = collection.originalValues; - ids = (ids || []).filter(id => originalValues.hasOwnProperty(id)); - if (ids.length === 0) { - return collection; - } - originalValues = { ...originalValues }; // clone it - ids.forEach(id => delete originalValues[id]); - return { ...collection, originalValues }; - } - - /** - * Revert entities with given ids to their original values. - * @param collection Source entity collection - * @param ids Ids of entities to revert to original values - */ - revert( - collection: EntityCollection, - ids: (number | string)[] - ): EntityCollection { - const newCollection = this._revertCore(collection, ids); - return newCollection === collection - ? collection - : this.removeFromTracker(newCollection, ids); - } - - /** - * Revert every entity that is tracked in originalValues - * @param collection Source entity collection - */ - revertAll(collection: EntityCollection) { - const ids = Object.keys(collection.originalValues); - return ids.length === 0 - ? collection - : { ...this._revertCore(collection, ids), originalValues: {} }; - } - - private _revertCore( + mergeSaveAdds(entities: T[], collection: EntityCollection, mergeStrategy?: MergeStrategy): EntityCollection; + /** + * Merge successful result of deleting entities on the server that have the given primary keys + * Clears the entity changeState for those keys unless the MergeStrategy is ignoreChanges. + * @param entities keys primary keys of the entities to remove/delete. + * @param collection The entity collection + * @param [mergeStrategy] How to adjust change tracking when the corresponding entity in the collection has an unsaved change. + * If not specified, implementation supplies a default strategy. + * @returns The merged EntityCollection. + */ + mergeSaveDeletes(keys: (number | string)[], collection: EntityCollection, mergeStrategy?: MergeStrategy): EntityCollection; + + /** + * Merge result of saving upserted entities into the collection, adjusting the ChangeState per the mergeStrategy. + * The default is MergeStrategy.OverwriteChanges. + * @param entities Entities returned from saving upsert entities to the server. + * @param collection The entity collection + * @param [mergeStrategy] How to merge a saved entity when the corresponding entity in the collection has an unsaved change. + * If not specified, implementation supplies a default strategy. + * @returns The merged EntityCollection. + */ + mergeSaveUpserts(entities: T[], collection: EntityCollection, mergeStrategy?: MergeStrategy): EntityCollection; + + /** + * Merge result of saving updated entities into the collection, adjusting the ChangeState per the mergeStrategy. + * The default is MergeStrategy.OverwriteChanges. + * @param entities Entities returned from saving updated entities to the server. + * @param [mergeStrategy] How to merge a saved entity when the corresponding entity in the collection has an unsaved change. + * If not specified, implementation supplies a default strategy. + * @param [skipUnchanged] True if should skip update when unchanged (for optimistic updates). False by default. + * @param collection The entity collection + * @returns The merged EntityCollection. + */ + mergeSaveUpdates( + updates: Update[], collection: EntityCollection, - ids: (number | string)[] - ): EntityCollection { - const originalValues = collection.originalValues; - ids = (ids || []).filter(id => originalValues.hasOwnProperty(id)); - if (ids.length === 0) { - return collection; - } - - // TODO: consider a more efficient approach than removing and adding - collection = this.mutator.removeMany(ids, collection); - - // `falsey` original entity indicates an added entity that should be removed - const originals = ids.map(id => originalValues[id]).filter(o => !!o); - return originals.length === 0 - ? collection - : this.mutator.addMany(originals, collection); - } + mergeStrategy?: MergeStrategy, + skipUnchanged?: boolean + ): EntityCollection; + // #endregion mergeSave + + // #region track + /** + * Track multiple entities before adding them to the collection. + * Does NOT add to the collection (the reducer's job). + * @param entities The entities to add. They must all have their ids. + * @param collection The entity collection + * @param [mergeStrategy] Track by default. Don't track if is MergeStrategy.IgnoreChanges. + * If not specified, implementation supplies a default strategy. + */ + trackAddMany(entities: T[], collection: EntityCollection, mergeStrategy?: MergeStrategy): EntityCollection; + + /** + * Track an entity before adding it to the collection. + * Does NOT add to the collection (the reducer's job). + * @param entity The entity to add. It must have an id. + * @param collection The entity collection + * @param [mergeStrategy] Track by default. Don't track if is MergeStrategy.IgnoreChanges. + * If not specified, implementation supplies a default strategy. + */ + trackAddOne(entity: T, collection: EntityCollection, mergeStrategy?: MergeStrategy): EntityCollection; + + /** + * Track multiple entities before removing them with the intention of deleting them on the server. + * Does NOT remove from the collection (the reducer's job). + * @param keys The primary keys of the entities to delete. + * @param collection The entity collection + * @param [mergeStrategy] Track by default. Don't track if is MergeStrategy.IgnoreChanges. + */ + trackDeleteMany(keys: (number | string)[], collection: EntityCollection, mergeStrategy?: MergeStrategy): EntityCollection; + + /** + * Track an entity before it is removed with the intention of deleting it on the server. + * Does NOT remove from the collection (the reducer's job). + * @param key The primary key of the entity to delete. + * @param collection The entity collection + * @param [mergeStrategy] Track by default. Don't track if is MergeStrategy.IgnoreChanges. + */ + trackDeleteOne(key: number | string, collection: EntityCollection, mergeStrategy?: MergeStrategy): EntityCollection; + + /** + * Track multiple entities before updating them in the collection. + * Does NOT update the collection (the reducer's job). + * @param updates The entities to update. + * @param collection The entity collection + * @param [mergeStrategy] Track by default. Don't track if is MergeStrategy.IgnoreChanges. + */ + trackUpdateMany(updates: Update[], collection: EntityCollection, mergeStrategy?: MergeStrategy): EntityCollection; + + /** + * Track an entity before updating it in the collection. + * Does NOT update the collection (the reducer's job). + * @param update The entity to update. + * @param collection The entity collection + * @param [mergeStrategy] Track by default. Don't track if is MergeStrategy.IgnoreChanges. + */ + trackUpdateOne(update: Update, collection: EntityCollection, mergeStrategy?: MergeStrategy): EntityCollection; + + /** + * Track multiple entities before upserting (adding and updating) them to the collection. + * Does NOT update the collection (the reducer's job). + * @param entities The entities to add or update. They must be complete entities with ids. + * @param collection The entity collection + * @param [mergeStrategy] Track by default. Don't track if is MergeStrategy.IgnoreChanges. + */ + trackUpsertMany(entities: T[], collection: EntityCollection, mergeStrategy?: MergeStrategy): EntityCollection; + + /** + * Track an entity before upsert (adding and updating) it to the collection. + * Does NOT update the collection (the reducer's job). + * @param entities The entity to add or update. It must be a complete entity with its id. + * @param collection The entity collection + * @param [mergeStrategy] Track by default. Don't track if is MergeStrategy.IgnoreChanges. + */ + trackUpsertOne(entity: T, collection: EntityCollection, mergeStrategy?: MergeStrategy): EntityCollection; + // #endregion track + + // #region undo + /** + * Revert the unsaved changes for all collection. + * Harmless when there are no entity changes to undo. + * @param collection The entity collection + */ + undoAll(collection: EntityCollection): EntityCollection; + + /** + * Revert the unsaved changes for the given entities. + * Harmless when there are no entity changes to undo. + * @param entityOrIdList The entities to revert or their ids. + * @param collection The entity collection + */ + undoMany(entityOrIdList: (number | string | T)[], collection: EntityCollection): EntityCollection; + + /** + * Revert the unsaved changes for the given entity. + * Harmless when there are no entity changes to undo. + * @param entityOrId The entity to revert or its id. + * @param collection The entity collection + */ + undoOne(entityOrId: number | string | T, collection: EntityCollection): EntityCollection; + // #endregion undo } diff --git a/lib/src/reducers/entity-collection-creator.ts b/lib/src/reducers/entity-collection-creator.ts index afb06095..61e0524f 100644 --- a/lib/src/reducers/entity-collection-creator.ts +++ b/lib/src/reducers/entity-collection-creator.ts @@ -5,37 +5,29 @@ import { EntityDefinitionService } from '../entity-metadata/entity-definition.se @Injectable() export class EntityCollectionCreator { - constructor( - @Optional() private entityDefinitionService?: EntityDefinitionService - ) {} + constructor(@Optional() private entityDefinitionService?: EntityDefinitionService) {} /** * Create the default collection for an entity type. * @param entityName {string} entity type name */ - create = EntityCollection>( - entityName: string - ): S { - const def = - this.entityDefinitionService && - this.entityDefinitionService.getDefinition( - entityName, - /*shouldThrow*/ false - ); + create = EntityCollection>(entityName: string): S { + const def = this.entityDefinitionService && this.entityDefinitionService.getDefinition(entityName, false /*shouldThrow*/); const initialState = def && def.initialState; - return (initialState || createEmptyEntityCollection()); + return (initialState || createEmptyEntityCollection(entityName)); } } -export function createEmptyEntityCollection(): EntityCollection { +export function createEmptyEntityCollection(entityName?: string): EntityCollection { return { + entityName, ids: [], entities: {}, filter: undefined, loaded: false, loading: false, - originalValues: {} + changeState: {} } as EntityCollection; } diff --git a/lib/src/reducers/entity-collection-reducer-methods.ts b/lib/src/reducers/entity-collection-reducer-methods.ts new file mode 100644 index 00000000..c20ff9c4 --- /dev/null +++ b/lib/src/reducers/entity-collection-reducer-methods.ts @@ -0,0 +1,589 @@ +import { Injectable } from '@angular/core'; + +import { Action } from '@ngrx/store'; +import { EntityAdapter } from '@ngrx/entity'; + +import { ChangeStateMap, ChangeType, EntityCollection } from './entity-collection'; +import { EntityChangeTrackerBase } from './entity-change-tracker-base'; +import { defaultSelectId, toUpdateFactory } from '../utils/utilities'; +import { Dictionary, IdSelector, Update, UpdateData } from '../utils/ngrx-entity-models'; +import { EntityAction } from '../actions/entity-action'; +import { EntityActionDataServiceError } from '../dataservices/data-service-error'; +import { EntityActionGuard } from '../actions/entity-action-guard'; +import { EntityChangeTracker } from './entity-change-tracker'; +import { EntityDefinition } from '../entity-metadata/entity-definition'; +import { EntityDefinitionService } from '../entity-metadata/entity-definition.service'; +import { EntityOp } from '../actions/entity-op'; +import { MergeStrategy } from '../actions/merge-strategy'; +import { merge } from 'rxjs/operators'; + +/** + * Map of {EntityOp} to reducer method for the operation. + * If an operation is missing, caller should return the collection for that reducer. + */ +export interface EntityCollectionReducerMethodMap { + [method: string]: (collection: EntityCollection, action?: EntityAction) => EntityCollection; +} + +/** + * Base implementation of reducer methods for an entity collection. + */ +export class EntityCollectionReducerMethods { + protected adapter: EntityAdapter; + protected guard: EntityActionGuard; + /** True if this collection tracks unsaved changes */ + protected isChangeTracking: boolean; + + /** Extract the primary key (id); default to `id` */ + selectId: IdSelector; + + /** + * Convert an entity (or partial entity) into the `Update` object + * `id`: the primary key and + * `changes`: the entity (or partial entity of changes). + */ + protected toUpdate: (entity: Partial) => Update; + + /** + * Dictionary of the {EntityCollectionReducerMethods} for this entity type, + * keyed by the {EntityOp} + */ + readonly methods: EntityCollectionReducerMethodMap = { + [EntityOp.CANCEL_PERSIST]: this.cancelPersist.bind(this), + + [EntityOp.QUERY_ALL]: this.queryAll.bind(this), + [EntityOp.QUERY_ALL_ERROR]: this.queryAllError.bind(this), + [EntityOp.QUERY_ALL_SUCCESS]: this.queryAllSuccess.bind(this), + + [EntityOp.QUERY_BY_KEY]: this.queryByKey.bind(this), + [EntityOp.QUERY_BY_KEY_ERROR]: this.queryByKeyError.bind(this), + [EntityOp.QUERY_BY_KEY_SUCCESS]: this.queryByKeySuccess.bind(this), + + [EntityOp.QUERY_LOAD]: this.queryLoad.bind(this), + [EntityOp.QUERY_LOAD_ERROR]: this.queryLoadError.bind(this), + [EntityOp.QUERY_LOAD_SUCCESS]: this.queryLoadSuccess.bind(this), + + [EntityOp.QUERY_MANY]: this.queryMany.bind(this), + [EntityOp.QUERY_MANY_ERROR]: this.queryManyError.bind(this), + [EntityOp.QUERY_MANY_SUCCESS]: this.queryManySuccess.bind(this), + + [EntityOp.SAVE_ADD_ONE]: this.saveAddOne.bind(this), + [EntityOp.SAVE_ADD_ONE_ERROR]: this.saveAddOneError.bind(this), + [EntityOp.SAVE_ADD_ONE_SUCCESS]: this.saveAddOneSuccess.bind(this), + + [EntityOp.SAVE_DELETE_ONE]: this.saveDeleteOne.bind(this), + [EntityOp.SAVE_DELETE_ONE_ERROR]: this.saveDeleteOneError.bind(this), + [EntityOp.SAVE_DELETE_ONE_SUCCESS]: this.saveDeleteOneSuccess.bind(this), + + [EntityOp.SAVE_UPDATE_ONE]: this.saveUpdateOne.bind(this), + [EntityOp.SAVE_UPDATE_ONE_ERROR]: this.saveUpdateOneError.bind(this), + [EntityOp.SAVE_UPDATE_ONE_SUCCESS]: this.saveUpdateOneSuccess.bind(this), + + // Do nothing on save errors except turn the loading flag off. + // See the ChangeTrackerMetaReducers + // Or the app could listen for those errors and do something + + /// cache only operations /// + + [EntityOp.ADD_ALL]: this.addAll.bind(this), + [EntityOp.ADD_MANY]: this.addMany.bind(this), + [EntityOp.ADD_ONE]: this.addOne.bind(this), + + [EntityOp.REMOVE_ALL]: this.removeAll.bind(this), + [EntityOp.REMOVE_MANY]: this.removeMany.bind(this), + [EntityOp.REMOVE_ONE]: this.removeOne.bind(this), + + [EntityOp.UPDATE_MANY]: this.updateMany.bind(this), + [EntityOp.UPDATE_ONE]: this.updateOne.bind(this), + + [EntityOp.UPSERT_MANY]: this.upsertMany.bind(this), + [EntityOp.UPSERT_ONE]: this.upsertOne.bind(this), + + [EntityOp.COMMIT_ALL]: this.commitAll.bind(this), + [EntityOp.COMMIT_MANY]: this.commitMany.bind(this), + [EntityOp.COMMIT_ONE]: this.commitOne.bind(this), + [EntityOp.UNDO_ALL]: this.undoAll.bind(this), + [EntityOp.UNDO_MANY]: this.undoMany.bind(this), + [EntityOp.UNDO_ONE]: this.undoOne.bind(this), + + [EntityOp.SET_CHANGE_STATE]: this.setChangeState.bind(this), + [EntityOp.SET_COLLECTION]: this.setCollection.bind(this), + [EntityOp.SET_FILTER]: this.setFilter.bind(this), + [EntityOp.SET_LOADED]: this.setLoaded.bind(this), + [EntityOp.SET_LOADING]: this.setLoading.bind(this) + }; + + constructor( + public entityName: string, + public definition: EntityDefinition, + /* + * Track changes to entities since the last query or save + * Can revert some or all of those changes + */ + public entityChangeTracker?: EntityChangeTracker + ) { + this.adapter = definition.entityAdapter; + this.isChangeTracking = definition.noChangeTracking !== true; + this.selectId = definition.selectId; + + this.guard = new EntityActionGuard(entityName, this.selectId); + this.toUpdate = toUpdateFactory(this.selectId); + + if (!entityChangeTracker) { + this.entityChangeTracker = new EntityChangeTrackerBase(this.adapter, this.selectId); + } + } + + /** Cancel a persistence operation */ + protected cancelPersist(collection: EntityCollection): EntityCollection { + return this.setLoadingFalse(collection); + } + + // #region query operations + + protected queryAll(collection: EntityCollection): EntityCollection { + return this.setLoadingTrue(collection); + } + + protected queryAllError(collection: EntityCollection, action: EntityAction): EntityCollection { + return this.setLoadingFalse(collection); + } + + /** + * Merges query results per the MergeStrategy + * Sets loading flag to false and loaded flag to true. + */ + protected queryAllSuccess(collection: EntityCollection, action: EntityAction): EntityCollection { + const data = this.extractData(action); + const mergeStrategy = this.extractMergeStrategy(action); + return { + ...this.entityChangeTracker.mergeQueryResults(data, collection, mergeStrategy), + loaded: true, + loading: false + }; + } + + protected queryByKey(collection: EntityCollection, action: EntityAction): EntityCollection { + return this.setLoadingTrue(collection); + } + + protected queryByKeyError(collection: EntityCollection, action: EntityAction): EntityCollection { + return this.setLoadingFalse(collection); + } + + protected queryByKeySuccess(collection: EntityCollection, action: EntityAction): EntityCollection { + const data = this.extractData(action); + const mergeStrategy = this.extractMergeStrategy(action); + collection = data == null ? collection : this.entityChangeTracker.mergeQueryResults([data], collection, mergeStrategy); + return this.setLoadingFalse(collection); + } + + protected queryLoad(collection: EntityCollection): EntityCollection { + return this.setLoadingTrue(collection); + } + + protected queryLoadError(collection: EntityCollection, action: EntityAction): EntityCollection { + return this.setLoadingFalse(collection); + } + + /** + * Replaces all entities in the collection + * Sets loaded flag to true, loading flag to false, + * and clears changeState for the entire collection. + */ + protected queryLoadSuccess(collection: EntityCollection, action: EntityAction): EntityCollection { + const data = this.extractData(action); + return { + ...this.adapter.addAll(data, collection), + loading: false, + loaded: true, + changeState: {} + }; + } + + protected queryMany(collection: EntityCollection, action: EntityAction): EntityCollection { + return this.setLoadingTrue(collection); + } + + protected queryManyError(collection: EntityCollection, action: EntityAction): EntityCollection { + return this.setLoadingFalse(collection); + } + + protected queryManySuccess(collection: EntityCollection, action: EntityAction): EntityCollection { + const data = this.extractData(action); + const mergeStrategy = this.extractMergeStrategy(action); + return { + ...this.entityChangeTracker.mergeQueryResults(data, collection, mergeStrategy), + loading: false + }; + } + // #endregion query operations + + // #region save operations + + /** + * Save a new entity. + * If saving pessimistically, delay adding to collection until server acknowledges success. + * If saving optimistically; add entity immediately. + * @param collection The collection to which the entity should be added. + * @param action The action payload holds options, including whether the save is optimistic, + * and the data, which must be an entity. + * If saving optimistically, the entity must have a key. + */ + protected saveAddOne(collection: EntityCollection, action: EntityAction): EntityCollection { + if (this.isOptimistic(action)) { + const entity = this.guard.mustBeEntity(action); // ensure the entity has a PK + const mergeStrategy = this.extractMergeStrategy(action); + collection = this.entityChangeTracker.trackAddOne(entity, collection, mergeStrategy); + collection = this.adapter.addOne(entity, collection); + } + return this.setLoadingTrue(collection); + } + + /** + * Attempt to save a new entity failed or timed-out. + * Action holds the error. + * If saved pessimistically, the entity is not in the collection and + * you may not have to compensate for the error. + * If saved optimistically, the unsaved entity is in the collection and + * you may need to compensate for the error. + */ + protected saveAddOneError(collection: EntityCollection, action: EntityAction): EntityCollection { + return this.setLoadingFalse(collection); + } + + /** + * Successfully saved a new entity to the server. + * If saved pessimistically, add the entity from the server to the collection. + * If saved optimistically, the added entity is already in the collection. + * However, the server might have set or modified other fields (e.g, concurrency field) + * Therefore, update the entity in the collection with the returned value (if any) + * Caution: in a race, this update could overwrite unsaved user changes. + * Use pessimistic add to avoid this risk. + */ + protected saveAddOneSuccess(collection: EntityCollection, action: EntityAction) { + // For pessimistic save, ensure the server generated the primary key if the client didn't send one. + const entity = this.guard.mustBeEntity(action); + const mergeStrategy = this.extractMergeStrategy(action); + if (this.isOptimistic(action)) { + const update = this.toUpdate(entity); + collection = this.entityChangeTracker.mergeSaveUpdates([update], collection, mergeStrategy, true /*skip unchanged*/); + } else { + collection = this.entityChangeTracker.mergeSaveAdds([entity], collection, mergeStrategy); + } + return this.setLoadingFalse(collection); + } + + /** + * Delete an entity from the server by key and remove it from the collection (if present). + * If the entity is an unsaved new entity, remove it from the collection immediately + * and skip the server delete request. + * If an existing entity, an optimistic save removes the entity from the collection immediately + * and a pessimistic save removes it after the server confirms successful delete. + * @param collection Will remove the entity with this key from the collection. + * @param action The action payload holds options, including whether the save is optimistic, + * and the data, which must be a primary key or an entity with a key; + * this reducer extracts the key from the entity. + */ + protected saveDeleteOne(collection: EntityCollection, action: EntityAction): EntityCollection { + const toDelete = this.extractData(action); + const deleteId = typeof toDelete === 'object' ? this.selectId(toDelete) : toDelete; + const change = collection.changeState[deleteId]; + // If entity is already tracked ... + if (change) { + if (change.changeType === ChangeType.Added) { + // Remove the added entity immediately and forget about its changes (via commit). + collection = this.adapter.removeOne(deleteId as string, collection); + collection = this.entityChangeTracker.commitOne(deleteId, collection); + // Should not waste effort trying to delete on the server because it can't be there. + action.payload.skip = true; + } else { + // Re-track it as a delete, even if tracking is turned off for this call. + collection = this.entityChangeTracker.trackDeleteOne(deleteId, collection); + } + } + + // If optimistic delete, track current state and remove immediately. + if (this.isOptimistic(action)) { + const mergeStrategy = this.extractMergeStrategy(action); + collection = this.entityChangeTracker.trackDeleteOne(deleteId, collection, mergeStrategy); + collection = this.adapter.removeOne(deleteId as string, collection); + } + + return this.setLoadingTrue(collection); + } + + /** + * Attempt to delete the entity on the server failed or timed-out. + * Action holds the error. + * If saved pessimistically, the entity could still be in the collection and + * you may not have to compensate for the error. + * If saved optimistically, the entity is not in the collection and + * you may need to compensate for the error. + */ + protected saveDeleteOneError(collection: EntityCollection, action: EntityAction): EntityCollection { + return this.setLoadingFalse(collection); + } + + /** + * Successfully deleted entity on the server. The key of the deleted entity is in the action payload data. + * If saved pessimistically, if the entity is still in the collection it will be removed. + * If saved optimistically, the entity has already been removed from the collection. + */ + protected saveDeleteOneSuccess(collection: EntityCollection, action: EntityAction): EntityCollection { + const deleteId = this.extractData(action); + if (this.isOptimistic(action)) { + const mergeStrategy = this.extractMergeStrategy(action); + collection = this.entityChangeTracker.mergeSaveDeletes([deleteId], collection, mergeStrategy); + } else { + // Pessimistic: ignore mergeStrategy. Remove entity from the collection and from change tracking. + collection = this.adapter.removeOne(deleteId as string, collection); + collection = this.entityChangeTracker.commitOne(deleteId, collection); + } + return this.setLoadingFalse(collection); + } + + /** + * Save an update to an existing entity. + * If saving pessimistically, update the entity in the collection after the server confirms success. + * If saving optimistically, update the entity immediately, before the save request. + * @param collection The collection to update + * @param action The action payload holds options, including if the save is optimistic, + * and the data which, must be an {Update} + */ + protected saveUpdateOne(collection: EntityCollection, action: EntityAction>): EntityCollection { + const update = this.guard.mustBeUpdate(action); + if (this.isOptimistic(action)) { + const mergeStrategy = this.extractMergeStrategy(action); + collection = this.entityChangeTracker.trackUpdateOne(update, collection, mergeStrategy); + collection = this.adapter.updateOne(update, collection); + } + return this.setLoadingTrue(collection); + } + + /** + * Attempt to update the entity on the server failed or timed-out. + * Action holds the error. + * If saved pessimistically, the entity in the collection is in the pre-save state + * you may not have to compensate for the error. + * If saved optimistically, the entity in the collection was updated + * and you may need to compensate for the error. + */ + protected saveUpdateOneError(collection: EntityCollection, action: EntityAction): EntityCollection { + return this.setLoadingFalse(collection); + } + + /** + * Successfully saved the updated entity to the server. + * If saved pessimistically, update the entity in the collection with data from the server. + * If saved optimistically, the entity was already updated in the collection. + * However, the server might have set or modified other fields (e.g, concurrency field) + * Therefore, update the entity in the collection with the returned value (if any) + * Caution: in a race, this update could overwrite unsaved user changes. + * Use pessimistic update to avoid this risk. + * @param collection The collection to update + * @param action The action payload holds options, including if the save is optimistic, + * and the data which, must be an {Update} + */ + protected saveUpdateOneSuccess(collection: EntityCollection, action: EntityAction>): EntityCollection { + const update = this.guard.mustBeUpdate(action) as UpdateData; + const isOptimistic = this.isOptimistic(action); + const mergeStrategy = this.extractMergeStrategy(action); + collection = this.entityChangeTracker.mergeSaveUpdates([update], collection, mergeStrategy, isOptimistic /*skip unchanged*/); + return this.setLoadingFalse(collection); + } + // #endregion save operations + + // #region cache-only operations + + /** + * Replaces all entities in the collection + * Sets loaded flag to true. + * Merges query results, preserving unsaved changes + */ + protected addAll(collection: EntityCollection, action: EntityAction): EntityCollection { + const entities = this.guard.mustBeEntities(action); + return { + ...this.adapter.addAll(entities, collection), + loading: false, + loaded: true, + changeState: {} + }; + } + + protected addMany(collection: EntityCollection, action: EntityAction): EntityCollection { + const entities = this.guard.mustBeEntities(action); + const mergeStrategy = this.extractMergeStrategy(action); + collection = this.entityChangeTracker.trackAddMany(entities, collection, mergeStrategy); + return this.adapter.addMany(entities, collection); + } + + protected addOne(collection: EntityCollection, action: EntityAction): EntityCollection { + const entity = this.guard.mustBeEntity(action); + const mergeStrategy = this.extractMergeStrategy(action); + collection = this.entityChangeTracker.trackAddOne(entity, collection, mergeStrategy); + return this.adapter.addOne(entity, collection); + } + + protected removeMany(collection: EntityCollection, action: EntityAction): EntityCollection { + // payload must be entity keys + const keys = this.guard.mustBeKeys(action) as string[]; + const mergeStrategy = this.extractMergeStrategy(action); + collection = this.entityChangeTracker.trackDeleteMany(keys, collection, mergeStrategy); + return this.adapter.removeMany(keys, collection); + } + + protected removeOne(collection: EntityCollection, action: EntityAction): EntityCollection { + // payload must be entity key + const key = this.guard.mustBeKey(action) as string; + const mergeStrategy = this.extractMergeStrategy(action); + collection = this.entityChangeTracker.trackDeleteOne(key, collection, mergeStrategy); + return this.adapter.removeOne(key, collection); + } + + protected removeAll(collection: EntityCollection, action: EntityAction): EntityCollection { + return { + ...this.adapter.removeAll(collection), + loaded: false, // Only REMOVE_ALL sets loaded to false + loading: false, + changeState: {} // Assume clearing the collection and not trying to delete all entities + }; + } + + protected updateMany(collection: EntityCollection, action: EntityAction[]>): EntityCollection { + // payload must be an array of `Updates`, not entities + const updates = this.guard.mustBeUpdates(action); + const mergeStrategy = this.extractMergeStrategy(action); + collection = this.entityChangeTracker.trackUpdateMany(updates, collection, mergeStrategy); + return this.adapter.updateMany(updates, collection); + } + + protected updateOne(collection: EntityCollection, action: EntityAction>): EntityCollection { + // payload must be an `Update`, not an entity + const update = this.guard.mustBeUpdate(action); + const mergeStrategy = this.extractMergeStrategy(action); + collection = this.entityChangeTracker.trackUpdateOne(update, collection, mergeStrategy); + return this.adapter.updateOne(update, collection); + } + + protected upsertMany(collection: EntityCollection, action: EntityAction): EntityCollection { + // `, not entities + // v6+: payload must be an array of T + const entities = this.guard.mustBeEntities(action); + const mergeStrategy = this.extractMergeStrategy(action); + collection = this.entityChangeTracker.trackUpsertMany(entities, collection, mergeStrategy); + return this.adapter.upsertMany(entities, collection); + } + + protected upsertOne(collection: EntityCollection, action: EntityAction): EntityCollection { + // `, not an entity + // v6+: payload must be a T + const entity = this.guard.mustBeEntity(action); + const mergeStrategy = this.extractMergeStrategy(action); + collection = this.entityChangeTracker.trackUpsertOne(entity, collection, mergeStrategy); + return this.adapter.upsertOne(entity, collection); + } + + protected commitAll(collection: EntityCollection) { + return this.entityChangeTracker.commitAll(collection); + } + + protected commitMany(collection: EntityCollection, action: EntityAction) { + return this.entityChangeTracker.commitMany(this.extractData(action), collection); + } + + protected commitOne(collection: EntityCollection, action: EntityAction) { + return this.entityChangeTracker.commitOne(this.extractData(action), collection); + } + + protected undoAll(collection: EntityCollection) { + return this.entityChangeTracker.undoAll(collection); + } + + protected undoMany(collection: EntityCollection, action: EntityAction) { + return this.entityChangeTracker.undoMany(this.extractData(action), collection); + } + + protected undoOne(collection: EntityCollection, action: EntityAction) { + return this.entityChangeTracker.undoOne(this.extractData(action), collection); + } + + /** Dangerous: Completely replace the collection's ChangeState. Use rarely and wisely. */ + protected setChangeState(collection: EntityCollection, action: EntityAction>) { + const changeState = this.extractData(action); + return collection.changeState === changeState ? collection : { ...collection, changeState }; + } + + /** + * Dangerous: Completely replace the collection. + * Primarily for testing and rehydration from local storage. + * Use rarely and wisely. + */ + protected setCollection(collection: EntityCollection, action: EntityAction>) { + const newCollection = this.extractData(action); + return collection === newCollection ? collection : newCollection; + } + + protected setFilter(collection: EntityCollection, action: EntityAction): EntityCollection { + const filter = this.extractData(action); + return collection.filter === filter ? collection : { ...collection, filter }; + } + + protected setLoaded(collection: EntityCollection, action: EntityAction): EntityCollection { + const loaded = this.extractData(action) === true || false; + return collection.loaded === loaded ? collection : { ...collection, loaded }; + } + + protected setLoading(collection: EntityCollection, action: EntityAction): EntityCollection { + return this.setLoadingFlag(collection, this.extractData(action)); + } + + protected setLoadingFalse(collection: EntityCollection): EntityCollection { + return this.setLoadingFlag(collection, false); + } + + protected setLoadingTrue(collection: EntityCollection): EntityCollection { + return this.setLoadingFlag(collection, true); + } + + /** Set the collection's loading flag */ + protected setLoadingFlag(collection: EntityCollection, loading: boolean) { + loading = loading === true ? true : false; + return collection.loading === loading ? collection : { ...collection, loading }; + } + // #endregion Cache-only operations + + // #region helpers + /** Safely extract data from the EntityAction payload */ + protected extractData(action: EntityAction): D { + return action.payload && action.payload.data; + } + + /** Safely extract MergeStrategy from EntityAction. Set to IgnoreChanges if collection itself is not tracked. */ + protected extractMergeStrategy(action: EntityAction) { + // If not tracking this collection, always ignore changes + return this.isChangeTracking ? action.payload && action.payload.mergeStrategy : MergeStrategy.IgnoreChanges; + } + + protected isOptimistic(action: EntityAction) { + return action.payload && action.payload.isOptimistic === true; + } + + // #endregion helpers +} + +/** + * Creates {EntityCollectionReducerMethods} for a given entity type. + */ +@Injectable() +export class EntityCollectionReducerMethodsFactory { + constructor(private entityDefinitionService: EntityDefinitionService) {} + + /** Create the {EntityCollectionReducerMethods} for the named entity type */ + create(entityName: string): EntityCollectionReducerMethodMap { + const definition = this.entityDefinitionService.getDefinition(entityName); + const methodsClass = new EntityCollectionReducerMethods(entityName, definition); + + return methodsClass.methods; + } +} diff --git a/lib/src/reducers/entity-collection-reducer-registry.spec.ts b/lib/src/reducers/entity-collection-reducer-registry.spec.ts new file mode 100644 index 00000000..77bd1e90 --- /dev/null +++ b/lib/src/reducers/entity-collection-reducer-registry.spec.ts @@ -0,0 +1,277 @@ +import { TestBed } from '@angular/core/testing'; +import { Action, ActionReducer, MetaReducer } from '@ngrx/store'; + +import { EntityAction } from '../actions/entity-action'; +import { EntityActionFactory } from '../actions/entity-action-factory'; +import { EntityCache } from './entity-cache'; +import { EntityCacheReducerFactory } from './entity-cache-reducer-factory'; +import { EntityCollection } from './entity-collection'; +import { EntityCollectionCreator } from './entity-collection-creator'; +import { ENTITY_COLLECTION_META_REDUCERS } from './constants'; +import { EntityCollectionReducerFactory } from './entity-collection-reducer'; +import { EntityCollectionReducerMethodsFactory } from './entity-collection-reducer-methods'; +import { EntityCollectionReducerRegistry, EntityCollectionReducers } from './entity-collection-reducer-registry'; +import { EntityDefinitionService } from '../entity-metadata/entity-definition.service'; +import { EntityMetadataMap, ENTITY_METADATA_TOKEN } from '../entity-metadata/entity-metadata'; +import { EntityOp } from '../actions/entity-op'; +import { IdSelector } from '../utils/ngrx-entity-models'; +import { Logger } from '../utils/interfaces'; + +class Bar { + id: number; + bar: string; +} +class Foo { + id: string; + foo: string; +} +class Hero { + id: number; + name: string; + power?: string; +} +class Villain { + key: string; + name: string; +} + +const metadata: EntityMetadataMap = { + Hero: {}, + Villain: { selectId: (villain: Villain) => villain.key } +}; +describe('EntityCollectionReducerRegistry', () => { + let collectionCreator: EntityCollectionCreator; + let entityActionFactory: EntityActionFactory; + let entityCacheReducer: ActionReducer; + let entityCollectionReducerRegistry: EntityCollectionReducerRegistry; + + beforeEach(() => { + entityActionFactory = new EntityActionFactory(); + const logger = jasmine.createSpyObj('Logger', ['error', 'log', 'warn']); + + TestBed.configureTestingModule({ + providers: [ + EntityCacheReducerFactory, + EntityCollectionCreator, + { + provide: EntityCollectionReducerMethodsFactory, + useClass: EntityCollectionReducerMethodsFactory + }, + EntityCollectionReducerFactory, + EntityCollectionReducerRegistry, + EntityDefinitionService, + { provide: ENTITY_METADATA_TOKEN, multi: true, useValue: metadata }, + { provide: Logger, useValue: logger } + ] + }); + }); + + /** Sets the test variables with injected values. Closes TestBed configuration. */ + function setup() { + collectionCreator = TestBed.get(EntityCollectionCreator); + const entityCacheReducerFactory = TestBed.get(EntityCacheReducerFactory) as EntityCacheReducerFactory; + entityCacheReducer = entityCacheReducerFactory.create(); + entityCollectionReducerRegistry = TestBed.get(EntityCollectionReducerRegistry); + } + + describe('#registerReducer', () => { + beforeEach(setup); + + it('can register a new reducer', () => { + const reducer = createNoopReducer(); + entityCollectionReducerRegistry.registerReducer('Foo', reducer); + const action = entityActionFactory.create('Foo', EntityOp.ADD_ONE, { + id: 'forty-two', + foo: 'fooz' + }); + // Must initialize the state by hand + const state = entityCacheReducer({}, action); + const collection = state['Foo']; + expect(collection.ids.length).toBe(0, 'ADD_ONE should not add'); + }); + + it('can replace existing reducer by registering with same name', () => { + // Just like ADD_ONE test above with default reducer + // but this time should not add the hero. + const hero: Hero = { id: 42, name: 'Bobby' }; + const reducer = createNoopReducer(); + entityCollectionReducerRegistry.registerReducer('Hero', reducer); + const action = entityActionFactory.create('Hero', EntityOp.ADD_ONE, hero); + const state = entityCacheReducer({}, action); + const collection = state['Hero']; + expect(collection.ids.length).toBe(0, 'ADD_ONE should not add'); + }); + }); + + describe('#registerReducers', () => { + beforeEach(setup); + + it('can register several reducers at the same time.', () => { + const reducer = createNoopReducer(); + const reducers: EntityCollectionReducers = { + Foo: reducer, + Bar: reducer + }; + entityCollectionReducerRegistry.registerReducers(reducers); + + const fooAction = entityActionFactory.create('Foo', EntityOp.ADD_ONE, { id: 'forty-two', foo: 'fooz' }); + const barAction = entityActionFactory.create('Bar', EntityOp.ADD_ONE, { id: 84, bar: 'baz' }); + + let state = entityCacheReducer({}, fooAction); + state = entityCacheReducer(state, barAction); + + expect(state['Foo'].ids.length).toBe(0, 'ADD_ONE Foo should not add'); + expect(state['Bar'].ids.length).toBe(0, 'ADD_ONE Bar should not add'); + }); + + it('can register several reducers that may override.', () => { + const reducer = createNoopReducer(); + const reducers: EntityCollectionReducers = { + Foo: reducer, + Hero: reducer + }; + entityCollectionReducerRegistry.registerReducers(reducers); + + const fooAction = entityActionFactory.create('Foo', EntityOp.ADD_ONE, { id: 'forty-two', foo: 'fooz' }); + const heroAction = entityActionFactory.create('Hero', EntityOp.ADD_ONE, { id: 84, name: 'Alex' }); + + let state = entityCacheReducer({}, fooAction); + state = entityCacheReducer(state, heroAction); + + expect(state['Foo'].ids.length).toBe(0, 'ADD_ONE Foo should not add'); + expect(state['Hero'].ids.length).toBe(0, 'ADD_ONE Hero should not add'); + }); + }); + + describe('with EntityCollectionMetadataReducers', () => { + let metaReducerA: MetaReducer; + let metaReducerB: MetaReducer; + let metaReducerOutput: any[]; + + // Create MetaReducer that reports how it was called on the way in and out + function testMetadataReducerFactory(name: string) { + // Return the MetaReducer + return (r: ActionReducer) => { + // Return the wrapped reducer + return (state: EntityCollection, action: EntityAction) => { + // entered + metaReducerOutput.push({ metaReducer: name, inOut: 'in', action }); + // called reducer + const newState = r(state, action); + // exited + metaReducerOutput.push({ metaReducer: name, inOut: 'out', action }); + return newState; + }; + }; + } + + let addOneAction: EntityAction; + let hero: Hero; + + beforeEach(() => { + metaReducerOutput = []; + metaReducerA = jasmine.createSpy('metaReducerA').and.callFake(testMetadataReducerFactory('A')); + metaReducerB = jasmine.createSpy('metaReducerA').and.callFake(testMetadataReducerFactory('B')); + const metaReducers = [metaReducerA, metaReducerB]; + + TestBed.configureTestingModule({ + providers: [{ provide: ENTITY_COLLECTION_META_REDUCERS, useValue: metaReducers }] + }); + + setup(); + + hero = { id: 42, name: 'Bobby' }; + addOneAction = entityActionFactory.create('Hero', EntityOp.ADD_ONE, hero); + }); + + it('should run inner default reducer as expected', () => { + const state = entityCacheReducer({}, addOneAction); + + // inner default reducer worked as expected + const collection = state['Hero']; + expect(collection.ids.length).toBe(1, 'should have added one'); + expect(collection.entities[42]).toEqual(hero, 'should be added hero'); + }); + + it('should call meta reducers for inner default reducer as expected', () => { + const expected = [ + { metaReducer: 'A', inOut: 'in', action: addOneAction }, + { metaReducer: 'B', inOut: 'in', action: addOneAction }, + { metaReducer: 'B', inOut: 'out', action: addOneAction }, + { metaReducer: 'A', inOut: 'out', action: addOneAction } + ]; + + const state = entityCacheReducer({}, addOneAction); + expect(metaReducerA).toHaveBeenCalled(); + expect(metaReducerB).toHaveBeenCalled(); + expect(metaReducerOutput).toEqual(expected); + }); + + it('should call meta reducers for custom registered reducer', () => { + const reducer = createNoopReducer(); + entityCollectionReducerRegistry.registerReducer('Foo', reducer); + const action = entityActionFactory.create('Foo', EntityOp.ADD_ONE, { + id: 'forty-two', + foo: 'fooz' + }); + + const state = entityCacheReducer({}, action); + expect(metaReducerA).toHaveBeenCalled(); + expect(metaReducerB).toHaveBeenCalled(); + }); + + it('should call meta reducers for multiple registered reducers', () => { + const reducer = createNoopReducer(); + const reducers: EntityCollectionReducers = { + Foo: reducer, + Hero: reducer + }; + entityCollectionReducerRegistry.registerReducers(reducers); + + const fooAction = entityActionFactory.create('Foo', EntityOp.ADD_ONE, { id: 'forty-two', foo: 'fooz' }); + + entityCacheReducer({}, fooAction); + expect(metaReducerA).toHaveBeenCalled(); + expect(metaReducerB).toHaveBeenCalled(); + + const heroAction = entityActionFactory.create('Hero', EntityOp.ADD_ONE, { id: 84, name: 'Alex' }); + + entityCacheReducer({}, heroAction); + expect(metaReducerA).toHaveBeenCalledTimes(2); + expect(metaReducerB).toHaveBeenCalledTimes(2); + }); + }); + + // #region helpers + function createCollection(entityName: string, data: T[], selectId: IdSelector) { + return { + ...collectionCreator.create(entityName), + ids: data.map(e => selectId(e)) as string[] | number[], + entities: data.reduce( + (acc, e) => { + acc[selectId(e)] = e; + return acc; + }, + {} as any + ) + } as EntityCollection; + } + + function createInitialCache(entityMap: { [entityName: string]: any[] }) { + const cache: EntityCache = {}; + // tslint:disable-next-line:forin + for (const entityName in entityMap) { + const selectId = metadata[entityName].selectId || ((entity: any) => entity.id); + cache[entityName] = createCollection(entityName, entityMap[entityName], selectId); + } + + return cache; + } + + function createNoopReducer() { + return function NoopReducer(collection: EntityCollection, action: EntityAction): EntityCollection { + return collection; + }; + } + // #endregion helpers +}); diff --git a/lib/src/reducers/entity-collection-reducer-registry.ts b/lib/src/reducers/entity-collection-reducer-registry.ts new file mode 100644 index 00000000..7a2f5dda --- /dev/null +++ b/lib/src/reducers/entity-collection-reducer-registry.ts @@ -0,0 +1,75 @@ +import { Inject, Injectable, Optional } from '@angular/core'; +import { ActionReducer, compose, MetaReducer } from '@ngrx/store'; + +import { EntityAction } from '../actions/entity-action'; +import { EntityCollection } from './entity-collection'; +import { ENTITY_COLLECTION_META_REDUCERS } from './constants'; +import { EntityCollectionReducer, EntityCollectionReducerFactory } from './entity-collection-reducer'; + +/** A hash of EntityCollectionReducers */ +export interface EntityCollectionReducers { + [entity: string]: EntityCollectionReducer; +} + +/** + * Registry of entity types and their previously-constructed reducers. + * Can create a new CollectionReducer, which it registers for subsequent use. + */ +@Injectable() +export class EntityCollectionReducerRegistry { + protected entityCollectionReducers: EntityCollectionReducers = {}; + private entityCollectionMetaReducer: MetaReducer; + + constructor( + private entityCollectionReducerFactory: EntityCollectionReducerFactory, + @Optional() + @Inject(ENTITY_COLLECTION_META_REDUCERS) + entityCollectionMetaReducers?: MetaReducer[] + ) { + this.entityCollectionMetaReducer = compose.apply(null, entityCollectionMetaReducers || []); + } + + /** + * Get the registered EntityCollectionReducer for this entity type or create one and register it. + * @param entityName Name of the entity type for this reducer + */ + getOrCreateReducer(entityName: string): EntityCollectionReducer { + let reducer: EntityCollectionReducer = this.entityCollectionReducers[entityName]; + + if (!reducer) { + reducer = this.entityCollectionReducerFactory.create(entityName); + reducer = this.registerReducer(entityName, reducer); + this.entityCollectionReducers[entityName] = reducer; + } + return reducer; + } + + /** + * Register an EntityCollectionReducer for an entity type + * @param entityName - the name of the entity type + * @param reducer - reducer for that entity type + * + * Examples: + * registerReducer('Hero', myHeroReducer); + * registerReducer('Villain', myVillainReducer); + */ + registerReducer(entityName: string, reducer: EntityCollectionReducer): ActionReducer, EntityAction> { + reducer = this.entityCollectionMetaReducer(reducer); + return (this.entityCollectionReducers[entityName.trim()] = reducer); + } + + /** + * Register a batch of EntityCollectionReducers. + * @param reducers - reducers to merge into existing reducers + * + * Examples: + * registerReducers({ + * Hero: myHeroReducer, + * Villain: myVillainReducer + * }); + */ + registerReducers(reducers: EntityCollectionReducers) { + const keys = reducers ? Object.keys(reducers) : []; + keys.forEach(key => this.registerReducer(key, reducers[key])); + } +} diff --git a/lib/src/reducers/entity-collection-reducer.spec.ts b/lib/src/reducers/entity-collection-reducer.spec.ts index e393fa12..8974f9f7 100644 --- a/lib/src/reducers/entity-collection-reducer.spec.ts +++ b/lib/src/reducers/entity-collection-reducer.spec.ts @@ -1,17 +1,17 @@ +// EntityCollectionReducer tests - tests of reducers for entity collections in the entity cache +// Tests for EntityCache-level reducers (e.g., SET_ENTITY_CACHE) are in `entity-cache-reducer.spec.ts` import { Action } from '@ngrx/store'; import { EntityAdapter } from '@ngrx/entity'; -import { EntityAction, EntityActionFactory } from '../actions/entity-action'; +import { DataServiceError, EntityActionDataServiceError } from '../dataservices/data-service-error'; +import { EntityAction, EntityActionOptions } from '../actions/entity-action'; +import { EntityActionFactory } from '../actions/entity-action-factory'; import { EntityOp } from '../actions/entity-op'; -import { EntityCollection } from './entity-collection'; +import { EntityCollection, ChangeState, ChangeStateMap, ChangeType } from './entity-collection'; import { EntityCache } from './entity-cache'; -import { - MERGE_ENTITY_CACHE, - SET_ENTITY_CACHE -} from '../actions/entity-cache-actions'; import { EntityCollectionCreator } from './entity-collection-creator'; -import { DefaultEntityCollectionReducerMethodsFactory } from './default-entity-collection-reducer-methods'; +import { EntityCollectionReducerMethodsFactory } from './entity-collection-reducer-methods'; import { EntityDefinitionService } from '../entity-metadata/entity-definition.service'; import { EntityMetadataMap } from '../entity-metadata/entity-metadata'; @@ -19,14 +19,10 @@ import { Logger } from '../utils/interfaces'; import { toUpdateFactory } from '../utils/utilities'; import { Dictionary, IdSelector, Update } from '../utils/ngrx-entity-models'; -import { - EntityCollectionReducer, - EntityCollectionReducerFactory -} from './entity-collection-reducer'; -import { - EntityCollectionReducers, - EntityReducerFactory -} from './entity-reducer'; +import { EntityCollectionReducer, EntityCollectionReducerFactory } from './entity-collection-reducer'; +import { EntityCollectionReducerRegistry } from './entity-collection-reducer-registry'; +import { EntityCollectionReducers } from './entity-collection-reducer-registry'; +import { EntityCacheReducerFactory } from './entity-cache-reducer-factory'; class Foo { id: string; @@ -53,12 +49,13 @@ describe('EntityCollectionReducer', () => { const createAction: ( entityName: string, op: EntityOp, - payload?: any + data?: any, + options?: EntityActionOptions ) => EntityAction = entityActionFactory.create.bind(entityActionFactory); const toHeroUpdate = toUpdateFactory(); - let entityReducerFactory: EntityReducerFactory; + let entityReducerRegistry: EntityCollectionReducerRegistry; let entityReducer: (state: EntityCache, action: Action) => EntityCache; let initialHeroes: Hero[]; @@ -69,32 +66,33 @@ describe('EntityCollectionReducer', () => { beforeEach(() => { const eds = new EntityDefinitionService([metadata]); collectionCreator = new EntityCollectionCreator(eds); - const collectionReducerMethodsFactory = new DefaultEntityCollectionReducerMethodsFactory( - eds - ); - const collectionReducerFactory = new EntityCollectionReducerFactory( - collectionReducerMethodsFactory - ); + const collectionReducerMethodsFactory = new EntityCollectionReducerMethodsFactory(eds); + const collectionReducerFactory = new EntityCollectionReducerFactory(collectionReducerMethodsFactory); logger = jasmine.createSpyObj('Logger', ['error', 'log', 'warn']); - entityReducerFactory = new EntityReducerFactory( - collectionCreator, - collectionReducerFactory, - logger - ); + entityReducerRegistry = new EntityCollectionReducerRegistry(collectionReducerFactory); + const entityCacheReducerFactory = new EntityCacheReducerFactory(collectionCreator, entityReducerRegistry, logger); + entityReducer = entityCacheReducerFactory.create(); - entityReducer = entityReducerFactory.create(); - - initialHeroes = [ - { id: 2, name: 'B', power: 'Fast' }, - { id: 1, name: 'A', power: 'invisible' } - ]; + initialHeroes = [{ id: 2, name: 'B', power: 'Fast' }, { id: 1, name: 'A', power: 'Invisible' }]; initialCache = createInitialCache({ Hero: initialHeroes }); }); - // Tests for EntityCache-level actions (e.g., SET_ENTITY_CACHE) are in `entity-reducer.spec.ts` + it('should ignore an action without an EntityOp', () => { + // should not throw + const action = { + type: 'does-not-matter', + payload: { + entityName: 'Hero', + entityOp: undefined as EntityOp + } + }; + const newCache = entityReducer(initialCache, action); + expect(newCache).toBe(initialCache, 'cache unchanged'); + }); - describe('#QUERY_ALL', () => { + // #region queries + describe('QUERY_ALL', () => { const queryAction = createAction('Hero', EntityOp.QUERY_ALL); it('QUERY_ALL sets loading flag but does not fill collection', () => { @@ -105,44 +103,23 @@ describe('EntityCollectionReducer', () => { expect(collection.loading).toBe(true, 'should be loading'); }); - it('QUERY_ALL_SUCCESS clears loading flag and fills collection', () => { + it('QUERY_ALL_SUCCESS can create the initial collection', () => { let state = entityReducer({}, queryAction); const heroes: Hero[] = [{ id: 2, name: 'B' }, { id: 1, name: 'A' }]; const action = createAction('Hero', EntityOp.QUERY_ALL_SUCCESS, heroes); state = entityReducer(state, action); const collection = state['Hero']; - expect(collection.ids).toEqual( - [2, 1], - 'should have expected ids in load order' - ); + expect(collection.ids).toEqual([2, 1], 'should have expected ids in load order'); expect(collection.entities['1']).toBe(heroes[1], 'hero with id:1'); expect(collection.entities['2']).toBe(heroes[0], 'hero with id:2'); - expect(collection.loaded).toBe(true, 'should be loaded'); - expect(collection.loading).toBe(false, 'should not be loading'); }); - it('QUERY_ALL_SUCCESS replaces previous collection contents with new contents', () => { - let state: EntityCache = { - Hero: { - ids: [42], - entities: { 42: { id: 42, name: 'Fribit' } }, - filter: 'xxx', - loaded: true, - loading: false, - originalValues: {} - } - }; - state = entityReducer(state, queryAction); + it('QUERY_ALL_SUCCESS sets the loaded flag and clears loading flag', () => { + let state = entityReducer({}, queryAction); const heroes: Hero[] = [{ id: 2, name: 'B' }, { id: 1, name: 'A' }]; const action = createAction('Hero', EntityOp.QUERY_ALL_SUCCESS, heroes); state = entityReducer(state, action); const collection = state['Hero']; - expect(collection.ids).toEqual( - [2, 1], - 'should have expected ids in load order' - ); - expect(collection.entities['1']).toBe(heroes[1], 'hero with id:1'); - expect(collection.entities['2']).toBe(heroes[0], 'hero with id:2'); expect(collection.loaded).toBe(true, 'should be loaded'); expect(collection.loading).toBe(false, 'should not be loading'); }); @@ -159,29 +136,80 @@ describe('EntityCollectionReducer', () => { it('QUERY_ALL_SUCCESS works for "Villain" entity with non-id primary key', () => { let state = entityReducer({}, queryAction); - const villains: Villain[] = [ - { key: '2', name: 'B' }, - { key: '1', name: 'A' } - ]; - const action = createAction( - 'Villain', - EntityOp.QUERY_ALL_SUCCESS, - villains - ); + const villains: Villain[] = [{ key: '2', name: 'B' }, { key: '1', name: 'A' }]; + const action = createAction('Villain', EntityOp.QUERY_ALL_SUCCESS, villains); state = entityReducer(state, action); const collection = state['Villain']; - expect(collection.ids).toEqual( - ['2', '1'], - 'should have expected ids in load order' - ); + expect(collection.ids).toEqual(['2', '1'], 'should have expected ids in load order'); expect(collection.entities['1']).toBe(villains[1], 'villain with key:1'); expect(collection.entities['2']).toBe(villains[0], 'villain with key:2'); expect(collection.loaded).toBe(true, 'should be loaded'); expect(collection.loading).toBe(false, 'should not be loading'); }); + + it('QUERY_ALL_SUCCESS can add to existing collection', () => { + let state = entityReducer(initialCache, queryAction); + const heroes: Hero[] = [{ id: 3, name: 'C' }]; + const action = createAction('Hero', EntityOp.QUERY_ALL_SUCCESS, heroes); + state = entityReducer(state, action); + const collection = state['Hero']; + + expect(collection.ids).toEqual([2, 1, 3], 'should have expected ids in load order'); + }); + + it('QUERY_ALL_SUCCESS can update existing collection', () => { + let state = entityReducer(initialCache, queryAction); + const heroes: Hero[] = [{ id: 1, name: 'A+' }]; + const action = createAction('Hero', EntityOp.QUERY_ALL_SUCCESS, heroes); + state = entityReducer(state, action); + const collection = state['Hero']; + + expect(collection.ids).toEqual([2, 1], 'should have expected ids in load order'); + expect(collection.entities['1'].name).toBe('A+', 'should update hero:1'); + }); + + it('QUERY_ALL_SUCCESS can add and update existing collection', () => { + let state = entityReducer(initialCache, queryAction); + const heroes: Hero[] = [{ id: 3, name: 'C' }, { id: 1, name: 'A+' }]; + const action = createAction('Hero', EntityOp.QUERY_ALL_SUCCESS, heroes); + state = entityReducer(state, action); + const collection = state['Hero']; + + expect(collection.ids).toEqual([2, 1, 3], 'should have expected ids in load order'); + expect(collection.entities['1'].name).toBe('A+', 'should update hero:1'); + }); + + it('QUERY_ALL_SUCCESS overwrites changeState.originalValue for updated entity', () => { + const { entityCache, preUpdatedEntity, updatedEntity } = createTestTrackedEntities(); + const queriedUpdate = { ...updatedEntity, name: 'Queried update' }; + + // a new entity and yet another version of the entity that is currently updated but not saved. + const queryResults: Hero[] = [{ id: 100, name: 'X' }, queriedUpdate]; + const action = createAction('Hero', EntityOp.QUERY_ALL_SUCCESS, queryResults); + const collection = entityReducer(entityCache, action)['Hero']; + const originalValue = collection.changeState[updatedEntity.id].originalValue; + + expect(collection.entities[updatedEntity.id]).toEqual(updatedEntity, 'current value still the update'); + expect(originalValue).toBeDefined('entity still in changeState'); + expect(originalValue).not.toEqual(preUpdatedEntity, 'no longer the initial entity'); + expect(originalValue).not.toEqual(updatedEntity, 'not the updated entity either'); + expect(originalValue).toEqual(queriedUpdate, 'originalValue is now the queried entity'); + }); + + it('QUERY_ALL_SUCCESS works when the query results are empty', () => { + let state = entityReducer(initialCache, queryAction); + const action = createAction('Hero', EntityOp.QUERY_ALL_SUCCESS, []); + state = entityReducer(state, action); + const collection = state['Hero']; + + expect(collection.entities).toBe(initialCache['Hero'].entities, 'collection.entities should be untouched'); + expect(collection.ids).toBe(initialCache['Hero'].ids, 'collection.entities should be untouched'); + expect(collection.ids).toEqual([2, 1], 'ids were not mutated'); + expect(collection).not.toBe(initialCache['Hero'], 'collection changed by loading flag'); + }); }); - describe('#QUERY_BY_KEY', () => { + describe('QUERY_BY_KEY', () => { const queryAction = createAction('Hero', EntityOp.QUERY_BY_KEY); it('QUERY_BY_KEY sets loading flag but does not touch the collection', () => { @@ -199,10 +227,7 @@ describe('EntityCollectionReducer', () => { state = entityReducer(state, action); const collection = state['Hero']; - expect(collection.ids).toEqual( - [3], - 'should have expected ids in load order' - ); + expect(collection.ids).toEqual([3], 'should have expected ids in load order'); expect(collection.loaded).toBe(false, 'should not be loaded'); expect(collection.loading).toBe(false, 'should not be loading'); }); @@ -214,10 +239,7 @@ describe('EntityCollectionReducer', () => { state = entityReducer(state, action); const collection = state['Hero']; - expect(collection.ids).toEqual( - [2, 1, 3], - 'should have expected ids in load order' - ); + expect(collection.ids).toEqual([2, 1, 3], 'should have expected ids in load order'); }); it('QUERY_BY_KEY_SUCCESS can update existing collection', () => { @@ -227,37 +249,38 @@ describe('EntityCollectionReducer', () => { state = entityReducer(state, action); const collection = state['Hero']; - expect(collection.ids).toEqual( - [2, 1], - 'should have expected ids in load order' - ); + expect(collection.ids).toEqual([2, 1], 'should have expected ids in load order'); expect(collection.entities['1'].name).toBe('A+', 'should update hero:1'); }); + it('QUERY_BY_KEY_SUCCESS updates the originalValue of a pending update', () => { + const { entityCache, preUpdatedEntity, updatedEntity } = createTestTrackedEntities(); + const queriedUpdate = { ...updatedEntity, name: 'Queried update' }; + const action = createAction('Hero', EntityOp.QUERY_BY_KEY_SUCCESS, queriedUpdate); + const collection = entityReducer(entityCache, action)['Hero']; + const originalValue = collection.changeState[updatedEntity.id].originalValue; + + expect(collection.entities[updatedEntity.id]).toEqual(updatedEntity, 'current value still the update'); + expect(originalValue).toBeDefined('entity still in changeState'); + expect(originalValue).not.toEqual(preUpdatedEntity, 'no longer the initial entity'); + expect(originalValue).not.toEqual(updatedEntity, 'not the updated entity either'); + expect(originalValue).toEqual(queriedUpdate, 'originalValue is now the queried entity'); + }); + // Normally would 404 but maybe this API just returns an empty result. it('QUERY_BY_KEY_SUCCESS works when the query results are empty', () => { let state = entityReducer(initialCache, queryAction); - const action = createAction( - 'Hero', - EntityOp.QUERY_BY_KEY_SUCCESS, - undefined - ); + const action = createAction('Hero', EntityOp.QUERY_BY_KEY_SUCCESS, undefined); state = entityReducer(state, action); const collection = state['Hero']; - expect(collection.entities).toBe( - initialCache['Hero'].entities, - 'collection.entities should be untouched' - ); - expect(collection.ids).toBe( - initialCache['Hero'].ids, - 'collection.entities should be untouched' - ); + expect(collection.entities).toBe(initialCache['Hero'].entities, 'collection.entities should be untouched'); + expect(collection.ids).toBe(initialCache['Hero'].ids, 'collection.entities should be untouched'); expect(collection.ids).toEqual([2, 1], 'ids were not mutated'); }); }); - describe('#QUERY_MANY', () => { + describe('QUERY_MANY', () => { const queryAction = createAction('Hero', EntityOp.QUERY_MANY); it('QUERY_MANY sets loading flag but does not touch the collection', () => { @@ -275,10 +298,7 @@ describe('EntityCollectionReducer', () => { state = entityReducer(state, action); const collection = state['Hero']; - expect(collection.ids).toEqual( - [3], - 'should have expected ids in load order' - ); + expect(collection.ids).toEqual([3], 'should have expected ids in load order'); expect(collection.loaded).toBe(false, 'should not be loaded'); expect(collection.loading).toBe(false, 'should not be loading'); }); @@ -290,10 +310,7 @@ describe('EntityCollectionReducer', () => { state = entityReducer(state, action); const collection = state['Hero']; - expect(collection.ids).toEqual( - [2, 1, 3], - 'should have expected ids in load order' - ); + expect(collection.ids).toEqual([2, 1, 3], 'should have expected ids in load order'); }); it('QUERY_MANY_SUCCESS can update existing collection', () => { @@ -303,10 +320,7 @@ describe('EntityCollectionReducer', () => { state = entityReducer(state, action); const collection = state['Hero']; - expect(collection.ids).toEqual( - [2, 1], - 'should have expected ids in load order' - ); + expect(collection.ids).toEqual([2, 1], 'should have expected ids in load order'); expect(collection.entities['1'].name).toBe('A+', 'should update hero:1'); }); @@ -317,77 +331,138 @@ describe('EntityCollectionReducer', () => { state = entityReducer(state, action); const collection = state['Hero']; - expect(collection.ids).toEqual( - [2, 1, 3], - 'should have expected ids in load order' - ); + expect(collection.ids).toEqual([2, 1, 3], 'should have expected ids in load order'); expect(collection.entities['1'].name).toBe('A+', 'should update hero:1'); }); + it('QUERY_MANY_SUCCESS overwrites changeState.originalValue for updated entity', () => { + const { entityCache, preUpdatedEntity, updatedEntity } = createTestTrackedEntities(); + const queriedUpdate = { ...updatedEntity, name: 'Queried update' }; + + // a new entity and yet another version of the entity that is currently updated but not saved. + const queryResults: Hero[] = [{ id: 100, name: 'X' }, queriedUpdate]; + const action = createAction('Hero', EntityOp.QUERY_MANY_SUCCESS, queryResults); + const collection = entityReducer(entityCache, action)['Hero']; + const originalValue = collection.changeState[updatedEntity.id].originalValue; + + expect(collection.entities[updatedEntity.id]).toEqual(updatedEntity, 'current value still the update'); + expect(originalValue).toBeDefined('entity still in changeState'); + expect(originalValue).not.toEqual(preUpdatedEntity, 'no longer the initial entity'); + expect(originalValue).not.toEqual(updatedEntity, 'not the updated entity either'); + expect(originalValue).toEqual(queriedUpdate, 'originalValue is now the queried entity'); + }); + it('QUERY_MANY_SUCCESS works when the query results are empty', () => { let state = entityReducer(initialCache, queryAction); const action = createAction('Hero', EntityOp.QUERY_MANY_SUCCESS, []); state = entityReducer(state, action); const collection = state['Hero']; - expect(collection.entities).toBe( - initialCache['Hero'].entities, - 'collection.entities should be untouched' - ); - expect(collection.ids).toBe( - initialCache['Hero'].ids, - 'collection.entities should be untouched' - ); + expect(collection.entities).toBe(initialCache['Hero'].entities, 'collection.entities should be untouched'); + expect(collection.ids).toBe(initialCache['Hero'].ids, 'collection.entities should be untouched'); expect(collection.ids).toEqual([2, 1], 'ids were not mutated'); - expect(collection).not.toBe( - initialCache['Hero'], - 'collection changed by loading flag' - ); + expect(collection).not.toBe(initialCache['Hero'], 'collection changed by loading flag'); }); }); - // Pessimistic SAVE_ADD_ONE operation should not touch the entities until success - // See tests for this below + describe('CANCEL_PERSIST', () => { + it('should only clear the loading flag', () => { + const { entityCache } = createTestTrackedEntities(); + let cache = entityReducer(entityCache, createAction('Hero', EntityOp.SET_LOADING, true)); + expect(cache['Hero'].loading).toBe(true, 'loading flag on at start'); + cache = entityReducer(cache, createAction('Hero', EntityOp.CANCEL_PERSIST, undefined, { correlationId: 42 })); + expect(cache['Hero'].loading).toBe(false, 'loading flag on at start'); + expect(cache).toEqual(entityCache, 'the rest of the cache is untouched'); + }); + }); - describe('#SAVE_ADD_ONE_OPTIMISTIC', () => { - function createTestAction(hero: Hero) { - return createAction('Hero', EntityOp.SAVE_ADD_ONE_OPTIMISTIC, hero); - } + describe('QUERY_LOAD', () => { + const queryAction = createAction('Hero', EntityOp.QUERY_LOAD); - it('should add a new hero to collection', () => { - const hero: Hero = { id: 13, name: 'New One', power: 'Strong' }; - const action = createTestAction(hero); - const state = entityReducer(initialCache, action); + it('QUERY_LOAD sets loading flag but does not fill collection', () => { + const state = entityReducer({}, queryAction); const collection = state['Hero']; + expect(collection.ids.length).toBe(0, 'should be empty collection'); + expect(collection.loaded).toBe(false, 'should not be loaded'); + expect(collection.loading).toBe(true, 'should be loading'); + }); - expect(collection.ids).toEqual([2, 1, 13], 'should have new hero'); + it('QUERY_LOAD_SUCCESS fills collection, clears loading flag, and sets loaded flag', () => { + let state = entityReducer({}, queryAction); + const heroes: Hero[] = [{ id: 2, name: 'B' }, { id: 1, name: 'A' }]; + const action = createAction('Hero', EntityOp.QUERY_LOAD_SUCCESS, heroes); + state = entityReducer(state, action); + const collection = state['Hero']; + expect(collection.ids).toEqual([2, 1], 'should have expected ids in load order'); + expect(collection.entities['1']).toBe(heroes[1], 'hero with id:1'); + expect(collection.entities['2']).toBe(heroes[0], 'hero with id:2'); + expect(collection.loaded).toBe(true, 'should be loaded'); + expect(collection.loading).toBe(false, 'should not be loading'); }); - it('should error if new hero lacks its pkey', () => { - const hero = { name: 'New One', power: 'Strong' }; - // bad add, no id. - const action = createTestAction(hero); - const state = entityReducer(initialCache, action); - expect(state).toBe(initialCache); - expect(action.error.message).toMatch(/missing or invalid entity key/); + it('QUERY_LOAD_SUCCESS clears changeState', () => { + const { entityCache, preUpdatedEntity, updatedEntity } = createTestTrackedEntities(); + + // Completely replaces existing Hero entities + const heroes: Hero[] = [{ id: 1000, name: 'X' }, { ...updatedEntity, name: 'Queried update' }]; + const action = createAction('Hero', EntityOp.QUERY_LOAD_SUCCESS, heroes); + const collection: EntityCollection = entityReducer(entityCache, action)['Hero']; + const { ids, changeState } = collection; + expect(changeState).toEqual({} as ChangeStateMap); + expect(ids).toEqual([1000, updatedEntity.id]); // no sort so in load order }); - it('should NOT update an existing entity in collection', () => { - const hero: Hero = { id: 2, name: 'B+' }; - const action = createTestAction(hero); - const state = entityReducer(initialCache, action); + it('QUERY_LOAD_SUCCESS replaces collection contents with queried entities', () => { + let state: EntityCache = { + Hero: { + entityName: 'Hero', + ids: [42], + entities: { 42: { id: 42, name: 'Fribit' } }, + filter: 'xxx', + loaded: true, + loading: false, + changeState: {} + } + }; + state = entityReducer(state, queryAction); + const heroes: Hero[] = [{ id: 2, name: 'B' }, { id: 1, name: 'A' }]; + const action = createAction('Hero', EntityOp.QUERY_LOAD_SUCCESS, heroes); + state = entityReducer(state, action); const collection = state['Hero']; + expect(collection.ids).toEqual([2, 1], 'should have expected ids in load order'); + expect(collection.entities['1']).toBe(heroes[1], 'hero with id:1'); + expect(collection.entities['2']).toBe(heroes[0], 'hero with id:2'); + }); - expect(collection.ids).toEqual([2, 1], 'ids are the same'); - expect(collection.entities[2].name).toBe('B', 'same old name'); - // unmentioned property stays the same - expect(collection.entities[2].power).toBe('Fast', 'power'); + it('QUERY_LOAD_ERROR clears loading flag and does not fill collection', () => { + let state = entityReducer({}, queryAction); + const action = createAction('Hero', EntityOp.QUERY_LOAD_ERROR); + state = entityReducer(state, action); + const collection = state['Hero']; + expect(collection.loading).toBe(false, 'should not be loading'); + expect(collection.loaded).toBe(false, 'should not be loaded'); + expect(collection.ids.length).toBe(0, 'should be empty collection'); + }); + + it('QUERY_LOAD_SUCCESS works for "Villain" entity with non-id primary key', () => { + let state = entityReducer({}, queryAction); + const villains: Villain[] = [{ key: '2', name: 'B' }, { key: '1', name: 'A' }]; + const action = createAction('Villain', EntityOp.QUERY_LOAD_SUCCESS, villains); + state = entityReducer(state, action); + const collection = state['Villain']; + expect(collection.ids).toEqual(['2', '1'], 'should have expected ids in load order'); + expect(collection.entities['1']).toBe(villains[1], 'villain with key:1'); + expect(collection.entities['2']).toBe(villains[0], 'villain with key:2'); + expect(collection.loaded).toBe(true, 'should be loaded'); + expect(collection.loading).toBe(false, 'should not be loading'); }); }); + // #endregion queries - describe('#SAVE_ADD_ONE_SUCCESS (Pessimistic)', () => { + // #region saves + describe('SAVE_ADD_ONE (Optimistic)', () => { function createTestAction(hero: Hero) { - return createAction('Hero', EntityOp.SAVE_ADD_ONE_SUCCESS, hero); + return createAction('Hero', EntityOp.SAVE_ADD_ONE, hero, { isOptimistic: true }); } it('should add a new hero to collection', () => { @@ -396,7 +471,7 @@ describe('EntityCollectionReducer', () => { const state = entityReducer(initialCache, action); const collection = state['Hero']; - expect(collection.ids).toEqual([2, 1, 13], 'no new hero'); + expect(collection.ids).toEqual([2, 1, 13], 'should have new hero'); }); it('should error if new hero lacks its pkey', () => { @@ -405,7 +480,7 @@ describe('EntityCollectionReducer', () => { const action = createTestAction(hero); const state = entityReducer(initialCache, action); expect(state).toBe(initialCache); - expect(action.error.message).toMatch(/missing or invalid entity key/); + expect(action.payload.error.message).toMatch(/missing or invalid entity key/); }); it('should NOT update an existing entity in collection', () => { @@ -421,34 +496,35 @@ describe('EntityCollectionReducer', () => { }); }); - describe('#SAVE_ADD_ONE_OPTIMISTIC_SUCCESS', () => { + describe('SAVE_ADD_ONE (Pessimistic)', () => { + it('should only set the loading flag', () => { + const addedEntity = { id: 42, name: 'New Guy' }; + const action = createAction('Hero', EntityOp.SAVE_ADD_ONE, addedEntity); + expectOnlySetLoadingFlag(action, initialCache); + }); + }); + + describe('SAVE_ADD_ONE_SUCCESS (Optimistic)', () => { function createTestAction(hero: Hero) { - return createAction( - 'Hero', - EntityOp.SAVE_ADD_ONE_OPTIMISTIC_SUCCESS, - hero - ); + return createAction('Hero', EntityOp.SAVE_ADD_ONE_SUCCESS, hero, { isOptimistic: true }); } - // The hero was already added to the collection by SAVE_ADD_ONE_OPTIMISTIC. + // The hero was already added to the collection by SAVE_ADD_ONE. it('should NOT add a new hero to collection', () => { - // pretend this hero was added by SAVE_ADD_ONE_OPTIMISTIC and returned by server with changes + // pretend this hero was added by SAVE_ADD_ONE and returned by server with changes const hero: Hero = { id: 13, name: 'New One', power: 'Strong' }; const action = createTestAction(hero); const state = entityReducer(initialCache, action); const collection = state['Hero']; - expect(collection.ids).toEqual( - [2, 1], - 'should have same ids, no added hero' - ); + expect(collection.ids).toEqual([2, 1], 'should have same ids, no added hero'); }); - // The hero was already added to the collection by SAVE_ADD_ONE_OPTIMISTIC - // You cannot change the key with SAVE_ADD_ONE_OPTIMISTIC_SUCCESS + // The hero was already added to the collection by SAVE_ADD_ONE + // You cannot change the key with SAVE_ADD_ONE_SUCCESS // You'd have to do it with SAVE_UPDATE_ONE... it('should NOT change the id of a newly added hero', () => { - // pretend this hero was added by SAVE_ADD_ONE_OPTIMISTIC and returned by server with new ID + // pretend this hero was added by SAVE_ADD_ONE and returned by server with new ID const hero = initialHeroes[0]; hero.id = 13; @@ -465,10 +541,10 @@ describe('EntityCollectionReducer', () => { const action = createTestAction(hero); const state = entityReducer(initialCache, action); expect(state).toBe(initialCache); - expect(action.error.message).toMatch(/missing or invalid entity key/); + expect(action.payload.error.message).toMatch(/missing or invalid entity key/); }); - // because the hero was already added to the collection by SAVE_ADD_ONE_OPTIMISTIC + // because the hero was already added to the collection by SAVE_ADD_ONE // should update values (but not id) if the server changed them // as it might with a concurrency property. it('should update an existing entity with that ID in collection', () => { @@ -485,121 +561,236 @@ describe('EntityCollectionReducer', () => { }); }); - // Pessimistic SAVE_DELETE_ONE operation should not remove the entity until success - // See tests for this below + describe('SAVE_ADD_ONE_SUCCESS (Pessimistic)', () => { + function createTestAction(hero: Hero) { + return createAction('Hero', EntityOp.SAVE_ADD_ONE_SUCCESS, hero); + } - describe('#SAVE_DELETE_ONE (Pessimistic)', () => { - it('should NOT remove the hero with SAVE_DELETE_ONE', () => { - const hero = initialHeroes[0]; - const action = createAction('Hero', EntityOp.SAVE_DELETE_ONE, hero); + it('should add a new hero to collection', () => { + const hero: Hero = { id: 13, name: 'New One', power: 'Strong' }; + const action = createTestAction(hero); + const collection = entityReducer(initialCache, action)['Hero']; + expect(collection.ids).toEqual([2, 1, 13], 'no new hero'); + }); + it('should error if new hero lacks its pkey', () => { + const hero = { name: 'New One', power: 'Strong' }; + // bad add, no id. + const action = createTestAction(hero); const state = entityReducer(initialCache, action); - const collection = state['Hero']; - expect(collection.entities[hero.id]).toBe(hero, 'hero still there'); + expect(state).toBe(initialCache); + expect(action.payload.error.message).toMatch(/missing or invalid entity key/); + }); + + it('should update an existing entity in collection', () => { + // ... because reducer calls mergeServerUpserts() + const hero: Hero = { id: 2, name: 'B+' }; + const action = createTestAction(hero); + const collection = entityReducer(initialCache, action)['Hero']; + + expect(collection.ids).toEqual([2, 1], 'ids are the same'); + expect(collection.entities[2].name).toBe('B+', 'same old name'); + // unmentioned property stays the same + expect(collection.entities[2].power).toBe('Fast', 'power'); + }); + }); + + describe('SAVE_ADD_ONE_ERROR', () => { + it('should only clear the loading flag', () => { + const { entityCache, addedEntity } = createTestTrackedEntities(); + const originalAction = createAction('Hero', EntityOp.SAVE_ADD_ONE, addedEntity); + const error: EntityActionDataServiceError = { + error: new DataServiceError(new Error('Test Error'), { method: 'POST', url: 'foo' }), + originalAction + }; + const action = createAction('Hero', EntityOp.SAVE_ADD_ONE_ERROR, error); + expectOnlySetLoadingFlag(action, entityCache); + }); + }); + + describe('SAVE_DELETE_ONE (Optimistic)', () => { + it('should immediately remove the existing hero', () => { + const hero = initialHeroes[0]; + expect(initialCache['Hero'].entities[hero.id]).toBe(hero, 'exists before delete'); + + const action = createAction('Hero', EntityOp.SAVE_DELETE_ONE, hero, { isOptimistic: true }); + + const collection = entityReducer(initialCache, action)['Hero']; + expect(collection.entities[hero.id]).toBeUndefined('hero removed'); expect(collection.loading).toBe(true, 'loading on'); }); - it('should NOT remove the hero with SAVE_DELETE_ERROR', () => { + it('should immediately remove the hero by id ', () => { const hero = initialHeroes[0]; - const action = createAction('Hero', EntityOp.SAVE_DELETE_ONE_ERROR, hero); + expect(initialCache['Hero'].entities[hero.id]).toBe(hero, 'exists before delete'); + + const action = createAction('Hero', EntityOp.SAVE_DELETE_ONE, hero.id, { isOptimistic: true }); + + const collection = entityReducer(initialCache, action)['Hero']; + expect(collection.entities[hero.id]).toBeUndefined('hero removed'); + expect(collection.loading).toBe(true, 'loading on'); + }); + + it('should immediately remove an unsaved added hero', () => { + const { entityCache, addedEntity } = createTestTrackedEntities(); + const id = addedEntity.id; + const action = createAction('Hero', EntityOp.SAVE_DELETE_ONE, id, { isOptimistic: true }); + const { entities, changeState } = entityReducer(entityCache, action)['Hero']; + expect(entities[id]).toBeUndefined('added entity removed'); + expect(changeState[id]).toBeUndefined('no longer tracked'); + expect(action.payload.skip).toBe(true, 'should skip save'); + }); + + it('should reclassify change of an unsaved updated hero to "deleted"', () => { + const { entityCache, updatedEntity } = createTestTrackedEntities(); + const id = updatedEntity.id; + const action = createAction('Hero', EntityOp.SAVE_DELETE_ONE, id, { isOptimistic: true }); + const collection = entityReducer(entityCache, action)['Hero']; + + expect(collection.entities[id]).toBeUndefined('updated entity removed from collection'); + const entityChangeState = collection.changeState[id]; + expect(entityChangeState).toBeDefined('updated entity still tracked'); + expect(entityChangeState.changeType).toBe(ChangeType.Deleted); + }); + + it('should be ok when the id is not in the collection', () => { + expect(initialCache['Hero'].entities[1000]).toBeUndefined('should not exist'); + + const action = createAction( + 'Hero', + EntityOp.SAVE_DELETE_ONE, + 1000, // id of entity that is not in the collection + { isOptimistic: true } + ); + + const collection = entityReducer(initialCache, action)['Hero']; + expect(collection.entities[1000]).toBeUndefined('hero removed'); + expect(collection.loading).toBe(true, 'loading on'); + }); + }); + + describe('SAVE_DELETE_ONE (Pessimistic)', () => { + it('should NOT remove the existing hero', () => { + const hero = initialHeroes[0]; + const action = createAction('Hero', EntityOp.SAVE_DELETE_ONE, hero); const state = entityReducer(initialCache, action); const collection = state['Hero']; expect(collection.entities[hero.id]).toBe(hero, 'hero still there'); - expect(collection.loading).toBe(false, 'loading off'); + expect(collection.loading).toBe(true, 'loading on'); }); - it('should remove the hero-by-id with SAVE_DELETE_ONE_SUCCESS', () => { - const hero = initialHeroes[0]; - expect(initialCache['Hero'].entities[hero.id]).toBe( - hero, - 'exists before delete' - ); + it('should immediately remove an unsaved added hero', () => { + const { entityCache, addedEntity } = createTestTrackedEntities(); + const id = addedEntity.id; + const action = createAction('Hero', EntityOp.SAVE_DELETE_ONE, id); + const { entities, changeState } = entityReducer(entityCache, action)['Hero']; + expect(entities[id]).toBeUndefined('added entity removed'); + expect(changeState[id]).toBeUndefined('no longer tracked'); + expect(action.payload.skip).toBe(true, 'should skip save'); + }); + + it('should reclassify change of an unsaved updated hero to "deleted"', () => { + const { entityCache, updatedEntity } = createTestTrackedEntities(); + const id = updatedEntity.id; + const action = createAction('Hero', EntityOp.SAVE_DELETE_ONE, id); + const collection = entityReducer(entityCache, action)['Hero']; + + expect(collection.entities[id]).toBeDefined('updated entity still in collection'); + const entityChangeState = collection.changeState[id]; + expect(entityChangeState).toBeDefined('updated entity still tracked'); + expect(entityChangeState.changeType).toBe(ChangeType.Deleted); + }); + }); + + describe('SAVE_DELETE_ONE_SUCCESS (Optimistic)', () => { + it('should turn loading flag off and clear change tracking for existing entity', () => { + const { entityCache, removedEntity } = createTestTrackedEntities(); + + // the action that would have saved the delete + const saveAction = createAction('Hero', EntityOp.SAVE_DELETE_ONE, removedEntity.id, { isOptimistic: true }); + + const { entities: initialEntities, changeState: initialChangeState } = entityCache['Hero']; + expect(initialChangeState[removedEntity.id]).toBeDefined('removed is tracked before save success'); const action = createAction( 'Hero', EntityOp.SAVE_DELETE_ONE_SUCCESS, - hero.id + removedEntity.id, // Pretend optimistically deleted this hero + { isOptimistic: true } ); - const state = entityReducer(initialCache, action); - const collection = state['Hero']; - - expect(collection.entities[hero.id]).toBeUndefined('hero removed'); + const collection = entityReducer(entityCache, action)['Hero']; + expect(collection.entities).toBe(initialEntities, 'entities untouched'); expect(collection.loading).toBe(false, 'loading off'); + expect(collection.changeState[removedEntity.id]).toBeUndefined('removed no longer tracked'); }); - it('should remove the hero with SAVE_DELETE_ONE_SUCCESS', () => { - const hero = initialHeroes[0]; - expect(initialCache['Hero'].entities[hero.id]).toBe( - hero, - 'exists before delete' - ); + it('should be ok when the id is not in the collection', () => { + expect(initialCache['Hero'].entities[1000]).toBeUndefined('should not exist'); const action = createAction( 'Hero', EntityOp.SAVE_DELETE_ONE_SUCCESS, - hero + 1000, // id of entity that is not in the collection + { isOptimistic: true } ); const state = entityReducer(initialCache, action); const collection = state['Hero']; - expect(collection.entities[hero.id]).toBeUndefined('hero removed'); + expect(collection.entities[1000]).toBeUndefined('hero removed'); expect(collection.loading).toBe(false, 'loading off'); }); }); - // Optimistic SAVE_DELETE_ONE_OPTIMISTIC operation should remove the entity immediately - // See tests for this below - describe('#SAVE_DELETE_ONE_OPTIMISTIC', () => { - it('should remove the hero immediately with SAVE_DELETE_ONE_OPTIMISTIC', () => { + describe('SAVE_DELETE_ONE_SUCCESS (Pessimistic)', () => { + it('should remove the hero by id', () => { const hero = initialHeroes[0]; - expect(initialCache['Hero'].entities[hero.id]).toBe( - hero, - 'exists before delete' - ); + expect(initialCache['Hero'].entities[hero.id]).toBe(hero, 'exists before delete'); - const action = createAction( - 'Hero', - EntityOp.SAVE_DELETE_ONE_OPTIMISTIC, - hero - ); + const action = createAction('Hero', EntityOp.SAVE_DELETE_ONE_SUCCESS, hero.id); const state = entityReducer(initialCache, action); const collection = state['Hero']; expect(collection.entities[hero.id]).toBeUndefined('hero removed'); - expect(collection.loading).toBe(true, 'loading on'); + expect(collection.loading).toBe(false, 'loading off'); }); - it('should remove the hero-by-id immediately with SAVE_DELETE_ONE_OPTIMISTIC', () => { - const hero = initialHeroes[0]; - expect(initialCache['Hero'].entities[hero.id]).toBe( - hero, - 'exists before delete' - ); + it('should be ok when the id is not in the collection', () => { + expect(initialCache['Hero'].entities[1000]).toBeUndefined('should not exist'); - const action = createAction( - 'Hero', - EntityOp.SAVE_DELETE_ONE_OPTIMISTIC, - hero.id - ); + const action = createAction('Hero', EntityOp.SAVE_DELETE_ONE_SUCCESS, 1000); const state = entityReducer(initialCache, action); const collection = state['Hero']; - expect(collection.entities[hero.id]).toBeUndefined('hero removed'); - expect(collection.loading).toBe(true, 'loading on'); + expect(collection.entities[1000]).toBeUndefined('hero removed'); + expect(collection.loading).toBe(false, 'loading off'); + }); + }); + + describe('SAVE_DELETE_ONE_ERROR', () => { + it('should only clear the loading flag', () => { + const { entityCache, removedEntity } = createTestTrackedEntities(); + const originalAction = createAction('Hero', EntityOp.SAVE_DELETE_ONE, removedEntity.id); + const error: EntityActionDataServiceError = { + error: new DataServiceError(new Error('Test Error'), { method: 'DELETE', url: 'foo' }), + originalAction + }; + const action = createAction('Hero', EntityOp.SAVE_DELETE_ONE_ERROR, error); + expectOnlySetLoadingFlag(action, entityCache); }); // No compensating action on error (yet) - it('should NOT restore the hero with SAVE_DELETE_ONE_OPTIMISTIC_ERROR', () => { + it('should NOT restore the hero after optimistic save', () => { const initialEntities = initialCache['Hero'].entities; const action = createAction( 'Hero', EntityOp.SAVE_DELETE_ONE_ERROR, - { id: 13, name: 'Deleted' } // Pretend optimistically deleted this hero + { id: 13, name: 'Deleted' }, // Pretend optimistically deleted this hero + { isOptimistic: true } ); const state = entityReducer(initialCache, action); @@ -608,27 +799,20 @@ describe('EntityCollectionReducer', () => { expect(collection.loading).toBe(false, 'loading off'); }); - it('should only turn loading flag off with SAVE_DELETE_ONE_OPTIMISTIC_SUCCESS', () => { - const initialEntities = initialCache['Hero'].entities; - const action = createAction( - 'Hero', - EntityOp.SAVE_DELETE_ONE_SUCCESS, - { id: 13, name: 'Deleted' } // Pretend optimistically deleted this hero - ); + it('should NOT remove the hero', () => { + const hero = initialHeroes[0]; + const action = createAction('Hero', EntityOp.SAVE_DELETE_ONE_ERROR, hero); const state = entityReducer(initialCache, action); const collection = state['Hero']; - expect(collection.entities).toBe(initialEntities, 'entities untouched'); + expect(collection.entities[hero.id]).toBe(hero, 'hero still there'); expect(collection.loading).toBe(false, 'loading off'); }); }); - // Pessimistic SAVE_UPDATE_ONE operation should not touch the entities until success - // See tests for this below - - describe('#SAVE_UPDATE_ONE_OPTIMISTIC', () => { + describe('SAVE_UPDATE_ONE (Optimistic)', () => { function createTestAction(hero: Update) { - return createAction('Hero', EntityOp.SAVE_UPDATE_ONE_OPTIMISTIC, hero); + return createAction('Hero', EntityOp.SAVE_UPDATE_ONE, hero, { isOptimistic: true }); } it('should update existing entity in collection', () => { @@ -668,9 +852,18 @@ describe('EntityCollectionReducer', () => { }); }); - describe('#SAVE_UPDATE_ONE_SUCCESS (Pessimistic)', () => { + describe('SAVE_UPDATE_ONE (Pessimistic)', () => { + it('should only set the loading flag', () => { + const updatedEntity = { ...initialHeroes[0], name: 'Updated' }; + const update = { id: updatedEntity.id, changes: updatedEntity }; + const action = createAction('Hero', EntityOp.SAVE_UPDATE_ONE, update); + expectOnlySetLoadingFlag(action, initialCache); + }); + }); + + describe('SAVE_UPDATE_ONE_SUCCESS (Optimistic)', () => { function createTestAction(hero: Update) { - return createAction('Hero', EntityOp.SAVE_UPDATE_ONE_SUCCESS, hero); + return createAction('Hero', EntityOp.SAVE_UPDATE_ONE_SUCCESS, hero, { isOptimistic: true }); } it('should update existing entity in collection', () => { @@ -700,7 +893,7 @@ describe('EntityCollectionReducer', () => { }); // Changed in v6. It used to add a new entity. - it('should add new hero to collection', () => { + it('should NOT add new hero to collection', () => { const hero: Hero = { id: 13, name: 'New One', power: 'Strong' }; const action = createTestAction(toHeroUpdate(hero)); const state = entityReducer(initialCache, action); @@ -710,13 +903,9 @@ describe('EntityCollectionReducer', () => { }); }); - describe('#SAVE_UPDATE_ONE_OPTIMISTIC_SUCCESS', () => { + describe('SAVE_UPDATE_ONE_SUCCESS (Pessimistic)', () => { function createTestAction(hero: Update) { - return createAction( - 'Hero', - EntityOp.SAVE_UPDATE_ONE_OPTIMISTIC_SUCCESS, - hero - ); + return createAction('Hero', EntityOp.SAVE_UPDATE_ONE_SUCCESS, hero); } it('should update existing entity in collection', () => { @@ -746,7 +935,7 @@ describe('EntityCollectionReducer', () => { }); // Changed in v6. It used to add a new entity. - it('should add new hero to collection', () => { + it('should NOT add new hero to collection', () => { const hero: Hero = { id: 13, name: 'New One', power: 'Strong' }; const action = createTestAction(toHeroUpdate(hero)); const state = entityReducer(initialCache, action); @@ -756,7 +945,22 @@ describe('EntityCollectionReducer', () => { }); }); - describe('#ADD_ONE', () => { + describe('SAVE_UPDATE_ONE_ERROR', () => { + it('should only clear the loading flag', () => { + const { entityCache, updatedEntity } = createTestTrackedEntities(); + const originalAction = createAction('Hero', EntityOp.SAVE_UPDATE_ONE, updatedEntity); + const error: EntityActionDataServiceError = { + error: new DataServiceError(new Error('Test Error'), { method: 'PUT', url: 'foo' }), + originalAction + }; + const action = createAction('Hero', EntityOp.SAVE_UPDATE_ONE_ERROR, error); + expectOnlySetLoadingFlag(action, entityCache); + }); + }); + // #endregion saves + + // #region cache-only + describe('ADD_ONE', () => { function createTestAction(hero: Hero) { return createAction('Hero', EntityOp.ADD_ONE, hero); } @@ -776,7 +980,7 @@ describe('EntityCollectionReducer', () => { const action = createTestAction(hero); const state = entityReducer(initialCache, action); expect(state).toBe(initialCache); - expect(action.error.message).toMatch(/missing or invalid entity key/); + expect(action.payload.error.message).toMatch(/missing or invalid entity key/); }); it('should NOT update an existing entity in collection', () => { @@ -792,7 +996,7 @@ describe('EntityCollectionReducer', () => { }); }); - describe('#UPDATE_MANY', () => { + describe('UPDATE_MANY', () => { function createTestAction(heroes: Update[]) { return createAction('Hero', EntityOp.UPDATE_MANY, heroes); } @@ -821,11 +1025,7 @@ describe('EntityCollectionReducer', () => { }); it('should update multiple existing entities in collection', () => { - const heroes: Hero[] = [ - { id: 1, name: 'A+' }, - { id: 2, name: 'B+' }, - { id: 3, name: 'New One' } - ]; + const heroes: Hero[] = [{ id: 1, name: 'A+' }, { id: 2, name: 'B+' }, { id: 3, name: 'New One' }]; const updates = heroes.map(h => toHeroUpdate(h)); const action = createTestAction(updates); const state = entityReducer(initialCache, action); @@ -853,7 +1053,7 @@ describe('EntityCollectionReducer', () => { }); }); - describe('#UPDATE_ONE', () => { + describe('UPDATE_ONE', () => { function createTestAction(hero: Update) { return createAction('Hero', EntityOp.UPDATE_ONE, hero); } @@ -873,7 +1073,7 @@ describe('EntityCollectionReducer', () => { const action = createTestAction(hero); const state = entityReducer(initialCache, action); expect(state).toBe(initialCache); - expect(action.error.message).toMatch(/missing or invalid entity key/); + expect(action.payload.error.message).toMatch(/missing or invalid entity key/); }); it('should update existing entity in collection', () => { @@ -903,7 +1103,7 @@ describe('EntityCollectionReducer', () => { }); }); - describe('#UPSERT_MANY', () => { + describe('UPSERT_MANY', () => { function createTestAction(heroes: Hero[]) { return createAction('Hero', EntityOp.UPSERT_MANY, heroes); } @@ -932,11 +1132,7 @@ describe('EntityCollectionReducer', () => { }); it('should update multiple existing entities in collection', () => { - const updates: Hero[] = [ - { id: 1, name: 'A+' }, - { id: 2, name: 'B+' }, - { id: 13, name: 'New One', power: 'Strong' } - ]; + const updates: Hero[] = [{ id: 1, name: 'A+' }, { id: 2, name: 'B+' }, { id: 13, name: 'New One', power: 'Strong' }]; const action = createTestAction(updates); const state = entityReducer(initialCache, action); const collection = state['Hero']; @@ -951,7 +1147,7 @@ describe('EntityCollectionReducer', () => { }); }); - describe('#UPSERT_ONE', () => { + describe('UPSERT_ONE', () => { function createTestAction(hero: Hero) { return createAction('Hero', EntityOp.UPSERT_ONE, hero); } @@ -982,11 +1178,7 @@ describe('EntityCollectionReducer', () => { describe('SET FLAGS', () => { it('should set filter value with SET_FILTER', () => { - const action = createAction( - 'Hero', - EntityOp.SET_FILTER, - 'test filter value' - ); + const action = createAction('Hero', EntityOp.SET_FILTER, 'test filter value'); const state = entityReducer(initialCache, action); const collection = state['Hero']; @@ -1006,83 +1198,15 @@ describe('EntityCollectionReducer', () => { it('should set loading flag with SET_LOADING', () => { const beforeLoading = initialCache['Hero'].loading; const expectedLoading = !beforeLoading; - const action = createAction( - 'Hero', - EntityOp.SET_LOADING, - expectedLoading - ); + const action = createAction('Hero', EntityOp.SET_LOADING, expectedLoading); const state = entityReducer(initialCache, action); const collection = state['Hero']; expect(collection.loading).toEqual(expectedLoading, 'loading flag'); }); }); + // #endregion cache-only - describe('"Do nothing" save actions', () => { - describe('ADD', () => { - [ - EntityOp.SAVE_ADD_ONE, - EntityOp.SAVE_ADD_ONE_ERROR, - EntityOp.SAVE_ADD_ONE_OPTIMISTIC_ERROR // no compensation - ].forEach(op => testAddNoop(op)); - - function testAddNoop(op: EntityOp) { - const hero: Hero = { id: 2, name: 'B+' }; - const action = createAction('Hero', op, hero); - shouldOnlySetLoadingFlag(action); - } - }); - - describe('DELETE', () => { - [ - EntityOp.SAVE_DELETE_ONE, - EntityOp.SAVE_DELETE_ONE_ERROR, - EntityOp.SAVE_DELETE_ONE_OPTIMISTIC_SUCCESS, - EntityOp.SAVE_DELETE_ONE_OPTIMISTIC_ERROR // no compensation - ].forEach(op => testDeleteNoop(op)); - - function testDeleteNoop(op: EntityOp) { - const action = createAction('Hero', op, 2); - shouldOnlySetLoadingFlag(action); - } - }); - - describe('UPDATE (when HTTP update returned nothing)', () => { - [ - EntityOp.SAVE_UPDATE_ONE, - EntityOp.SAVE_UPDATE_ONE_ERROR, - EntityOp.SAVE_UPDATE_ONE_OPTIMISTIC_SUCCESS, - EntityOp.SAVE_UPDATE_ONE_OPTIMISTIC_ERROR // no compensation - ].forEach(op => testUpdateNoop(op)); - - function testUpdateNoop(op: EntityOp) { - const hero: Hero = { id: 2, name: 'B+' }; - // A data service like `DefaultDataService` will add `unchanged:true` - // if the server responded without data, meaning there is nothing to - // update if already updated optimistically. - const update: any = { ...toHeroUpdate(hero), unchanged: true }; - const action = createAction('Hero', op, update); - shouldOnlySetLoadingFlag(action); - } - }); - - function shouldOnlySetLoadingFlag(action: EntityAction) { - const expectedLoadingFlag = !/error|success/i.test(action.op); - - it(`#${ - action.op - } should only set loading to ${expectedLoadingFlag}`, () => { - // Flag should be true when op starts, false after error or success - const initialCollection = initialCache['Hero']; - const newCollection = entityReducer(initialCache, action)['Hero']; - expect(newCollection.loading).toBe(expectedLoadingFlag, 'loading flag'); - expect({ - ...newCollection, - loading: initialCollection.loading // revert flag for test - }).toEqual(initialCollection); - }); - } - }); /** TODO: TEST REMAINING ACTIONS **/ /*** @@ -1092,79 +1216,64 @@ describe('EntityCollectionReducer', () => { ***/ describe('reducer override', () => { - const queryAllAction = createAction('Hero', EntityOp.QUERY_ALL); + const queryLoadAction = createAction('Hero', EntityOp.QUERY_LOAD); beforeEach(() => { const eds = new EntityDefinitionService([metadata]); const def = eds.getDefinition('Hero'); const reducer = createReadOnlyHeroReducer(def.entityAdapter); // override regular Hero reducer - entityReducerFactory.registerReducer('Hero', reducer); + entityReducerRegistry.registerReducer('Hero', reducer); }); // Make sure read-only reducer doesn't change QUERY_ALL behavior - it('QUERY_ALL_SUCCESS —clears loading flag and fills collection', () => { - let state = entityReducer({}, queryAllAction); + it('QUERY_LOAD_SUCCESS —clears loading flag and fills collection', () => { + let state = entityReducer({}, queryLoadAction); let collection = state['Hero']; expect(collection.loaded).toBe(false, 'should not be loaded at first'); expect(collection.loading).toBe(true, 'should be loading at first'); const heroes: Hero[] = [{ id: 2, name: 'B' }, { id: 1, name: 'A' }]; - const action = createAction('Hero', EntityOp.QUERY_ALL_SUCCESS, heroes); + const action = createAction('Hero', EntityOp.QUERY_LOAD_SUCCESS, heroes); state = entityReducer(state, action); collection = state['Hero']; - expect(collection.ids).toEqual( - [2, 1], - 'should have expected ids in load order' - ); + expect(collection.ids).toEqual([2, 1], 'should have expected ids in load order'); expect(collection.entities['1']).toBe(heroes[1], 'hero with id:1'); expect(collection.entities['2']).toBe(heroes[0], 'hero with id:2'); expect(collection.loaded).toBe(true, 'should be loaded '); expect(collection.loading).toBe(false, 'should not be loading'); }); - it('QUERY_ALL_ERROR clears loading flag and does not fill collection', () => { - let state = entityReducer({}, queryAllAction); - const action = createAction('Hero', EntityOp.QUERY_ALL_ERROR); + it('QUERY_LOAD_ERROR clears loading flag and does not fill collection', () => { + let state = entityReducer({}, queryLoadAction); + const action = createAction('Hero', EntityOp.QUERY_LOAD_ERROR); state = entityReducer(state, action); const collection = state['Hero']; expect(collection.loading).toBe(false, 'should not be loading'); expect(collection.ids.length).toBe(0, 'should be empty collection'); }); - it('QUERY_ALL_SUCCESS works for "Villain" entity with non-id primary key', () => { - let state = entityReducer({}, queryAllAction); - const villains: Villain[] = [ - { key: '2', name: 'B' }, - { key: '1', name: 'A' } - ]; - const action = createAction( - 'Villain', - EntityOp.QUERY_ALL_SUCCESS, - villains - ); + it('QUERY_LOAD_SUCCESS works for "Villain" entity with non-id primary key', () => { + let state = entityReducer({}, queryLoadAction); + const villains: Villain[] = [{ key: '2', name: 'B' }, { key: '1', name: 'A' }]; + const action = createAction('Villain', EntityOp.QUERY_LOAD_SUCCESS, villains); state = entityReducer(state, action); const collection = state['Villain']; expect(collection.loading).toBe(false, 'should not be loading'); - expect(collection.ids).toEqual( - ['2', '1'], - 'should have expected ids in load order' - ); + expect(collection.ids).toEqual(['2', '1'], 'should have expected ids in load order'); expect(collection.entities['1']).toBe(villains[1], 'villain with key:1'); expect(collection.entities['2']).toBe(villains[0], 'villain with key:2'); }); it('QUERY_MANY is illegal for "Hero" collection', () => { - const initialState = entityReducer({}, queryAllAction); + const initialState = entityReducer({}, queryLoadAction); const action = createAction('Hero', EntityOp.QUERY_MANY); const state = entityReducer(initialState, action); // Expect override reducer to throw error and for - // EntityReducer to catch it and set the `EntityAction.error` - expect(action.error.message).toMatch( - /illegal operation for the "Hero" collection/ - ); + // EntityReducer to catch it and set the `EntityAction.payload.error` + expect(action.payload.error.message).toMatch(/illegal operation for the "Hero" collection/); expect(state).toBe(initialState); }); @@ -1175,46 +1284,34 @@ describe('EntityCollectionReducer', () => { expect(collection.loading).toBe(true, 'should be loading'); }); - /** Make Hero collection readonly except for QUERY_ALL */ + /** Make Hero collection readonly except for QUERY_LOAD */ function createReadOnlyHeroReducer(adapter: EntityAdapter) { - return function heroReducer( - collection: EntityCollection, - action: EntityAction - ): EntityCollection { - switch (action.op) { - case EntityOp.QUERY_ALL: - return collection.loading - ? collection - : { ...collection, loading: true }; - - case EntityOp.QUERY_ALL_SUCCESS: + return function heroReducer(collection: EntityCollection, action: EntityAction): EntityCollection { + switch (action.payload.entityOp) { + case EntityOp.QUERY_LOAD: + return collection.loading ? collection : { ...collection, loading: true }; + + case EntityOp.QUERY_LOAD_SUCCESS: return { - ...adapter.addAll(action.payload, collection), + ...adapter.addAll(action.payload.data, collection), loaded: true, - loading: false + loading: false, + changeState: {} }; - case EntityOp.QUERY_ALL_ERROR: { - return collection.loading - ? { ...collection, loading: false } - : collection; + case EntityOp.QUERY_LOAD_ERROR: { + return collection.loading ? { ...collection, loading: false } : collection; } default: - throw new Error( - `${action.op} is an illegal operation for the "Hero" collection` - ); + throw new Error(`${action.payload.entityOp} is an illegal operation for the "Hero" collection`); } }; } }); // #region helpers - function createCollection( - entityName: string, - data: T[], - selectId: IdSelector - ) { + function createCollection(entityName: string, data: T[], selectId: IdSelector) { return { ...collectionCreator.create(entityName), ids: data.map(e => selectId(e)) as string[] | number[], @@ -1232,16 +1329,58 @@ describe('EntityCollectionReducer', () => { const cache: EntityCache = {}; // tslint:disable-next-line:forin for (const entityName in entityMap) { - const selectId = - metadata[entityName].selectId || ((entity: any) => entity.id); - cache[entityName] = createCollection( - entityName, - entityMap[entityName], - selectId - ); + const selectId = metadata[entityName].selectId || ((entity: any) => entity.id); + cache[entityName] = createCollection(entityName, entityMap[entityName], selectId); } return cache; } + + /** + * Prepare the state of the collection with some test data. + * Assumes that ADD_ALL, ADD_ONE, REMOVE_ONE, and UPDATE_ONE are working + */ + function createTestTrackedEntities() { + const startingHeroes = [ + { id: 2, name: 'B', power: 'Fast' }, + { id: 1, name: 'A', power: 'Invisible' }, + { id: 3, name: 'C', power: 'Strong' } + ]; + + const [removedEntity, preUpdatedEntity] = startingHeroes; + let action = createAction('Hero', EntityOp.ADD_ALL, startingHeroes); + let entityCache = entityReducer({}, action); + + const addedEntity = { id: 42, name: 'E', power: 'Smart' }; + action = createAction('Hero', EntityOp.ADD_ONE, addedEntity); + entityCache = entityReducer(entityCache, action); + + action = createAction('Hero', EntityOp.REMOVE_ONE, removedEntity.id); + entityCache = entityReducer(entityCache, action); + + const updatedEntity = { ...preUpdatedEntity, name: 'A Updated' }; + action = createAction('Hero', EntityOp.UPDATE_ONE, { id: updatedEntity.id, changes: updatedEntity }); + entityCache = entityReducer(entityCache, action); + + return { entityCache, addedEntity, removedEntity, preUpdatedEntity, startingHeroes, updatedEntity }; + } + + /** Test for ChangeState with expected ChangeType */ + function expectChangeType(change: ChangeState, expectedChangeType: ChangeType, msg?: string) { + expect(ChangeType[change.changeType]).toEqual(ChangeType[expectedChangeType], msg); + } + + /** Test that loading flag changed in expected way and the rest of the collection stayed the same. */ + function expectOnlySetLoadingFlag(action: EntityAction, entityCache: EntityCache) { + // Flag should be true when op starts, false after error or success + const expectedLoadingFlag = !/error|success/i.test(action.payload.entityOp); + const initialCollection = entityCache['Hero']; + const newCollection = entityReducer(entityCache, action)['Hero']; + expect(newCollection.loading).toBe(expectedLoadingFlag, 'loading flag'); + expect({ + ...newCollection, + loading: initialCollection.loading // revert flag for test + }).toEqual(initialCollection); + } // #endregion helpers }); diff --git a/lib/src/reducers/entity-collection-reducer.ts b/lib/src/reducers/entity-collection-reducer.ts index 2c1e6809..f7fb3e18 100644 --- a/lib/src/reducers/entity-collection-reducer.ts +++ b/lib/src/reducers/entity-collection-reducer.ts @@ -2,30 +2,9 @@ import { Injectable } from '@angular/core'; import { EntityAction } from '../actions/entity-action'; import { EntityCollection } from './entity-collection'; +import { EntityCollectionReducerMethodsFactory } from './entity-collection-reducer-methods'; -export type EntityCollectionReducer = ( - collection: EntityCollection, - action: EntityAction -) => EntityCollection; - -/** - * Map of {EntityOp} to reducer method for the operation. - * If an operation is missing, caller should return the collection for that reducer. - */ -export interface EntityCollectionReducerMethods { - [method: string]: ( - collection: EntityCollection, - action?: EntityAction - ) => EntityCollection; -} - -/** - * Creates {EntityCollectionReducerMethods} for a given entity type. - * See {DefaultEntityCollectionReducerMethodsFactory}. - */ -export abstract class EntityCollectionReducerMethodsFactory { - abstract create(entityName: string): EntityCollectionReducerMethods; -} +export type EntityCollectionReducer = (collection: EntityCollection, action: EntityAction) => EntityCollection; /** Create a default reducer for a specific entity collection */ @Injectable() @@ -37,11 +16,8 @@ export class EntityCollectionReducerFactory { const methods = this.methodsFactory.create(entityName); /** Perform Actions against a particular entity collection in the EntityCache */ - return function entityCollectionReducer( - collection: EntityCollection, - action: EntityAction - ): EntityCollection { - const reducerMethod = methods[action.op]; + return function entityCollectionReducer(collection: EntityCollection, action: EntityAction): EntityCollection { + const reducerMethod = methods[action.payload.entityOp]; return reducerMethod ? reducerMethod(collection, action) : collection; }; } diff --git a/lib/src/reducers/entity-collection.ts b/lib/src/reducers/entity-collection.ts index 4848c4d8..8bc282fa 100644 --- a/lib/src/reducers/entity-collection.ts +++ b/lib/src/reducers/entity-collection.ts @@ -1,13 +1,46 @@ import { EntityState } from '@ngrx/entity'; import { Dictionary } from '../utils/ngrx-entity-models'; +/** Types of change in a ChangeState instance */ +export enum ChangeType { + /** The entity has not changed from its last known server state. */ + Unchanged = 0, + /** The entity was added to the collection */ + Added, + /** The entity is scheduled for delete and was removed from the collection */ + Deleted, + /** The entity in the collection was updated */ + Updated +} + +/** + * Change state for an entity with unsaved changes; + * an entry in an EntityCollection.changeState map + */ +export interface ChangeState { + changeType: ChangeType; + originalValue?: T | undefined; +} + +/** + * Map of entity primary keys to entity ChangeStates. + * Each entry represents an entity with unsaved changes. + */ +export type ChangeStateMap = Dictionary>; + +/** + * Data and information about a collection of entities of a single type. + * EntityCollections are maintained in the EntityCache within the ngrx store. + */ export interface EntityCollection extends EntityState { - /** user's filter pattern */ + /** Name of the entity type for this collection */ + entityName: string; + /** A map of ChangeStates, keyed by id, for entities with unsaved changes */ + changeState: ChangeStateMap; + /** The user's current collection filter pattern */ filter: string; /** true if collection was ever filled by QueryAll; forced false if cleared */ loaded: boolean; - /** true when multi-entity HTTP query operation is in flight */ + /** true when a query or save operation is in progress */ loading: boolean; - /** Original entity values for entities with unsaved changes */ - originalValues: Dictionary; } diff --git a/lib/src/reducers/entity-reducer.spec.ts b/lib/src/reducers/entity-reducer.spec.ts deleted file mode 100644 index 27805e5b..00000000 --- a/lib/src/reducers/entity-reducer.spec.ts +++ /dev/null @@ -1,488 +0,0 @@ -import { Action, ActionReducer, MetaReducer } from '@ngrx/store'; -import { EntityAdapter } from '@ngrx/entity'; - -import { EntityAction, EntityActionFactory } from '../actions/entity-action'; -import { EntityOp } from '../actions/entity-op'; -import { EntityCache } from './entity-cache'; -import { - EntityCacheMerge, - EntityCacheSet -} from '../actions/entity-cache-actions'; -import { EntityCollection } from './entity-collection'; -import { EntityCollectionCreator } from './entity-collection-creator'; -import { DefaultEntityCollectionReducerMethodsFactory } from './default-entity-collection-reducer-methods'; - -import { EntityDefinitionService } from '../entity-metadata/entity-definition.service'; -import { EntityMetadataMap } from '../entity-metadata/entity-metadata'; -import { Logger } from '../utils/interfaces'; -import { IdSelector, Update } from '../utils/ngrx-entity-models'; - -import { - EntityCollectionReducer, - EntityCollectionReducerFactory -} from './entity-collection-reducer'; -import { - EntityCollectionReducers, - EntityReducerFactory -} from './entity-reducer'; - -class Bar { - id: number; - bar: string; -} -class Foo { - id: string; - foo: string; -} -class Hero { - id: number; - name: string; - power?: string; -} -class Villain { - key: string; - name: string; -} - -const metadata: EntityMetadataMap = { - Hero: {}, - Villain: { selectId: (villain: Villain) => villain.key } -}; - -describe('EntityReducer', () => { - // action factory never changes in these tests - const entityActionFactory = new EntityActionFactory(); - const createAction:

( - entityName: string, - op: EntityOp, - payload?: P - ) => EntityAction = entityActionFactory.create.bind(entityActionFactory); - - let collectionCreator: EntityCollectionCreator; - let collectionReducerFactory: EntityCollectionReducerFactory; - let eds: EntityDefinitionService; - let entityReducer: ActionReducer; - let entityReducerFactory: EntityReducerFactory; - let logger: Logger; - - beforeEach(() => { - eds = new EntityDefinitionService([metadata]); - collectionCreator = new EntityCollectionCreator(eds); - const collectionReducerMethodsFactory = new DefaultEntityCollectionReducerMethodsFactory( - eds - ); - collectionReducerFactory = new EntityCollectionReducerFactory( - collectionReducerMethodsFactory - ); - logger = jasmine.createSpyObj('Logger', ['error', 'log', 'warn']); - - entityReducerFactory = new EntityReducerFactory( - collectionCreator, - collectionReducerFactory, - logger - ); - }); - - describe('#create', () => { - beforeEach(() => { - entityReducer = entityReducerFactory.create(); - }); - - it('creates a default hero reducer when QUERY_ALL for hero', () => { - const hero: Hero = { id: 42, name: 'Bobby' }; - const action = createAction('Hero', EntityOp.ADD_ONE, hero); - - const state = entityReducer({}, action); - const collection = state['Hero']; - expect(collection.ids.length).toBe(1, 'should have added one'); - expect(collection.entities[42]).toEqual(hero, 'should be added hero'); - }); - - it('throws when ask for reducer of unknown entity type', () => { - const action = entityActionFactory.create('Foo', EntityOp.QUERY_ALL); - expect(() => entityReducer({}, action)).toThrowError( - /no EntityDefinition/i - ); - }); - }); - - describe('#registerReducer', () => { - beforeEach(() => { - entityReducer = entityReducerFactory.create(); - }); - - it('can register a new reducer', () => { - const reducer = createNoopReducer(); - entityReducerFactory.registerReducer('Foo', reducer); - const action = entityActionFactory.create('Foo', EntityOp.ADD_ONE, { - id: 'forty-two', - foo: 'fooz' - }); - // Must initialize the state by hand - const state = entityReducer({}, action); - const collection = state['Foo']; - expect(collection.ids.length).toBe(0, 'ADD_ONE should not add'); - }); - - it('can replace existing reducer by registering with same name', () => { - // Just like ADD_ONE test above with default reducer - // but this time should not add the hero. - const hero: Hero = { id: 42, name: 'Bobby' }; - const reducer = createNoopReducer(); - entityReducerFactory.registerReducer('Hero', reducer); - const action = entityActionFactory.create( - 'Hero', - EntityOp.ADD_ONE, - hero - ); - const state = entityReducer({}, action); - const collection = state['Hero']; - expect(collection.ids.length).toBe(0, 'ADD_ONE should not add'); - }); - }); - - /** - * Test the EntityCache-level actions, SET and MERGE, which can - * be used to restore the entity cache from a know state such as - * re-hydrating from browser storage. - * Useful for an offline-capable app. - */ - describe('EntityCache-level actions', () => { - let initialHeroes: Hero[]; - let initialCache: EntityCache; - - beforeEach(() => { - entityReducer = entityReducerFactory.create(); - initialHeroes = [ - { id: 2, name: 'B', power: 'Fast' }, - { id: 1, name: 'A', power: 'invisible' } - ]; - initialCache = createInitialCache({ Hero: initialHeroes }); - }); - - describe('#SET_ENTITY_CACHE', () => { - it('should initialize cache', () => { - const cache = createInitialCache({ - Hero: initialHeroes, - Villain: [{ key: 'DE', name: 'Dr. Evil' }] - }); - - const action = new EntityCacheSet(cache); - // const action = { // equivalent - // type: SET_ENTITY_CACHE, - // payload: cache - // }; - - const state = entityReducer(cache, action); - expect(state['Hero'].ids).toEqual([2, 1], 'Hero ids'); - expect(state['Hero'].entities).toEqual({ - 1: initialHeroes[1], - 2: initialHeroes[0] - }); - expect(state['Villain'].ids).toEqual(['DE'], 'Villain ids'); - }); - - it('should clear the cache when set with empty object', () => { - const action = new EntityCacheSet({}); - const state = entityReducer(initialCache, action); - expect(Object.keys(state)).toEqual([]); - }); - - it('should replace prior cache with new cache', () => { - const priorCache = createInitialCache({ - Hero: initialHeroes, - Villain: [{ key: 'DE', name: 'Dr. Evil' }] - }); - - const newHeroes = [{ id: 42, name: 'Bobby' }]; - const newCache = createInitialCache({ Hero: newHeroes }); - - const action = new EntityCacheSet(newCache); - const state = entityReducer(priorCache, action); - expect(state['Villain']).toBeUndefined('No villains'); - - const heroCollection = state['Hero']; - expect(heroCollection.ids).toEqual([42], 'hero ids'); - expect(heroCollection.entities[42]).toEqual(newHeroes[0], 'heroes'); - }); - }); - - describe('#MERGE_ENTITY_CACHE', () => { - function shouldHaveExpectedHeroes(state: EntityCache) { - expect(state['Hero'].ids).toEqual([2, 1], 'Hero ids'); - expect(state['Hero'].entities).toEqual({ - 1: initialHeroes[1], - 2: initialHeroes[0] - }); - } - - it('should initialize an empty cache', () => { - const cache = createInitialCache({ - Hero: initialHeroes, - Villain: [{ key: 'DE', name: 'Dr. Evil' }] - }); - - const action = new EntityCacheMerge(cache); - // const action = { - // type: MERGE_ENTITY_CACHE, - // payload: cache - // }; - - const state = entityReducer({}, action); - shouldHaveExpectedHeroes(state); - expect(state['Villain'].ids).toEqual(['DE'], 'Villain ids'); - }); - - it('should return cache matching existing cache when merge empty', () => { - const action = new EntityCacheMerge({}); - const state = entityReducer(initialCache, action); - shouldHaveExpectedHeroes(state); - }); - - it('should add a new collection to existing cache', () => { - const mergeCache = createInitialCache({ - Villain: [{ key: 'DE', name: 'Dr. Evil' }] - }); - const action = new EntityCacheMerge(mergeCache); - const state = entityReducer(initialCache, action); - shouldHaveExpectedHeroes(state); - expect(state['Villain'].ids).toEqual(['DE'], 'Villain ids'); - }); - - it('should overwrite an existing cached collection', () => { - const mergeCache = createInitialCache({ - Hero: [{ id: 42, name: 'Bobby' }] - }); - const action = new EntityCacheMerge(mergeCache); - const state = entityReducer(initialCache, action); - const heroCollection = state['Hero']; - expect(heroCollection.ids).toEqual([42], 'revised ids'); - expect(heroCollection.entities[42]).toEqual( - { id: 42, name: 'Bobby' }, - 'revised heroes' - ); - }); - }); - }); - - describe('#registerReducers', () => { - beforeEach(() => { - entityReducer = entityReducerFactory.create(); - }); - - it('can register several reducers at the same time.', () => { - const reducer = createNoopReducer(); - const reducers: EntityCollectionReducers = { - Foo: reducer, - Bar: reducer - }; - entityReducerFactory.registerReducers(reducers); - - const fooAction = entityActionFactory.create( - 'Foo', - EntityOp.ADD_ONE, - { id: 'forty-two', foo: 'fooz' } - ); - const barAction = entityActionFactory.create( - 'Bar', - EntityOp.ADD_ONE, - { id: 84, bar: 'baz' } - ); - - let state = entityReducer({}, fooAction); - state = entityReducer(state, barAction); - - expect(state['Foo'].ids.length).toBe(0, 'ADD_ONE Foo should not add'); - expect(state['Bar'].ids.length).toBe(0, 'ADD_ONE Bar should not add'); - }); - - it('can register several reducers that may override.', () => { - const reducer = createNoopReducer(); - const reducers: EntityCollectionReducers = { - Foo: reducer, - Hero: reducer - }; - entityReducerFactory.registerReducers(reducers); - - const fooAction = entityActionFactory.create( - 'Foo', - EntityOp.ADD_ONE, - { id: 'forty-two', foo: 'fooz' } - ); - const heroAction = entityActionFactory.create( - 'Hero', - EntityOp.ADD_ONE, - { id: 84, name: 'Alex' } - ); - - let state = entityReducer({}, fooAction); - state = entityReducer(state, heroAction); - - expect(state['Foo'].ids.length).toBe(0, 'ADD_ONE Foo should not add'); - expect(state['Hero'].ids.length).toBe(0, 'ADD_ONE Hero should not add'); - }); - }); - - describe('with EntityCollectionMetadataReducers', () => { - let metaReducerA: MetaReducer; - let metaReducerB: MetaReducer; - let metaReducerOutput: any[]; - - // Create MetaReducer that reports how it was called on the way in and out - function testMetadataReducerFactory(name: string) { - // Return the MetaReducer - return (r: ActionReducer) => { - // Return the wrapped reducer - return (state: EntityCollection, action: EntityAction) => { - // entered - metaReducerOutput.push({ metaReducer: name, inOut: 'in', action }); - // called reducer - const newState = r(state, action); - // exited - metaReducerOutput.push({ metaReducer: name, inOut: 'out', action }); - return newState; - }; - }; - } - - let addOneAction: EntityAction; - let hero: Hero; - - beforeEach(() => { - metaReducerOutput = []; - metaReducerA = jasmine - .createSpy('metaReducerA') - .and.callFake(testMetadataReducerFactory('A')); - metaReducerB = jasmine - .createSpy('metaReducerA') - .and.callFake(testMetadataReducerFactory('B')); - const metaReducers = [metaReducerA, metaReducerB]; - - entityReducerFactory = new EntityReducerFactory( - collectionCreator, - collectionReducerFactory, - logger, - metaReducers - ); - - entityReducer = entityReducerFactory.create(); - - hero = { id: 42, name: 'Bobby' }; - addOneAction = entityActionFactory.create( - 'Hero', - EntityOp.ADD_ONE, - hero - ); - }); - - it('should run inner default reducer as expected', () => { - const state = entityReducer({}, addOneAction); - - // inner default reducer worked as expected - const collection = state['Hero']; - expect(collection.ids.length).toBe(1, 'should have added one'); - expect(collection.entities[42]).toEqual(hero, 'should be added hero'); - }); - - it('should call meta reducers for inner default reducer as expected', () => { - const expected = [ - { metaReducer: 'A', inOut: 'in', action: addOneAction }, - { metaReducer: 'B', inOut: 'in', action: addOneAction }, - { metaReducer: 'B', inOut: 'out', action: addOneAction }, - { metaReducer: 'A', inOut: 'out', action: addOneAction } - ]; - - const state = entityReducer({}, addOneAction); - expect(metaReducerA).toHaveBeenCalled(); - expect(metaReducerB).toHaveBeenCalled(); - expect(metaReducerOutput).toEqual(expected); - }); - - it('should call meta reducers for custom registered reducer', () => { - const reducer = createNoopReducer(); - entityReducerFactory.registerReducer('Foo', reducer); - const action = entityActionFactory.create('Foo', EntityOp.ADD_ONE, { - id: 'forty-two', - foo: 'fooz' - }); - - const state = entityReducer({}, action); - expect(metaReducerA).toHaveBeenCalled(); - expect(metaReducerB).toHaveBeenCalled(); - }); - - it('should call meta reducers for multiple registered reducers', () => { - const reducer = createNoopReducer(); - const reducers: EntityCollectionReducers = { - Foo: reducer, - Hero: reducer - }; - entityReducerFactory.registerReducers(reducers); - - const fooAction = entityActionFactory.create( - 'Foo', - EntityOp.ADD_ONE, - { id: 'forty-two', foo: 'fooz' } - ); - - entityReducer({}, fooAction); - expect(metaReducerA).toHaveBeenCalled(); - expect(metaReducerB).toHaveBeenCalled(); - - const heroAction = entityActionFactory.create( - 'Hero', - EntityOp.ADD_ONE, - { id: 84, name: 'Alex' } - ); - - entityReducer({}, heroAction); - expect(metaReducerA).toHaveBeenCalledTimes(2); - expect(metaReducerB).toHaveBeenCalledTimes(2); - }); - }); - - // #region helpers - function createCollection( - entityName: string, - data: T[], - selectId: IdSelector - ) { - return { - ...collectionCreator.create(entityName), - ids: data.map(e => selectId(e)) as string[] | number[], - entities: data.reduce( - (acc, e) => { - acc[selectId(e)] = e; - return acc; - }, - {} as any - ) - } as EntityCollection; - } - - function createInitialCache(entityMap: { [entityName: string]: any[] }) { - const cache: EntityCache = {}; - // tslint:disable-next-line:forin - for (const entityName in entityMap) { - const selectId = - metadata[entityName].selectId || ((entity: any) => entity.id); - cache[entityName] = createCollection( - entityName, - entityMap[entityName], - selectId - ); - } - - return cache; - } - - function createNoopReducer() { - return function NoopReducer( - collection: EntityCollection, - action: EntityAction - ): EntityCollection { - return collection; - }; - } - // #endregion helpers -}); diff --git a/lib/src/reducers/entity-reducer.ts b/lib/src/reducers/entity-reducer.ts deleted file mode 100644 index c0ab0db9..00000000 --- a/lib/src/reducers/entity-reducer.ts +++ /dev/null @@ -1,157 +0,0 @@ -import { Inject, Injectable, InjectionToken, Optional } from '@angular/core'; - -import { Action, ActionReducer, compose, MetaReducer } from '@ngrx/store'; - -import { EntityAction } from '../actions/entity-action'; -import { EntityCache } from './entity-cache'; -import { - MERGE_ENTITY_CACHE, - SET_ENTITY_CACHE -} from '../actions/entity-cache-actions'; -import { EntityCollection } from './entity-collection'; -import { EntityCollectionCreator } from './entity-collection-creator'; -import { ENTITY_COLLECTION_META_REDUCERS } from './constants'; -import { - EntityCollectionReducer, - EntityCollectionReducerFactory -} from './entity-collection-reducer'; -import { Logger } from '../utils/interfaces'; - -export interface EntityCollectionReducers { - [entity: string]: EntityCollectionReducer; -} - -@Injectable() -export class EntityReducerFactory { - /** Registry of entity types and their previously-constructed reducers */ - protected entityCollectionReducers: EntityCollectionReducers = {}; - - private entityCollectionMetaReducer: MetaReducer< - EntityCollection, - EntityAction - >; - - constructor( - private entityCollectionCreator: EntityCollectionCreator, - private entityCollectionReducerFactory: EntityCollectionReducerFactory, - private logger: Logger, - @Optional() - @Inject(ENTITY_COLLECTION_META_REDUCERS) - entityCollectionMetaReducers?: MetaReducer[] - ) { - this.entityCollectionMetaReducer = compose.apply( - null, - entityCollectionMetaReducers || [] - ); - } - - /** - * Create the ngrx-data entity cache reducer which either responds to entity cache level actions - * or (more commonly) delegates to an EntityCollectionReducer based on the action.entityName. - */ - create(): ActionReducer { - return entityCacheReducer.bind(this); - - function entityCacheReducer( - this: EntityReducerFactory, - state: EntityCache = {}, - action: { type: string; payload?: any } - ): EntityCache { - switch (action.type) { - case SET_ENTITY_CACHE: { - // Completely replace the EntityCache. Be careful! - return action.payload; - } - case MERGE_ENTITY_CACHE: { - // Replace collections in the current cache with collections in the payload. - // Beware: unsaved changes in the replaced collections are lost - return { ...state, ...action.payload }; - } - } - - return this.applyCollectionReducer(state, action as EntityAction); - } - } - - /** Apply reducer for the action's EntityCollection (if the action targets a collection) */ - private applyCollectionReducer( - state: EntityCache = {}, - action: EntityAction - ) { - const entityName = action.entityName; - if (!entityName || action.error) { - return state; // not an EntityAction or an errant one - } - const collection = state[entityName]; - const reducer = this.getOrCreateReducer(entityName); - - let newCollection: EntityCollection; - try { - newCollection = collection - ? reducer(collection, action) - : reducer(this.entityCollectionCreator.create(entityName), action); - } catch (error) { - this.logger.error(error); - action.error = error; - } - - return action.error || collection === newCollection - ? state - : { ...state, [entityName]: newCollection }; - } - - /** - * Get the registered EntityCollectionReducer for this entity type or create one and register it. - * @param entityName Name of the entity type for this reducer - */ - getOrCreateReducer(entityName: string): EntityCollectionReducer { - let reducer: EntityCollectionReducer = this.entityCollectionReducers[ - entityName - ]; - - if (!reducer) { - reducer = this.entityCollectionReducerFactory.create(entityName); - reducer = this.registerReducer(entityName, reducer); - this.entityCollectionReducers[entityName] = reducer; - } - return reducer; - } - - /** - * Register an EntityCollectionReducer for an entity type - * @param entityName - the name of the entity type - * @param reducer - reducer for that entity type - * - * Examples: - * registerReducer('Hero', myHeroReducer); - * registerReducer('Villain', myVillainReducer); - */ - registerReducer( - entityName: string, - reducer: EntityCollectionReducer - ): ActionReducer, EntityAction> { - reducer = this.entityCollectionMetaReducer(reducer); - return (this.entityCollectionReducers[entityName.trim()] = reducer); - } - - /** - * Register a batch of EntityCollectionReducers. - * @param reducers - reducers to merge into existing reducers - * - * Examples: - * registerReducers({ - * Hero: myHeroReducer, - * Villain: myVillainReducer - * }); - */ - registerReducers(reducers: EntityCollectionReducers) { - const keys = reducers ? Object.keys(reducers) : []; - keys.forEach(key => this.registerReducer(key, reducers[key])); - } -} - -export function createEntityReducer( - entityReducerFactory: EntityReducerFactory -): ActionReducer { - return entityReducerFactory.create(); -} diff --git a/lib/src/selectors/entity-selectors$.spec.ts b/lib/src/selectors/entity-selectors$.spec.ts index 69d66ab4..1cf4b4eb 100644 --- a/lib/src/selectors/entity-selectors$.spec.ts +++ b/lib/src/selectors/entity-selectors$.spec.ts @@ -3,26 +3,17 @@ import { Actions } from '@ngrx/effects'; import { BehaviorSubject, Observable, Subject } from 'rxjs'; -import { EntityAction, EntityActionFactory } from '../actions/entity-action'; +import { EntityAction } from '../actions/entity-action'; +import { EntityActionFactory } from '../actions/entity-action-factory'; import { EntityOp } from '../actions/entity-op'; import { EntityCache } from '../reducers/entity-cache'; import { EntityCollection } from '../reducers/entity-collection'; import { ENTITY_CACHE_NAME } from '../reducers/constants'; -import { - EntityCollectionCreator, - createEmptyEntityCollection -} from '../reducers/entity-collection-creator'; -import { - EntityMetadata, - EntityMetadataMap -} from '../entity-metadata/entity-metadata'; +import { EntityCollectionCreator, createEmptyEntityCollection } from '../reducers/entity-collection-creator'; +import { EntityMetadata, EntityMetadataMap } from '../entity-metadata/entity-metadata'; import { PropsFilterFnFactory } from '../entity-metadata/entity-filters'; -import { - ENTITY_CACHE_SELECTOR_TOKEN, - EntityCacheSelector, - createEntityCacheSelector -} from './entity-cache-selector'; +import { ENTITY_CACHE_SELECTOR_TOKEN, EntityCacheSelector, createEntityCacheSelector } from './entity-cache-selector'; import { EntitySelectors, EntitySelectorsFactory } from './entity-selectors'; import { EntitySelectors$, EntitySelectors$Factory } from './entity-selectors$'; @@ -73,8 +64,7 @@ describe('EntitySelectors$', () => { let actions$: Subject; - const nextCacheState = (cache: EntityCache) => - state$.next({ entityCache: cache }); + const nextCacheState = (cache: EntityCache) => state$.next({ entityCache: cache }); let heroCollectionSelectors: HeroSelectors; @@ -86,29 +76,16 @@ describe('EntitySelectors$', () => { store = new Store<{ entityCache: EntityCache }>(state$, null, null); // EntitySelectors - collectionCreator = jasmine.createSpyObj('entityCollectionCreator', [ - 'create' - ]); + collectionCreator = jasmine.createSpyObj('entityCollectionCreator', ['create']); collectionCreator.create.and.returnValue(emptyHeroCollection); - const entitySelectorsFactory = new EntitySelectorsFactory( - collectionCreator - ); - heroCollectionSelectors = entitySelectorsFactory.create< - Hero, - HeroSelectors - >(heroMetadata); + const entitySelectorsFactory = new EntitySelectorsFactory(collectionCreator); + heroCollectionSelectors = entitySelectorsFactory.create(heroMetadata); // EntitySelectorFactory - factory = new EntitySelectors$Factory( - store, - actions$ as any, - createEntityCacheSelector(ENTITY_CACHE_NAME) - ); + factory = new EntitySelectors$Factory(store, actions$ as any, createEntityCacheSelector(ENTITY_CACHE_NAME)); // listen for changes to the hero collection - store - .select(ENTITY_CACHE_NAME, 'Hero') - .subscribe((c: HeroCollection) => (collection = c)); + store.select(ENTITY_CACHE_NAME, 'Hero').subscribe((c: HeroCollection) => (collection = c)); }); function subscribeToSelectors(selectors$: HeroSelectors$) { @@ -120,10 +97,7 @@ describe('EntitySelectors$', () => { } it('can select$ the default empty collection when store collection is undefined ', () => { - const selectors$ = factory.create( - 'Hero', - heroCollectionSelectors - ); + const selectors$ = factory.create('Hero', heroCollectionSelectors); let selectorCollection: EntityCollection; selectors$.collection$.subscribe(c => (selectorCollection = c)); expect(selectorCollection).toBeDefined('selector collection'); @@ -131,16 +105,11 @@ describe('EntitySelectors$', () => { // Important: the selector is returning these values; // They are not actually in the store's entity cache collection! - expect(collection).toBeUndefined( - 'no collection until reducer creates it.' - ); + expect(collection).toBeUndefined('no collection until reducer creates it.'); }); it('selectors$ emit default empty values when collection is undefined', () => { - const selectors$ = factory.create( - 'Hero', - heroCollectionSelectors - ); + const selectors$ = factory.create('Hero', heroCollectionSelectors); subscribeToSelectors(selectors$); @@ -152,10 +121,7 @@ describe('EntitySelectors$', () => { }); it('selectors$ emit updated hero values', () => { - const selectors$ = factory.create( - 'Hero', - heroCollectionSelectors - ); + const selectors$ = factory.create('Hero', heroCollectionSelectors); subscribeToSelectors(selectors$); @@ -198,10 +164,7 @@ describe('EntitySelectors$', () => { }); collectionCreator.create.and.returnValue(defaultHeroState); - const selectors$ = factory.create( - 'Hero', - heroCollectionSelectors - ); // <- override default state + const selectors$ = factory.create('Hero', heroCollectionSelectors); // <- override default state subscribeToSelectors(selectors$); @@ -211,9 +174,7 @@ describe('EntitySelectors$', () => { // Important: the selector is returning these values; // They are not actually in the store's entity cache collection! - expect(collection).toBeUndefined( - 'no collection until reducer creates it.' - ); + expect(collection).toBeUndefined('no collection until reducer creates it.'); }); it('`entityCache$` should observe the entire entity cache', () => { @@ -230,10 +191,7 @@ describe('EntitySelectors$', () => { it('`actions$` emits hero collection EntityActions and no other actions', () => { const actionsReceived: Action[] = []; - const selectors$ = factory.create( - 'Hero', - heroCollectionSelectors - ); + const selectors$ = factory.create('Hero', heroCollectionSelectors); const entityActions$ = selectors$.entityActions$; entityActions$.subscribe(action => actionsReceived.push(action)); @@ -251,10 +209,7 @@ describe('EntitySelectors$', () => { it('`errors$` emits hero collection EntityAction errors and no other actions', () => { const actionsReceived: Action[] = []; - const selectors$ = factory.create( - 'Hero', - heroCollectionSelectors - ); + const selectors$ = factory.create('Hero', heroCollectionSelectors); const errors$ = selectors$.errors$; errors$.subscribe(action => actionsReceived.push(action)); @@ -265,16 +220,10 @@ describe('EntitySelectors$', () => { // Hero EntityAction (but not an error) actions$.next(eaFactory.create('Hero', EntityOp.QUERY_ALL)); // Hero EntityAction Error - const heroErrorAction = eaFactory.create( - 'Hero', - EntityOp.QUERY_ALL_ERROR - ); + const heroErrorAction = eaFactory.create('Hero', EntityOp.QUERY_ALL_ERROR); actions$.next(heroErrorAction); expect(actionsReceived.length).toBe(1, 'only one hero action'); - expect(actionsReceived[0]).toBe( - heroErrorAction, - 'expected error hero action' - ); + expect(actionsReceived[0]).toBe(heroErrorAction, 'expected error hero action'); }); }); }); @@ -282,7 +231,7 @@ describe('EntitySelectors$', () => { /////// Test values and helpers ///////// function createHeroState(state: Partial): HeroCollection { - return { ...createEmptyEntityCollection(), ...state } as HeroCollection; + return { ...createEmptyEntityCollection('Hero'), ...state } as HeroCollection; } function nameFilter(entities: T[], pattern: string) { diff --git a/lib/src/selectors/entity-selectors$.ts b/lib/src/selectors/entity-selectors$.ts index 5bb3c0ee..34ab9149 100644 --- a/lib/src/selectors/entity-selectors$.ts +++ b/lib/src/selectors/entity-selectors$.ts @@ -1,27 +1,19 @@ import { Inject, Injectable } from '@angular/core'; -import { - createFeatureSelector, - createSelector, - Selector, - Store -} from '@ngrx/store'; +import { createFeatureSelector, createSelector, Selector, Store } from '@ngrx/store'; import { Actions } from '@ngrx/effects'; import { Observable } from 'rxjs'; -import { filter } from 'rxjs/operators'; +import { filter, shareReplay } from 'rxjs/operators'; import { Dictionary } from '../utils/ngrx-entity-models'; import { EntityAction } from '../actions/entity-action'; import { OP_ERROR } from '../actions/entity-op'; import { ofEntityType } from '../actions/entity-action-operators'; -import { - ENTITY_CACHE_SELECTOR_TOKEN, - EntityCacheSelector -} from './entity-cache-selector'; +import { ENTITY_CACHE_SELECTOR_TOKEN, EntityCacheSelector } from './entity-cache-selector'; import { EntitySelectors } from './entity-selectors'; import { EntityCache } from '../reducers/entity-cache'; -import { EntityCollection } from '../reducers/entity-collection'; +import { EntityCollection, ChangeStateMap } from '../reducers/entity-collection'; import { EntityCollectionCreator } from '../reducers/entity-collection-creator'; import { EntitySelectorsFactory } from './entity-selectors'; @@ -65,23 +57,30 @@ export interface EntitySelectors$ { /** Observable true when a multi-entity query command is in progress. */ readonly loading$: Observable | Store; - /** Original entity values for entities with unsaved changes */ - readonly originalValues$: Observable> | Store>; + /** ChangeState (including original values) of entities with unsaved changes */ + readonly changeState$: Observable> | Store>; } +/** Creates observable EntitySelectors$ for entity collections. */ @Injectable() export class EntitySelectors$Factory { /** Observable of the EntityCache */ entityCache$: Observable; + /** Observable of error EntityActions (e.g. QUERY_ALL_ERROR) for all entity types */ + entityActionErrors$: Observable; + constructor( private store: Store, private actions: Actions, - @Inject(ENTITY_CACHE_SELECTOR_TOKEN) - private selectEntityCache: EntityCacheSelector + @Inject(ENTITY_CACHE_SELECTOR_TOKEN) private selectEntityCache: EntityCacheSelector ) { // This service applies to the cache in ngrx/store named `cacheName` this.entityCache$ = this.store.select(this.selectEntityCache); + this.entityActionErrors$ = actions.pipe( + filter((ea: EntityAction) => ea.payload && ea.payload.entityOp && ea.payload.entityOp.endsWith(OP_ERROR)), + shareReplay(1) + ); } /** @@ -90,10 +89,7 @@ export class EntitySelectors$Factory { * @param entityName - is also the name of the collection. * @param selectors - selector functions for this collection. **/ - create = EntitySelectors$>( - entityName: string, - selectors?: EntitySelectors - ): S$ { + create = EntitySelectors$>(entityName: string, selectors?: EntitySelectors): S$ { const selectors$: { [prop: string]: any } = { entityName }; @@ -107,9 +103,7 @@ export class EntitySelectors$Factory { } }); selectors$.entityActions$ = this.actions.pipe(ofEntityType(entityName)); - selectors$.errors$ = selectors$.entityActions$.pipe( - filter((ea: EntityAction) => ea.op.endsWith(OP_ERROR)) - ); + selectors$.errors$ = this.entityActionErrors$.pipe(ofEntityType(entityName)); return selectors$ as S$; } } diff --git a/lib/src/selectors/entity-selectors.spec.ts b/lib/src/selectors/entity-selectors.spec.ts index 5f8d2816..2287af91 100644 --- a/lib/src/selectors/entity-selectors.spec.ts +++ b/lib/src/selectors/entity-selectors.spec.ts @@ -4,10 +4,7 @@ import { EntityCache } from '../reducers/entity-cache'; import { ENTITY_CACHE_NAME } from '../reducers/constants'; import { EntityCollection } from '../reducers/entity-collection'; import { createEmptyEntityCollection } from '../reducers/entity-collection-creator'; -import { - EntityMetadata, - EntityMetadataMap -} from '../entity-metadata/entity-metadata'; +import { EntityMetadata, EntityMetadataMap } from '../entity-metadata/entity-metadata'; import { PropsFilterFnFactory } from '../entity-metadata/entity-filters'; import { EntitySelectors, EntitySelectorsFactory } from './entity-selectors'; @@ -32,9 +29,7 @@ describe('EntitySelectors', () => { let entitySelectorsFactory: EntitySelectorsFactory; beforeEach(() => { - collectionCreator = jasmine.createSpyObj('entityCollectionCreator', [ - 'create' - ]); + collectionCreator = jasmine.createSpyObj('entityCollectionCreator', ['create']); entitySelectorsFactory = new EntitySelectorsFactory(collectionCreator); }); @@ -48,10 +43,7 @@ describe('EntitySelectors', () => { it('creates collection selector that defaults to initial state', () => { collectionCreator.create.and.returnValue(initialState); - const selectors = entitySelectorsFactory.createCollectionSelector< - Hero, - HeroCollection - >('Hero'); + const selectors = entitySelectorsFactory.createCollectionSelector('Hero'); const state = { entityCache: {} }; // ngrx store with empty cache const collection = selectors(state); expect(collection.entities).toEqual(initialState.entities, 'entities'); @@ -61,10 +53,7 @@ describe('EntitySelectors', () => { it('collection selector should return cached collection when it exists', () => { // must specify type-args when initialState isn't available for type inference - const selectors = entitySelectorsFactory.createCollectionSelector< - Hero, - HeroCollection - >('Hero'); + const selectors = entitySelectorsFactory.createCollectionSelector('Hero'); // ngrx store with populated Hero collection const state = { @@ -81,10 +70,7 @@ describe('EntitySelectors', () => { }; const collection = selectors(state); - expect(collection.entities[42]).toEqual( - { id: 42, name: 'The Answer' }, - 'entities' - ); + expect(collection.entities[42]).toEqual({ id: 42, name: 'The Answer' }, 'entities'); expect(collection.foo).toBe('towel', 'foo'); expect(collectionCreator.create).not.toHaveBeenCalled(); }); @@ -111,20 +97,12 @@ describe('EntitySelectors', () => { it('should have expected Hero selectors (a super-set of EntitySelectors)', () => { const store = { entityCache: { Hero: heroCollection } }; - const selectors = entitySelectorsFactory.create( - heroMetadata - ); + const selectors = entitySelectorsFactory.create(heroMetadata); expect(selectors.selectEntities).toBeDefined('selectEntities'); - expect(selectors.selectEntities(store)).toEqual( - heroEntities, - 'selectEntities' - ); + expect(selectors.selectEntities(store)).toEqual(heroEntities, 'selectEntities'); - expect(selectors.selectFilteredEntities(store)).toEqual( - heroEntities.filter(h => h.name === 'B'), - 'filtered B heroes' - ); + expect(selectors.selectFilteredEntities(store)).toEqual(heroEntities.filter(h => h.name === 'B'), 'filtered B heroes'); expect(selectors.selectFoo).toBeDefined('selectFoo exists'); expect(selectors.selectFoo(store)).toBe('Foo', 'execute `selectFoo`'); @@ -143,15 +121,9 @@ describe('EntitySelectors', () => { const selectors = eaFactory.create(heroMetadata); expect(selectors.selectEntities).toBeDefined('selectEntities'); - expect(selectors.selectEntities(store)).toEqual( - heroEntities, - 'selectEntities' - ); + expect(selectors.selectEntities(store)).toEqual(heroEntities, 'selectEntities'); - expect(selectors.selectFilteredEntities(store)).toEqual( - heroEntities.filter(h => h.name === 'B'), - 'filtered B heroes' - ); + expect(selectors.selectFilteredEntities(store)).toEqual(heroEntities.filter(h => h.name === 'B'), 'filtered B heroes'); expect(selectors.selectFoo).toBeDefined('selectFoo exists'); expect(selectors.selectFoo(store)).toBe('Foo', 'execute `selectFoo`'); @@ -165,10 +137,7 @@ describe('EntitySelectors', () => { const selectors = entitySelectorsFactory.create('Hero'); expect(selectors.selectEntities).toBeDefined('selectEntities'); expect(selectors.selectFoo).not.toBeDefined('selectFoo should not exist'); - expect(selectors.selectFilteredEntities(store)).toEqual( - heroEntities, - 'filtered same as all hero entities' - ); + expect(selectors.selectFilteredEntities(store)).toEqual(heroEntities, 'filtered same as all hero entities'); }); it('should have expected Villain selectors', () => { @@ -183,15 +152,9 @@ describe('EntitySelectors', () => { const expectedEntities: Villain[] = [{ key: 'evil', name: 'A' }]; expect(selectors.selectEntities).toBeDefined('selectAll'); - expect(selectors.selectEntities(store)).toEqual( - expectedEntities, - 'try selectAll' - ); - - expect(selectors.selectFilteredEntities(store)).toEqual( - expectedEntities, - 'all villains because no filter fn' - ); + expect(selectors.selectEntities(store)).toEqual(expectedEntities, 'try selectAll'); + + expect(selectors.selectFilteredEntities(store)).toEqual(expectedEntities, 'all villains because no filter fn'); }); }); }); @@ -199,7 +162,7 @@ describe('EntitySelectors', () => { /////// Test values and helpers ///////// function createHeroState(state: Partial): HeroCollection { - return { ...createEmptyEntityCollection(), ...state } as HeroCollection; + return { ...createEmptyEntityCollection('Hero'), ...state } as HeroCollection; } function nameFilter(entities: T[], pattern: string) { diff --git a/lib/src/selectors/entity-selectors.ts b/lib/src/selectors/entity-selectors.ts index 88f055bb..1a9c1a74 100644 --- a/lib/src/selectors/entity-selectors.ts +++ b/lib/src/selectors/entity-selectors.ts @@ -8,13 +8,9 @@ import { Observable } from 'rxjs'; import { Dictionary } from '../utils/ngrx-entity-models'; import { EntityCache } from '../reducers/entity-cache'; -import { - ENTITY_CACHE_SELECTOR_TOKEN, - EntityCacheSelector, - createEntityCacheSelector -} from './entity-cache-selector'; +import { ENTITY_CACHE_SELECTOR_TOKEN, EntityCacheSelector, createEntityCacheSelector } from './entity-cache-selector'; import { ENTITY_CACHE_NAME } from '../reducers/constants'; -import { EntityCollection } from '../reducers/entity-collection'; +import { EntityCollection, ChangeStateMap } from '../reducers/entity-collection'; import { EntityCollectionCreator } from '../reducers/entity-collection-creator'; import { EntityFilterFn } from '../entity-metadata/entity-filters'; import { EntityMetadata } from '../entity-metadata/entity-metadata'; @@ -51,8 +47,8 @@ export interface CollectionSelectors { /** True when a multi-entity query command is in progress. */ readonly selectLoading: Selector, boolean>; - /** Original entity values for entities with unsaved changes */ - readonly selectOriginalValues: Selector, Dictionary>; + /** ChangeState (including original values) of entities with unsaved changes */ + readonly selectChangeState: Selector, ChangeStateMap>; } /** @@ -96,10 +92,11 @@ export interface EntitySelectors { /** True when a multi-entity query command is in progress. */ readonly selectLoading: MemoizedSelector; - /** Original entity values for entities with unsaved changes */ - readonly selectOriginalValues: MemoizedSelector>; + /** ChangeState (including original values) of entities with unsaved changes */ + readonly selectChangeState: MemoizedSelector>; } +/** Creates EntitySelector functions for entity collections. */ @Injectable() export class EntitySelectorsFactory { constructor( @@ -108,10 +105,8 @@ export class EntitySelectorsFactory { @Inject(ENTITY_CACHE_SELECTOR_TOKEN) private selectEntityCache?: EntityCacheSelector ) { - this.entityCollectionCreator = - entityCollectionCreator || new EntityCollectionCreator(); - this.selectEntityCache = - selectEntityCache || createEntityCacheSelector(ENTITY_CACHE_NAME); + this.entityCollectionCreator = entityCollectionCreator || new EntityCollectionCreator(); + this.selectEntityCache = selectEntityCache || createEntityCacheSelector(ENTITY_CACHE_NAME); } /** @@ -119,13 +114,8 @@ export class EntitySelectorsFactory { * e.g. from Object to Heroes. * @param entityName the name of the collection */ - createCollectionSelector< - T = any, - C extends EntityCollection = EntityCollection - >(entityName: string) { - const getCollection = (cache: EntityCache = {}) => - (cache[entityName] || - this.entityCollectionCreator.create(entityName)); + createCollectionSelector = EntityCollection>(entityName: string) { + const getCollection = (cache: EntityCache = {}) => (cache[entityName] || this.entityCollectionCreator.create(entityName)); return createSelector(this.selectEntityCache, getCollection); } @@ -140,67 +130,42 @@ export class EntitySelectorsFactory { * @param metadata - EntityMetadata for the collection. * May be partial but much have `entityName`. */ - createCollectionSelectors< - T, - S extends CollectionSelectors = CollectionSelectors - >(metadata: EntityMetadata): S; + createCollectionSelectors = CollectionSelectors>(metadata: EntityMetadata): S; + // tslint:disable:unified-signatures // createCollectionSelectors(entityName) overload /** * Creates default entity collection selectors for an entity type. * Use the metadata overload for additional collection selectors. * @param entityName - name of the entity type */ - createCollectionSelectors< - // tslint:disable-next-line:unified-signatures - // tslint:disable-next-line:unified-signatures - // tslint:disable-next-line:unified-signatures - // tslint:disable-next-line:unified-signatures - // tslint:disable-next-line:unified-signatures - // tslint:disable-next-line:unified-signatures - T, - S extends CollectionSelectors = CollectionSelectors - >(entityName: string): S; + createCollectionSelectors = CollectionSelectors>(entityName: string): S; // createCollectionSelectors implementation - createCollectionSelectors< - T, - S extends CollectionSelectors = CollectionSelectors - >(metadataOrName: EntityMetadata | string): S { - const metadata = - typeof metadataOrName === 'string' - ? { entityName: metadataOrName } - : metadataOrName; + createCollectionSelectors = CollectionSelectors>(metadataOrName: EntityMetadata | string): S { + const metadata = typeof metadataOrName === 'string' ? { entityName: metadataOrName } : metadataOrName; const selectKeys = (c: EntityCollection) => c.ids; const selectEntityMap = (c: EntityCollection) => c.entities; const selectEntities: Selector, T[]> = createSelector( selectKeys, selectEntityMap, - (keys: (number | string)[], entities: Dictionary): T[] => - keys.map(key => entities[key] as T) + (keys: (number | string)[], entities: Dictionary): T[] => keys.map(key => entities[key] as T) ); - const selectCount: Selector, number> = createSelector( - selectKeys, - keys => keys.length - ); + const selectCount: Selector, number> = createSelector(selectKeys, keys => keys.length); // EntityCollection selectors that go beyond the ngrx/entity/EntityState selectors const selectFilter = (c: EntityCollection) => c.filter; const filterFn = metadata.filterFn; const selectFilteredEntities: Selector, T[]> = filterFn - ? createSelector( - selectEntities, - selectFilter, - (entities: T[], pattern: any): T[] => filterFn(entities, pattern) - ) + ? createSelector(selectEntities, selectFilter, (entities: T[], pattern: any): T[] => filterFn(entities, pattern)) : selectEntities; const selectLoaded = (c: EntityCollection) => c.loaded; const selectLoading = (c: EntityCollection) => c.loading; - const selectOriginalValues = (c: EntityCollection) => c.originalValues; + const selectChangeState = (c: EntityCollection) => c.changeState; // Create collection selectors for each `additionalCollectionState` property. // These all extend from `selectCollection` @@ -209,9 +174,7 @@ export class EntitySelectorsFactory { [name: string]: Selector, any>; } = {}; Object.keys(extra).forEach(k => { - extraSelectors['select' + k[0].toUpperCase() + k.slice(1)] = ( - c: EntityCollection - ) => (c)[k]; + extraSelectors['select' + k[0].toUpperCase() + k.slice(1)] = (c: EntityCollection) => (c)[k]; }); return { @@ -223,7 +186,7 @@ export class EntitySelectorsFactory { selectKeys, selectLoaded, selectLoading, - selectOriginalValues, + selectChangeState, ...extraSelectors } as S; } @@ -242,9 +205,7 @@ export class EntitySelectorsFactory { * Differs in that these selectors select from the NgRx store root, * through the collection, to the collection members. */ - create = EntitySelectors>( - metadata: EntityMetadata - ): S; + create = EntitySelectors>(metadata: EntityMetadata): S; // create(entityName) overload /** @@ -264,28 +225,17 @@ export class EntitySelectorsFactory { ): S; // createCollectionSelectors implementation - create = EntitySelectors>( - metadataOrName: EntityMetadata | string - ): S { - const metadata = - typeof metadataOrName === 'string' - ? { entityName: metadataOrName } - : metadataOrName; + create = EntitySelectors>(metadataOrName: EntityMetadata | string): S { + const metadata = typeof metadataOrName === 'string' ? { entityName: metadataOrName } : metadataOrName; const entityName = metadata.entityName; - const selectCollection: Selector< - Object, - EntityCollection - > = this.createCollectionSelector(entityName); + const selectCollection: Selector> = this.createCollectionSelector(entityName); const collectionSelectors = this.createCollectionSelectors(metadata); const entitySelectors: { [name: string]: Selector, any>; } = {}; Object.keys(collectionSelectors).forEach(k => { - entitySelectors[k] = createSelector( - selectCollection, - collectionSelectors[k] - ); + entitySelectors[k] = createSelector(selectCollection, collectionSelectors[k]); }); return { diff --git a/lib/src/selectors/related-entity-selectors.spec.ts b/lib/src/selectors/related-entity-selectors.spec.ts index 5b4fcdfe..127ac849 100644 --- a/lib/src/selectors/related-entity-selectors.spec.ts +++ b/lib/src/selectors/related-entity-selectors.spec.ts @@ -1,28 +1,19 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { - Action, - createSelector, - Selector, - StoreModule, - Store -} from '@ngrx/store'; +import { Action, createSelector, Selector, StoreModule, Store } from '@ngrx/store'; import { Actions } from '@ngrx/effects'; import { Observable, Subject } from 'rxjs'; import { skip } from 'rxjs/operators'; import { Dictionary, Update } from '../utils/ngrx-entity-models'; -import { EntityAction, EntityActionFactory } from '../actions/entity-action'; +import { EntityAction } from '../actions/entity-action'; +import { EntityActionFactory } from '../actions/entity-action-factory'; import { EntityOp } from '../actions/entity-op'; import { EntityCache } from '../reducers/entity-cache'; import { EntityCollection } from '../reducers/entity-collection'; -import { - EntityMetadata, - EntityMetadataMap, - ENTITY_METADATA_TOKEN -} from '../entity-metadata/entity-metadata'; +import { EntityMetadata, EntityMetadataMap, ENTITY_METADATA_TOKEN } from '../entity-metadata/entity-metadata'; import { EntitySelectorsFactory } from '../selectors/entity-selectors'; @@ -71,9 +62,7 @@ describe('Related-entity Selectors', () => { const heroSelectors = entitySelectorsFactory.create('Hero'); const selectHeroMap = heroSelectors.selectEntityMap; - const sidekickSelectors = entitySelectorsFactory.create( - 'Sidekick' - ); + const sidekickSelectors = entitySelectorsFactory.create('Sidekick'); const selectSidekickMap = sidekickSelectors.selectEntityMap; return { @@ -84,18 +73,11 @@ describe('Related-entity Selectors', () => { function createHeroSidekickSelector$(heroId: number): Observable { const { selectHeroMap, selectSidekickMap } = setCollectionSelectors(); - const selectHero = createSelector( - selectHeroMap, - heroes => heroes[heroId] - ); - const selectSideKick = createSelector( - selectHero, - selectSidekickMap, - (hero, sidekicks) => { - const sidekickId = hero && hero.sidekickFk; - return sidekicks[sidekickId]; - } - ); + const selectHero = createSelector(selectHeroMap, heroes => heroes[heroId]); + const selectSideKick = createSelector(selectHero, selectSidekickMap, (hero, sidekicks) => { + const sidekickId = hero && hero.sidekickFk; + return sidekicks[sidekickId]; + }); return store.select(selectSideKick); } @@ -118,11 +100,7 @@ describe('Related-entity Selectors', () => { }); // update the related sidekick - const action = eaFactory.create>( - 'Sidekick', - EntityOp.UPDATE_ONE, - { id: 1, changes: { id: 1, name: 'Robert' } } - ); + const action = eaFactory.create>('Sidekick', EntityOp.UPDATE_ONE, { id: 1, changes: { id: 1, name: 'Robert' } }); store.dispatch(action); }); @@ -156,10 +134,7 @@ describe('Related-entity Selectors', () => { .pipe(skip(1)) .subscribe(sk => { expect(sk.name).toBe('Bob'); - expect(alphaCount).toEqual( - 1, - 'should only callback for Hero #1 once' - ); + expect(alphaCount).toEqual(1, 'should only callback for Hero #1 once'); done(); }); @@ -197,14 +172,10 @@ describe('Related-entity Selectors', () => { }); // create a new sidekick - let action: EntityAction = eaFactory.create( - 'Sidekick', - EntityOp.ADD_ONE, - { - id: 42, - name: 'Robin' - } - ); + let action: EntityAction = eaFactory.create('Sidekick', EntityOp.ADD_ONE, { + id: 42, + name: 'Robin' + }); store.dispatch(action); // assign new sidekick to Gamma @@ -224,24 +195,22 @@ describe('Related-entity Selectors', () => { const battleSelectors = entitySelectorsFactory.create('Battle'); const selectBattleEntities = battleSelectors.selectEntities; - const selectHeroBattleMap = createSelector( - selectBattleEntities, - battles => - battles.reduce( - (acc, battle) => { - const hid = battle.heroFk; - if (hid) { - const hbs = acc[hid]; - if (hbs) { - hbs.push(battle); - } else { - acc[hid] = [battle]; - } + const selectHeroBattleMap = createSelector(selectBattleEntities, battles => + battles.reduce( + (acc, battle) => { + const hid = battle.heroFk; + if (hid) { + const hbs = acc[hid]; + if (hbs) { + hbs.push(battle); + } else { + acc[hid] = [battle]; } - return acc; - }, - {} as { [heroId: number]: Battle[] } - ) + } + return acc; + }, + {} as { [heroId: number]: Battle[] } + ) ); return { @@ -253,19 +222,12 @@ describe('Related-entity Selectors', () => { function createHeroBattlesSelector$(heroId: number): Observable { const { selectHeroMap, selectHeroBattleMap } = setCollectionSelectors(); - const selectHero = createSelector( - selectHeroMap, - heroes => heroes[heroId] - ); + const selectHero = createSelector(selectHeroMap, heroes => heroes[heroId]); - const selectHeroBattles = createSelector( - selectHero, - selectHeroBattleMap, - (hero, heroBattleMap) => { - const hid = hero && hero.id; - return heroBattleMap[hid] || []; - } - ); + const selectHeroBattles = createSelector(selectHero, selectHeroBattleMap, (hero, heroBattleMap) => { + const hid = hero && hero.id; + return heroBattleMap[hid] || []; + }); return store.select(selectHeroBattles); } @@ -289,11 +251,7 @@ describe('Related-entity Selectors', () => { }); // update the first of the related battles - const action = eaFactory.create>( - 'Battle', - EntityOp.UPDATE_ONE, - { id: 100, changes: { id: 100, name: 'Scalliwag' } } - ); + const action = eaFactory.create>('Battle', EntityOp.UPDATE_ONE, { id: 100, changes: { id: 100, name: 'Scalliwag' } }); store.dispatch(action); }); @@ -313,29 +271,25 @@ describe('Related-entity Selectors', () => { const powerSelectors = entitySelectorsFactory.create('Power'); const selectPowerMap = powerSelectors.selectEntityMap; - const heroPowerMapSelectors = entitySelectorsFactory.create( - 'HeroPowerMap' - ); + const heroPowerMapSelectors = entitySelectorsFactory.create('HeroPowerMap'); const selectHeroPowerMapEntities = heroPowerMapSelectors.selectEntities; - const selectHeroPowerIds = createSelector( - selectHeroPowerMapEntities, - hpMaps => - hpMaps.reduce( - (acc, hpMap) => { - const hid = hpMap.heroFk; - if (hid) { - const hpIds = acc[hid]; - if (hpIds) { - hpIds.push(hpMap.powerFk); - } else { - acc[hid] = [hpMap.powerFk]; - } + const selectHeroPowerIds = createSelector(selectHeroPowerMapEntities, hpMaps => + hpMaps.reduce( + (acc, hpMap) => { + const hid = hpMap.heroFk; + if (hid) { + const hpIds = acc[hid]; + if (hpIds) { + hpIds.push(hpMap.powerFk); + } else { + acc[hid] = [hpMap.powerFk]; } - return acc; - }, - {} as { [heroId: number]: number[] } - ) + } + return acc; + }, + {} as { [heroId: number]: number[] } + ) ); return { @@ -346,28 +300,16 @@ describe('Related-entity Selectors', () => { } function createHeroPowersSelector$(heroId: number): Observable { - const { - selectHeroMap, - selectHeroPowerIds, - selectPowerMap - } = setCollectionSelectors(); + const { selectHeroMap, selectHeroPowerIds, selectPowerMap } = setCollectionSelectors(); - const selectHero = createSelector( - selectHeroMap, - heroes => heroes[heroId] - ); + const selectHero = createSelector(selectHeroMap, heroes => heroes[heroId]); - const selectHeroPowers = createSelector( - selectHero, - selectHeroPowerIds, - selectPowerMap, - (hero, heroPowerIds, powerMap) => { - const hid = hero && hero.id; - const pids = heroPowerIds[hid] || []; - const powers = pids.map(id => powerMap[id]).filter(power => power); - return powers; - } - ); + const selectHeroPowers = createSelector(selectHero, selectHeroPowerIds, selectPowerMap, (hero, heroPowerIds, powerMap) => { + const hid = hero && hero.id; + const pids = heroPowerIds[hid] || []; + const powers = pids.map(id => powerMap[id]).filter(power => power); + return powers; + }); return store.select(selectHeroPowers); } @@ -398,11 +340,7 @@ describe('Related-entity Selectors', () => { }); // delete Beta's one power via the HeroPowerMap - const action: EntityAction = eaFactory.create( - 'HeroPowerMap', - EntityOp.REMOVE_ONE, - 96 - ); + const action: EntityAction = eaFactory.create('HeroPowerMap', EntityOp.REMOVE_ONE, 96); store.dispatch(action); }); @@ -452,16 +390,10 @@ export function sortByName(a: { name: string }, b: { name: string }): number { return a.name.localeCompare(b.name); } -function initializeCache( - eaFactory: EntityActionFactory, - store: Store -) { +function initializeCache(eaFactory: EntityActionFactory, store: Store) { let action: EntityAction; - action = eaFactory.create('Sidekick', EntityOp.ADD_ALL, [ - { id: 1, name: 'Bob' }, - { id: 2, name: 'Sally' } - ]); + action = eaFactory.create('Sidekick', EntityOp.ADD_ALL, [{ id: 1, name: 'Bob' }, { id: 2, name: 'Sally' }]); store.dispatch(action); action = eaFactory.create('Hero', EntityOp.ADD_ALL, [ diff --git a/lib/src/utils/correlation-id-generator.ts b/lib/src/utils/correlation-id-generator.ts new file mode 100644 index 00000000..16d7b96d --- /dev/null +++ b/lib/src/utils/correlation-id-generator.ts @@ -0,0 +1,23 @@ +import { Injectable } from '@angular/core'; + +/** + * Generates a string id beginning 'CRID', + * followed by a monotonically increasing integer for use as a correlation id. + * As they are produced locally by a singleton service, + * these ids are guaranteed to be unique only + * for the duration of a single client browser instance. + * Ngrx entity dispatcher query and save methods call this service to generate default correlation ids. + * Do NOT use for entity keys. + */ +@Injectable() +export class CorrelationIdGenerator { + /** Seed for the ids */ + protected seed = 0; + /** Prefix of the id, 'CRID; */ + protected prefix = 'CRID'; + /** Return the next correlation id */ + next() { + this.seed += 1; + return this.prefix + this.seed; + } +} diff --git a/lib/src/utils/guid-fns.ts b/lib/src/utils/guid-fns.ts new file mode 100644 index 00000000..8738c434 --- /dev/null +++ b/lib/src/utils/guid-fns.ts @@ -0,0 +1,69 @@ +/* +Client-side id-generators + +These GUID utility functions are not used by ngrx-data itself at this time. +They are included as candidates for generating persistable correlation ids if that becomes desirable. +They are also safe for generating unique entity ids on the client. + +Note they produce 32-character hexadecimal UUID strings, +not the 128-bit representation found in server-side languages and databases. + +These utilities are experimental and may be withdrawn or replaced in future. +*/ + +/** + * Creates a Universally Unique Identifier (AKA GUID) + */ +export function getUuid() { + // The original implementation is based on this SO answer: + // http://stackoverflow.com/a/2117523/200253 + return 'xxxxxxxxxx4xxyxxxxxxxxxxxxxx'.replace(/[xy]/g, function(c) { + // tslint:disable-next-line:no-bitwise + const r = (Math.random() * 16) | 0, + // tslint:disable-next-line:no-bitwise + v = c === 'x' ? r : (r & 0x3) | 0x8; + return v.toString(16); + }); +} + +/** Alias for getUuid(). Compare with getGuidComb(). */ +export function getGuid() { + return getUuid(); +} + +/** + * Creates a sortable, pseudo-GUID (globally unique identifier) + * whose trailing 6 bytes (12 hex digits) are time-based + * Start either with the given getTime() value, seedTime, + * or get the current time in ms. + * + * @param seed {number} - optional seed for reproducible time-part + */ +export function getGuidComb(seed?: number) { + // Each new Guid is greater than next if more than 1ms passes + // See http://thatextramile.be/blog/2009/05/using-the-guidcomb-identifier-strategy + // Based on breeze.core.getUuid which is based on this StackOverflow answer + // http://stackoverflow.com/a/2117523/200253 + // + // Convert time value to hex: n.toString(16) + // Make sure it is 6 bytes long: ('00'+ ...).slice(-12) ... from the rear + // Replace LAST 6 bytes (12 hex digits) of regular Guid (that's where they sort in a Db) + // + // Play with this in jsFiddle: http://jsfiddle.net/wardbell/qS8aN/ + const timePart = ('00' + (seed || new Date().getTime()).toString(16)).slice(-12); + return ( + 'xxxxxxxxxx4xxyxxx'.replace(/[xy]/g, function(c) { + // tslint:disable:no-bitwise + const r = (Math.random() * 16) | 0, + v = c === 'x' ? r : (r & 0x3) | 0x8; + return v.toString(16); + }) + timePart + ); +} + +// Sort comparison value that's good enough +export function guidComparer(l: string, r: string) { + const l_low = l.slice(-12); + const r_low = r.slice(-12); + return l_low !== r_low ? (l_low < r_low ? -1 : +(l_low !== r_low)) : l < r ? -1 : +(l !== r); +} diff --git a/lib/src/utils/ngrx-entity-models.ts b/lib/src/utils/ngrx-entity-models.ts index a7871438..21cb8005 100644 --- a/lib/src/utils/ngrx-entity-models.ts +++ b/lib/src/utils/ngrx-entity-models.ts @@ -25,4 +25,16 @@ export interface UpdateNum { changes: Partial; } +/** Update data for an Update action */ export type Update = UpdateStr | UpdateNum; + +export interface UpdateDataStr extends UpdateStr { + unchanged?: boolean; +} + +export interface UpdateDataNum extends UpdateNum { + unchanged?: boolean; +} + +/** Update data from an Update HTTP response action. See EntityEffects */ +export type UpdateData = UpdateDataStr | UpdateDataNum; diff --git a/lib/src/utils/utils.spec.ts b/lib/src/utils/utils.spec.ts new file mode 100644 index 00000000..b0996f3b --- /dev/null +++ b/lib/src/utils/utils.spec.ts @@ -0,0 +1,32 @@ +import { CorrelationIdGenerator } from './correlation-id-generator'; + +describe('Utilities (utils)', () => { + describe('CorrelationIdGenerator', () => { + const prefix = 'CRID'; + + it('generates a non-zero integer id', () => { + const generator = new CorrelationIdGenerator(); + const id = generator.next(); + expect(id).toBe(prefix + 1); + }); + + it('generates successive integer ids', () => { + const generator = new CorrelationIdGenerator(); + const id1 = generator.next(); + const id2 = generator.next(); + expect(id1).toBe(prefix + 1); + expect(id2).toBe(prefix + 2); + }); + + it('new instance of the service has its own ids', () => { + const generator1 = new CorrelationIdGenerator(); + const generator2 = new CorrelationIdGenerator(); + const id1 = generator1.next(); + const id2 = generator1.next(); + const id3 = generator2.next(); + expect(id1).toBe(prefix + 1, 'gen1 first'); + expect(id2).toBe(prefix + 2, 'gen1 second'); + expect(id3).toBe(prefix + 1, 'gen2 first'); + }); + }); +}); diff --git a/package.json b/package.json index 0d5fb727..65b02f19 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "angular-ngrx-data", - "version": "0.6.1", + "version": "0.6.2", "license": "MIT", "scripts": { "ng": "ng", @@ -10,8 +10,7 @@ "start-lite": "lite-server --baseDir=\"dist/app\"", "build-lib": "ng-packagr -p lib", "build-all": "rm -rf dist && npm run build-setup && npm run build-prod", - "build-setup": - "npm run build-lib && npm install dist/ngrx-data --no-save --no-package-lock", + "build-setup": "npm run build-lib && npm install dist/ngrx-data --no-save --no-package-lock", "build-publish": "npm run build-lib && npm publish dist/ngrx-data", "e2e": "ng e2e --port=8088", "sme": "./node_modules/.bin/source-map-explorer", @@ -20,16 +19,12 @@ "test": "ng test", "lint": "ng lint", "docker-up": "docker-compose up -d --build", - "docker-up-debug": - "docker-compose -f docker-compose.debug.yml up -d --build", + "docker-up-debug": "docker-compose -f docker-compose.debug.yml up -d --build", "docker-down": "docker-compose down", - "dev": - "concurrently \"npm run debug\" \"ng serve --proxy-config proxy.conf.json --open\"", + "dev": "concurrently \"npm run debug\" \"ng serve --proxy-config proxy.conf.json --open\"", "start-server": "node ./src/server/index.js", - "debug": - "PUBLICWEB=./dist/publicweb node -r dotenv/config --inspect src/server/index.js", - "serve-dist": - "PUBLICWEB=./dist/publicweb node -r dotenv/config --inspect dist/index.js", + "debug": "PUBLICWEB=./dist/publicweb node -r dotenv/config --inspect src/server/index.js", + "serve-dist": "PUBLICWEB=./dist/publicweb node -r dotenv/config --inspect dist/index.js", "snyk-protect": "snyk protect", "prepare": "npm run snyk-protect", "precommit": "pretty-quick --staged" diff --git a/src/app/heroes/heroes.service.ts b/src/app/heroes/heroes.service.ts index 434fabca..55dbe6e2 100644 --- a/src/app/heroes/heroes.service.ts +++ b/src/app/heroes/heroes.service.ts @@ -1,8 +1,5 @@ import { Injectable } from '@angular/core'; -import { - EntityCollectionServiceBase, - EntityCollectionServiceFactory -} from 'ngrx-data'; +import { EntityCollectionServiceBase, EntityCollectionServiceElementsFactory } from 'ngrx-data'; import { shareReplay, tap } from 'rxjs/operators'; @@ -15,15 +12,10 @@ export class HeroesService extends EntityCollectionServiceBase { filterObserver: FilterObserver; /** Run `getAll` if the datasource changes. */ - getAllOnDataSourceChange = this.appSelectors - .dataSource$() - .pipe(tap(_ => this.getAll()), shareReplay(1)); + getAllOnDataSourceChange = this.appSelectors.dataSource$().pipe(tap(_ => this.getAll()), shareReplay(1)); - constructor( - entityCollectionServiceFactory: EntityCollectionServiceFactory, - private appSelectors: AppSelectors - ) { - super('Hero', entityCollectionServiceFactory); + constructor(serviceElementsFactory: EntityCollectionServiceElementsFactory, private appSelectors: AppSelectors) { + super('Hero', serviceElementsFactory); /** User's filter pattern */ this.filterObserver = { diff --git a/src/app/heroes/heroes/heroes.component.effects.spec.ts b/src/app/heroes/heroes/heroes.component.effects.spec.ts index f3cdfbbf..feed8b51 100644 --- a/src/app/heroes/heroes/heroes.component.effects.spec.ts +++ b/src/app/heroes/heroes/heroes.component.effects.spec.ts @@ -31,14 +31,15 @@ import { EntityActionFactory, EntityCache, EntityOp, + EntityActionOptions, EntityEffects, EntityCollectionReducer, - EntityReducerFactory, + EntityCollectionReducerRegistry, EntityCollectionService, persistOps } from 'ngrx-data'; -import { BehaviorSubject, Observable, Subject } from 'rxjs'; +import { BehaviorSubject, Observable, ReplaySubject } from 'rxjs'; import { first, skip } from 'rxjs/operators'; import { AppSelectors } from '../../store/app-config/selectors'; @@ -64,27 +65,16 @@ describe('HeroesComponent (mock effects)', () => { }); it('should initialize component with getAll() [no helper]', () => { - const { - createHeroAction, - initialHeroes, - setPersistResponses - } = heroesComponentClassSetup(); + const { createHeroAction, initialHeroes, setPersistResponses } = heroesComponentClassSetup(); - const getAllSuccessAction = createHeroAction( - EntityOp.QUERY_ALL_SUCCESS, - initialHeroes - ); + const getAllSuccessAction = createHeroAction(EntityOp.QUERY_ALL_SUCCESS, initialHeroes); let subscriptionCalled = false; const component: HeroesComponent = TestBed.get(HeroesComponent); component.ngOnInit(); - component.loading$ - .pipe(first()) - .subscribe(loading => - expect(loading).toBe(true, 'loading while getting all') - ); + component.loading$.pipe(first()).subscribe(loading => expect(loading).toBe(true, 'loading while getting all')); setPersistResponses(getAllSuccessAction); @@ -93,11 +83,7 @@ describe('HeroesComponent (mock effects)', () => { expect(heroes.length).toBe(initialHeroes.length); }); - component.loading$ - .pipe(first()) - .subscribe(loading => - expect(loading).toBe(false, 'loading after getting all') - ); + component.loading$.pipe(first()).subscribe(loading => expect(loading).toBe(false, 'loading after getting all')); expect(subscriptionCalled).toBe(true, 'should have gotten heroes'); }); @@ -146,9 +132,7 @@ describe('HeroesComponent (mock effects)', () => { component.delete(initialHeroes[1]); // 'B' - const success = createHeroAction( - EntityOp.SAVE_DELETE_ONE_OPTIMISTIC_SUCCESS - ); + const success = createHeroAction(EntityOp.SAVE_DELETE_ONE); setPersistResponses(success); @@ -159,20 +143,12 @@ describe('HeroesComponent (mock effects)', () => { }); expect(subscriptionCalled).toBe(true, 'subscription was called'); - expect(dispatchSpy.calls.count()).toBe( - 1, - 'can only see the direct dispatch to the store!' - ); + expect(dispatchSpy.calls.count()).toBe(1, 'can only see the direct dispatch to the store!'); expect(heroReducerSpy.calls.count()).toBe(2, 'HroReducer called twice'); }); it('should add a hero', () => { - const { - component, - createHeroAction, - initialHeroes, - setPersistResponses - } = getInitializedComponentClass(); + const { component, createHeroAction, initialHeroes, setPersistResponses } = getInitializedComponentClass(); let subscriptionCalled = false; @@ -204,12 +180,7 @@ describe('HeroesComponent (mock effects)', () => { beforeEach(heroesComponentDeclarationsSetup); it('should display all heroes', () => { - const { - component, - fixture, - initialHeroes, - view - } = getInitializedComponent(); + const { component, fixture, initialHeroes, view } = getInitializedComponent(); const itemEls = view.querySelectorAll('ul.heroes li'); expect(itemEls.length).toBe(initialHeroes.length); }); @@ -220,12 +191,7 @@ describe('HeroesComponent (mock effects)', () => { function heroesComponentClassSetup() { TestBed.configureTestingModule({ - imports: [ - StoreModule.forRoot({}), - EffectsModule.forRoot([]), - CoreModule, - EntityStoreModule - ], + imports: [StoreModule.forRoot({}), EffectsModule.forRoot([]), CoreModule, EntityStoreModule], providers: [ Actions, AppEntityServices, @@ -243,19 +209,17 @@ function heroesComponentClassSetup() { spyOn(appSelectors, 'dataSource$').and.returnValue(appSelectorsDataSource); // Create Hero entity actions as ngrx-data will do it - const entityActionFactory: EntityActionFactory = TestBed.get( - EntityActionFactory - ); - function createHeroAction(op: EntityOp, payload?: any) { - return entityActionFactory.create('Hero', op, payload); + const entityActionFactory: EntityActionFactory = TestBed.get(EntityActionFactory); + function createHeroAction(op: EntityOp, data?: any, options?: EntityActionOptions) { + return entityActionFactory.create('Hero', op, data, options); } // Spy on EntityEffects const effects: EntityEffects = TestBed.get(EntityEffects); - let persistResponsesSubject: Subject; + let persistResponsesSubject: ReplaySubject; const persistSpy = spyOn(effects, 'persist').and.callFake( - (action: EntityAction) => (persistResponsesSubject = new Subject()) + (action: EntityAction) => (persistResponsesSubject = new ReplaySubject(1)) ); // Control EntityAction responses from EntityEffects spy @@ -295,17 +259,9 @@ function heroesComponentClassSetup() { */ function getInitializedComponentClass() { const setup = heroesComponentClassSetup(); - const { - createHeroAction, - dispatchSpy, - initialHeroes, - setPersistResponses - } = setup; + const { createHeroAction, dispatchSpy, initialHeroes, setPersistResponses } = setup; - const getAllSuccessAction = createHeroAction( - EntityOp.QUERY_ALL_SUCCESS, - initialHeroes - ); + const getAllSuccessAction = createHeroAction(EntityOp.QUERY_ALL_SUCCESS, initialHeroes); // When testing the class-only, can inject it as if it were a service const component: HeroesComponent = TestBed.get(HeroesComponent); @@ -321,17 +277,11 @@ function getInitializedComponentClass() { } function spyOnHeroReducer() { - const entityReducerFactory: EntityReducerFactory = TestBed.get( - EntityReducerFactory - ); - const heroReducer: EntityCollectionReducer< - Hero - > = entityReducerFactory.getOrCreateReducer('Hero'); - const heroReducerSpy = jasmine - .createSpy('HeroReducer', heroReducer) - .and.callThrough(); + const registry: EntityCollectionReducerRegistry = TestBed.get(EntityCollectionReducerRegistry); + const heroReducer: EntityCollectionReducer = registry.getOrCreateReducer('Hero'); + const heroReducerSpy = jasmine.createSpy('HeroReducer', heroReducer).and.callThrough(); // re-register the spy version - entityReducerFactory.registerReducer('Hero', heroReducerSpy); + registry.registerReducer('Hero', heroReducerSpy); return heroReducerSpy; } @@ -363,10 +313,7 @@ function getInitializedComponent() { fixture.detectChanges(); // triggers ngOnInit() which gets all heroes. - const getAllSuccessAction = createHeroAction( - EntityOp.QUERY_ALL_SUCCESS, - initialHeroes - ); + const getAllSuccessAction = createHeroAction(EntityOp.QUERY_ALL_SUCCESS, initialHeroes); setPersistResponses(getAllSuccessAction); fixture.detectChanges(); // populate view with heroes from store. diff --git a/src/app/store/entity/app-entity-services.ts b/src/app/store/entity/app-entity-services.ts index ceaa4aba..5c002842 100644 --- a/src/app/store/entity/app-entity-services.ts +++ b/src/app/store/entity/app-entity-services.ts @@ -1,10 +1,5 @@ import { Injectable } from '@angular/core'; -import { Store } from '@ngrx/store'; -import { - EntityCache, - EntityCollectionServiceFactory, - EntityServicesBase -} from 'ngrx-data'; +import { EntityServicesElements, EntityServicesBase } from 'ngrx-data'; import { HeroesService } from '../../heroes/heroes.service'; import { VillainsService } from '../../villains/villains.service'; @@ -12,13 +7,12 @@ import { VillainsService } from '../../villains/villains.service'; @Injectable() export class AppEntityServices extends EntityServicesBase { constructor( - public readonly store: Store, - public readonly entityCollectionServiceFactory: EntityCollectionServiceFactory, + entityServicesElements: EntityServicesElements, // Inject custom services, register them with the EntityServices, and expose in API. public readonly heroesService: HeroesService, public readonly villainsService: VillainsService ) { - super(store, entityCollectionServiceFactory); + super(entityServicesElements); this.registerEntityCollectionServices([heroesService, villainsService]); } diff --git a/src/app/store/entity/ngrx-data-toast.service.ts b/src/app/store/entity/ngrx-data-toast.service.ts index 0ea18e46..41c609a3 100644 --- a/src/app/store/entity/ngrx-data-toast.service.ts +++ b/src/app/store/entity/ngrx-data-toast.service.ts @@ -10,16 +10,8 @@ import { ToastService } from '../../core/toast.service'; export class NgrxDataToastService { constructor(actions$: Actions, toast: ToastService) { actions$ - .pipe( - ofEntityOp(), - filter( - (ea: EntityAction) => - ea.op.endsWith(OP_SUCCESS) || ea.op.endsWith(OP_ERROR) - ) - ) + .pipe(ofEntityOp(), filter((ea: EntityAction) => ea.payload.entityOp.endsWith(OP_SUCCESS) || ea.payload.entityOp.endsWith(OP_ERROR))) // this service never dies so no need to unsubscribe - .subscribe(action => - toast.openSnackBar(`${action.entityName} action`, action.op) - ); + .subscribe(action => toast.openSnackBar(`${action.payload.entityName} action`, action.payload.entityOp)); } } diff --git a/src/app/villains/villains.service.ts b/src/app/villains/villains.service.ts index 531444d5..8ace47a6 100644 --- a/src/app/villains/villains.service.ts +++ b/src/app/villains/villains.service.ts @@ -2,10 +2,7 @@ import { Injectable } from '@angular/core'; import { Villain, IdGeneratorService } from '../core'; import { AppSelectors } from '../store/app-config'; -import { - EntityCollectionServiceBase, - EntityCollectionServiceFactory -} from 'ngrx-data'; +import { EntityCollectionServiceBase, EntityCollectionServiceElementsFactory } from 'ngrx-data'; import { FilterObserver } from '../shared/filter'; import { shareReplay, tap } from 'rxjs/operators'; @@ -14,15 +11,13 @@ export class VillainsService extends EntityCollectionServiceBase { filterObserver: FilterObserver; /** Run `getAll` if the datasource changes. */ - getAllOnDataSourceChange = this.appSelectors - .dataSource$() - .pipe(tap(_ => this.getAll()), shareReplay(1)); + getAllOnDataSourceChange = this.appSelectors.dataSource$().pipe(tap(_ => this.getAll()), shareReplay(1)); constructor( - private entityCollectionServiceFactory: EntityCollectionServiceFactory, + private serviceElementsFactory: EntityCollectionServiceElementsFactory, private appSelectors: AppSelectors, private idGenerator: IdGeneratorService ) { - super('Villain', entityCollectionServiceFactory); + super('Villain', serviceElementsFactory); /** User's filter pattern */ this.filterObserver = { @@ -38,6 +33,6 @@ export class VillainsService extends EntityCollectionServiceBase { const id = this.idGenerator.nextId(); villain = { ...villain, id }; } - super.add(villain); + return super.add(villain); } }