diff --git a/.circleci/config.yml b/.circleci/config.yml index a247d3cdef..b946a1b254 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -82,6 +82,11 @@ jobs: - ~/.cache/yarn - ~/.cache/Cypress - node_modules + # required since `publish-*` jobs have tag filters AND requires `test` + # https://circleci.com/docs/2.0/workflows/#executing-workflows-for-a-git-tag + filters: + tags: + only: /.*/ schematics-core-check: <<: *run_in_browser @@ -211,7 +216,9 @@ jobs: - run: name: Authenticate with registry command: echo "//registry.npmjs.org/:_authToken=$npm_TOKEN" > ~/repo/.npmrc - - run: yarn run publish:stable + - run: + name: Publish stable to npm + command: ./node_modules/.bin/ts-node ./build/publish-stable.ts publish-next: <<: *run_in_node @@ -224,7 +231,9 @@ jobs: - run: name: Authenticate with registry command: echo "//registry.npmjs.org/:_authToken=$npm_TOKEN" > ~/repo/.npmrc - - run: yarn run publish:next + - run: + name: Publish next to npm + command: ./node_modules/.bin/ts-node ./build/publish-next.ts cleanup-previews: <<: *run_in_node @@ -282,27 +291,27 @@ workflows: filters: branches: only: master - # - publish-stable: - # requires: - # - test - # filters: - # branches: - # ignore: /.*/ - # tags: - # only: /\d+\.\d+\.\d+(?!-\w+\.\d)/ - # - deploy-docs-stable: - # requires: - # - test - # filters: - # branches: - # ignore: /.*/ - # tags: - # only: /\d+\.\d+\.\d+(?!-\w+\.\d)/ - - publish-next: + - publish-stable: requires: - test filters: + tags: + only: /8\.\d+\.\d+(?!-\w+\.\d)/ branches: ignore: /.*/ + - deploy-docs-stable: + requires: + - test + filters: tags: - only: /\d+\.\d+\.\d+(-\w+\.\d)/ \ No newline at end of file + only: /8\.\d+\.\d+(?!-\w+\.\d)/ + branches: + ignore: /.*/ + - publish-next: + requires: + - test + filters: + tags: + only: /8\.\d+\.\d+(-\w+\.\d)/ + branches: + ignore: /.*/ \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c26b276f1..c025342515 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,35 @@ + + +# [8.4.0](https://github.com/ngrx/platform/compare/8.3.0...8.4.0) (2019-10-09) + +### Bug Fixes + +- **schematics:** fixed the schematics/action spec template ([#2092](https://github.com/ngrx/platform/issues/2092)) ([ed3b1f9](https://github.com/ngrx/platform/commit/ed3b1f9)), closes [#2082](https://github.com/ngrx/platform/issues/2082) +- **store:** improve consistency of memoized selector result when projection fails ([#2101](https://github.com/ngrx/platform/issues/2101)) ([c63941c](https://github.com/ngrx/platform/commit/c63941c)), closes [#2100](https://github.com/ngrx/platform/issues/2100) + +### Features + +- **effects:** throw error when forRoot() is used more than once ([b46748c](https://github.com/ngrx/platform/commit/b46748c)) +- **schematics:** add createEffect migration schematic ([#2136](https://github.com/ngrx/platform/issues/2136)) ([9eb1bd5](https://github.com/ngrx/platform/commit/9eb1bd5)) +- **store:** add refreshState method to mock store ([#2148](https://github.com/ngrx/platform/issues/2148)) ([30e876f](https://github.com/ngrx/platform/commit/30e876f)), closes [#2121](https://github.com/ngrx/platform/issues/2121) +- **store:** allow multiple on handlers for the same action in createReducer([#2103](https://github.com/ngrx/platform/issues/2103)) ([9a70262](https://github.com/ngrx/platform/commit/9a70262)), closes [#1956](https://github.com/ngrx/platform/issues/1956) +- **store:** cleanup selector after a test ([2964e2b](https://github.com/ngrx/platform/commit/2964e2b)) +- **store:** throw error when forRoot() is used more than once ([4304865](https://github.com/ngrx/platform/commit/4304865)) + + + +# [8.3.0](https://github.com/ngrx/platform/compare/8.2.0...8.3.0) (2019-08-29) + +### Bug Fixes + +- **data:** use correct guard when handling optimistic update ([#2060](https://github.com/ngrx/platform/issues/2060)) ([34c0420](https://github.com/ngrx/platform/commit/34c0420)), closes [#2059](https://github.com/ngrx/platform/issues/2059) +- **store:** add DefaultProjectorFn to public API ([#2090](https://github.com/ngrx/platform/issues/2090)) ([2d37b48](https://github.com/ngrx/platform/commit/2d37b48)) +- **store:** should not run schematics when not using named imports ([#2095](https://github.com/ngrx/platform/issues/2095)) ([7cadbc0](https://github.com/ngrx/platform/commit/7cadbc0)), closes [#2093](https://github.com/ngrx/platform/issues/2093) + +### Features + +- **store:** add verbose error message for undefined feature state in development mode ([#2078](https://github.com/ngrx/platform/issues/2078)) ([6946e2e](https://github.com/ngrx/platform/commit/6946e2e)), closes [#1897](https://github.com/ngrx/platform/issues/1897) + # [8.2.0](https://github.com/ngrx/platform/compare/8.1.0...8.2.0) (2019-07-31) diff --git a/modules/data/schematics-core/utility/ast-utils.ts b/modules/data/schematics-core/utility/ast-utils.ts index 0404340135..8471c23228 100644 --- a/modules/data/schematics-core/utility/ast-utils.ts +++ b/modules/data/schematics-core/utility/ast-utils.ts @@ -13,6 +13,8 @@ import { NoopChange, createReplaceChange, ReplaceChange, + RemoveChange, + createRemoveChange, } from './change'; import { Path } from '@angular-devkit/core'; @@ -650,7 +652,7 @@ export function replaceImport( importFrom: string, importAsIs: string, importToBe: string -): ReplaceChange[] { +): (ReplaceChange | RemoveChange)[] { const imports = sourceFile.statements .filter(ts.isImportDeclaration) .filter( @@ -663,32 +665,67 @@ export function replaceImport( return []; } - const changes = imports - .map(p => (p.importClause!.namedBindings! as ts.NamedImports).elements) - .reduce((imports, curr) => imports.concat(curr), [] as ts.ImportSpecifier[]) - .map(specifier => { - if (!ts.isImportSpecifier(specifier)) { - return { hit: false }; + const importText = (specifier: ts.ImportSpecifier) => { + if (specifier.name.text) { + return specifier.name.text; + } + + // if import is renamed + if (specifier.propertyName && specifier.propertyName.text) { + return specifier.propertyName.text; + } + + return ''; + }; + + const changes = imports.map(p => { + const importSpecifiers = (p.importClause!.namedBindings! as ts.NamedImports) + .elements; + + const isAlreadyImported = importSpecifiers + .map(importText) + .includes(importToBe); + + const importChanges = importSpecifiers.map((specifier, index) => { + const text = importText(specifier); + + // import is not the one we're looking for, can be skipped + if (text !== importAsIs) { + return undefined; } - if (specifier.name.text === importAsIs) { - return { hit: true, specifier, text: specifier.name.text }; + // identifier has not been imported, simply replace the old text with the new text + if (!isAlreadyImported) { + return createReplaceChange( + sourceFile, + specifier!, + importAsIs, + importToBe + ); } - // if import is renamed - if ( - specifier.propertyName && - specifier.propertyName.text === importAsIs - ) { - return { hit: true, specifier, text: specifier.propertyName.text }; + const nextIdentifier = importSpecifiers[index + 1]; + // identifer is not the last, also clean up the comma + if (nextIdentifier) { + return createRemoveChange( + sourceFile, + specifier, + specifier.getStart(sourceFile), + nextIdentifier.getStart(sourceFile) + ); } - return { hit: false }; - }) - .filter(({ hit }) => hit) - .map(({ specifier, text }) => - createReplaceChange(sourceFile, specifier!, text!, importToBe) - ); + // there are no imports following, just remove it + return createRemoveChange( + sourceFile, + specifier, + specifier.getStart(sourceFile), + specifier.getEnd() + ); + }); + + return importChanges.filter(Boolean) as (ReplaceChange | RemoveChange)[]; + }); - return changes; + return changes.reduce((imports, curr) => imports.concat(curr), []); } diff --git a/modules/data/schematics-core/utility/change.ts b/modules/data/schematics-core/utility/change.ts index 98c7fc6405..d4a22ae4a5 100644 --- a/modules/data/schematics-core/utility/change.ts +++ b/modules/data/schematics-core/utility/change.ts @@ -148,6 +148,15 @@ export function createReplaceChange( ); } +export function createRemoveChange( + sourceFile: ts.SourceFile, + node: ts.Node, + from = node.getStart(sourceFile), + to = node.getEnd() +): RemoveChange { + return new RemoveChange(sourceFile.fileName, from, to); +} + export function createChangeRecorder( tree: Tree, path: string, diff --git a/modules/data/spec/actions/entity-action-factory.spec.ts b/modules/data/spec/actions/entity-action-factory.spec.ts index 0c1801ea8f..7e648b731b 100644 --- a/modules/data/spec/actions/entity-action-factory.spec.ts +++ b/modules/data/spec/actions/entity-action-factory.spec.ts @@ -1,11 +1,9 @@ import { - EntityAction, EntityActionOptions, EntityActionPayload, EntityOp, EntityActionFactory, MergeStrategy, - CorrelationIdGenerator, } from '../../'; class Hero { diff --git a/modules/data/spec/actions/entity-action-operators.spec.ts b/modules/data/spec/actions/entity-action-operators.spec.ts index 02d2b8f2d7..b4a9ed29f4 100644 --- a/modules/data/spec/actions/entity-action-operators.spec.ts +++ b/modules/data/spec/actions/entity-action-operators.spec.ts @@ -1,5 +1,4 @@ import { Action } from '@ngrx/store'; -import { Actions } from '@ngrx/effects'; import { Subject } from 'rxjs'; diff --git a/modules/data/spec/actions/entity-cache-changes-set.spec.ts b/modules/data/spec/actions/entity-cache-changes-set.spec.ts index 3c53af7020..9a51ac6e48 100644 --- a/modules/data/spec/actions/entity-cache-changes-set.spec.ts +++ b/modules/data/spec/actions/entity-cache-changes-set.spec.ts @@ -1,8 +1,4 @@ -import { - ChangeSet, - ChangeSetOperation, - changeSetItemFactory as cif, -} from '../../'; +import { ChangeSetOperation, changeSetItemFactory as cif } from '../../'; describe('changeSetItemFactory', () => { const hero = { id: 1, name: 'Hero 1' }; diff --git a/modules/data/spec/dataservices/data-service-error.spec.ts b/modules/data/spec/dataservices/data-service-error.spec.ts index 4ef9fbb219..630a764af6 100644 --- a/modules/data/spec/dataservices/data-service-error.spec.ts +++ b/modules/data/spec/dataservices/data-service-error.spec.ts @@ -1,5 +1,5 @@ import { HttpErrorResponse } from '@angular/common/http'; -import { DataServiceError, RequestData } from '../../'; +import { DataServiceError } from '../../'; describe('DataServiceError', () => { describe('#message', () => { diff --git a/modules/data/spec/dataservices/default-data.service.spec.ts b/modules/data/spec/dataservices/default-data.service.spec.ts index 071806d67e..00378d4ad8 100644 --- a/modules/data/spec/dataservices/default-data.service.spec.ts +++ b/modules/data/spec/dataservices/default-data.service.spec.ts @@ -1,6 +1,6 @@ import { TestBed } from '@angular/core/testing'; -import { HttpClient, HttpResponse } from '@angular/common/http'; +import { HttpClient } from '@angular/common/http'; import { HttpClientTestingModule, HttpTestingController, @@ -14,7 +14,6 @@ import { DefaultDataService, DefaultDataServiceFactory, DefaultHttpUrlGenerator, - EntityHttpResourceUrls, HttpUrlGenerator, DefaultDataServiceConfig, DataServiceError, diff --git a/modules/data/spec/dataservices/entity-data.service.spec.ts b/modules/data/spec/dataservices/entity-data.service.spec.ts index e7a43773a2..51bb5d2736 100644 --- a/modules/data/spec/dataservices/entity-data.service.spec.ts +++ b/modules/data/spec/dataservices/entity-data.service.spec.ts @@ -7,11 +7,6 @@ import { Observable } from 'rxjs'; import { Update } from '@ngrx/entity'; import { - createEntityDefinition, - EntityDefinition, - EntityMetadata, - EntityMetadataMap, - ENTITY_METADATA_TOKEN, DefaultDataService, DefaultDataServiceFactory, HttpUrlGenerator, diff --git a/modules/data/spec/effects/entity-cache-effects.spec.ts b/modules/data/spec/effects/entity-cache-effects.spec.ts index 7a24186597..14210a0fe9 100644 --- a/modules/data/spec/effects/entity-cache-effects.spec.ts +++ b/modules/data/spec/effects/entity-cache-effects.spec.ts @@ -2,17 +2,8 @@ import { TestBed } from '@angular/core/testing'; import { Action } from '@ngrx/store'; import { Actions } from '@ngrx/effects'; - -import { - asapScheduler, - Observable, - of, - merge, - ReplaySubject, - Subject, - throwError, -} from 'rxjs'; -import { first, mergeMap, observeOn, tap } from 'rxjs/operators'; +import { observeOn } from 'rxjs/operators'; +import { asapScheduler, ReplaySubject, Subject } from 'rxjs'; import { EntityCacheEffects, diff --git a/modules/data/spec/effects/entity-effects.marbles.spec.ts b/modules/data/spec/effects/entity-effects.marbles.spec.ts index 3bb238bcf4..32094c00f2 100644 --- a/modules/data/spec/effects/entity-effects.marbles.spec.ts +++ b/modules/data/spec/effects/entity-effects.marbles.spec.ts @@ -2,7 +2,7 @@ import { TestBed } from '@angular/core/testing'; import { cold, hot, getTestScheduler } from 'jasmine-marbles'; -import { Observable, of, Subject } from 'rxjs'; +import { Observable } from 'rxjs'; import { Actions } from '@ngrx/effects'; import { Update } from '@ngrx/entity'; diff --git a/modules/data/spec/effects/entity-effects.spec.ts b/modules/data/spec/effects/entity-effects.spec.ts index aebee4e4de..a2c4b4ddbc 100644 --- a/modules/data/spec/effects/entity-effects.spec.ts +++ b/modules/data/spec/effects/entity-effects.spec.ts @@ -4,7 +4,7 @@ import { Action } from '@ngrx/store'; import { Actions } from '@ngrx/effects'; import { Update } from '@ngrx/entity'; -import { Observable, of, merge, ReplaySubject, throwError, timer } from 'rxjs'; +import { of, merge, ReplaySubject, throwError, timer } from 'rxjs'; import { delay, first, mergeMap } from 'rxjs/operators'; import { diff --git a/modules/data/spec/entity-data.module.spec.ts b/modules/data/spec/entity-data.module.spec.ts index 93f401fd30..5964711ce3 100644 --- a/modules/data/spec/entity-data.module.spec.ts +++ b/modules/data/spec/entity-data.module.spec.ts @@ -6,13 +6,13 @@ import { Store, StoreModule, } from '@ngrx/store'; -import { Actions, Effect, EffectsModule } from '@ngrx/effects'; +import { Actions, EffectsModule, createEffect } from '@ngrx/effects'; // Not using marble testing import { TestBed } from '@angular/core/testing'; -import { Observable, of, Subject } from 'rxjs'; -import { map, skip, tap } from 'rxjs/operators'; +import { Observable } from 'rxjs'; +import { map, skip } from 'rxjs/operators'; import { EntityCache, @@ -35,11 +35,12 @@ const EC_METAREDUCER_TOKEN = new InjectionToken< @Injectable() class TestEntityEffects { - @Effect() - test$: Observable = this.actions.pipe( - // tap(action => console.log('test$ effect', action)), - ofEntityOp(persistOps), - map(this.testHook) + test$: Observable = createEffect(() => + this.actions.pipe( + // tap(action => console.log('test$ effect', action)), + ofEntityOp(persistOps), + map(this.testHook) + ) ); testHook(action: EntityAction) { diff --git a/modules/data/spec/entity-services/entity-collection-service.spec.ts b/modules/data/spec/entity-services/entity-collection-service.spec.ts index 06e10d1334..36fe10df22 100644 --- a/modules/data/spec/entity-services/entity-collection-service.spec.ts +++ b/modules/data/spec/entity-services/entity-collection-service.spec.ts @@ -1,19 +1,11 @@ import { Injectable } from '@angular/core'; -import { ComponentFixture, inject, TestBed } from '@angular/core/testing'; +import { 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 { Observable, of, throwError, timer } from 'rxjs'; +import { delay, filter, mergeMap, tap, withLatestFrom } from 'rxjs/operators'; import { commandDispatchTest } from '../dispatchers/entity-dispatcher.spec'; import { @@ -24,7 +16,6 @@ import { EntityAction, EntityActionFactory, EntityCache, - EntityCollection, EntityOp, EntityMetadataMap, EntityDataModule, diff --git a/modules/data/spec/entity-services/entity-services.spec.ts b/modules/data/spec/entity-services/entity-services.spec.ts index a2f8d3f36e..4dec048237 100644 --- a/modules/data/spec/entity-services/entity-services.spec.ts +++ b/modules/data/spec/entity-services/entity-services.spec.ts @@ -1,19 +1,8 @@ -import { Injectable } from '@angular/core'; -import { ComponentFixture, inject, TestBed } from '@angular/core/testing'; -import { HttpErrorResponse } from '@angular/common/http'; +import { TestBed } from '@angular/core/testing'; 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 { Observable } from 'rxjs'; +import { first, skip } from 'rxjs/operators'; import { EntityAction, diff --git a/modules/data/spec/reducers/entity-cache-reducer.spec.ts b/modules/data/spec/reducers/entity-cache-reducer.spec.ts index f66ec95975..2d5235bdf7 100644 --- a/modules/data/spec/reducers/entity-cache-reducer.spec.ts +++ b/modules/data/spec/reducers/entity-cache-reducer.spec.ts @@ -28,7 +28,6 @@ import { ChangeSet, ChangeSetOperation, Logger, - EntityMetadata, } from '../..'; class Hero { diff --git a/modules/data/spec/selectors/related-entity-selectors.spec.ts b/modules/data/spec/selectors/related-entity-selectors.spec.ts index 0d56cd407f..612b1dbb24 100644 --- a/modules/data/spec/selectors/related-entity-selectors.spec.ts +++ b/modules/data/spec/selectors/related-entity-selectors.spec.ts @@ -1,15 +1,9 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { - Action, - createSelector, - Selector, - StoreModule, - Store, -} from '@ngrx/store'; +import { TestBed } from '@angular/core/testing'; +import { createSelector, StoreModule, Store } from '@ngrx/store'; import { Actions } from '@ngrx/effects'; import { Update } from '@ngrx/entity'; -import { Observable, Subject } from 'rxjs'; +import { Observable } from 'rxjs'; import { skip } from 'rxjs/operators'; import { diff --git a/modules/data/src/actions/entity-action-factory.ts b/modules/data/src/actions/entity-action-factory.ts index b2b838ca57..9f69700f78 100644 --- a/modules/data/src/actions/entity-action-factory.ts +++ b/modules/data/src/actions/entity-action-factory.ts @@ -1,5 +1,4 @@ import { Injectable } from '@angular/core'; -import { Action } from '@ngrx/store'; import { EntityOp } from './entity-op'; import { diff --git a/modules/data/src/actions/entity-action-operators.ts b/modules/data/src/actions/entity-action-operators.ts index 82adeab936..a712c05253 100644 --- a/modules/data/src/actions/entity-action-operators.ts +++ b/modules/data/src/actions/entity-action-operators.ts @@ -1,7 +1,4 @@ -import { Action } from '@ngrx/store'; -import { Actions } from '@ngrx/effects'; - -import { Observable, OperatorFunction } from 'rxjs'; +import { OperatorFunction } from 'rxjs'; import { filter } from 'rxjs/operators'; import { EntityAction } from './entity-action'; diff --git a/modules/data/src/actions/entity-action.ts b/modules/data/src/actions/entity-action.ts index f658e4f680..2bdce209cf 100644 --- a/modules/data/src/actions/entity-action.ts +++ b/modules/data/src/actions/entity-action.ts @@ -1,4 +1,3 @@ -import { Injectable } from '@angular/core'; import { Action } from '@ngrx/store'; import { EntityOp } from './entity-op'; diff --git a/modules/data/src/actions/entity-cache-change-set.ts b/modules/data/src/actions/entity-cache-change-set.ts index 6da7c10eab..b25ee4450a 100644 --- a/modules/data/src/actions/entity-cache-change-set.ts +++ b/modules/data/src/actions/entity-cache-change-set.ts @@ -1,10 +1,5 @@ -import { Action } from '@ngrx/store'; import { Update } from '@ngrx/entity'; -import { EntityActionOptions } from './entity-action'; -import { EntityCacheAction } from './entity-cache-action'; -import { DataServiceError } from '../dataservices/data-service-error'; - export enum ChangeSetOperation { Add = 'Add', Delete = 'Delete', @@ -53,7 +48,7 @@ export interface ChangeSet { /** * An arbitrary, serializable object that should travel with the ChangeSet. - * Meaningful to the ChangeSet producer and consumer. Ignored by ngrx-data. + * Meaningful to the ChangeSet producer and consumer. Ignored by @ngrx/data. */ extras?: T; diff --git a/modules/data/src/dataservices/default-data-service-config.ts b/modules/data/src/dataservices/default-data-service-config.ts index 0beff79a1c..b4bfff3329 100644 --- a/modules/data/src/dataservices/default-data-service-config.ts +++ b/modules/data/src/dataservices/default-data-service-config.ts @@ -1,11 +1,14 @@ -import { HttpUrlGenerator, EntityHttpResourceUrls } from './http-url-generator'; +import { EntityHttpResourceUrls } from './http-url-generator'; /** * Optional configuration settings for an entity collection data service * such as the `DefaultDataService`. */ export abstract class DefaultDataServiceConfig { - /** root path of the web api (default: 'api') */ + /** + * root path of the web api. may also include protocol, domain, and port + * for remote api, e.g.: `'https://api-domain.com:8000/api/v1'` (default: 'api') + */ root?: string; /** * Known entity HttpResourceUrls. diff --git a/modules/data/src/dataservices/default-data.service.ts b/modules/data/src/dataservices/default-data.service.ts index 02b5d5b835..1108dadd35 100644 --- a/modules/data/src/dataservices/default-data.service.ts +++ b/modules/data/src/dataservices/default-data.service.ts @@ -6,7 +6,7 @@ import { } from '@angular/common/http'; import { Observable, of, throwError } from 'rxjs'; -import { catchError, delay, map, tap, timeout } from 'rxjs/operators'; +import { catchError, delay, map, timeout } from 'rxjs/operators'; import { Update } from '@ngrx/entity'; diff --git a/modules/data/src/dataservices/entity-cache-data.service.ts b/modules/data/src/dataservices/entity-cache-data.service.ts index 8efe1256b8..101e5def77 100644 --- a/modules/data/src/dataservices/entity-cache-data.service.ts +++ b/modules/data/src/dataservices/entity-cache-data.service.ts @@ -1,9 +1,5 @@ import { Injectable, Optional } from '@angular/core'; -import { - HttpClient, - HttpErrorResponse, - HttpParams, -} from '@angular/common/http'; +import { HttpClient } from '@angular/common/http'; import { Observable, throwError } from 'rxjs'; import { catchError, delay, map, timeout } from 'rxjs/operators'; diff --git a/modules/data/src/dataservices/http-url-generator.ts b/modules/data/src/dataservices/http-url-generator.ts index 24418f4d60..c950ccf59a 100644 --- a/modules/data/src/dataservices/http-url-generator.ts +++ b/modules/data/src/dataservices/http-url-generator.ts @@ -1,4 +1,4 @@ -import { Inject, Injectable, InjectionToken, Optional } from '@angular/core'; +import { Injectable } from '@angular/core'; import { Pluralizer } from '../utils/interfaces'; /** diff --git a/modules/data/src/dataservices/persistence-result-handler.service.ts b/modules/data/src/dataservices/persistence-result-handler.service.ts index accb398b50..e4f9cc7dad 100644 --- a/modules/data/src/dataservices/persistence-result-handler.service.ts +++ b/modules/data/src/dataservices/persistence-result-handler.service.ts @@ -1,15 +1,13 @@ import { Injectable } from '@angular/core'; import { Action } from '@ngrx/store'; -import { Observable, of } from 'rxjs'; - 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 { makeErrorOp, makeSuccessOp } from '../actions/entity-op'; import { Logger } from '../utils/interfaces'; /** diff --git a/modules/data/src/dispatchers/entity-cache-dispatcher.ts b/modules/data/src/dispatchers/entity-cache-dispatcher.ts index d2f255246c..cdeedaf691 100644 --- a/modules/data/src/dispatchers/entity-cache-dispatcher.ts +++ b/modules/data/src/dispatchers/entity-cache-dispatcher.ts @@ -1,24 +1,16 @@ import { Injectable, Inject } from '@angular/core'; -import { - Action, - createSelector, - ScannedActionsSubject, - select, - Store, -} from '@ngrx/store'; +import { Action, ScannedActionsSubject, Store } from '@ngrx/store'; import { Observable, of, Subscription, throwError } from 'rxjs'; -import { filter, map, mergeMap, shareReplay, take } from 'rxjs/operators'; +import { filter, mergeMap, shareReplay, take } from 'rxjs/operators'; import { CorrelationIdGenerator } from '../utils/correlation-id-generator'; -import { EntityAction, EntityActionOptions } from '../actions/entity-action'; -import { EntityActionFactory } from '../actions/entity-action-factory'; +import { EntityActionOptions } from '../actions/entity-action'; import { EntityCache } from '../reducers/entity-cache'; import { EntityDispatcherDefaultOptions } from './entity-dispatcher-default-options'; import { MergeStrategy } from '../actions/merge-strategy'; import { PersistanceCanceled } from './entity-dispatcher'; -import { UpdateResponseData } from '../actions/update-response-data'; import { ChangeSet, ChangeSetItem } from '../actions/entity-cache-change-set'; import { diff --git a/modules/data/src/dispatchers/entity-commands.ts b/modules/data/src/dispatchers/entity-commands.ts index 3b563c306e..96d2b8d262 100644 --- a/modules/data/src/dispatchers/entity-commands.ts +++ b/modules/data/src/dispatchers/entity-commands.ts @@ -1,6 +1,5 @@ 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. */ diff --git a/modules/data/src/dispatchers/entity-dispatcher-base.ts b/modules/data/src/dispatchers/entity-dispatcher-base.ts index aaf5761533..1e1aa7d496 100644 --- a/modules/data/src/dispatchers/entity-dispatcher-base.ts +++ b/modules/data/src/dispatchers/entity-dispatcher-base.ts @@ -1,7 +1,7 @@ -import { Action, createSelector, select, Store } from '@ngrx/store'; +import { Action, createSelector, Store } from '@ngrx/store'; import { IdSelector, Update } from '@ngrx/entity'; -import { Observable, of, throwError, OperatorFunction } from 'rxjs'; +import { Observable, of, throwError } from 'rxjs'; import { filter, map, @@ -344,7 +344,7 @@ export class EntityDispatcherBase implements EntityDispatcher { options ); if (options.isOptimistic) { - this.guard.mustBeEntity(action as EntityAction); + this.guard.mustBeUpdate(action); } this.dispatch(action); return this.getResponseData$>( diff --git a/modules/data/src/dispatchers/entity-dispatcher-factory.ts b/modules/data/src/dispatchers/entity-dispatcher-factory.ts index 5f21792643..d71c38b700 100644 --- a/modules/data/src/dispatchers/entity-dispatcher-factory.ts +++ b/modules/data/src/dispatchers/entity-dispatcher-factory.ts @@ -1,24 +1,20 @@ import { Inject, Injectable, OnDestroy } from '@angular/core'; import { Action, Store, ScannedActionsSubject } from '@ngrx/store'; -import { IdSelector, Update } from '@ngrx/entity'; +import { IdSelector } from '@ngrx/entity'; import { Observable, Subscription } from 'rxjs'; import { shareReplay } 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 } from '../actions/entity-action'; +import { defaultSelectId } from '../utils/utilities'; import { EntityActionFactory } from '../actions/entity-action-factory'; import { EntityCache } from '../reducers/entity-cache'; 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 { QueryParams } from '../dataservices/interfaces'; /** Creates EntityDispatchers for entity collections */ @Injectable() diff --git a/modules/data/src/effects/entity-cache-effects.ts b/modules/data/src/effects/entity-cache-effects.ts index 39b468b67c..9503bc794b 100644 --- a/modules/data/src/effects/entity-cache-effects.ts +++ b/modules/data/src/effects/entity-cache-effects.ts @@ -1,6 +1,6 @@ import { Inject, Injectable, Optional } from '@angular/core'; import { Action } from '@ngrx/store'; -import { Actions, Effect, ofType } from '@ngrx/effects'; +import { Actions, ofType, createEffect } from '@ngrx/effects'; import { asyncScheduler, @@ -38,7 +38,6 @@ import { import { EntityCacheDataService } from '../dataservices/entity-cache-data.service'; import { ENTITY_EFFECTS_SCHEDULER } from './entity-effects-scheduler'; import { Logger } from '../utils/interfaces'; -import { PersistenceResultHandler } from '../dataservices/persistence-result-handler.service'; @Injectable() export class EntityCacheEffects { @@ -64,18 +63,22 @@ export class EntityCacheEffects { /** * Observable of SAVE_ENTITIES_CANCEL actions with non-null correlation ids */ - @Effect({ dispatch: false }) - saveEntitiesCancel$: Observable = this.actions.pipe( - ofType(EntityCacheAction.SAVE_ENTITIES_CANCEL), - filter((a: SaveEntitiesCancel) => a.payload.correlationId != null) + saveEntitiesCancel$: Observable = createEffect( + () => + this.actions.pipe( + ofType(EntityCacheAction.SAVE_ENTITIES_CANCEL), + filter((a: SaveEntitiesCancel) => a.payload.correlationId != null) + ), + { dispatch: false } ); - @Effect() // Concurrent persistence requests considered unsafe. // `mergeMap` allows for concurrent requests which may return in any order - saveEntities$: Observable = this.actions.pipe( - ofType(EntityCacheAction.SAVE_ENTITIES), - mergeMap((action: SaveEntities) => this.saveEntities(action)) + saveEntities$: Observable = createEffect(() => + this.actions.pipe( + ofType(EntityCacheAction.SAVE_ENTITIES), + mergeMap((action: SaveEntities) => this.saveEntities(action)) + ) ); /** diff --git a/modules/data/src/effects/entity-effects-scheduler.ts b/modules/data/src/effects/entity-effects-scheduler.ts index 5e028c3b6f..5808e3ce9f 100644 --- a/modules/data/src/effects/entity-effects-scheduler.ts +++ b/modules/data/src/effects/entity-effects-scheduler.ts @@ -1,4 +1,4 @@ -import { Inject, Injectable, InjectionToken, Optional } from '@angular/core'; +import { InjectionToken } from '@angular/core'; import { SchedulerLike } from 'rxjs'; // See https://github.com/ReactiveX/rxjs/blob/master/doc/marble-testing.md diff --git a/modules/data/src/effects/entity-effects.ts b/modules/data/src/effects/entity-effects.ts index 363e9f5e8b..a7838f82a4 100644 --- a/modules/data/src/effects/entity-effects.ts +++ b/modules/data/src/effects/entity-effects.ts @@ -1,17 +1,10 @@ import { Inject, Injectable, Optional } from '@angular/core'; import { Action } from '@ngrx/store'; -import { Actions, Effect } from '@ngrx/effects'; +import { Actions, createEffect } from '@ngrx/effects'; import { Update } from '@ngrx/entity'; import { asyncScheduler, Observable, of, race, SchedulerLike } from 'rxjs'; -import { - concatMap, - catchError, - delay, - filter, - map, - mergeMap, -} from 'rxjs/operators'; +import { catchError, delay, filter, map, mergeMap } from 'rxjs/operators'; import { EntityAction } from '../actions/entity-action'; import { EntityActionFactory } from '../actions/entity-action-factory'; @@ -43,18 +36,22 @@ export class EntityEffects { /** * Observable of non-null cancellation correlation ids from CANCEL_PERSIST actions */ - @Effect({ dispatch: false }) - cancel$: Observable = this.actions.pipe( - ofEntityOp(EntityOp.CANCEL_PERSIST), - map((action: EntityAction) => action.payload.correlationId), - filter(id => id != null) + cancel$: Observable = createEffect( + () => + this.actions.pipe( + ofEntityOp(EntityOp.CANCEL_PERSIST), + map((action: EntityAction) => action.payload.correlationId), + filter(id => id != null) + ), + { dispatch: false } ); - @Effect() // `mergeMap` allows for concurrent requests which may return in any order - persist$: Observable = this.actions.pipe( - ofEntityOp(persistOps), - mergeMap(action => this.persist(action)) + persist$: Observable = createEffect(() => + this.actions.pipe( + ofEntityOp(persistOps), + mergeMap(action => this.persist(action)) + ) ); constructor( diff --git a/modules/data/src/entity-data-without-effects.module.ts b/modules/data/src/entity-data-without-effects.module.ts index 4acb6f090b..63da2f9c96 100644 --- a/modules/data/src/entity-data-without-effects.module.ts +++ b/modules/data/src/entity-data-without-effects.module.ts @@ -10,7 +10,6 @@ import { import { Action, - ActionReducer, combineReducers, MetaReducer, ReducerManager, @@ -24,13 +23,9 @@ import { EntityActionFactory } from './actions/entity-action-factory'; import { EntityCache } from './reducers/entity-cache'; import { EntityCacheDispatcher } from './dispatchers/entity-cache-dispatcher'; import { entityCacheSelectorProvider } from './selectors/entity-cache-selector'; -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 { EntityServices } from './entity-services/entity-services'; import { EntityCollection } from './reducers/entity-collection'; import { EntityCollectionCreator } from './reducers/entity-collection-creator'; import { EntityCollectionReducerFactory } from './reducers/entity-collection-reducer'; @@ -38,12 +33,7 @@ import { EntityCollectionReducerMethodsFactory } from './reducers/entity-collect 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 { EntityMetadataMap } from './entity-metadata/entity-metadata'; import { EntityCacheReducerFactory } from './reducers/entity-cache-reducer'; import { ENTITY_CACHE_NAME, @@ -54,13 +44,11 @@ import { } from './reducers/constants'; 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 { EntityServicesBase } from './entity-services/entity-services-base'; import { EntityServicesElements } from './entity-services/entity-services-elements'; -import { Logger, Pluralizer, PLURAL_NAMES_TOKEN } from './utils/interfaces'; +import { Logger, PLURAL_NAMES_TOKEN } from './utils/interfaces'; export interface EntityDataModuleConfig { entityMetadata?: EntityMetadataMap; @@ -151,7 +139,7 @@ export class EntityDataModuleWithoutEffects implements OnDestroy { | MetaReducer | InjectionToken>)[] ) { - // Add the ngrx-data feature to the Store's features + // Add the @ngrx/data feature to the Store's features // as Store.forFeature does for StoreFeatureModule const key = entityCacheName || ENTITY_CACHE_NAME; diff --git a/modules/data/src/entity-data.module.ts b/modules/data/src/entity-data.module.ts index 2410a98b53..658f7e31f4 100644 --- a/modules/data/src/entity-data.module.ts +++ b/modules/data/src/entity-data.module.ts @@ -110,7 +110,7 @@ export class EntityDataModule { } /** - * Add another class instance that contains @Effect methods. + * Add another class instance that contains effects. * @param effectSourceInstance a class instance that implements effects. * Warning: undocumented @ngrx/effects API */ diff --git a/modules/data/src/entity-metadata/entity-definition.service.ts b/modules/data/src/entity-metadata/entity-definition.service.ts index 29631f2816..2c568632d2 100644 --- a/modules/data/src/entity-metadata/entity-definition.service.ts +++ b/modules/data/src/entity-metadata/entity-definition.service.ts @@ -1,4 +1,4 @@ -import { Inject, Injectable, InjectionToken, Optional } from '@angular/core'; +import { Inject, Injectable, Optional } from '@angular/core'; import { createEntityDefinition, EntityDefinition } from './entity-definition'; import { @@ -6,7 +6,6 @@ import { EntityMetadataMap, ENTITY_METADATA_TOKEN, } from './entity-metadata'; -import { ENTITY_CACHE_NAME } from '../reducers/constants'; export interface EntityDefinitions { [entityName: string]: EntityDefinition; diff --git a/modules/data/src/entity-metadata/entity-definition.ts b/modules/data/src/entity-metadata/entity-definition.ts index ad15199616..193c68cc17 100644 --- a/modules/data/src/entity-metadata/entity-definition.ts +++ b/modules/data/src/entity-metadata/entity-definition.ts @@ -1,14 +1,9 @@ -import { EntityState, EntityAdapter, createEntityAdapter } from '@ngrx/entity'; -import { Comparer, Dictionary, IdSelector, Update } from '@ngrx/entity'; +import { EntityAdapter, createEntityAdapter } from '@ngrx/entity'; +import { Comparer, IdSelector } from '@ngrx/entity'; -import { - EntitySelectors, - EntitySelectorsFactory, -} from '../selectors/entity-selectors'; import { EntityDispatcherDefaultOptions } from '../dispatchers/entity-dispatcher-default-options'; import { defaultSelectId } from '../utils/utilities'; import { EntityCollection } from '../reducers/entity-collection'; -import { EntityFilterFn } from './entity-filters'; import { EntityMetadata } from './entity-metadata'; export interface EntityDefinition { diff --git a/modules/data/src/entity-metadata/entity-metadata.ts b/modules/data/src/entity-metadata/entity-metadata.ts index 5906e1dd1a..b2acbe6c27 100644 --- a/modules/data/src/entity-metadata/entity-metadata.ts +++ b/modules/data/src/entity-metadata/entity-metadata.ts @@ -9,7 +9,7 @@ export const ENTITY_METADATA_TOKEN = new InjectionToken( '@ngrx/data/entity-metadata' ); -/** Metadata that describe an entity type and its collection to ngrx-data */ +/** Metadata that describe an entity type and its collection to @ngrx/data */ export interface EntityMetadata { entityName: string; entityDispatcherOptions?: Partial; diff --git a/modules/data/src/entity-services/entity-collection-service-base.ts b/modules/data/src/entity-services/entity-collection-service-base.ts index 3044e87f70..02d23c0025 100644 --- a/modules/data/src/entity-services/entity-collection-service-base.ts +++ b/modules/data/src/entity-services/entity-collection-service-base.ts @@ -1,13 +1,10 @@ -import { Injectable } from '@angular/core'; import { Action, Store } from '@ngrx/store'; -import { Actions } from '@ngrx/effects'; import { Dictionary, IdSelector, Update } from '@ngrx/entity'; import { Observable } from 'rxjs'; import { EntityAction, EntityActionOptions } from '../actions/entity-action'; import { EntityActionGuard } from '../actions/entity-action-guard'; -import { EntityCache } from '../reducers/entity-cache'; import { EntityCollection, ChangeStateMap, diff --git a/modules/data/src/entity-services/entity-collection-service-elements-factory.ts b/modules/data/src/entity-services/entity-collection-service-elements-factory.ts index 6ea26ec862..113beea20e 100644 --- a/modules/data/src/entity-services/entity-collection-service-elements-factory.ts +++ b/modules/data/src/entity-services/entity-collection-service-elements-factory.ts @@ -1,6 +1,4 @@ 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'; diff --git a/modules/data/src/entity-services/entity-services-base.ts b/modules/data/src/entity-services/entity-services-base.ts index cfcf41554f..6edd648966 100644 --- a/modules/data/src/entity-services/entity-services-base.ts +++ b/modules/data/src/entity-services/entity-services-base.ts @@ -1,4 +1,4 @@ -import { Inject, Injectable } from '@angular/core'; +import { Injectable } from '@angular/core'; import { Action, Store } from '@ngrx/store'; import { Observable } from 'rxjs'; @@ -6,14 +6,9 @@ import { Observable } from 'rxjs'; import { EntityAction } from '../actions/entity-action'; import { EntityCache } from '../reducers/entity-cache'; 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 { EntitySelectors$ } from '../selectors/entity-selectors$'; import { EntityServicesElements } from './entity-services-elements'; // tslint:disable:member-ordering @@ -40,7 +35,7 @@ import { EntityServicesElements } from './entity-services-elements'; */ @Injectable() export class EntityServicesBase implements EntityServices { - // Dear ngrx-data developer: think hard before changing the constructor. + // Dear @ngrx/data developer: think hard before changing the constructor. // Doing so will break apps that derive from this base class, // and many apps will derive from this class. // diff --git a/modules/data/src/entity-services/entity-services.ts b/modules/data/src/entity-services/entity-services.ts index af4ae4c0e5..aeabd6e3d6 100644 --- a/modules/data/src/entity-services/entity-services.ts +++ b/modules/data/src/entity-services/entity-services.ts @@ -4,7 +4,6 @@ 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 diff --git a/modules/data/src/index.ts b/modules/data/src/index.ts index c561cd12c2..09a40d42a4 100644 --- a/modules/data/src/index.ts +++ b/modules/data/src/index.ts @@ -1,5 +1,5 @@ // AOT v5 bug: -// NO BARRELS or else `ng build --aot` of any app using ngrx-data produces strange errors +// NO BARRELS or else `ng build --aot` of any app using @ngrx/data produces strange errors // actions export * from './actions/entity-action-factory'; export * from './actions/entity-action-guard'; diff --git a/modules/data/src/reducers/constants.ts b/modules/data/src/reducers/constants.ts index 152cb215f5..3991f681f3 100644 --- a/modules/data/src/reducers/constants.ts +++ b/modules/data/src/reducers/constants.ts @@ -1,5 +1,5 @@ import { InjectionToken } from '@angular/core'; -import { Action, ActionReducer, MetaReducer } from '@ngrx/store'; +import { MetaReducer } from '@ngrx/store'; import { EntityCache } from './entity-cache'; export const ENTITY_CACHE_NAME = 'entityCache'; diff --git a/modules/data/src/reducers/entity-cache-reducer.ts b/modules/data/src/reducers/entity-cache-reducer.ts index f26e0d4ea8..5562738830 100644 --- a/modules/data/src/reducers/entity-cache-reducer.ts +++ b/modules/data/src/reducers/entity-cache-reducer.ts @@ -2,7 +2,6 @@ import { Injectable } from '@angular/core'; import { Action, ActionReducer } from '@ngrx/store'; import { EntityAction } from '../actions/entity-action'; -import { EntityActionDataServiceError } from '../dataservices/data-service-error'; import { EntityCache } from './entity-cache'; import { @@ -40,7 +39,7 @@ export class EntityCacheReducerFactory { ) {} /** - * Create the ngrx-data entity cache reducer which either responds to entity cache level actions + * 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 { diff --git a/modules/data/src/reducers/entity-change-tracker-base.ts b/modules/data/src/reducers/entity-change-tracker-base.ts index 91ad97d969..46f36bd1b7 100644 --- a/modules/data/src/reducers/entity-change-tracker-base.ts +++ b/modules/data/src/reducers/entity-change-tracker-base.ts @@ -1,19 +1,7 @@ -import { - EntityAdapter, - EntityState, - Dictionary, - IdSelector, - Update, -} from '@ngrx/entity'; - -import { - ChangeState, - ChangeStateMap, - ChangeType, - EntityCollection, -} from './entity-collection'; +import { EntityAdapter, IdSelector, Update } from '@ngrx/entity'; + +import { ChangeType, EntityCollection } from './entity-collection'; import { defaultSelectId } from '../utils/utilities'; -import { EntityAction, EntityActionOptions } from '../actions/entity-action'; import { EntityChangeTracker } from './entity-change-tracker'; import { MergeStrategy } from '../actions/merge-strategy'; import { UpdateResponseData } from '../actions/update-response-data'; diff --git a/modules/data/src/reducers/entity-change-tracker.ts b/modules/data/src/reducers/entity-change-tracker.ts index b494d0cd0a..a402077f3d 100644 --- a/modules/data/src/reducers/entity-change-tracker.ts +++ b/modules/data/src/reducers/entity-change-tracker.ts @@ -1,10 +1,5 @@ import { Update } from '@ngrx/entity'; -import { - ChangeState, - ChangeStateMap, - ChangeType, - EntityCollection, -} from './entity-collection'; +import { EntityCollection } from './entity-collection'; import { MergeStrategy } from '../actions/merge-strategy'; import { UpdateResponseData } from '../actions/update-response-data'; diff --git a/modules/data/src/reducers/entity-collection-reducer-methods.ts b/modules/data/src/reducers/entity-collection-reducer-methods.ts index 3883876711..3cba7c5e64 100644 --- a/modules/data/src/reducers/entity-collection-reducer-methods.ts +++ b/modules/data/src/reducers/entity-collection-reducer-methods.ts @@ -1,17 +1,12 @@ import { Injectable } from '@angular/core'; - -import { Action } from '@ngrx/store'; -import { EntityAdapter, Dictionary, IdSelector, Update } from '@ngrx/entity'; - -import { merge } from 'rxjs/operators'; - +import { EntityAdapter, IdSelector, Update } from '@ngrx/entity'; import { ChangeStateMap, ChangeType, EntityCollection, } from './entity-collection'; import { EntityChangeTrackerBase } from './entity-change-tracker-base'; -import { defaultSelectId, toUpdateFactory } from '../utils/utilities'; +import { toUpdateFactory } from '../utils/utilities'; import { EntityAction } from '../actions/entity-action'; import { EntityActionDataServiceError } from '../dataservices/data-service-error'; import { EntityActionGuard } from '../actions/entity-action-guard'; diff --git a/modules/data/src/reducers/entity-collection-reducer-registry.ts b/modules/data/src/reducers/entity-collection-reducer-registry.ts index 29ed2d166a..fa74a52473 100644 --- a/modules/data/src/reducers/entity-collection-reducer-registry.ts +++ b/modules/data/src/reducers/entity-collection-reducer-registry.ts @@ -1,5 +1,5 @@ import { Inject, Injectable, Optional } from '@angular/core'; -import { ActionReducer, compose, MetaReducer } from '@ngrx/store'; +import { compose, MetaReducer } from '@ngrx/store'; import { EntityAction } from '../actions/entity-action'; import { EntityCollection } from './entity-collection'; diff --git a/modules/data/src/selectors/entity-cache-selector.ts b/modules/data/src/selectors/entity-cache-selector.ts index 732e361f73..88ec62d155 100644 --- a/modules/data/src/selectors/entity-cache-selector.ts +++ b/modules/data/src/selectors/entity-cache-selector.ts @@ -1,15 +1,5 @@ -import { - Inject, - Injectable, - InjectionToken, - Optional, - FactoryProvider, -} from '@angular/core'; -import { - createFeatureSelector, - createSelector, - MemoizedSelector, -} from '@ngrx/store'; +import { InjectionToken, Optional, FactoryProvider } from '@angular/core'; +import { createFeatureSelector, MemoizedSelector } from '@ngrx/store'; import { EntityCache } from '../reducers/entity-cache'; import { ENTITY_CACHE_NAME, diff --git a/modules/data/src/selectors/entity-selectors$.ts b/modules/data/src/selectors/entity-selectors$.ts index 4c8684386c..f1dde43f95 100644 --- a/modules/data/src/selectors/entity-selectors$.ts +++ b/modules/data/src/selectors/entity-selectors$.ts @@ -1,11 +1,5 @@ import { Inject, Injectable } from '@angular/core'; - -import { - createFeatureSelector, - createSelector, - Selector, - Store, -} from '@ngrx/store'; +import { Store } from '@ngrx/store'; import { Actions } from '@ngrx/effects'; import { Dictionary } from '@ngrx/entity'; @@ -25,8 +19,6 @@ import { EntityCollection, ChangeStateMap, } from '../reducers/entity-collection'; -import { EntityCollectionCreator } from '../reducers/entity-collection-creator'; -import { EntitySelectorsFactory } from './entity-selectors'; /** * The selector observable functions for entity collection members. diff --git a/modules/data/src/selectors/entity-selectors.ts b/modules/data/src/selectors/entity-selectors.ts index 67bd11652a..a8810802a6 100644 --- a/modules/data/src/selectors/entity-selectors.ts +++ b/modules/data/src/selectors/entity-selectors.ts @@ -2,11 +2,9 @@ import { Inject, Injectable, Optional } from '@angular/core'; // Prod build requires `MemoizedSelector even though not used. import { MemoizedSelector } from '@ngrx/store'; -import { createFeatureSelector, createSelector, Selector } from '@ngrx/store'; +import { createSelector, Selector } from '@ngrx/store'; import { Dictionary } from '@ngrx/entity'; -import { Observable } from 'rxjs'; - import { EntityCache } from '../reducers/entity-cache'; import { ENTITY_CACHE_SELECTOR_TOKEN, @@ -19,7 +17,6 @@ import { 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'; /** diff --git a/modules/data/src/utils/guid-fns.ts b/modules/data/src/utils/guid-fns.ts index 2ae6898672..3667f522f9 100644 --- a/modules/data/src/utils/guid-fns.ts +++ b/modules/data/src/utils/guid-fns.ts @@ -1,7 +1,7 @@ /* Client-side id-generators -These GUID utility functions are not used by ngrx-data itself at this time. +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. diff --git a/modules/effects/schematics-core/utility/ast-utils.ts b/modules/effects/schematics-core/utility/ast-utils.ts index 0404340135..8471c23228 100644 --- a/modules/effects/schematics-core/utility/ast-utils.ts +++ b/modules/effects/schematics-core/utility/ast-utils.ts @@ -13,6 +13,8 @@ import { NoopChange, createReplaceChange, ReplaceChange, + RemoveChange, + createRemoveChange, } from './change'; import { Path } from '@angular-devkit/core'; @@ -650,7 +652,7 @@ export function replaceImport( importFrom: string, importAsIs: string, importToBe: string -): ReplaceChange[] { +): (ReplaceChange | RemoveChange)[] { const imports = sourceFile.statements .filter(ts.isImportDeclaration) .filter( @@ -663,32 +665,67 @@ export function replaceImport( return []; } - const changes = imports - .map(p => (p.importClause!.namedBindings! as ts.NamedImports).elements) - .reduce((imports, curr) => imports.concat(curr), [] as ts.ImportSpecifier[]) - .map(specifier => { - if (!ts.isImportSpecifier(specifier)) { - return { hit: false }; + const importText = (specifier: ts.ImportSpecifier) => { + if (specifier.name.text) { + return specifier.name.text; + } + + // if import is renamed + if (specifier.propertyName && specifier.propertyName.text) { + return specifier.propertyName.text; + } + + return ''; + }; + + const changes = imports.map(p => { + const importSpecifiers = (p.importClause!.namedBindings! as ts.NamedImports) + .elements; + + const isAlreadyImported = importSpecifiers + .map(importText) + .includes(importToBe); + + const importChanges = importSpecifiers.map((specifier, index) => { + const text = importText(specifier); + + // import is not the one we're looking for, can be skipped + if (text !== importAsIs) { + return undefined; } - if (specifier.name.text === importAsIs) { - return { hit: true, specifier, text: specifier.name.text }; + // identifier has not been imported, simply replace the old text with the new text + if (!isAlreadyImported) { + return createReplaceChange( + sourceFile, + specifier!, + importAsIs, + importToBe + ); } - // if import is renamed - if ( - specifier.propertyName && - specifier.propertyName.text === importAsIs - ) { - return { hit: true, specifier, text: specifier.propertyName.text }; + const nextIdentifier = importSpecifiers[index + 1]; + // identifer is not the last, also clean up the comma + if (nextIdentifier) { + return createRemoveChange( + sourceFile, + specifier, + specifier.getStart(sourceFile), + nextIdentifier.getStart(sourceFile) + ); } - return { hit: false }; - }) - .filter(({ hit }) => hit) - .map(({ specifier, text }) => - createReplaceChange(sourceFile, specifier!, text!, importToBe) - ); + // there are no imports following, just remove it + return createRemoveChange( + sourceFile, + specifier, + specifier.getStart(sourceFile), + specifier.getEnd() + ); + }); + + return importChanges.filter(Boolean) as (ReplaceChange | RemoveChange)[]; + }); - return changes; + return changes.reduce((imports, curr) => imports.concat(curr), []); } diff --git a/modules/effects/schematics-core/utility/change.ts b/modules/effects/schematics-core/utility/change.ts index 98c7fc6405..d4a22ae4a5 100644 --- a/modules/effects/schematics-core/utility/change.ts +++ b/modules/effects/schematics-core/utility/change.ts @@ -148,6 +148,15 @@ export function createReplaceChange( ); } +export function createRemoveChange( + sourceFile: ts.SourceFile, + node: ts.Node, + from = node.getStart(sourceFile), + to = node.getEnd() +): RemoveChange { + return new RemoveChange(sourceFile.fileName, from, to); +} + export function createChangeRecorder( tree: Tree, path: string, diff --git a/modules/effects/spec/BUILD b/modules/effects/spec/BUILD index c05178d287..406313fd8f 100644 --- a/modules/effects/spec/BUILD +++ b/modules/effects/spec/BUILD @@ -11,6 +11,7 @@ ts_test_library( deps = [ "//modules/effects", "//modules/store", + "@npm//@angular/router", "@npm//rxjs", "@npm//ts-snippet", ], diff --git a/modules/effects/spec/integration.spec.ts b/modules/effects/spec/integration.spec.ts index e47fbaabe7..fe58788a17 100644 --- a/modules/effects/spec/integration.spec.ts +++ b/modules/effects/spec/integration.spec.ts @@ -1,4 +1,10 @@ +import { NgModuleFactoryLoader, NgModule } from '@angular/core'; import { TestBed } from '@angular/core/testing'; +import { + RouterTestingModule, + SpyNgModuleFactoryLoader, +} from '@angular/router/testing'; +import { Router } from '@angular/router'; import { Store, Action } from '@ngrx/store'; import { EffectsModule, @@ -20,6 +26,7 @@ describe('NgRx Effects Integration spec', () => { RootEffectWithInitActionWithPayload, ]), EffectsModule.forFeature([FeatEffectWithInitAction]), + RouterTestingModule.withRoutes([]), ], providers: [ { @@ -73,6 +80,21 @@ describe('NgRx Effects Integration spec', () => { ]); }); + it('throws if forRoot() is used more than once', (done: DoneFn) => { + let router: Router = TestBed.get(Router); + const loader: SpyNgModuleFactoryLoader = TestBed.get(NgModuleFactoryLoader); + + loader.stubbedModules = { feature: FeatModuleWithForRoot }; + router.resetConfig([{ path: 'feature-path', loadChildren: 'feature' }]); + + router.navigateByUrl('/feature-path').catch((err: TypeError) => { + expect(err.message).toBe( + 'EffectsModule.forRoot() called twice. Feature modules should use EffectsModule.forFeature() instead.' + ); + done(); + }); + }); + class RootEffectWithInitAction implements OnInitEffects { ngrxOnInitEffects(): Action { return { type: '[RootEffectWithInitAction]: INIT' }; @@ -110,4 +132,9 @@ describe('NgRx Effects Integration spec', () => { constructor(private effectIdentifier: string) {} } + + @NgModule({ + imports: [EffectsModule.forRoot([])], + }) + class FeatModuleWithForRoot {} }); diff --git a/modules/effects/src/effects_module.ts b/modules/effects/src/effects_module.ts index e7470cf841..187f1dc753 100644 --- a/modules/effects/src/effects_module.ts +++ b/modules/effects/src/effects_module.ts @@ -1,7 +1,13 @@ -import { NgModule, ModuleWithProviders, Type } from '@angular/core'; +import { + NgModule, + ModuleWithProviders, + Type, + Optional, + SkipSelf, +} from '@angular/core'; import { EffectSources } from './effect_sources'; import { Actions } from './actions'; -import { ROOT_EFFECTS, FEATURE_EFFECTS } from './tokens'; +import { ROOT_EFFECTS, FEATURE_EFFECTS, _ROOT_EFFECTS_GUARD } from './tokens'; import { EffectsFeatureModule } from './effects_feature_module'; import { EffectsRootModule } from './effects_root_module'; import { EffectsRunner } from './effects_runner'; @@ -31,6 +37,11 @@ export class EffectsModule { return { ngModule: EffectsRootModule, providers: [ + { + provide: _ROOT_EFFECTS_GUARD, + useFactory: _provideForRootGuard, + deps: [[EffectsRunner, new Optional(), new SkipSelf()]], + }, EffectsRunner, EffectSources, Actions, @@ -48,3 +59,12 @@ export class EffectsModule { export function createSourceInstances(...instances: any[]) { return instances; } + +export function _provideForRootGuard(runner: EffectsRunner): any { + if (runner) { + throw new TypeError( + `EffectsModule.forRoot() called twice. Feature modules should use EffectsModule.forFeature() instead.` + ); + } + return 'guarded'; +} diff --git a/modules/effects/src/effects_root_module.ts b/modules/effects/src/effects_root_module.ts index dfd5dd3681..1bf4dc219b 100644 --- a/modules/effects/src/effects_root_module.ts +++ b/modules/effects/src/effects_root_module.ts @@ -7,7 +7,7 @@ import { } from '@ngrx/store'; import { EffectsRunner } from './effects_runner'; import { EffectSources } from './effect_sources'; -import { ROOT_EFFECTS } from './tokens'; +import { ROOT_EFFECTS, _ROOT_EFFECTS_GUARD } from './tokens'; export const ROOT_EFFECTS_INIT = '@ngrx/effects/init'; @@ -19,7 +19,10 @@ export class EffectsRootModule { store: Store, @Inject(ROOT_EFFECTS) rootEffects: any[], @Optional() storeRootModule: StoreRootModule, - @Optional() storeFeatureModule: StoreFeatureModule + @Optional() storeFeatureModule: StoreFeatureModule, + @Optional() + @Inject(_ROOT_EFFECTS_GUARD) + guard: any ) { runner.start(); diff --git a/modules/effects/src/tokens.ts b/modules/effects/src/tokens.ts index b4bc66d18d..de3923be1e 100644 --- a/modules/effects/src/tokens.ts +++ b/modules/effects/src/tokens.ts @@ -1,5 +1,8 @@ import { InjectionToken, Type } from '@angular/core'; +export const _ROOT_EFFECTS_GUARD = new InjectionToken( + '@ngrx/effects Internal Root Guard' +); export const IMMEDIATE_EFFECTS = new InjectionToken( 'ngrx/effects: Immediate Effects' ); diff --git a/modules/entity/schematics-core/utility/ast-utils.ts b/modules/entity/schematics-core/utility/ast-utils.ts index 0404340135..8471c23228 100644 --- a/modules/entity/schematics-core/utility/ast-utils.ts +++ b/modules/entity/schematics-core/utility/ast-utils.ts @@ -13,6 +13,8 @@ import { NoopChange, createReplaceChange, ReplaceChange, + RemoveChange, + createRemoveChange, } from './change'; import { Path } from '@angular-devkit/core'; @@ -650,7 +652,7 @@ export function replaceImport( importFrom: string, importAsIs: string, importToBe: string -): ReplaceChange[] { +): (ReplaceChange | RemoveChange)[] { const imports = sourceFile.statements .filter(ts.isImportDeclaration) .filter( @@ -663,32 +665,67 @@ export function replaceImport( return []; } - const changes = imports - .map(p => (p.importClause!.namedBindings! as ts.NamedImports).elements) - .reduce((imports, curr) => imports.concat(curr), [] as ts.ImportSpecifier[]) - .map(specifier => { - if (!ts.isImportSpecifier(specifier)) { - return { hit: false }; + const importText = (specifier: ts.ImportSpecifier) => { + if (specifier.name.text) { + return specifier.name.text; + } + + // if import is renamed + if (specifier.propertyName && specifier.propertyName.text) { + return specifier.propertyName.text; + } + + return ''; + }; + + const changes = imports.map(p => { + const importSpecifiers = (p.importClause!.namedBindings! as ts.NamedImports) + .elements; + + const isAlreadyImported = importSpecifiers + .map(importText) + .includes(importToBe); + + const importChanges = importSpecifiers.map((specifier, index) => { + const text = importText(specifier); + + // import is not the one we're looking for, can be skipped + if (text !== importAsIs) { + return undefined; } - if (specifier.name.text === importAsIs) { - return { hit: true, specifier, text: specifier.name.text }; + // identifier has not been imported, simply replace the old text with the new text + if (!isAlreadyImported) { + return createReplaceChange( + sourceFile, + specifier!, + importAsIs, + importToBe + ); } - // if import is renamed - if ( - specifier.propertyName && - specifier.propertyName.text === importAsIs - ) { - return { hit: true, specifier, text: specifier.propertyName.text }; + const nextIdentifier = importSpecifiers[index + 1]; + // identifer is not the last, also clean up the comma + if (nextIdentifier) { + return createRemoveChange( + sourceFile, + specifier, + specifier.getStart(sourceFile), + nextIdentifier.getStart(sourceFile) + ); } - return { hit: false }; - }) - .filter(({ hit }) => hit) - .map(({ specifier, text }) => - createReplaceChange(sourceFile, specifier!, text!, importToBe) - ); + // there are no imports following, just remove it + return createRemoveChange( + sourceFile, + specifier, + specifier.getStart(sourceFile), + specifier.getEnd() + ); + }); + + return importChanges.filter(Boolean) as (ReplaceChange | RemoveChange)[]; + }); - return changes; + return changes.reduce((imports, curr) => imports.concat(curr), []); } diff --git a/modules/entity/schematics-core/utility/change.ts b/modules/entity/schematics-core/utility/change.ts index 98c7fc6405..d4a22ae4a5 100644 --- a/modules/entity/schematics-core/utility/change.ts +++ b/modules/entity/schematics-core/utility/change.ts @@ -148,6 +148,15 @@ export function createReplaceChange( ); } +export function createRemoveChange( + sourceFile: ts.SourceFile, + node: ts.Node, + from = node.getStart(sourceFile), + to = node.getEnd() +): RemoveChange { + return new RemoveChange(sourceFile.fileName, from, to); +} + export function createChangeRecorder( tree: Tree, path: string, diff --git a/modules/router-store/schematics-core/utility/ast-utils.ts b/modules/router-store/schematics-core/utility/ast-utils.ts index 0404340135..8471c23228 100644 --- a/modules/router-store/schematics-core/utility/ast-utils.ts +++ b/modules/router-store/schematics-core/utility/ast-utils.ts @@ -13,6 +13,8 @@ import { NoopChange, createReplaceChange, ReplaceChange, + RemoveChange, + createRemoveChange, } from './change'; import { Path } from '@angular-devkit/core'; @@ -650,7 +652,7 @@ export function replaceImport( importFrom: string, importAsIs: string, importToBe: string -): ReplaceChange[] { +): (ReplaceChange | RemoveChange)[] { const imports = sourceFile.statements .filter(ts.isImportDeclaration) .filter( @@ -663,32 +665,67 @@ export function replaceImport( return []; } - const changes = imports - .map(p => (p.importClause!.namedBindings! as ts.NamedImports).elements) - .reduce((imports, curr) => imports.concat(curr), [] as ts.ImportSpecifier[]) - .map(specifier => { - if (!ts.isImportSpecifier(specifier)) { - return { hit: false }; + const importText = (specifier: ts.ImportSpecifier) => { + if (specifier.name.text) { + return specifier.name.text; + } + + // if import is renamed + if (specifier.propertyName && specifier.propertyName.text) { + return specifier.propertyName.text; + } + + return ''; + }; + + const changes = imports.map(p => { + const importSpecifiers = (p.importClause!.namedBindings! as ts.NamedImports) + .elements; + + const isAlreadyImported = importSpecifiers + .map(importText) + .includes(importToBe); + + const importChanges = importSpecifiers.map((specifier, index) => { + const text = importText(specifier); + + // import is not the one we're looking for, can be skipped + if (text !== importAsIs) { + return undefined; } - if (specifier.name.text === importAsIs) { - return { hit: true, specifier, text: specifier.name.text }; + // identifier has not been imported, simply replace the old text with the new text + if (!isAlreadyImported) { + return createReplaceChange( + sourceFile, + specifier!, + importAsIs, + importToBe + ); } - // if import is renamed - if ( - specifier.propertyName && - specifier.propertyName.text === importAsIs - ) { - return { hit: true, specifier, text: specifier.propertyName.text }; + const nextIdentifier = importSpecifiers[index + 1]; + // identifer is not the last, also clean up the comma + if (nextIdentifier) { + return createRemoveChange( + sourceFile, + specifier, + specifier.getStart(sourceFile), + nextIdentifier.getStart(sourceFile) + ); } - return { hit: false }; - }) - .filter(({ hit }) => hit) - .map(({ specifier, text }) => - createReplaceChange(sourceFile, specifier!, text!, importToBe) - ); + // there are no imports following, just remove it + return createRemoveChange( + sourceFile, + specifier, + specifier.getStart(sourceFile), + specifier.getEnd() + ); + }); + + return importChanges.filter(Boolean) as (ReplaceChange | RemoveChange)[]; + }); - return changes; + return changes.reduce((imports, curr) => imports.concat(curr), []); } diff --git a/modules/router-store/schematics-core/utility/change.ts b/modules/router-store/schematics-core/utility/change.ts index 98c7fc6405..d4a22ae4a5 100644 --- a/modules/router-store/schematics-core/utility/change.ts +++ b/modules/router-store/schematics-core/utility/change.ts @@ -148,6 +148,15 @@ export function createReplaceChange( ); } +export function createRemoveChange( + sourceFile: ts.SourceFile, + node: ts.Node, + from = node.getStart(sourceFile), + to = node.getEnd() +): RemoveChange { + return new RemoveChange(sourceFile.fileName, from, to); +} + export function createChangeRecorder( tree: Tree, path: string, diff --git a/modules/schematics-core/utility/ast-utils.ts b/modules/schematics-core/utility/ast-utils.ts index 0404340135..8471c23228 100644 --- a/modules/schematics-core/utility/ast-utils.ts +++ b/modules/schematics-core/utility/ast-utils.ts @@ -13,6 +13,8 @@ import { NoopChange, createReplaceChange, ReplaceChange, + RemoveChange, + createRemoveChange, } from './change'; import { Path } from '@angular-devkit/core'; @@ -650,7 +652,7 @@ export function replaceImport( importFrom: string, importAsIs: string, importToBe: string -): ReplaceChange[] { +): (ReplaceChange | RemoveChange)[] { const imports = sourceFile.statements .filter(ts.isImportDeclaration) .filter( @@ -663,32 +665,67 @@ export function replaceImport( return []; } - const changes = imports - .map(p => (p.importClause!.namedBindings! as ts.NamedImports).elements) - .reduce((imports, curr) => imports.concat(curr), [] as ts.ImportSpecifier[]) - .map(specifier => { - if (!ts.isImportSpecifier(specifier)) { - return { hit: false }; + const importText = (specifier: ts.ImportSpecifier) => { + if (specifier.name.text) { + return specifier.name.text; + } + + // if import is renamed + if (specifier.propertyName && specifier.propertyName.text) { + return specifier.propertyName.text; + } + + return ''; + }; + + const changes = imports.map(p => { + const importSpecifiers = (p.importClause!.namedBindings! as ts.NamedImports) + .elements; + + const isAlreadyImported = importSpecifiers + .map(importText) + .includes(importToBe); + + const importChanges = importSpecifiers.map((specifier, index) => { + const text = importText(specifier); + + // import is not the one we're looking for, can be skipped + if (text !== importAsIs) { + return undefined; } - if (specifier.name.text === importAsIs) { - return { hit: true, specifier, text: specifier.name.text }; + // identifier has not been imported, simply replace the old text with the new text + if (!isAlreadyImported) { + return createReplaceChange( + sourceFile, + specifier!, + importAsIs, + importToBe + ); } - // if import is renamed - if ( - specifier.propertyName && - specifier.propertyName.text === importAsIs - ) { - return { hit: true, specifier, text: specifier.propertyName.text }; + const nextIdentifier = importSpecifiers[index + 1]; + // identifer is not the last, also clean up the comma + if (nextIdentifier) { + return createRemoveChange( + sourceFile, + specifier, + specifier.getStart(sourceFile), + nextIdentifier.getStart(sourceFile) + ); } - return { hit: false }; - }) - .filter(({ hit }) => hit) - .map(({ specifier, text }) => - createReplaceChange(sourceFile, specifier!, text!, importToBe) - ); + // there are no imports following, just remove it + return createRemoveChange( + sourceFile, + specifier, + specifier.getStart(sourceFile), + specifier.getEnd() + ); + }); + + return importChanges.filter(Boolean) as (ReplaceChange | RemoveChange)[]; + }); - return changes; + return changes.reduce((imports, curr) => imports.concat(curr), []); } diff --git a/modules/schematics-core/utility/change.ts b/modules/schematics-core/utility/change.ts index 98c7fc6405..d4a22ae4a5 100644 --- a/modules/schematics-core/utility/change.ts +++ b/modules/schematics-core/utility/change.ts @@ -148,6 +148,15 @@ export function createReplaceChange( ); } +export function createRemoveChange( + sourceFile: ts.SourceFile, + node: ts.Node, + from = node.getStart(sourceFile), + to = node.getEnd() +): RemoveChange { + return new RemoveChange(sourceFile.fileName, from, to); +} + export function createChangeRecorder( tree: Tree, path: string, diff --git a/modules/schematics/BUILD b/modules/schematics/BUILD index ea6795c34a..77112ca118 100644 --- a/modules/schematics/BUILD +++ b/modules/schematics/BUILD @@ -63,6 +63,7 @@ ts_test_library( deps = [ ":schematics", "//modules/schematics-core", + "@npm//@angular-devkit/core", "@npm//@angular-devkit/schematics", "@npm//@schematics/angular", ], diff --git a/modules/schematics/collection.json b/modules/schematics/collection.json index fd19a4c03d..d270e72dd3 100644 --- a/modules/schematics/collection.json +++ b/modules/schematics/collection.json @@ -22,6 +22,13 @@ "description": "Add side effect class" }, + "create-effect-migration": { + "aliases": ["cefm"], + "factory": "./src/create-effect-migration", + "schema": "./src/create-effect-migration/schema.json", + "description": "Migrated usages of @Effect() to createEffect()" + }, + "entity": { "aliases": ["en"], "factory": "./src/entity", diff --git a/modules/schematics/schematics-core/utility/ast-utils.ts b/modules/schematics/schematics-core/utility/ast-utils.ts index 0404340135..8471c23228 100644 --- a/modules/schematics/schematics-core/utility/ast-utils.ts +++ b/modules/schematics/schematics-core/utility/ast-utils.ts @@ -13,6 +13,8 @@ import { NoopChange, createReplaceChange, ReplaceChange, + RemoveChange, + createRemoveChange, } from './change'; import { Path } from '@angular-devkit/core'; @@ -650,7 +652,7 @@ export function replaceImport( importFrom: string, importAsIs: string, importToBe: string -): ReplaceChange[] { +): (ReplaceChange | RemoveChange)[] { const imports = sourceFile.statements .filter(ts.isImportDeclaration) .filter( @@ -663,32 +665,67 @@ export function replaceImport( return []; } - const changes = imports - .map(p => (p.importClause!.namedBindings! as ts.NamedImports).elements) - .reduce((imports, curr) => imports.concat(curr), [] as ts.ImportSpecifier[]) - .map(specifier => { - if (!ts.isImportSpecifier(specifier)) { - return { hit: false }; + const importText = (specifier: ts.ImportSpecifier) => { + if (specifier.name.text) { + return specifier.name.text; + } + + // if import is renamed + if (specifier.propertyName && specifier.propertyName.text) { + return specifier.propertyName.text; + } + + return ''; + }; + + const changes = imports.map(p => { + const importSpecifiers = (p.importClause!.namedBindings! as ts.NamedImports) + .elements; + + const isAlreadyImported = importSpecifiers + .map(importText) + .includes(importToBe); + + const importChanges = importSpecifiers.map((specifier, index) => { + const text = importText(specifier); + + // import is not the one we're looking for, can be skipped + if (text !== importAsIs) { + return undefined; } - if (specifier.name.text === importAsIs) { - return { hit: true, specifier, text: specifier.name.text }; + // identifier has not been imported, simply replace the old text with the new text + if (!isAlreadyImported) { + return createReplaceChange( + sourceFile, + specifier!, + importAsIs, + importToBe + ); } - // if import is renamed - if ( - specifier.propertyName && - specifier.propertyName.text === importAsIs - ) { - return { hit: true, specifier, text: specifier.propertyName.text }; + const nextIdentifier = importSpecifiers[index + 1]; + // identifer is not the last, also clean up the comma + if (nextIdentifier) { + return createRemoveChange( + sourceFile, + specifier, + specifier.getStart(sourceFile), + nextIdentifier.getStart(sourceFile) + ); } - return { hit: false }; - }) - .filter(({ hit }) => hit) - .map(({ specifier, text }) => - createReplaceChange(sourceFile, specifier!, text!, importToBe) - ); + // there are no imports following, just remove it + return createRemoveChange( + sourceFile, + specifier, + specifier.getStart(sourceFile), + specifier.getEnd() + ); + }); + + return importChanges.filter(Boolean) as (ReplaceChange | RemoveChange)[]; + }); - return changes; + return changes.reduce((imports, curr) => imports.concat(curr), []); } diff --git a/modules/schematics/schematics-core/utility/change.ts b/modules/schematics/schematics-core/utility/change.ts index 98c7fc6405..d4a22ae4a5 100644 --- a/modules/schematics/schematics-core/utility/change.ts +++ b/modules/schematics/schematics-core/utility/change.ts @@ -148,6 +148,15 @@ export function createReplaceChange( ); } +export function createRemoveChange( + sourceFile: ts.SourceFile, + node: ts.Node, + from = node.getStart(sourceFile), + to = node.getEnd() +): RemoveChange { + return new RemoveChange(sourceFile.fileName, from, to); +} + export function createChangeRecorder( tree: Tree, path: string, diff --git a/modules/schematics/src/action/files/__name@dasherize@if-flat__/__name@dasherize__.actions.spec.ts.template b/modules/schematics/src/action/files/__name@dasherize@if-flat__/__name@dasherize__.actions.spec.ts.template index 95819022eb..0d100f89a5 100644 --- a/modules/schematics/src/action/files/__name@dasherize@if-flat__/__name@dasherize__.actions.spec.ts.template +++ b/modules/schematics/src/action/files/__name@dasherize@if-flat__/__name@dasherize__.actions.spec.ts.template @@ -1,7 +1,7 @@ -import { <%= classify(name) %> } from './<%= dasherize(name) %>.actions'; +import * as <%= classify(name) %>Actions from './<%= dasherize(name) %>.actions'; describe('<%= classify(name) %>', () => { it('should create an instance', () => { - expect(new <%= classify(name) %>()).toBeTruthy(); + expect(new <%= classify(name)%>Actions.Load<%= classify(name) %>s()).toBeTruthy(); }); }); diff --git a/modules/schematics/src/action/index.spec.ts b/modules/schematics/src/action/index.spec.ts index 04414cdaef..226ea558b8 100644 --- a/modules/schematics/src/action/index.spec.ts +++ b/modules/schematics/src/action/index.spec.ts @@ -114,6 +114,16 @@ describe('Action Schematic', () => { expect(fileContent).toMatch(/export type FooActions = LoadFoos/); }); + + it('should create spec class with right imports', () => { + const options = { ...defaultOptions, spec: true }; + const tree = schematicRunner.runSchematic('action', options, appTree); + const fileContent = tree.readContent( + `${projectPath}/src/app/foo.actions.spec.ts` + ); + + expect(fileContent).toMatch(/expect\(new FooActions.LoadFoos\(\)\)/); + }); }); describe('action creators', () => { diff --git a/modules/schematics/src/create-effect-migration/index.spec.ts b/modules/schematics/src/create-effect-migration/index.spec.ts new file mode 100644 index 0000000000..6d587bb2ce --- /dev/null +++ b/modules/schematics/src/create-effect-migration/index.spec.ts @@ -0,0 +1,227 @@ +import { tags } from '@angular-devkit/core'; +import { + SchematicTestRunner, + UnitTestTree, +} from '@angular-devkit/schematics/testing'; +import * as path from 'path'; +import { createWorkspace } from '../../../schematics-core/testing'; + +describe('Creator migration', async () => { + const schematicRunner = new SchematicTestRunner( + '@ngrx/schematics', + path.join(__dirname, '../../collection.json') + ); + + let appTree: UnitTestTree; + + beforeEach(async () => { + appTree = await createWorkspace(schematicRunner, appTree); + }); + + it('should use createEffect for non-dispatching effects', async () => { + const input = tags.stripIndent` + @Injectable() + export class SomeEffectsClass { + constructor(private actions$: Actions) {} + @Effect() + foo$ = this.actions$.pipe( + ofType(AuthActions.login), + tap(action => console.log(action)) + ); + } + `; + + const output = tags.stripIndent` + @Injectable() + export class SomeEffectsClass { + constructor(private actions$: Actions) {} + ** + foo$ = createEffect(() => this.actions$.pipe( + ofType(AuthActions.login), + tap(action => console.log(action)) + )); + } + `; + + await runTest(input, output); + }); + + it('should use createEffect for non-dispatching effects', async () => { + const input = tags.stripIndent` + @Injectable() + export class SomeEffectsClass { + constructor(private actions$: Actions) {} + @Effect({ dispatch: false }) + bar$ = this.actions$.pipe( + ofType(AuthActions.login, AuthActions.logout), + tap(action => console.log(action)) + ); + } + `; + + const output = tags.stripIndent` + @Injectable() + export class SomeEffectsClass { + constructor(private actions$: Actions) {} + ** + bar$ = createEffect(() => this.actions$.pipe( + ofType(AuthActions.login, AuthActions.logout), + tap(action => console.log(action)) + ), { dispatch: false }); + } + `; + + await runTest(input, output); + }); + + it('should use createEffect for effects as functions', async () => { + const input = tags.stripIndent` + @Injectable() + export class SomeEffectsClass { + constructor(private actions$: Actions) {} + @Effect() + baz$ = ({ debounce = 300, scheduler = asyncScheduler } = {}) => this.actions$.pipe( + ofType(login), + tap(action => console.log(action)) + ); + } + `; + + const output = tags.stripIndent` + @Injectable() + export class SomeEffectsClass { + constructor(private actions$: Actions) {} + ** + baz$ = createEffect(() => ({ debounce = 300, scheduler = asyncScheduler } = {}) => this.actions$.pipe( + ofType(login), + tap(action => console.log(action)) + )); + } + `; + + await runTest(input, output); + }); + + it('should stay off of other decorators', async () => { + const input = tags.stripIndent` + @Injectable() + export class SomeEffectsClass { + constructor(private actions$: Actions) {} + @Effect() + @Log() + login$ = this.actions$.pipe( + ofType('LOGIN'), + map(() => ({ type: 'LOGGED_IN' })) + ); + @Log() + @Effect() + logout$ = this.actions$.pipe( + ofType('LOGOUT'), + map(() => ({ type: 'LOGGED_OUT' })) + ); + } + `; + + const output = tags.stripIndent` + @Injectable() + export class SomeEffectsClass { + constructor(private actions$: Actions) {} + ** + @Log() + login$ = createEffect(() => this.actions$.pipe( + ofType('LOGIN'), + map(() => ({ type: 'LOGGED_IN' })) + )); + @Log() + ** + logout$ = createEffect(() => this.actions$.pipe( + ofType('LOGOUT'), + map(() => ({ type: 'LOGGED_OUT' })) + )); + } + `; + + await runTest(input, output); + }); + + it('should import createEffect', async () => { + const input = tags.stripIndent` + import { Actions, ofType, Effect } from '@ngrx/effects'; + @Injectable() + export class SomeEffectsClass { + constructor(private actions$: Actions) {} + } + `; + + const output = tags.stripIndent` + import { Actions, ofType, createEffect } from '@ngrx/effects'; + @Injectable() + export class SomeEffectsClass { + constructor(private actions$: Actions) {} + } + `; + + await runTest(input, output); + }); + + it('should not import createEffect if already imported', async () => { + const input = tags.stripIndent` + import { Actions, Effect, createEffect, ofType } from '@ngrx/effects'; + @Injectable() + export class SomeEffectsClass { + @Effect() + logout$ = this.actions$.pipe( + ofType('LOGOUT'), + map(() => ({ type: 'LOGGED_OUT' })) + ); + constructor(private actions$: Actions) {} + } + `; + + const output = tags.stripIndent` + import { Actions, createEffect, ofType } from '@ngrx/effects'; + @Injectable() + export class SomeEffectsClass { + ** + logout$ = createEffect(() => this.actions$.pipe( + ofType('LOGOUT'), + map(() => ({ type: 'LOGGED_OUT' })) + )); + constructor(private actions$: Actions) {} + } + `; + + await runTest(input, output); + }); + + it('should not run the schematic if the createEffect syntax is already used', async () => { + const input = tags.stripIndent` + import { Actions, createEffect, ofType } from '@ngrx/effects'; + @Injectable() + export class SomeEffectsClass { + logout$ = createEffect(() => this.actions$.pipe( + ofType('LOGOUT'), + map(() => ({ type: 'LOGGED_OUT' })) + )); + constructor(private actions$: Actions) {} + } + `; + + await runTest(input, input); + }); + + async function runTest(input: string, expected: string) { + const options = {}; + const effectPath = '/some.effects.ts'; + appTree.create(effectPath, input); + + const tree = await schematicRunner + .runSchematicAsync('create-effect-migration', options, appTree) + .toPromise(); + + const actual = tree.readContent(effectPath); + + // ** for indentation because empty lines are formatted on auto-save + expect(actual).toBe(expected.replace(/\*\*/g, ' ')); + } +}); diff --git a/modules/schematics/src/create-effect-migration/index.ts b/modules/schematics/src/create-effect-migration/index.ts new file mode 100644 index 0000000000..4b394cd5b3 --- /dev/null +++ b/modules/schematics/src/create-effect-migration/index.ts @@ -0,0 +1,128 @@ +import * as ts from 'typescript'; +import { Path } from '@angular-devkit/core'; +import { Tree, Rule, chain } from '@angular-devkit/schematics'; +import { + InsertChange, + RemoveChange, + replaceImport, + commitChanges, +} from '@ngrx/schematics/schematics-core'; + +export function migrateToCreators(): Rule { + return (host: Tree) => + host.visit(path => { + if (!path.endsWith('.ts')) { + return; + } + + const sourceFile = ts.createSourceFile( + path, + host.read(path)!.toString(), + ts.ScriptTarget.Latest + ); + + if (sourceFile.isDeclarationFile) { + return; + } + + const effectsPerClass = sourceFile.statements + .filter(ts.isClassDeclaration) + .map(clas => + clas.members + .filter(ts.isPropertyDeclaration) + .filter( + property => + property.decorators && + property.decorators.some(isEffectDecorator) + ) + ); + + const effects = effectsPerClass.reduce( + (acc, effects) => acc.concat(effects), + [] + ); + + const createEffectsChanges = replaceEffectDecorators(host, path, effects); + const importChanges = replaceImport( + sourceFile, + path, + '@ngrx/effects', + 'Effect', + 'createEffect' + ); + + return commitChanges(host, sourceFile.fileName, [ + ...importChanges, + ...createEffectsChanges, + ]); + }); +} + +function replaceEffectDecorators( + host: Tree, + path: Path, + effects: ts.PropertyDeclaration[] +) { + const inserts = effects + .filter(effect => !!effect.initializer) + .map(effect => { + const decorator = (effect.decorators || []).find(isEffectDecorator)!; + const effectArguments = getDispatchProperties(host, path, decorator); + const end = effectArguments ? `, ${effectArguments})` : ')'; + + return [ + new InsertChange(path, effect.initializer!.pos, ' createEffect(() =>'), + new InsertChange(path, effect.initializer!.end, end), + ]; + }) + .reduce((acc, inserts) => acc.concat(inserts), []); + + const removes = effects + .map(effect => effect.decorators) + .filter(decorators => decorators) + .map(decorators => { + const effectDecorators = decorators!.filter(isEffectDecorator); + return effectDecorators.map(decorator => { + return new RemoveChange( + path, + decorator.expression.pos - 1, // also get the @ sign + decorator.expression.end + ); + }); + }) + .reduce((acc, removes) => acc.concat(removes), []); + + return [...inserts, ...removes]; +} + +function isEffectDecorator(decorator: ts.Decorator) { + return ( + ts.isCallExpression(decorator.expression) && + ts.isIdentifier(decorator.expression.expression) && + decorator.expression.expression.text === 'Effect' + ); +} + +function getDispatchProperties( + host: Tree, + path: Path, + decorator: ts.Decorator +) { + if (!decorator.expression || !ts.isCallExpression(decorator.expression)) { + return ''; + } + + // just copy the effect properties + const content = host.read(path)!.toString('utf8'); + const args = content + .substring( + decorator.expression.arguments.pos, + decorator.expression.arguments.end + ) + .trim(); + return args; +} + +export default function(): Rule { + return chain([migrateToCreators()]); +} diff --git a/modules/schematics/src/create-effect-migration/schema.json b/modules/schematics/src/create-effect-migration/schema.json new file mode 100644 index 0000000000..fdcfffba1a --- /dev/null +++ b/modules/schematics/src/create-effect-migration/schema.json @@ -0,0 +1,8 @@ +{ + "$schema": "http://json-schema.org/schema", + "id": "SchematicsNgRxCreateEffectMigration", + "title": "NgRx Effect Migration from @Effect to createEffect", + "type": "object", + "properties": {}, + "required": [] +} diff --git a/modules/schematics/src/create-effect-migration/schema.ts b/modules/schematics/src/create-effect-migration/schema.ts new file mode 100644 index 0000000000..e53f1202a2 --- /dev/null +++ b/modules/schematics/src/create-effect-migration/schema.ts @@ -0,0 +1 @@ +export interface Schema {} diff --git a/modules/store-devtools/schematics-core/utility/ast-utils.ts b/modules/store-devtools/schematics-core/utility/ast-utils.ts index 0404340135..8471c23228 100644 --- a/modules/store-devtools/schematics-core/utility/ast-utils.ts +++ b/modules/store-devtools/schematics-core/utility/ast-utils.ts @@ -13,6 +13,8 @@ import { NoopChange, createReplaceChange, ReplaceChange, + RemoveChange, + createRemoveChange, } from './change'; import { Path } from '@angular-devkit/core'; @@ -650,7 +652,7 @@ export function replaceImport( importFrom: string, importAsIs: string, importToBe: string -): ReplaceChange[] { +): (ReplaceChange | RemoveChange)[] { const imports = sourceFile.statements .filter(ts.isImportDeclaration) .filter( @@ -663,32 +665,67 @@ export function replaceImport( return []; } - const changes = imports - .map(p => (p.importClause!.namedBindings! as ts.NamedImports).elements) - .reduce((imports, curr) => imports.concat(curr), [] as ts.ImportSpecifier[]) - .map(specifier => { - if (!ts.isImportSpecifier(specifier)) { - return { hit: false }; + const importText = (specifier: ts.ImportSpecifier) => { + if (specifier.name.text) { + return specifier.name.text; + } + + // if import is renamed + if (specifier.propertyName && specifier.propertyName.text) { + return specifier.propertyName.text; + } + + return ''; + }; + + const changes = imports.map(p => { + const importSpecifiers = (p.importClause!.namedBindings! as ts.NamedImports) + .elements; + + const isAlreadyImported = importSpecifiers + .map(importText) + .includes(importToBe); + + const importChanges = importSpecifiers.map((specifier, index) => { + const text = importText(specifier); + + // import is not the one we're looking for, can be skipped + if (text !== importAsIs) { + return undefined; } - if (specifier.name.text === importAsIs) { - return { hit: true, specifier, text: specifier.name.text }; + // identifier has not been imported, simply replace the old text with the new text + if (!isAlreadyImported) { + return createReplaceChange( + sourceFile, + specifier!, + importAsIs, + importToBe + ); } - // if import is renamed - if ( - specifier.propertyName && - specifier.propertyName.text === importAsIs - ) { - return { hit: true, specifier, text: specifier.propertyName.text }; + const nextIdentifier = importSpecifiers[index + 1]; + // identifer is not the last, also clean up the comma + if (nextIdentifier) { + return createRemoveChange( + sourceFile, + specifier, + specifier.getStart(sourceFile), + nextIdentifier.getStart(sourceFile) + ); } - return { hit: false }; - }) - .filter(({ hit }) => hit) - .map(({ specifier, text }) => - createReplaceChange(sourceFile, specifier!, text!, importToBe) - ); + // there are no imports following, just remove it + return createRemoveChange( + sourceFile, + specifier, + specifier.getStart(sourceFile), + specifier.getEnd() + ); + }); + + return importChanges.filter(Boolean) as (ReplaceChange | RemoveChange)[]; + }); - return changes; + return changes.reduce((imports, curr) => imports.concat(curr), []); } diff --git a/modules/store-devtools/schematics-core/utility/change.ts b/modules/store-devtools/schematics-core/utility/change.ts index 98c7fc6405..d4a22ae4a5 100644 --- a/modules/store-devtools/schematics-core/utility/change.ts +++ b/modules/store-devtools/schematics-core/utility/change.ts @@ -148,6 +148,15 @@ export function createReplaceChange( ); } +export function createRemoveChange( + sourceFile: ts.SourceFile, + node: ts.Node, + from = node.getStart(sourceFile), + to = node.getEnd() +): RemoveChange { + return new RemoveChange(sourceFile.fileName, from, to); +} + export function createChangeRecorder( tree: Tree, path: string, diff --git a/modules/store-devtools/spec/extension.spec.ts b/modules/store-devtools/spec/extension.spec.ts index 884aa4a93e..1090c5d71b 100644 --- a/modules/store-devtools/spec/extension.spec.ts +++ b/modules/store-devtools/spec/extension.spec.ts @@ -7,12 +7,12 @@ import { import { Action } from '@ngrx/store'; import { DevtoolsExtension, ReduxDevtoolsExtension } from '../src/extension'; -import { createConfig } from '../src/config'; +import { createConfig, DevToolsFeatureOptions } from '../src/config'; import { unliftState } from '../src/utils'; function createOptions( name: string = 'NgRx Store DevTools', - features: any = { + features: DevToolsFeatureOptions = { pause: true, lock: true, persist: true, @@ -109,7 +109,6 @@ describe('DevtoolsExtension', () => { reduxDevtoolsExtension, createConfig({ name: 'ngrx-store-devtool-todolist', - features: 'some features', maxAge: 10, serialize: true, // these two should not be added @@ -122,7 +121,7 @@ describe('DevtoolsExtension', () => { devtoolsExtension.actions$.subscribe(() => null); const options = createOptions( 'ngrx-store-devtool-todolist', - 'some features', + undefined, true, 10 ); @@ -172,7 +171,6 @@ describe('DevtoolsExtension', () => { reduxDevtoolsExtension, createConfig({ name: 'ngrx-store-devtool-todolist', - features: 'some features', maxAge: 10, serialize: true, // these two should not be added @@ -181,9 +179,10 @@ describe('DevtoolsExtension', () => { }), null ); + const options = createOptions( 'ngrx-store-devtool-todolist', - 'some features', + undefined, true, 10 ); diff --git a/modules/store-devtools/src/config.ts b/modules/store-devtools/src/config.ts index d252722cc0..f645bb665a 100644 --- a/modules/store-devtools/src/config.ts +++ b/modules/store-devtools/src/config.ts @@ -11,6 +11,18 @@ export type SerializationOptions = { refs?: Array; }; export type Predicate = (state: any, action: Action) => boolean; +export interface DevToolsFeatureOptions { + pause?: boolean; + lock?: boolean; + persist?: boolean; + export?: boolean; + import?: 'custom' | boolean; + jump?: boolean; + skip?: boolean; + reorder?: boolean; + dispatch?: boolean; + test?: boolean; +} export class StoreDevtoolsConfig { maxAge: number | false; @@ -20,7 +32,7 @@ export class StoreDevtoolsConfig { name?: string; serialize?: boolean | SerializationOptions; logOnly?: boolean; - features?: any; + features?: DevToolsFeatureOptions; actionsBlocklist?: string[]; actionsSafelist?: string[]; predicate?: Predicate; diff --git a/modules/store-devtools/src/index.ts b/modules/store-devtools/src/index.ts index 1821a257d1..ae08b50033 100644 --- a/modules/store-devtools/src/index.ts +++ b/modules/store-devtools/src/index.ts @@ -1,4 +1,8 @@ export { StoreDevtoolsModule } from './instrument'; export { LiftedState, RECOMPUTE } from './reducer'; export { StoreDevtools } from './devtools'; -export { StoreDevtoolsConfig, StoreDevtoolsOptions } from './config'; +export { + StoreDevtoolsConfig, + StoreDevtoolsOptions, + DevToolsFeatureOptions, +} from './config'; diff --git a/modules/store/migrations/8_0_0-beta/index.spec.ts b/modules/store/migrations/8_0_0-beta/index.spec.ts index 1dd5e627b1..10d0f4ff97 100644 --- a/modules/store/migrations/8_0_0-beta/index.spec.ts +++ b/modules/store/migrations/8_0_0-beta/index.spec.ts @@ -110,4 +110,48 @@ describe('Store Migration 8_0_0 beta', () => { expect(file).toBe(expected); }); + + it(`should not run schematics when not using named imports`, () => { + const contents = ` + import * as store from '@ngrx/store'; + + @NgModule({ + imports: [ + CommonModule, + BrowserModule, + BrowserAnimationsModule, + HttpClientModule, + AuthModule, + AppRoutingModule, + store.StoreModule.forRoot(reducers), + ], + providers: [ + { + provide: store.META_REDUCERS, + useValue: [fooReducer, barReducer] + } + ] + bootstrap: [AppComponent], + }) + export class AppModule {} + `; + + appTree.create('./app.module.ts', contents); + const runner = new SchematicTestRunner('schematics', collectionPath); + + let logs: string[] = []; + runner.logger.subscribe(log => logs.push(log.message)); + + const newTree = runner.runSchematic( + `ngrx-${pkgName}-migration-02`, + {}, + appTree + ); + const file = newTree.readContent('app.module.ts'); + + expect(file).toBe(contents); + + expect(logs.length).toBe(1); + expect(logs[0]).toMatch(/NgRx 8 Migration: Unable to run the schematics/); + }); }); diff --git a/modules/store/migrations/8_0_0-beta/index.ts b/modules/store/migrations/8_0_0-beta/index.ts index 433fd0ba59..e906000312 100644 --- a/modules/store/migrations/8_0_0-beta/index.ts +++ b/modules/store/migrations/8_0_0-beta/index.ts @@ -1,5 +1,11 @@ import * as ts from 'typescript'; -import { Rule, chain, Tree } from '@angular-devkit/schematics'; +import { tags, logging } from '@angular-devkit/core'; +import { + Rule, + chain, + Tree, + SchematicContext, +} from '@angular-devkit/schematics'; import { ReplaceChange, createReplaceChange, @@ -10,7 +16,7 @@ import { const META_REDUCERS = 'META_REDUCERS'; function updateMetaReducersToken(): Rule { - return (tree: Tree) => { + return (tree: Tree, context: SchematicContext) => { visitTSSourceFiles(tree, sourceFile => { const createChange = (node: ts.Node) => createReplaceChange( @@ -22,7 +28,11 @@ function updateMetaReducersToken(): Rule { const changes: ReplaceChange[] = []; changes.push( - ...findMetaReducersImportStatements(sourceFile, createChange) + ...findMetaReducersImportStatements( + sourceFile, + createChange, + context.logger + ) ); changes.push(...findMetaReducersAssignment(sourceFile, createChange)); @@ -37,11 +47,22 @@ export default function(): Rule { function findMetaReducersImportStatements( sourceFile: ts.SourceFile, - createChange: (node: ts.Node) => ReplaceChange + createChange: (node: ts.Node) => ReplaceChange, + logger: logging.LoggerApi ) { + let canRunSchematics = false; + const metaReducerImports = sourceFile.statements .filter(ts.isImportDeclaration) .filter(isNgRxStoreImport) + .filter(p => { + canRunSchematics = Boolean( + p.importClause && + p.importClause.namedBindings && + (p.importClause!.namedBindings! as ts.NamedImports).elements + ); + return canRunSchematics; + }) .map(p => (p.importClause!.namedBindings! as ts.NamedImports).elements.filter( isMetaReducersImportSpecifier @@ -50,6 +71,15 @@ function findMetaReducersImportStatements( .reduce((imports, curr) => imports.concat(curr), []); const changes = metaReducerImports.map(createChange); + if (!canRunSchematics && changes.length === 0) { + logger.info(tags.stripIndent` + NgRx 8 Migration: Unable to run the schematics to rename \`META_REDUCERS\` to \`USER_PROVIDED_META_REDUCERS\` + in file '${sourceFile.fileName}'. + + For more info see https://ngrx.io/guide/migration/v8#meta_reducers-token. + `); + } + return changes; function isNgRxStoreImport(importDeclaration: ts.ImportDeclaration) { diff --git a/modules/store/schematics-core/utility/ast-utils.ts b/modules/store/schematics-core/utility/ast-utils.ts index 0404340135..8471c23228 100644 --- a/modules/store/schematics-core/utility/ast-utils.ts +++ b/modules/store/schematics-core/utility/ast-utils.ts @@ -13,6 +13,8 @@ import { NoopChange, createReplaceChange, ReplaceChange, + RemoveChange, + createRemoveChange, } from './change'; import { Path } from '@angular-devkit/core'; @@ -650,7 +652,7 @@ export function replaceImport( importFrom: string, importAsIs: string, importToBe: string -): ReplaceChange[] { +): (ReplaceChange | RemoveChange)[] { const imports = sourceFile.statements .filter(ts.isImportDeclaration) .filter( @@ -663,32 +665,67 @@ export function replaceImport( return []; } - const changes = imports - .map(p => (p.importClause!.namedBindings! as ts.NamedImports).elements) - .reduce((imports, curr) => imports.concat(curr), [] as ts.ImportSpecifier[]) - .map(specifier => { - if (!ts.isImportSpecifier(specifier)) { - return { hit: false }; + const importText = (specifier: ts.ImportSpecifier) => { + if (specifier.name.text) { + return specifier.name.text; + } + + // if import is renamed + if (specifier.propertyName && specifier.propertyName.text) { + return specifier.propertyName.text; + } + + return ''; + }; + + const changes = imports.map(p => { + const importSpecifiers = (p.importClause!.namedBindings! as ts.NamedImports) + .elements; + + const isAlreadyImported = importSpecifiers + .map(importText) + .includes(importToBe); + + const importChanges = importSpecifiers.map((specifier, index) => { + const text = importText(specifier); + + // import is not the one we're looking for, can be skipped + if (text !== importAsIs) { + return undefined; } - if (specifier.name.text === importAsIs) { - return { hit: true, specifier, text: specifier.name.text }; + // identifier has not been imported, simply replace the old text with the new text + if (!isAlreadyImported) { + return createReplaceChange( + sourceFile, + specifier!, + importAsIs, + importToBe + ); } - // if import is renamed - if ( - specifier.propertyName && - specifier.propertyName.text === importAsIs - ) { - return { hit: true, specifier, text: specifier.propertyName.text }; + const nextIdentifier = importSpecifiers[index + 1]; + // identifer is not the last, also clean up the comma + if (nextIdentifier) { + return createRemoveChange( + sourceFile, + specifier, + specifier.getStart(sourceFile), + nextIdentifier.getStart(sourceFile) + ); } - return { hit: false }; - }) - .filter(({ hit }) => hit) - .map(({ specifier, text }) => - createReplaceChange(sourceFile, specifier!, text!, importToBe) - ); + // there are no imports following, just remove it + return createRemoveChange( + sourceFile, + specifier, + specifier.getStart(sourceFile), + specifier.getEnd() + ); + }); + + return importChanges.filter(Boolean) as (ReplaceChange | RemoveChange)[]; + }); - return changes; + return changes.reduce((imports, curr) => imports.concat(curr), []); } diff --git a/modules/store/schematics-core/utility/change.ts b/modules/store/schematics-core/utility/change.ts index 98c7fc6405..d4a22ae4a5 100644 --- a/modules/store/schematics-core/utility/change.ts +++ b/modules/store/schematics-core/utility/change.ts @@ -148,6 +148,15 @@ export function createReplaceChange( ); } +export function createRemoveChange( + sourceFile: ts.SourceFile, + node: ts.Node, + from = node.getStart(sourceFile), + to = node.getEnd() +): RemoveChange { + return new RemoveChange(sourceFile.fileName, from, to); +} + export function createChangeRecorder( tree: Tree, path: string, diff --git a/modules/store/spec/BUILD b/modules/store/spec/BUILD index 6e127ca627..96a6eebf9e 100644 --- a/modules/store/spec/BUILD +++ b/modules/store/spec/BUILD @@ -12,6 +12,7 @@ ts_test_library( "//modules/store", "//modules/store/spec/fixtures", "//modules/store/testing", + "@npm//@angular/router", "@npm//rxjs", "@npm//ts-snippet", ], diff --git a/modules/store/spec/integration.spec.ts b/modules/store/spec/integration.spec.ts index a1b0885755..a6fa363d01 100644 --- a/modules/store/spec/integration.spec.ts +++ b/modules/store/spec/integration.spec.ts @@ -21,6 +21,12 @@ import { visibilityFilter, VisibilityFilters, } from './fixtures/todos'; +import { + RouterTestingModule, + SpyNgModuleFactoryLoader, +} from '@angular/router/testing'; +import { Component, NgModuleFactoryLoader, NgModule } from '@angular/core'; +import { Router } from '@angular/router'; interface Todo { id: number; @@ -478,5 +484,31 @@ describe('ngRx Integration spec', () => { expect(state).toEqual(expected); }); }); + + it('throws if forRoot() is used more than once', (done: DoneFn) => { + @NgModule({ + imports: [StoreModule.forRoot({})], + }) + class FeatureModule {} + + TestBed.configureTestingModule({ + imports: [StoreModule.forRoot({}), RouterTestingModule.withRoutes([])], + }); + + let router: Router = TestBed.get(Router); + const loader: SpyNgModuleFactoryLoader = TestBed.get( + NgModuleFactoryLoader + ); + + loader.stubbedModules = { feature: FeatureModule }; + router.resetConfig([{ path: 'feature-path', loadChildren: 'feature' }]); + + router.navigateByUrl('/feature-path').catch((err: TypeError) => { + expect(err.message).toBe( + 'StoreModule.forRoot() called twice. Feature modules should use StoreModule.forFeature() instead.' + ); + done(); + }); + }); }); }); diff --git a/modules/store/spec/reducer_creator.spec.ts b/modules/store/spec/reducer_creator.spec.ts index ff2a2d4e7a..c7593128f8 100644 --- a/modules/store/spec/reducer_creator.spec.ts +++ b/modules/store/spec/reducer_creator.spec.ts @@ -86,6 +86,23 @@ import {on} from './modules/store/src/reducer_creator'; state = fooBarReducer(state, bar({ bar: 54 })); expect(state).toEqual(['[foobar] FOO', '[foobar] BAR']); }); + + it('should support "on"s to have identical action types', () => { + const increase = createAction('[COUNTER] increase'); + + const counterReducer = createReducer( + 0, + on(increase, state => state + 1), + on(increase, state => state + 1) + ); + + expect(typeof counterReducer).toEqual('function'); + + let state = 5; + + state = counterReducer(state, increase()); + expect(state).toEqual(7); + }); }); }); }); diff --git a/modules/store/spec/selector.spec.ts b/modules/store/spec/selector.spec.ts index 4f24b2f7a4..f1198ae677 100644 --- a/modules/store/spec/selector.spec.ts +++ b/modules/store/spec/selector.spec.ts @@ -1,3 +1,4 @@ +import * as ngCore from '@angular/core'; import { cold } from 'jasmine-marbles'; import { createSelector, @@ -112,6 +113,29 @@ describe('Selectors', () => { expect(projectFn).toHaveBeenCalledTimes(2); }); + it('should not memoize last successful projection result in case of error', () => { + const firstState = { ok: true }; + const secondState = { ok: false }; + const fail = () => { + throw new Error(); + }; + const projectorFn = jasmine + .createSpy('projectorFn', (s: any) => (s.ok ? s.ok : fail())) + .and.callThrough(); + const selectorFn = jasmine + .createSpy('selectorFn', createSelector(state => state, projectorFn)) + .and.callThrough(); + + selectorFn(firstState); + + expect(() => selectorFn(secondState)).toThrow(new Error()); + expect(() => selectorFn(secondState)).toThrow(new Error()); + + selectorFn(firstState); + expect(selectorFn).toHaveBeenCalledTimes(4); + expect(projectorFn).toHaveBeenCalledTimes(3); + }); + it('should allow you to release memoized arguments', () => { const state = { first: 'state' }; const projectFn = jasmine.createSpy('projectionFn'); @@ -433,9 +457,11 @@ describe('Selectors', () => { describe('createFeatureSelector', () => { let featureName = '@ngrx/router-store'; let featureSelector: (state: any) => number; + let warnSpy: jasmine.Spy; beforeEach(() => { featureSelector = createFeatureSelector(featureName); + warnSpy = spyOn(console, 'warn'); }); it('should memoize the result', () => { @@ -455,6 +481,50 @@ describe('Selectors', () => { ); expect(featureState$).toBeObservable(expected$); + expect(warnSpy).not.toHaveBeenCalled(); + }); + + it('should warn if the feature does not exist in the state', () => { + spyOn(ngCore, 'isDevMode').and.returnValue(true); + + const state = { otherState: '' }; + + const state$ = cold('a', { a: state }); + const expected$ = cold('a', { a: undefined }); + + const featureState$ = state$.pipe( + map(featureSelector), + distinctUntilChanged() + ); + + expect(featureState$).toBeObservable(expected$); + expect(warnSpy).toHaveBeenCalledWith( + 'The feature name "@ngrx/router-store" does not exist ' + + 'in the state, therefore createFeatureSelector cannot ' + + 'access it. Be sure it is imported in a loaded module using ' + + "StoreModule.forRoot('@ngrx/router-store', ...) or " + + "StoreModule.forFeature('@ngrx/router-store', ...). If the " + + 'default state is intended to be undefined, as is the case ' + + 'with router state, this development-only warning message can ' + + 'be ignored.' + ); + }); + + it('should not warn if not in development mode', () => { + spyOn(ngCore, 'isDevMode').and.returnValue(false); + + const state = { otherState: '' }; + + const state$ = cold('a', { a: state }); + const expected$ = cold('a', { a: undefined }); + + const featureState$ = state$.pipe( + map(featureSelector), + distinctUntilChanged() + ); + + expect(featureState$).toBeObservable(expected$); + expect(warnSpy).not.toHaveBeenCalled(); }); }); diff --git a/modules/store/src/index.ts b/modules/store/src/index.ts index 420576bb8b..8e3e2b65b1 100644 --- a/modules/store/src/index.ts +++ b/modules/store/src/index.ts @@ -4,6 +4,7 @@ export { ActionReducer, ActionReducerMap, ActionReducerFactory, + ActionType, Creator, MetaReducer, Selector, @@ -32,6 +33,7 @@ export { MemoizedSelector, MemoizedSelectorWithProps, resultMemoize, + DefaultProjectorFn, } from './selector'; export { State, StateObservable, reduceState } from './state'; export { diff --git a/modules/store/src/models.ts b/modules/store/src/models.ts index 37ab155a6b..831d7a3ad9 100644 --- a/modules/store/src/models.ts +++ b/modules/store/src/models.ts @@ -92,8 +92,20 @@ export type ParametersType = T extends (...args: infer U) => unknown : never; export interface RuntimeChecks { + /** + * Verifies if the state is serializable + */ strictStateSerializability: boolean; + /** + * Verifies if the actions are serializable. Please note, you may not need to set it to `true` unless you are storing/replaying actions using external resources, for example `localStorage`. + */ strictActionSerializability: boolean; + /** + * Verifies that the state isn't mutated + */ strictStateImmutability: boolean; + /** + * Verifies that actions aren't mutated + */ strictActionImmutability: boolean; } diff --git a/modules/store/src/reducer_creator.ts b/modules/store/src/reducer_creator.ts index ba22d2a93d..2ec7b33733 100644 --- a/modules/store/src/reducer_creator.ts +++ b/modules/store/src/reducer_creator.ts @@ -202,8 +202,7 @@ export function on( * @usageNotes * * - Must be used with `ActionCreator`'s (returned by `createAction`). Cannot be used with class-based action creators. - * - An action type should only be associated with at most one state change function, similar to switch statements. - * - In the case this is violated, the latest defined associated will be used (the latest `on` function passed). + * - An action can be associated with multiple state change functions. In this case the functions will be executed in the specified order. * - The returned `ActionReducer` should additionally be returned from an exported `reducer` function. * This is because [function calls are not supported](https://angular.io/guide/aot-compiler#function-calls-are-not-supported) by the AOT compiler. * @@ -232,7 +231,14 @@ export function createReducer( const map = new Map>(); for (let on of ons) { for (let type of on.types) { - map.set(type, on.reducer); + if (map.has(type)) { + const existingReducer = map.get(type) as ActionReducer; + const newReducer: ActionReducer = (state, action) => + on.reducer(existingReducer(state, action), action); + map.set(type, newReducer); + } else { + map.set(type, on.reducer); + } } } diff --git a/modules/store/src/selector.ts b/modules/store/src/selector.ts index 3600cd78a9..5cffc55426 100644 --- a/modules/store/src/selector.ts +++ b/modules/store/src/selector.ts @@ -1,4 +1,5 @@ import { Selector, SelectorWithProps } from './models'; +import { isDevMode } from '@angular/core'; export type AnyFn = (...args: any[]) => any; @@ -94,9 +95,9 @@ export function defaultMemoize( return lastResult; } + const newResult = projectionFn.apply(null, arguments as any); lastArguments = arguments; - const newResult = projectionFn.apply(null, arguments as any); if (isResultEqual(lastResult, newResult)) { return lastResult; } @@ -274,6 +275,7 @@ export function createSelector( Selector, Selector, Selector, + Selector, Selector, Selector @@ -601,8 +603,19 @@ export function createFeatureSelector( export function createFeatureSelector( featureName: any ): MemoizedSelector { - return createSelector( - (state: any) => state[featureName], - (featureState: any) => featureState - ); + return createSelector((state: any) => { + const featureState = state[featureName]; + if (isDevMode() && featureState === undefined) { + console.warn( + `The feature name \"${featureName}\" does ` + + 'not exist in the state, therefore createFeatureSelector ' + + 'cannot access it. Be sure it is imported in a loaded module ' + + `using StoreModule.forRoot('${featureName}', ...) or ` + + `StoreModule.forFeature('${featureName}', ...). If the default ` + + 'state is intended to be undefined, as is the case with router ' + + 'state, this development-only warning message can be ignored.' + ); + } + return featureState; + }, (featureState: any) => featureState); } diff --git a/modules/store/src/store_module.ts b/modules/store/src/store_module.ts index 121de3516a..19ecc745fd 100644 --- a/modules/store/src/store_module.ts +++ b/modules/store/src/store_module.ts @@ -5,6 +5,8 @@ import { OnDestroy, InjectionToken, Injector, + Optional, + SkipSelf, } from '@angular/core'; import { Action, @@ -16,7 +18,7 @@ import { MetaReducer, RuntimeChecks, } from './models'; -import { compose, combineReducers, createReducerFactory } from './utils'; +import { combineReducers, createReducerFactory } from './utils'; import { INITIAL_STATE, INITIAL_REDUCERS, @@ -34,6 +36,7 @@ import { _FEATURE_CONFIGS, USER_PROVIDED_META_REDUCERS, _RESOLVED_META_REDUCERS, + _ROOT_STORE_GUARD, } from './tokens'; import { ACTIONS_SUBJECT_PROVIDERS, ActionsSubject } from './actions_subject'; import { @@ -55,7 +58,10 @@ export class StoreRootModule { actions$: ActionsSubject, reducer$: ReducerObservable, scannedActions$: ScannedActionsSubject, - store: Store + store: Store, + @Optional() + @Inject(_ROOT_STORE_GUARD) + guard: any ) {} } @@ -112,6 +118,11 @@ export class StoreModule { return { ngModule: StoreRootModule, providers: [ + { + provide: _ROOT_STORE_GUARD, + useFactory: _provideForRootGuard, + deps: [[Store, new Optional(), new SkipSelf()]], + }, { provide: _INITIAL_STATE, useValue: config.initialState }, { provide: INITIAL_STATE, @@ -285,3 +296,12 @@ export function _concatMetaReducers( ): MetaReducer[] { return metaReducers.concat(userProvidedMetaReducers); } + +export function _provideForRootGuard(store: Store): any { + if (store) { + throw new TypeError( + `StoreModule.forRoot() called twice. Feature modules should use StoreModule.forFeature() instead.` + ); + } + return 'guarded'; +} diff --git a/modules/store/src/tokens.ts b/modules/store/src/tokens.ts index 7aa12edb47..d0d6e1861d 100644 --- a/modules/store/src/tokens.ts +++ b/modules/store/src/tokens.ts @@ -1,6 +1,9 @@ import { InjectionToken } from '@angular/core'; import { RuntimeChecks, MetaReducer } from './models'; +export const _ROOT_STORE_GUARD = new InjectionToken( + '@ngrx/store Internal Root Guard' +); export const _INITIAL_STATE = new InjectionToken( '@ngrx/store Internal Initial State' ); diff --git a/modules/store/testing/spec/BUILD b/modules/store/testing/spec/BUILD index 49a3118f63..64c2a4a799 100644 --- a/modules/store/testing/spec/BUILD +++ b/modules/store/testing/spec/BUILD @@ -9,6 +9,7 @@ ts_test_library( "//modules/store", "//modules/store/spec/fixtures", "//modules/store/testing", + "@npm//@angular/platform-browser", "@npm//rxjs", ], ) diff --git a/modules/store/testing/spec/mock_store.spec.ts b/modules/store/testing/spec/mock_store.spec.ts index 06708aa336..5ecb49ca91 100644 --- a/modules/store/testing/spec/mock_store.spec.ts +++ b/modules/store/testing/spec/mock_store.spec.ts @@ -1,8 +1,18 @@ -import { TestBed } from '@angular/core/testing'; -import { INCREMENT } from '../../spec/fixtures/counter'; -import { skip, take } from 'rxjs/operators'; +import { TestBed, ComponentFixture } from '@angular/core/testing'; +import { skip, take, tap } from 'rxjs/operators'; import { MockStore, provideMockStore } from '@ngrx/store/testing'; -import { Store, createSelector, select } from '@ngrx/store'; +import { + Store, + createSelector, + select, + StoreModule, + MemoizedSelector, + createFeatureSelector, +} from '@ngrx/store'; +import { INCREMENT } from '../../spec/fixtures/counter'; +import { Component, OnInit } from '@angular/core'; +import { Observable } from 'rxjs'; +import { By } from '@angular/platform-browser'; interface TestAppSchema { counter1: number; @@ -259,3 +269,122 @@ describe('Mock Store', () => { .subscribe(result => expect(result).toBe(1)); }); }); + +describe('Refreshing state', () => { + type TodoState = { + items: { name: string; done: boolean }[]; + }; + const selectTodosState = createFeatureSelector('todos'); + const todos = createSelector(selectTodosState, todos => todos.items); + const getTodoItems = (elSelector: string) => + fixture.debugElement.queryAll(By.css(elSelector)); + let mockStore: MockStore; + let mockSelector: MemoizedSelector; + const initialTodos = [{ name: 'aaa', done: false }]; + let fixture: ComponentFixture; + + @Component({ + selector: 'app-todos', + template: ` +
    +
  • + {{ todo.name }} +
  • + +

    + {{ todo.name }} +

    +
+ `, + }) + class TodosComponent implements OnInit { + todos: Observable; + todosSelect: Observable; + + constructor(private store: Store<{}>) {} + + ngOnInit() { + this.todos = this.store.pipe(select(todos)); + this.todosSelect = this.store.select(todos); + } + } + + beforeEach(() => { + TestBed.configureTestingModule({ + declarations: [TodosComponent], + providers: [provideMockStore()], + }).compileComponents(); + + mockStore = TestBed.get(Store); + mockSelector = mockStore.overrideSelector(todos, initialTodos); + + fixture = TestBed.createComponent(TodosComponent); + fixture.detectChanges(); + }); + + it('should work with store and select operator', () => { + const newTodos = [{ name: 'bbb', done: true }]; + mockSelector.setResult(newTodos); + mockStore.refreshState(); + + fixture.detectChanges(); + + expect(getTodoItems('li').length).toBe(1); + expect(getTodoItems('li')[0].nativeElement.textContent.trim()).toBe('bbb'); + }); + + it('should work with store.select method', () => { + const newTodos = [{ name: 'bbb', done: true }]; + mockSelector.setResult(newTodos); + mockStore.refreshState(); + + fixture.detectChanges(); + + expect(getTodoItems('p').length).toBe(1); + expect(getTodoItems('p')[0].nativeElement.textContent.trim()).toBe('bbb'); + }); +}); + +describe('Cleans up after each test', () => { + const selectData = createSelector( + (state: any) => state, + state => state.value + ); + + it('should return the mocked selectors value', (done: DoneFn) => { + TestBed.configureTestingModule({ + providers: [ + provideMockStore({ + initialState: { + value: 100, + }, + selectors: [{ selector: selectData, value: 200 }], + }), + ], + }); + + const store = TestBed.get>(Store) as Store; + store.pipe(select(selectData)).subscribe(v => { + expect(v).toBe(200); + done(); + }); + }); + + it('should return the real value', (done: DoneFn) => { + TestBed.configureTestingModule({ + imports: [ + StoreModule.forRoot({} as any, { + initialState: { + value: 300, + }, + }), + ], + }); + + const store = TestBed.get>(Store) as Store; + store.pipe(select(selectData)).subscribe(v => { + expect(v).toBe(300); + done(); + }); + }); +}); diff --git a/modules/store/testing/src/mock_store.ts b/modules/store/testing/src/mock_store.ts index 7b844bfb9b..7a3eb0a249 100644 --- a/modules/store/testing/src/mock_store.ts +++ b/modules/store/testing/src/mock_store.ts @@ -1,4 +1,5 @@ import { Inject, Injectable } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; import { Observable, BehaviorSubject } from 'rxjs'; import { Action, @@ -14,6 +15,17 @@ import { MockState } from './mock_state'; import { MockSelector } from './mock_selector'; import { MOCK_SELECTORS } from './tokens'; +if (typeof afterEach === 'function') { + afterEach(() => { + try { + const store = TestBed.get(Store) as MockStore; + if (store && 'resetSelectors' in store) { + store.resetSelectors(); + } + } catch {} + }); +} + @Injectable() export class MockStore extends Store { static selectors = new Map< @@ -24,6 +36,7 @@ export class MockStore extends Store { >(); public scannedActions$: Observable; + private lastState: T; constructor( private state$: MockState, @@ -34,7 +47,7 @@ export class MockStore extends Store { ) { super(state$, actionsObserver, reducerManager); this.resetSelectors(); - this.state$.next(this.initialState); + this.setState(this.initialState); this.scannedActions$ = actionsObserver.asObservable(); if (mockSelectors) { mockSelectors.forEach(mockSelector => { @@ -50,6 +63,7 @@ export class MockStore extends Store { setState(nextState: T): void { this.state$.next(nextState); + this.lastState = nextState; } overrideSelector( @@ -96,7 +110,7 @@ export class MockStore extends Store { } select(selector: any, prop?: any) { - if (MockStore.selectors.has(selector)) { + if (typeof selector === 'string' && MockStore.selectors.has(selector)) { return new BehaviorSubject( MockStore.selectors.get(selector) ).asObservable(); @@ -112,4 +126,11 @@ export class MockStore extends Store { removeReducer() { /* noop */ } + + /** + * Refreshes the existing state. + */ + refreshState() { + this.setState({ ...(this.lastState as T) }); + } } diff --git a/modules/store/testing/src/testing.ts b/modules/store/testing/src/testing.ts index a81c6bd30b..70f41c742e 100644 --- a/modules/store/testing/src/testing.ts +++ b/modules/store/testing/src/testing.ts @@ -23,7 +23,7 @@ export function provideMockStore( return [ ActionsSubject, MockState, - { provide: INITIAL_STATE, useValue: config.initialState }, + { provide: INITIAL_STATE, useValue: config.initialState || {} }, { provide: MOCK_SELECTORS, useValue: config.selectors }, { provide: StateObservable, useClass: MockState }, { provide: ReducerManager, useClass: MockReducerManager }, diff --git a/package.json b/package.json index 8d4ea4714f..b202ee456e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@ngrx/platform", - "version": "8.2.0", + "version": "8.4.0", "description": "monorepo for ngrx development", "scripts": { "build": "bazel build //modules/...", @@ -187,4 +187,4 @@ "pre-commit": "lint-staged" } } -} \ No newline at end of file +} diff --git a/projects/example-app/src/app/auth/containers/login-page.component.spec.ts b/projects/example-app/src/app/auth/containers/login-page.component.spec.ts index b77965b95d..71b2361be8 100644 --- a/projects/example-app/src/app/auth/containers/login-page.component.spec.ts +++ b/projects/example-app/src/app/auth/containers/login-page.component.spec.ts @@ -25,7 +25,9 @@ describe('Login Page', () => { declarations: [LoginPageComponent, LoginFormComponent], providers: [ provideMockStore({ - selectors: [{ selector: fromAuth.getLoginPagePending, value: false }], + selectors: [ + { selector: fromAuth.selectLoginPagePending, value: false }, + ], }), ], }); diff --git a/projects/example-app/src/app/auth/containers/login-page.component.ts b/projects/example-app/src/app/auth/containers/login-page.component.ts index c9893e9192..e00c4edc5d 100644 --- a/projects/example-app/src/app/auth/containers/login-page.component.ts +++ b/projects/example-app/src/app/auth/containers/login-page.component.ts @@ -17,8 +17,8 @@ import { LoginPageActions } from '@example-app/auth/actions'; styles: [], }) export class LoginPageComponent implements OnInit { - pending$ = this.store.pipe(select(fromAuth.getLoginPagePending)); - error$ = this.store.pipe(select(fromAuth.getLoginPageError)); + pending$ = this.store.pipe(select(fromAuth.selectLoginPagePending)); + error$ = this.store.pipe(select(fromAuth.selectLoginPageError)); constructor(private store: Store) {} diff --git a/projects/example-app/src/app/auth/reducers/index.ts b/projects/example-app/src/app/auth/reducers/index.ts index 5e53f84623..8dc2860334 100644 --- a/projects/example-app/src/app/auth/reducers/index.ts +++ b/projects/example-app/src/app/auth/reducers/index.ts @@ -34,18 +34,21 @@ export const selectAuthStatusState = createSelector( selectAuthState, (state: AuthState) => state.status ); -export const getUser = createSelector(selectAuthStatusState, fromAuth.getUser); -export const getLoggedIn = createSelector(getUser, user => !!user); +export const selectUser = createSelector( + selectAuthStatusState, + fromAuth.getUser +); +export const selectLoggedIn = createSelector(selectUser, user => !!user); export const selectLoginPageState = createSelector( selectAuthState, (state: AuthState) => state.loginPage ); -export const getLoginPageError = createSelector( +export const selectLoginPageError = createSelector( selectLoginPageState, fromLoginPage.getError ); -export const getLoginPagePending = createSelector( +export const selectLoginPagePending = createSelector( selectLoginPageState, fromLoginPage.getPending ); diff --git a/projects/example-app/src/app/auth/services/auth-guard.service.spec.ts b/projects/example-app/src/app/auth/services/auth-guard.service.spec.ts index fd8901f9e5..a2483de2ba 100644 --- a/projects/example-app/src/app/auth/services/auth-guard.service.spec.ts +++ b/projects/example-app/src/app/auth/services/auth-guard.service.spec.ts @@ -18,7 +18,7 @@ describe('Auth Guard', () => { store = TestBed.get(Store); guard = TestBed.get(AuthGuard); - loggedIn = store.overrideSelector(fromAuth.getLoggedIn, false); + loggedIn = store.overrideSelector(fromAuth.selectLoggedIn, false); }); it('should return false if the user state is not logged in', () => { diff --git a/projects/example-app/src/app/auth/services/auth-guard.service.ts b/projects/example-app/src/app/auth/services/auth-guard.service.ts index 0537d667bd..fbeae2a6a9 100644 --- a/projects/example-app/src/app/auth/services/auth-guard.service.ts +++ b/projects/example-app/src/app/auth/services/auth-guard.service.ts @@ -14,7 +14,7 @@ export class AuthGuard implements CanActivate { canActivate(): Observable { return this.store.pipe( - select(fromAuth.getLoggedIn), + select(fromAuth.selectLoggedIn), map(authed => { if (!authed) { this.store.dispatch(AuthApiActions.loginRedirect()); diff --git a/projects/example-app/src/app/books/containers/collection-page.component.spec.ts b/projects/example-app/src/app/books/containers/collection-page.component.spec.ts index eaadeeb9e6..67af6d900c 100644 --- a/projects/example-app/src/app/books/containers/collection-page.component.spec.ts +++ b/projects/example-app/src/app/books/containers/collection-page.component.spec.ts @@ -40,7 +40,7 @@ describe('Collection Page', () => { ], providers: [ provideMockStore({ - selectors: [{ selector: fromBooks.getBookCollection, value: [] }], + selectors: [{ selector: fromBooks.selectBookCollection, value: [] }], }), ], }); diff --git a/projects/example-app/src/app/books/containers/collection-page.component.ts b/projects/example-app/src/app/books/containers/collection-page.component.ts index 0792a5c910..f069459958 100644 --- a/projects/example-app/src/app/books/containers/collection-page.component.ts +++ b/projects/example-app/src/app/books/containers/collection-page.component.ts @@ -36,7 +36,7 @@ export class CollectionPageComponent implements OnInit { books$: Observable; constructor(private store: Store) { - this.books$ = store.pipe(select(fromBooks.getBookCollection)); + this.books$ = store.pipe(select(fromBooks.selectBookCollection)); } ngOnInit() { diff --git a/projects/example-app/src/app/books/containers/find-book-page.component.spec.ts b/projects/example-app/src/app/books/containers/find-book-page.component.spec.ts index e6b8802e00..8a7ce3681c 100644 --- a/projects/example-app/src/app/books/containers/find-book-page.component.spec.ts +++ b/projects/example-app/src/app/books/containers/find-book-page.component.spec.ts @@ -50,10 +50,10 @@ describe('Find Book Page', () => { providers: [ provideMockStore({ selectors: [ - { selector: fromBooks.getSearchQuery, value: '' }, - { selector: fromBooks.getSearchResults, value: [] }, - { selector: fromBooks.getSearchLoading, value: false }, - { selector: fromBooks.getSearchError, value: '' }, + { selector: fromBooks.selectSearchQuery, value: '' }, + { selector: fromBooks.selectSearchResults, value: [] }, + { selector: fromBooks.selectSearchLoading, value: false }, + { selector: fromBooks.selectSearchError, value: '' }, ], }), ], diff --git a/projects/example-app/src/app/books/containers/find-book-page.component.ts b/projects/example-app/src/app/books/containers/find-book-page.component.ts index d467559913..34e9d1b19e 100644 --- a/projects/example-app/src/app/books/containers/find-book-page.component.ts +++ b/projects/example-app/src/app/books/containers/find-book-page.component.ts @@ -31,12 +31,12 @@ export class FindBookPageComponent { constructor(private store: Store) { this.searchQuery$ = store.pipe( - select(fromBooks.getSearchQuery), + select(fromBooks.selectSearchQuery), take(1) ); - this.books$ = store.pipe(select(fromBooks.getSearchResults)); - this.loading$ = store.pipe(select(fromBooks.getSearchLoading)); - this.error$ = store.pipe(select(fromBooks.getSearchError)); + this.books$ = store.pipe(select(fromBooks.selectSearchResults)); + this.loading$ = store.pipe(select(fromBooks.selectSearchLoading)); + this.error$ = store.pipe(select(fromBooks.selectSearchError)); } search(query: string) { diff --git a/projects/example-app/src/app/books/containers/selected-book-page.component.ts b/projects/example-app/src/app/books/containers/selected-book-page.component.ts index 99e2c64563..14a73232a1 100644 --- a/projects/example-app/src/app/books/containers/selected-book-page.component.ts +++ b/projects/example-app/src/app/books/containers/selected-book-page.component.ts @@ -24,7 +24,7 @@ export class SelectedBookPageComponent { isSelectedBookInCollection$: Observable; constructor(private store: Store) { - this.book$ = store.pipe(select(fromBooks.getSelectedBook)) as Observable< + this.book$ = store.pipe(select(fromBooks.selectSelectedBook)) as Observable< Book >; this.isSelectedBookInCollection$ = store.pipe( diff --git a/projects/example-app/src/app/books/guards/book-exists.guard.ts b/projects/example-app/src/app/books/guards/book-exists.guard.ts index 6a33f24615..1191f48c94 100644 --- a/projects/example-app/src/app/books/guards/book-exists.guard.ts +++ b/projects/example-app/src/app/books/guards/book-exists.guard.ts @@ -30,7 +30,7 @@ export class BookExistsGuard implements CanActivate { */ waitForCollectionToLoad(): Observable { return this.store.pipe( - select(fromBooks.getCollectionLoaded), + select(fromBooks.selectCollectionLoaded), filter(loaded => loaded), take(1) ); @@ -42,7 +42,7 @@ export class BookExistsGuard implements CanActivate { */ hasBookInStore(id: string): Observable { return this.store.pipe( - select(fromBooks.getBookEntities), + select(fromBooks.selectBookEntities), map(entities => !!entities[id]), take(1) ); diff --git a/projects/example-app/src/app/books/reducers/__snapshots__/books.reducer.spec.ts.snap b/projects/example-app/src/app/books/reducers/__snapshots__/books.reducer.spec.ts.snap index 0582de529a..7a6ab4f044 100644 --- a/projects/example-app/src/app/books/reducers/__snapshots__/books.reducer.spec.ts.snap +++ b/projects/example-app/src/app/books/reducers/__snapshots__/books.reducer.spec.ts.snap @@ -350,7 +350,7 @@ Object { } `; -exports[`BooksReducer Selectors getSelectedId should return the selected id 1`] = `"1"`; +exports[`BooksReducer Selectors selectId should return the selected id 1`] = `"1"`; exports[`BooksReducer undefined action should return the default state 1`] = ` Object { diff --git a/projects/example-app/src/app/books/reducers/books.reducer.spec.ts b/projects/example-app/src/app/books/reducers/books.reducer.spec.ts index 1be327a452..549926ad37 100644 --- a/projects/example-app/src/app/books/reducers/books.reducer.spec.ts +++ b/projects/example-app/src/app/books/reducers/books.reducer.spec.ts @@ -130,9 +130,9 @@ describe('BooksReducer', () => { }); describe('Selectors', () => { - describe('getSelectedId', () => { + describe('selectId', () => { it('should return the selected id', () => { - const result = fromBooks.getSelectedId({ + const result = fromBooks.selectId({ ...initialState, selectedBookId: book1.id, }); diff --git a/projects/example-app/src/app/books/reducers/books.reducer.ts b/projects/example-app/src/app/books/reducers/books.reducer.ts index f219200635..ad07f315ff 100644 --- a/projects/example-app/src/app/books/reducers/books.reducer.ts +++ b/projects/example-app/src/app/books/reducers/books.reducer.ts @@ -81,4 +81,4 @@ export const reducer = createReducer( * use-case. */ -export const getSelectedId = (state: State) => state.selectedBookId; +export const selectId = (state: State) => state.selectedBookId; diff --git a/projects/example-app/src/app/books/reducers/index.ts b/projects/example-app/src/app/books/reducers/index.ts index e63207fd17..3392bddd38 100644 --- a/projects/example-app/src/app/books/reducers/index.ts +++ b/projects/example-app/src/app/books/reducers/index.ts @@ -51,7 +51,7 @@ export function reducers(state: BooksState | undefined, action: Action) { * The createFeatureSelector function selects a piece of state from the root of the state object. * This is used for selecting feature states that are loaded eagerly or lazily. */ -export const getBooksState = createFeatureSelector( +export const selectBooksState = createFeatureSelector( booksFeatureKey ); @@ -64,14 +64,14 @@ export const getBooksState = createFeatureSelector( * only recompute when arguments change. The created selectors can also be composed * together to select different pieces of state. */ -export const getBookEntitiesState = createSelector( - getBooksState, +export const selectBookEntitiesState = createSelector( + selectBooksState, state => state.books ); -export const getSelectedBookId = createSelector( - getBookEntitiesState, - fromBooks.getSelectedId +export const selectSelectedBookId = createSelector( + selectBookEntitiesState, + fromBooks.selectId ); /** @@ -83,15 +83,15 @@ export const getSelectedBookId = createSelector( * in selecting records from the entity state. */ export const { - selectIds: getBookIds, - selectEntities: getBookEntities, - selectAll: getAllBooks, - selectTotal: getTotalBooks, -} = fromBooks.adapter.getSelectors(getBookEntitiesState); - -export const getSelectedBook = createSelector( - getBookEntities, - getSelectedBookId, + selectIds: selectBookIds, + selectEntities: selectBookEntities, + selectAll: selectAllBooks, + selectTotal: selectTotalBooks, +} = fromBooks.adapter.getSelectors(selectBookEntitiesState); + +export const selectSelectedBook = createSelector( + selectBookEntities, + selectSelectedBookId, (entities, selectedId) => { return selectedId && entities[selectedId]; } @@ -101,25 +101,25 @@ export const getSelectedBook = createSelector( * Just like with the books selectors, we also have to compose the search * reducer's and collection reducer's selectors. */ -export const getSearchState = createSelector( - getBooksState, +export const selectSearchState = createSelector( + selectBooksState, (state: BooksState) => state.search ); -export const getSearchBookIds = createSelector( - getSearchState, +export const selectSearchBookIds = createSelector( + selectSearchState, fromSearch.getIds ); -export const getSearchQuery = createSelector( - getSearchState, +export const selectSearchQuery = createSelector( + selectSearchState, fromSearch.getQuery ); -export const getSearchLoading = createSelector( - getSearchState, +export const selectSearchLoading = createSelector( + selectSearchState, fromSearch.getLoading ); -export const getSearchError = createSelector( - getSearchState, +export const selectSearchError = createSelector( + selectSearchState, fromSearch.getError ); @@ -127,9 +127,9 @@ export const getSearchError = createSelector( * Some selector functions create joins across parts of state. This selector * composes the search result IDs to return an array of books in the store. */ -export const getSearchResults = createSelector( - getBookEntities, - getSearchBookIds, +export const selectSearchResults = createSelector( + selectBookEntities, + selectSearchBookIds, (books, searchIds) => { return searchIds .map(id => books[id]) @@ -137,27 +137,27 @@ export const getSearchResults = createSelector( } ); -export const getCollectionState = createSelector( - getBooksState, +export const selectCollectionState = createSelector( + selectBooksState, (state: BooksState) => state.collection ); -export const getCollectionLoaded = createSelector( - getCollectionState, +export const selectCollectionLoaded = createSelector( + selectCollectionState, fromCollection.getLoaded ); export const getCollectionLoading = createSelector( - getCollectionState, + selectCollectionState, fromCollection.getLoading ); -export const getCollectionBookIds = createSelector( - getCollectionState, +export const selectCollectionBookIds = createSelector( + selectCollectionState, fromCollection.getIds ); -export const getBookCollection = createSelector( - getBookEntities, - getCollectionBookIds, +export const selectBookCollection = createSelector( + selectBookEntities, + selectCollectionBookIds, (entities, ids) => { return ids .map(id => entities[id]) @@ -166,8 +166,8 @@ export const getBookCollection = createSelector( ); export const isSelectedBookInCollection = createSelector( - getCollectionBookIds, - getSelectedBookId, + selectCollectionBookIds, + selectSelectedBookId, (ids, selected) => { return !!selected && ids.indexOf(selected) > -1; } diff --git a/projects/example-app/src/app/core/containers/app.component.ts b/projects/example-app/src/app/core/containers/app.component.ts index 94c0cd8d27..e1cf39f7af 100644 --- a/projects/example-app/src/app/core/containers/app.component.ts +++ b/projects/example-app/src/app/core/containers/app.component.ts @@ -43,8 +43,8 @@ export class AppComponent { * Selectors can be applied with the `select` operator which passes the state * tree to the provided selector */ - this.showSidenav$ = this.store.pipe(select(fromRoot.getShowSidenav)); - this.loggedIn$ = this.store.pipe(select(fromAuth.getLoggedIn)); + this.showSidenav$ = this.store.pipe(select(fromRoot.selecthowSidenav)); + this.loggedIn$ = this.store.pipe(select(fromAuth.selectLoggedIn)); } closeSidenav() { diff --git a/projects/example-app/src/app/core/effects/user.effects.ts b/projects/example-app/src/app/core/effects/user.effects.ts index 5d11660efb..2733e1e3ff 100644 --- a/projects/example-app/src/app/core/effects/user.effects.ts +++ b/projects/example-app/src/app/core/effects/user.effects.ts @@ -1,4 +1,4 @@ -import { Injectable, Inject } from '@angular/core'; +import { Injectable } from '@angular/core'; import { fromEvent, merge, timer } from 'rxjs'; import { map, switchMapTo } from 'rxjs/operators'; diff --git a/projects/example-app/src/app/core/reducers/layout.reducer.ts b/projects/example-app/src/app/core/reducers/layout.reducer.ts index 74d93adf48..59fd4fbc0f 100644 --- a/projects/example-app/src/app/core/reducers/layout.reducer.ts +++ b/projects/example-app/src/app/core/reducers/layout.reducer.ts @@ -19,4 +19,4 @@ export const reducer = createReducer( on(LayoutActions.openSidenav, state => ({ showSidenav: true })) ); -export const getShowSidenav = (state: State) => state.showSidenav; +export const selectShowSidenav = (state: State) => state.showSidenav; diff --git a/projects/example-app/src/app/reducers/index.ts b/projects/example-app/src/app/reducers/index.ts index 89337bcf8c..abcaa27a29 100644 --- a/projects/example-app/src/app/reducers/index.ts +++ b/projects/example-app/src/app/reducers/index.ts @@ -4,7 +4,6 @@ import { ActionReducer, MetaReducer, Action, - combineReducers, ActionReducerMap, } from '@ngrx/store'; import { environment } from '../../environments/environment'; @@ -69,11 +68,11 @@ export const metaReducers: MetaReducer[] = !environment.production /** * Layout Reducers */ -export const getLayoutState = createFeatureSelector( +export const selectLayoutState = createFeatureSelector( 'layout' ); -export const getShowSidenav = createSelector( - getLayoutState, - fromLayout.getShowSidenav +export const selecthowSidenav = createSelector( + selectLayoutState, + fromLayout.selectShowSidenav ); diff --git a/projects/ngrx.io/content/examples/store/src/app/counter.reducer.ts b/projects/ngrx.io/content/examples/store/src/app/counter.reducer.ts index f593fa8584..4940496c69 100644 --- a/projects/ngrx.io/content/examples/store/src/app/counter.reducer.ts +++ b/projects/ngrx.io/content/examples/store/src/app/counter.reducer.ts @@ -4,8 +4,12 @@ import { increment, decrement, reset } from './counter.actions'; export const initialState = 0; -export const counterReducer = createReducer(initialState, +const _counterReducer = createReducer(initialState, on(increment, state => state + 1), on(decrement, state => state - 1), on(reset, state => 0), ); + +export function counterReducer(state, action) { + return _counterReducer(state, action); +} diff --git a/projects/ngrx.io/content/guide/data/entity-actions.md b/projects/ngrx.io/content/guide/data/entity-actions.md index d29b0b2149..178c6f3db9 100644 --- a/projects/ngrx.io/content/guide/data/entity-actions.md +++ b/projects/ngrx.io/content/guide/data/entity-actions.md @@ -113,8 +113,9 @@ e.g., `'[Hero] NgRx Data/query-all'`. Here's an example that uses the injectable `EntityActionFactory` to construct the default "query all heroes" action. ```typescript -const action = entityActionFactory( - 'Hero', EntityOp.QUERY_ALL, null, 'Load Heroes On Start' +const action = this.entityActionFactory.create( + 'Hero', + EntityOp.QUERY_ALL ); store.dispatch(action); @@ -123,8 +124,8 @@ store.dispatch(action); Thanks to the NgRx Data _Effects_, this produces _two_ actions in the log, the first to initiate the request and the second with the successful response: ```typescript -[Hero] NgRx Data/query-all -[Hero] NgRx Data/query-all-success +[Hero] ngrx/data/query-all +[Hero] ngrx/data/query-all/success ``` This default `entityName` tag identifies the action's target entity collection. @@ -138,8 +139,11 @@ where that action is dispatched by your code. For example, ```typescript -const action = entityActionFactory( - 'Hero', EntityOp.QUERY_ALL, null, 'Load Heroes On Start' +const action = this.entityActionFactory.create( + 'Hero', + EntityOp.QUERY_ALL, + null, + { tag: 'Load Heroes On Start' } ); store.dispatch(action); @@ -148,8 +152,8 @@ store.dispatch(action); The action log now looks like this: ```typescript -[Load Heroes On Start] NgRx Data/query-all -[Load Heroes On Start] NgRx Data/query-all-success +[Load Heroes On Start] ngrx/data/query-all +[Load Heroes On Start] ngrx/data/query-all/success ``` ### Handcrafted _EntityAction_ diff --git a/projects/ngrx.io/content/guide/data/entity-collection-service.md b/projects/ngrx.io/content/guide/data/entity-collection-service.md index 5c21964a18..943afad709 100644 --- a/projects/ngrx.io/content/guide/data/entity-collection-service.md +++ b/projects/ngrx.io/content/guide/data/entity-collection-service.md @@ -157,13 +157,6 @@ An entity argument **must never be a cached entity object**. It can be a _copy_ of a cached entity object and it often is. The demo application always calls these command methods with copies of the entity data. -
- -The current _NgRx_ libraries do not guard against mutation of the objects (or arrays of objects) in the store. -A future _NgRx_ **_freeze_** feature will provide such a guard in _development_ builds. - -
- All _command methods_ return `void`. A core principle of the _redux pattern_ is that _commands_ never return a value. They just _do things_ that have side-effects. diff --git a/projects/ngrx.io/content/guide/data/entity-dataservice.md b/projects/ngrx.io/content/guide/data/entity-dataservice.md index 931b3d9f22..e667dfe086 100644 --- a/projects/ngrx.io/content/guide/data/entity-dataservice.md +++ b/projects/ngrx.io/content/guide/data/entity-dataservice.md @@ -101,7 +101,7 @@ The shared configuration values are almost always specific to the application an The NgRx Data library defines a `DefaultDataServiceConfig` for conveying shared configuration to an entity collection data service. -The most important configuration property, `root`, returns the _root_ of every web api URL, the parts that come before the entity resource name. +The most important configuration property, `root`, returns the _root_ of every web api URL, the parts that come before the entity resource name. If you are using a remote API, this value can include the protocol, domain, port, and root path, such as `https://my-api-domain.com:8000/api/v1`. For a `DefaultDataService`, the default value is `'api'`, which results in URLs such as `api/heroes`. @@ -121,7 +121,7 @@ First, create a custom configuration object of type `DefaultDataServiceConfig` : ```typescript const defaultDataServiceConfig: DefaultDataServiceConfig = { - root: 'api', + root: 'https://my-api-domain.com:8000/api/v1', timeout: 3000, // request timeout } ``` diff --git a/projects/ngrx.io/content/guide/data/entity-metadata.md b/projects/ngrx.io/content/guide/data/entity-metadata.md index 95cdf75780..380b109bab 100644 --- a/projects/ngrx.io/content/guide/data/entity-metadata.md +++ b/projects/ngrx.io/content/guide/data/entity-metadata.md @@ -135,7 +135,7 @@ The demo uses this helper to create hero and villain filters. Here's how the app * matches the case-insensitive pattern. */ export function nameAndSayingFilter(entities: Villain[], pattern: string) { - return PropsFilterFnFactory < Villain > ['name', 'saying'](guide/data/entities, pattern); + return PropsFilterFnFactory ['name', 'saying'](entities, pattern); } ``` diff --git a/projects/ngrx.io/content/guide/data/extension-points.md b/projects/ngrx.io/content/guide/data/extension-points.md index cb9bea42b8..ad63ee94d6 100644 --- a/projects/ngrx.io/content/guide/data/extension-points.md +++ b/projects/ngrx.io/content/guide/data/extension-points.md @@ -108,6 +108,51 @@ or replace the default service entirely. The [_Entity Reducer_ guide](guide/data/entity-reducer#customizing) explains how to customize entity reducers. +## Custom _Selectors_ + +### Introduction + +`@ngrx/data` has several built-in selectors that are defined in the [EntitySelectors](https://ngrx.io/api/data/EntitySelectors) interface. These can be used outside of a component. + +Many apps use `@ngrx/data` in conjunction with @ngrx/store including manually written reducers, actions, and so on. `@ngrx/data` selectors can be used to combine @ngrx/data state with the state of the entire application. + +### Using EntitySelectorsFactory + +[EntitySelectorsFactory](https://ngrx.io/api/data/EntitySelectorsFactory) exposes a `create` method that can be used to create selectors outside the context of a component, such as in a `reducers/index.ts` file. + +#### Example + +```ts +/* src/app/reducers/index.ts */ +import * as fromCat from './cat.reducer'; +import { Owner } from '~/app/models' + +export const ownerSelectors = new EntitySelectorsFactory().create('Owner'); + +export interface State { + cat: fromCat.State; +} + +export const reducers: ActionReducerMap = { + cat: fromCat.reducer +}; + +export const selectCatState = (state: State) => state.cat; + +export const { + selectAll: selectAllCats +} = fromCat.adapter.getSelectors(selectCatState); + +export const selectedCatsWithOwners = createSelector( + selectAllCats, + ownerSelectors.selectEntities, + (cats, ownerEntities) => cats.map(c => ({ + ...c, + owner: ownerEntities[c.owner] + })) +); +``` + ## Custom data service ### Replace the generic-type data service diff --git a/projects/ngrx.io/content/guide/entity/adapter.md b/projects/ngrx.io/content/guide/entity/adapter.md index 2fe0c66b3d..e657899e86 100644 --- a/projects/ngrx.io/content/guide/entity/adapter.md +++ b/projects/ngrx.io/content/guide/entity/adapter.md @@ -155,7 +155,7 @@ const userReducer = createReducer( return adapter.addMany(users, state); }), on(UserActions.upsertUsers, (state, { users }) => { - return adapter.upsertUsers(users, state); + return adapter.upsertMany(users, state); }), on(UserActions.updateUser, (state, { user }) => { return adapter.updateOne(user, state); diff --git a/projects/ngrx.io/content/guide/store-devtools/recipes/exclude.md b/projects/ngrx.io/content/guide/store-devtools/recipes/exclude.md new file mode 100644 index 0000000000..dbe3c6a0da --- /dev/null +++ b/projects/ngrx.io/content/guide/store-devtools/recipes/exclude.md @@ -0,0 +1,53 @@ +# Excluding Store Devtools In Production + +To prevent Store Devtools from being included in your bundle, you can exclude it from the build process. + + +## Step 1: Create build specific files + +Create a folder for your build specific files. In this case, it is `build-specifics`. Now create a file for a common build. Within this file, export an array that defines the `StoreDevtoolsModule`. + + +import { StoreDevtoolsModule } from '@ngrx/store-devtools'; + +export const extModules = [ + StoreDevtoolsModule.instrument({ + maxAge: 25 + }) +]; + + +Now create a file for a production build (`ng build --prod=true`) that simply exports an empty array. + + +export const extModules = []; + + +## Step 2: Import extModules + +Modify `app.module.ts` to include `extModules` in the `imports` array. + + +import { extModules } from './build-specifics'; + +@NgModule({ + imports: [ + StoreModule.forRoot(reducers), + // Instrumentation must be imported after importing StoreModule + extModules, + ], +}) + + +## Step 3: Modify angular.json + +Add a new entry in the `fileReplacements` section in your `angular.json`. For more information on this topic, look at the build section of the angular documentation. [Configure target-specific file replacements](https://angular.io/guide/build#configure-target-specific-file-replacements) + + +"fileReplacements": [ + { + "replace": "src/app/build-specifics/index.ts", + "with": "src/app/build-specifics/index.prod.ts" + } +] + \ No newline at end of file diff --git a/projects/ngrx.io/content/guide/store/configuration/runtime-checks.md b/projects/ngrx.io/content/guide/store/configuration/runtime-checks.md index e1ac4e0a6a..ebfe1766e9 100644 --- a/projects/ngrx.io/content/guide/store/configuration/runtime-checks.md +++ b/projects/ngrx.io/content/guide/store/configuration/runtime-checks.md @@ -165,3 +165,9 @@ function logTodo (todo: Todo) { console.log(todo); } ``` + +
+ +Please note, you may not need to set `strictActionSerializability` to `true` unless you are storing/replaying actions using external resources, for example `localStorage`. + +
\ No newline at end of file diff --git a/projects/ngrx.io/content/guide/store/index.md b/projects/ngrx.io/content/guide/store/index.md index 09c03690f8..03f8756175 100644 --- a/projects/ngrx.io/content/guide/store/index.md +++ b/projects/ngrx.io/content/guide/store/index.md @@ -7,12 +7,19 @@ Store is RxJS powered state management for Angular applications, inspired by Red - [Actions](guide/store/actions) describe unique events that are dispatched from components and services. - State changes are handled by pure functions called [reducers](guide/store/reducers) that take the current state and the latest action to compute a new state. - [Selectors](guide/store/selectors) are pure functions used to select, derive and compose pieces of state. -- State accessed with the `Store`, an observable of state and an observer of actions. +- State is accessed with the `Store`, an observable of state and an observer of actions. ## Installation Detailed installation instructions can be found on the [Installation](guide/store/install) page. +## Diagram + +The following diagram represents the overall general flow of application state in NgRx. +
+ NgRx State Management Lifecycle Diagram +
+ ## Tutorial The following tutorial shows you how to manage the state of a counter, and how to select and display it within an Angular component. Try the . diff --git a/projects/ngrx.io/content/guide/store/recipes/injecting.md b/projects/ngrx.io/content/guide/store/recipes/injecting.md index 5b29ea5a85..e6c3e0240c 100644 --- a/projects/ngrx.io/content/guide/store/recipes/injecting.md +++ b/projects/ngrx.io/content/guide/store/recipes/injecting.md @@ -65,10 +65,12 @@ import { MetaReducer, META_REDUCERS } from '@ngrx/store'; import { SomeService } from './some.service'; import * as fromRoot from './reducers'; -export function getMetaReducers( - some: SomeService -): MetaReducer<fromRoot.State>[] { - // return array of meta reducers; +export function metaReducerFactory(): MetaReducer<fromRoot.State> { + return (reducer: ActionReducer<any>) => (state, action) => { + console.log('state', state); + console.log('action', action); + return reducer(state, action); + }; } @NgModule({ @@ -76,7 +78,7 @@ export function getMetaReducers( { provide: META_REDUCERS, deps: [SomeService], - useFactory: getMetaReducers, + useFactory: metaReducerFactory, multi: true, }, ], diff --git a/projects/ngrx.io/content/guide/store/reducers.md b/projects/ngrx.io/content/guide/store/reducers.md index 874fe7a282..bbf3317050 100644 --- a/projects/ngrx.io/content/guide/store/reducers.md +++ b/projects/ngrx.io/content/guide/store/reducers.md @@ -25,7 +25,7 @@ import { createAction } from '@ngrx/store'; export const homeScore = createAction('[Scoreboard Page] Home Score'); export const awayScore = createAction('[Scoreboard Page] Away Score'); export const resetScore = createAction('[Scoreboard Page] Score Reset'); -export const setScores = createAction('[Scoreboard Page] Set Scores'); +export const setScores = createAction('[Scoreboard Page] Set Scores', props<{game: Game}>()); diff --git a/projects/ngrx.io/content/guide/store/selectors.md b/projects/ngrx.io/content/guide/store/selectors.md index d3fd59c49f..e39eca060d 100644 --- a/projects/ngrx.io/content/guide/store/selectors.md +++ b/projects/ngrx.io/content/guide/store/selectors.md @@ -333,7 +333,7 @@ export const selectLastStateTransitions = (count: number) => {    select(selectProjectedValues),    // Combines the last `count` state values in array    scan((acc, curr) => { - return [ curr, acc[0], acc[1] ].filter(val => val !== undefined); + return [ curr, ...acc ].filter((val, index) => index < count && val !== undefined) }, [] as {foo: number; bar: string}[]) // XX: Explicit type hint for the array. // Equivalent to what is emitted by the selector ); @@ -346,5 +346,3 @@ Finally, the component will subscribe to the store, telling the number of state // Subscribe to the store using the custom pipeable operator store.pipe(selectLastStateTransitions(3)).subscribe(/* .. */); - -See the [advanced example live in action in a Stackblitz](https://stackblitz.com/edit/angular-ngrx-effects-1rj88y?file=app%2Fstore%2Ffoo.ts) diff --git a/projects/ngrx.io/content/guide/store/testing.md b/projects/ngrx.io/content/guide/store/testing.md index 80fe285ebe..0492b5d23b 100644 --- a/projects/ngrx.io/content/guide/store/testing.md +++ b/projects/ngrx.io/content/guide/store/testing.md @@ -7,7 +7,7 @@ You can write tests validating behaviors corresponding to the specific state sna
-**Note:** All dispatched actions don't affect to the state, but you can see them in the `Actions` stream. +**Note:** All dispatched actions don't affect the state, but you can see them in the `Actions` stream.
@@ -74,6 +74,12 @@ Usage: In this example, we mock the `getLoggedIn` selector by using `overrideSelector`, passing in the `getLoggedIn` selector with a default mocked return value of `false`. In the second test, we use `setResult()` to update the mock selector to return `true`. +
+ +**Note:** `MockStore` will reset all of the mocked selectors after each test (in the `afterEach()` hook) by calling the `MockStore.resetSelectors()` method. + +
+ Try the . ### Integration Testing diff --git a/projects/ngrx.io/content/images/guide/store/state-management-lifecycle.png b/projects/ngrx.io/content/images/guide/store/state-management-lifecycle.png new file mode 100644 index 0000000000..649a8c9703 Binary files /dev/null and b/projects/ngrx.io/content/images/guide/store/state-management-lifecycle.png differ diff --git a/projects/ngrx.io/content/marketing/docs.md b/projects/ngrx.io/content/marketing/docs.md index bad2c7bac6..ededc7e213 100644 --- a/projects/ngrx.io/content/marketing/docs.md +++ b/projects/ngrx.io/content/marketing/docs.md @@ -9,7 +9,7 @@ NgRx provides state management for creating maintainable explicit applications, ### Serializability -By normalizing state changes and pass them through observables, NgRx provides serializability and ensures state is predictably stored. This enables to save the state to an external storage, for example, `localStorage`. +By normalizing state changes and passing them through observables, NgRx provides serializability and ensures state is predictably stored. This enables to save the state to an external storage, for example, `localStorage`. In addition, it also allows to inspect, download, upload, and dispatch actions, all from the [Store Devtools](guide/store-devtools). diff --git a/projects/ngrx.io/content/navigation.json b/projects/ngrx.io/content/navigation.json index fa45d29112..b1f3d4c815 100644 --- a/projects/ngrx.io/content/navigation.json +++ b/projects/ngrx.io/content/navigation.json @@ -148,6 +148,15 @@ { "title": "Instrumentation", "url": "guide/store-devtools/config" + }, + { + "title": "Recipes", + "children": [ + { + "title": "Exclude from Production", + "url": "guide/store-devtools/recipes/exclude" + } + ] } ] },