From 200cb00e6a01a5bf888029a0f9c953fbc156df72 Mon Sep 17 00:00:00 2001 From: DorianGrey Date: Thu, 20 Jul 2017 12:32:37 +0200 Subject: [PATCH] @ngrx/* update, including required code adoptions. --- docs/app_state.md | 81 +++++++------ package.json | 8 +- src/app/+lazy-test/lazy-test.module.ts | 12 +- src/app/+lazy-test/lazy-test.service.ts | 14 +-- src/app/+lazy-test/lazy-test.store.ts | 92 ++++++++++++-- src/app/app.component.ts | 11 +- src/app/app.imports.ts | 10 +- src/app/app.module.ts | 15 ++- src/app/app.store.ts | 154 +++++++++++++++--------- src/app/i18n/language.store.ts | 69 ++++++++--- src/app/todos/todo.service.spec.ts | 30 +++-- src/app/todos/todo.service.ts | 13 +- src/app/todos/todos.module.ts | 3 +- src/app/todos/todos.store.ts | 82 ++++++++++--- webpack/dll.config.js | 1 - yarn.lock | 26 ++-- 16 files changed, 416 insertions(+), 205 deletions(-) diff --git a/docs/app_state.md b/docs/app_state.md index 44c3a8e..918d0ca 100644 --- a/docs/app_state.md +++ b/docs/app_state.md @@ -1,9 +1,10 @@ # The application state -The application's globally relevant state is stored using a store from the [ngrx/store](https://github.com/ngrx/store) library. It's content is described in `src/app.store.ts`, in the interface `AppState`: +The application's core state is stored using a store from the [ngrx/store](https://github.com/ngrx/store) library. It's content is described in `src/app.store.ts`, in the interface `AppState`: - export interface AppState { - todos: List; - watchTime: number; + export interface CoreAppState { + todos: TodoState; + language: LanguageState; + router: RouterReducerState; } It is not required to define an interface for describing your application state, but it simplifies type safe injection of the application's store. E.g., in `src/todos/todo.service.ts`, you will see an injection like this: @@ -14,14 +15,15 @@ Without defining an explicit interface, it would be required to use `any` here. To properly work with your application state during runtime, you need to define a set of `reducers` for each of its entries. In our case, this is also defined in `src/app.store.ts`: - const reducers = { - todos: todosReducer, - watchTime: watchTimeReducer + export const reducers: ActionReducerMap = { + todos: todosReducer, + language: languageReducer, + router: routerReducer }; -This hash of reducers gets combined to a single root reducer using the `combineReducers` helper function from `ngrx/store`. +This hash of reducers gets combined to a single root reducer using `StoreModule.forRoot` (see `src/app/app.imports.ts`). -You might have recognized that the name of the keys used in the reducers object and the interface definition is equivalent. This is *intended*, since it simplifies to understand how an entry of the interface is mapped to its counterpart in the reducers objects. We strongly recommend to keep follow this convention. +You might have recognized the `ActionReducerMap` interface. Technically, it's a helper to ensure that the hash contains a reducer for exactly every field of `CoreAppState` - if you don't, the transpiler will comply and crash you build. Please read the source documentation in `src/app.store.ts` to get more detailed information about the values and structures used in there. @@ -30,17 +32,32 @@ Extending the application's state is easier than it appears - we'll go through t First, you should create a `[component-or-module-name].store.ts` along the component or module file that this part of the application state belongs to or is primarily used by. State parts that do not refer to a particular component or module should be added to the global definitions in `src/app.store.ts`. -To properly deal with the state itself, you need to define a list of actions that may alter that state. It most cases, it is sufficient to use an enum or a hash with string fields inside. E.g., for the `todos` page, we've use the latter version like: +To properly deal with the state itself, you need to define a list of actions that may alter that state. First of all, you need to define some named actions that illustrate the potential modifications. An example from the `todos` part of the store: - export const ACTION_TYPES = { + const ACTION_TYPES = { ADD_TODO: "ADD_TODO" }; - -In case of the hash strategy, don't forget to properly freeze this object to prevent its accidental modification: - Object.freeze(ACTION_TYPES); - -This is not required for (const) enums, since they cannot be modified during runtime. However, this might need some more `.toString()` calls, since the actions dispatched by the stored have to be identified by strings. +To get the most out of types, it is recommended to use classes to represent you actions. The `type` should be turned into a `readonly` field to avoid accidental modification. + + export class AddTodoAction implements Action { + readonly type = ACTION_TYPES.ADD_TODO; + constructor(public payload: Todo) {} + } +Please note that since ngrx v4, the `Action` interface no longer contains a `payload`, so you have to define it yourself as illustrated below. However, it is recommended to stick to this naming convention. + +Next, define a type alias with a union of all potential action types to properly type you reducer. Example from `src/app/todos/todos.store.ts`: + + export type TodoActions = AddTodoAction | CompleteTodoAction; + +Furthermore, you should define an interface or a type alias describing your state part's type. + + export interface State { + current: List; + completed: List; + } + +This simplifies defining your reducer (later) and composing your part with the others. Next, you need to define an initial value for your state. This value will be used when your application gets started, before the first dispatch is executed. In the example above, we've used an empty list of `Todo` entries: @@ -50,9 +67,9 @@ For those who argued: This template uses [immutable-js](https://facebook.github. Go ahead with defining a proper `reducer` for your state. A `reducer` receives two parameters: - The current state for the particular entry. -- The requested action. This parameters contains two fields: - - `type` is one of your defined actions names. In the example above, `"ADD_TODO"` would be the only possible value. Take care that you define your initial state as the default value of this parameter to get things to properly work on startup and hot reload. - - `payload` is an optional value referring to this action. In the example above, this would be a `Todo` instance that should be added to the list of todos. +- The requested action. If you have followed the convention above when defining your actions, it will have some fields: + - `type` is one of your defined action's name. In the example above, `"ADD_TODO"` would be the only possible value. Take care that you define your initial state as the default value of this parameter to get things to properly work on startup and hot reload. + - `payload` is an optional value referring to this action. In the example above, this would be a `Todo` instance that should be added to the list of todos. Please keep in mind that you might have to type-cast this field, since you're asserting a union type. The reducer is responsible for properly evaluating these parameters and - in case it accepts the provided action type - returning a new application state. If you want things to work in a reasonable manner, you should take care of two aspects: 1. **Never** alter the state that is provided here! @@ -60,33 +77,25 @@ The reducer is responsible for properly evaluating these parameters and - in cas If you want to omit at least one of these aspects... don't tell anyone you've not been warned. -In the example mentioned above, the reducer looks like: +In the example mentioned above, the reducer looks like (make sure to export it as a function to remain AoT conforming): - export const todosReducer: ActionReducer = (state: List = initialTodoList, action: Action) => { + export function todosReducer( + state: State = initialTodoList, action: TodoActions + ): State { switch (action.type) { case ACTION_TYPES.ADD_TODO: return state.push(action.payload); default: return state; - } - }; - + } + As the last step, add your state part to the global `AppState` definition and the corresponding reducer to the global one. Oh, and take care that your reducer and your set of action types is properly exported, to be usable outside of the definition file. Once you did all this stuff, you are ready to select your new state part from the injected `Store` instance: store.select(state => state.todos) -# Optional: Use action creators - -While exploring the file that we picked the examples from, you might have recognized a so-called `ActionCreator`: - - export class TodoActionCreator { - add: (todo: Todo) => Action = todo => { - return {type: ACTION_TYPES.ADD_TODO, payload: todo}; - }; - } - -First of all, using these constructs is entirely optional. However, it simplifies the process of creating mutation actions for your stated, since it hides the concrete structure of the action that gets dispatched by the store. Also, it adds some expressiveness. +# State extension using feature modules -If you decide to use these, don't forget to add them as providers, so that you can properly inject them. Alternatively, since the action creators themselves do not contain any kind of state, you can boil them down to simple helper functions placed in your module. \ No newline at end of file +Since ngrx v4, it is possible to extend the application store during runtime when loading feature modules. Please see +`src/app/+lazy-test/lazy-test.store.ts` and `src/app/+lazy-test/lazy-test.module.ts` for further details and explanations - the steps are almost equivalent to the ones for extending the core state, except that the definition must not be added to the root in `app.store.ts`. \ No newline at end of file diff --git a/package.json b/package.json index ca0133e..abc1266 100644 --- a/package.json +++ b/package.json @@ -61,7 +61,7 @@ "@angular/platform-server": "^4.3.4", "@angularclass/hmr": "^2.1.3", "@angularclass/hmr-loader": "^3.0.4", - "@ngrx/store-devtools": "^3.2.4", + "@ngrx/store-devtools": "^4.0.0", "@ngtools/webpack": "^1.6.0", "@types/jasmine": "^2.5.53", "@types/lodash-es": "^4.14.6", @@ -135,9 +135,8 @@ "@angular/platform-browser": "^4.3.4", "@angular/platform-browser-dynamic": "^4.3.4", "@angular/router": "^4.3.4", - "@ngrx/core": "1.2.0", - "@ngrx/router-store": "^1.2.6", - "@ngrx/store": "^2.2.3", + "@ngrx/router-store": "^4.0.4", + "@ngrx/store": "^4.0.3", "@ngx-translate/core": "^7.2.0", "@types/lodash": "^4.14.72", "angular-router-loader": "^0.6.0", @@ -145,7 +144,6 @@ "immutable": "3.8.1", "lodash-es": "^4.17.4", "normalize.css": "^7.0.0", - "reselect": "^3.0.1", "rxjs": "5.4.2", "zone.js": "^0.8.16" }, diff --git a/src/app/+lazy-test/lazy-test.module.ts b/src/app/+lazy-test/lazy-test.module.ts index 4cc2c0f..cd1c170 100644 --- a/src/app/+lazy-test/lazy-test.module.ts +++ b/src/app/+lazy-test/lazy-test.module.ts @@ -4,12 +4,18 @@ import { SharedModule } from "../shared/shared.module"; import { LAZY_TEST_ROUTES } from "./lazy-test.routes"; import { LazyTestComponent } from "./lazy-test.component"; -import { LazyTestActionCreator } from "./lazy-test.store"; +import { LAZY_TEST_FEATURE_NAME, watchTimeReducer } from "./lazy-test.store"; + import { LazyTestService } from "./lazy-test.service"; +import { StoreModule } from "@ngrx/store"; @NgModule({ - imports: [LAZY_TEST_ROUTES, SharedModule], + imports: [ + LAZY_TEST_ROUTES, + SharedModule, + StoreModule.forFeature(LAZY_TEST_FEATURE_NAME, watchTimeReducer) + ], declarations: [LazyTestComponent], - providers: [LazyTestService, LazyTestActionCreator] + providers: [LazyTestService] }) export class LazyTestModule {} diff --git a/src/app/+lazy-test/lazy-test.service.ts b/src/app/+lazy-test/lazy-test.service.ts index 72fe066..f38fcb5 100644 --- a/src/app/+lazy-test/lazy-test.service.ts +++ b/src/app/+lazy-test/lazy-test.service.ts @@ -2,21 +2,21 @@ import { Injectable } from "@angular/core"; import { Store } from "@ngrx/store"; import { Observable } from "rxjs/Observable"; -import { AppState, getWatchTime } from "../app.store"; -import { LazyTestActionCreator } from "./lazy-test.store"; +import { + getWatchTime, + LazyTestStateSlice, + IncrementSecondsAction +} from "./lazy-test.store"; @Injectable() export class LazyTestService { watchTime: Observable; - constructor( - private store: Store, - private actionCreator: LazyTestActionCreator - ) { + constructor(private store: Store) { this.watchTime = this.store.select(getWatchTime); } updateSeconds() { - this.store.dispatch(this.actionCreator.increaseWatchTimeSecond()); + this.store.dispatch(new IncrementSecondsAction()); } } diff --git a/src/app/+lazy-test/lazy-test.store.ts b/src/app/+lazy-test/lazy-test.store.ts index ea966b5..8dfd88d 100644 --- a/src/app/+lazy-test/lazy-test.store.ts +++ b/src/app/+lazy-test/lazy-test.store.ts @@ -1,27 +1,97 @@ -import { Action, ActionReducer } from "@ngrx/store"; +// tslint:disable max-classes-per-file +import { Action } from "@ngrx/store"; -export const LAZY_TEST_ACTION_TYPES = { +/** + * Define a set of constants that represent the actions that + * your locally defined reducer can deal with. + * + * Note: As long as you are using strongly-typed actions using + * classes as illustrated in the definitions below, there is + * no need to export this definition. If you ever want to do so, + * it is recommended to freeze it using `Object.freeze`. + */ +const LAZY_TEST_ACTION_TYPES = { INC_SECONDS: "INC_SECONDS" }; -Object.freeze(LAZY_TEST_ACTION_TYPES); +/** + * Since @ngrx/store v4, the `Action` interface no longer contains a `payload` + * field. To get the best out of action definitions, it is recommended + * to use classes for them - this breaking change makes code tend towards + * this practice. + * It is considered best practice to override the `type` field with a readonly + * one, which carries an entry of the definition set on top of this file. + * If you want add a field containing your payload, just use "payload" for + * conventionally naming it. + * + * Using these kind of actions is pretty straight forward: + * + * store.dispatch(new YourAction(yourOptionalPayload)) + */ +export class IncrementSecondsAction implements Action { + readonly type = LAZY_TEST_ACTION_TYPES.INC_SECONDS; +} + +/** + * Put all of your actions together in a union type to optimize you reducer's accepted + * action type. It's trivial in this case, but might get more complicated later on. + * + * Feel free to add more actions if required. + */ +export type LazyTestActions = IncrementSecondsAction; + +/** + * Export a type alias or interface describing the type of the store-part you're defining + * here. It's recommended in general to do so for more complex entries, and I recommend + * it as well for more simple structures to ensure consistency. + */ +export type State = number; + +/** + * This file defines the utility required for a store entry added by a feature module. + * The name of this feature has to be provided as a string constant, which will turn + * into the name of the store entry. + * Names like this should be defined in the definition of the store part, so that anyone + * accessing it from external can just pick up the constant and has no need to duplicate + * this value. + */ +export const LAZY_TEST_FEATURE_NAME = "watchTime"; -export class LazyTestActionCreator { - increaseWatchTimeSecond: () => Action = () => { - return { type: LAZY_TEST_ACTION_TYPES.INC_SECONDS }; - }; +/** + * The slice of the application state defined by this module. + * One this module got loaded, the fields defined by it will + * be added to the application's store, i.e. the resulting + * state is of type {CoreAppState & LazyTestStateSlice}. + * + * Keep in mind that the name defined here has to match the content of the constant + * above - otherwise, this value won't be found during runtime. + */ +export interface LazyTestStateSlice { + watchTime: State; } +/** + * A pre-defined selector for retrieving the `watchTime` from the application's state. + */ +export const getWatchTime = (state: LazyTestStateSlice) => state.watchTime; + +/** + * The initial state for this reducer. Used in the reducer definition below. + * Note that it is also possible to define the initial state globally via `StoreModule.forRoot`. + * It's a matter of personal preference if you prefer that centralized approach or the more + * localized one used in this template. + */ const initialWatchTime = 0; -export const watchTimeReducer: ActionReducer = ( +export function watchTimeReducer( state: number = initialWatchTime, - action: Action -) => { + action: LazyTestActions +): State { switch (action.type) { case LAZY_TEST_ACTION_TYPES.INC_SECONDS: return state + 1; default: return state; } -}; +} +// tslint:enable max-classes-per-file diff --git a/src/app/app.component.ts b/src/app/app.component.ts index 15703c6..907abf4 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -2,10 +2,10 @@ import indexOf from "lodash-es/indexOf"; import { Component } from "@angular/core"; import { Store } from "@ngrx/store"; import { TranslateService } from "@ngx-translate/core"; - -import { AppState, getLanguage } from "./app.store"; import { Observable } from "rxjs/Observable"; -import { LangActionCreator } from "./i18n/language.store"; + +import { CoreAppState, getLanguage } from "./app.store"; +import { SetLanguageAction } from "./i18n/language.store"; @Component({ selector: "app-root", @@ -19,8 +19,7 @@ export class AppComponent { constructor( private translate: TranslateService, - private store: Store, - private langCreator: LangActionCreator + private store: Store ) { this.currentLanguage = this.store.select(getLanguage); this.availableLanguages = this.translate.getLangs(); @@ -33,7 +32,7 @@ export class AppComponent { this.availableLanguages.length; const nextLang = this.availableLanguages[idx]; this.translate.use(nextLang); - this.store.dispatch(this.langCreator.setLang(nextLang)); + this.store.dispatch(new SetLanguageAction(nextLang)); }); } } diff --git a/src/app/app.imports.ts b/src/app/app.imports.ts index 8ed7351..105fdd4 100644 --- a/src/app/app.imports.ts +++ b/src/app/app.imports.ts @@ -1,7 +1,7 @@ import { BrowserModule } from "@angular/platform-browser"; import { StoreModule } from "@ngrx/store"; -import { RouterStoreModule } from "@ngrx/router-store"; +import { StoreRouterConnectingModule } from "@ngrx/router-store"; import { TranslateLoader, TranslateModule } from "@ngx-translate/core"; import { createTranslateLoader } from "./translate.factory"; @@ -10,7 +10,7 @@ import { APP_ROUTES } from "./app.routes"; import { SharedModule } from "./shared/shared.module"; import { InputTestModule } from "./input-test/input-test.module"; import { TodosModule } from "./todos/todos.module"; -import { rootReducer } from "./app.store"; +import { reducers, metaReducers } from "./app.store"; import { StoreDevtoolsModule } from "@ngrx/store-devtools"; /* @@ -30,8 +30,8 @@ const imports = [ SharedModule.forRoot(), InputTestModule, TodosModule, - StoreModule.provideStore(rootReducer), - RouterStoreModule.connectRouter() + StoreModule.forRoot(reducers, { metaReducers }), + StoreRouterConnectingModule ]; /* @@ -39,7 +39,7 @@ const imports = [ If you want to use in production as well, just remove the ENV-specific condition. */ if ("production" !== process.env.NODE_ENV) { - imports.push(StoreDevtoolsModule.instrumentOnlyWithExtension()); + imports.push(StoreDevtoolsModule.instrument({ maxAge: 50 })); } export const APP_IMPORTS = imports; diff --git a/src/app/app.module.ts b/src/app/app.module.ts index 174510d..353acd3 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -5,6 +5,7 @@ import "rxjs/add/operator/take"; import { TranslateService } from "@ngx-translate/core"; import { Store } from "@ngrx/store"; +import { RouterStateSerializer } from "@ngrx/router-store"; import { createNewHosts, @@ -14,22 +15,28 @@ import { import { AppComponent } from "./app.component"; import { appRoutingProviders } from "./app.routes"; -import { AppState, getLanguage } from "./app.store"; +import { + CoreAppState, + CustomRouterStateSerializer, + getLanguage +} from "./app.store"; import { NotFoundComponent } from "./not-found/not-found.component"; import translations from "../generated/translations"; -import { LangActionCreator } from "./i18n/language.store"; import { APP_IMPORTS } from "./app.imports"; @NgModule({ imports: APP_IMPORTS, - providers: [appRoutingProviders, LangActionCreator], + providers: [ + appRoutingProviders, + { provide: RouterStateSerializer, useClass: CustomRouterStateSerializer } + ], declarations: [NotFoundComponent, AppComponent], bootstrap: [AppComponent] }) export class AppModule { constructor( public appRef: ApplicationRef, - private _store: Store, + private _store: Store, translate: TranslateService ) { translate.addLangs(Object.keys(translations)); diff --git a/src/app/app.store.ts b/src/app/app.store.ts index 0608f93..269a10f 100644 --- a/src/app/app.store.ts +++ b/src/app/app.store.ts @@ -1,6 +1,16 @@ -import { ActionReducer, combineReducers, Action } from "@ngrx/store"; -import { routerReducer, RouterState } from "@ngrx/router-store"; -import { compose } from "@ngrx/core/compose"; +// tslint:disable max-classes-per-file +import { + ActionReducer, + Action, + ActionReducerMap, + MetaReducer +} from "@ngrx/store"; +import { + routerReducer, + RouterReducerState, + RouterStateSerializer +} from "@ngrx/router-store"; +import { createSelector } from "@ngrx/store"; /** * storeFreeze prevents state from being mutated. When mutation occurs, an * exception will be thrown. This is useful during development mode to @@ -14,67 +24,114 @@ import { todosReducer, State as TodoState } from "./todos/todos.store"; -import { watchTimeReducer } from "./+lazy-test/lazy-test.store"; -import { languageReducer } from "./i18n/language.store"; -import { createSelector } from "reselect"; + +import { languageReducer, State as LanguageState } from "./i18n/language.store"; +import { Params, RouterStateSnapshot } from "@angular/router"; /** - * This interface describes the relevant "state" of your application, + * This interface describes the core "state" of your application, * or at least what you need to restore it properly. - * When you inject a {Store} to any of your structures, it will always be a - * {Store} (as long as you follow our convention, obviously). + * When you inject a {Store} to any of your structures, it will at least + * always be a {Store} (as long as you follow our convention, obviously). + * The store might contain more entries, depending on the feature modules you have + * loaded already. * To access a particular part of your state, you might then just call * `.select(state => state.{whatever})`. This will return an {Observable}, * where {T} is the type of the partial state you selected. You might combine these * as well. */ -export interface AppState { +export interface CoreAppState { todos: TodoState; - watchTime: number; - language: string; + language: LanguageState; // This entry is NOT part of our own state, but provided by the @ngrx/router-store module. - router: RouterState; + router: RouterReducerState; } -// TODO: Should be more documented how this works... -export const getTodos = (state: AppState) => state.todos; +/** + * These constants are memoized selectors for properly subscribing on specific parts of your state. + * They come in quite handy in case you're subscribing to the same part of your state multiple times, + * esp. in case these subscriptions are more complex ones, e.g. when multiple parts get involved. + * Since these selectors can be combined with the `createSelector` function as well, it's considered + * good practice to provide these selectors as constants in all places you're defining a part of your + * application's state. + * + * A simple example: + * Before: + * `store.select(state => state.todos.current)` + * After: + * `store.select(getCurrentTodos)` + * + * This works because the "todos" state provides a selector `currentTodos` to select the `.current` + * field from itself, and the global level provides a selector to pick up `todos` from the application's + * state => selector composition. + */ +export const getTodos = (state: CoreAppState) => state.todos; export const getCurrentTodos = createSelector(getTodos, currentTodos); export const getCompletedTodos = createSelector(getTodos, completedTodos); -export const getWatchTime = (state: AppState) => state.watchTime; -export const getLanguage = (state: AppState) => state.language; -export const getRouterState = (state: AppState) => state.router; +export const getLanguage = (state: CoreAppState) => state.language; +export const getRouterState = (state: CoreAppState) => state.router; /** - * These reducers in this object refer to the {AppState} mentioned above. + * These reducers in this object refer to the {CoreAppState} mentioned above. * Our conventions - which is also preferred by most projects out there - is: - * - There should be exactly one reducer per entry in your {AppState}. This optimizes usability and maintainability. - * - The reducers registered here should be listed with the same key as the corresponding entry in {AppState} has. + * - There should be exactly one reducer per entry in your {CoreAppState}. This optimizes usability and maintainability. + * - The reducers registered here should be listed with the same key as the corresponding entry in {CoreAppState} has. * This simplifies relation management. */ -const reducers = { +export const reducers: ActionReducerMap = { todos: todosReducer, - watchTime: watchTimeReducer, language: languageReducer, // This entry is NOT part of our own state, but provided by the @ngrx/router-store module. router: routerReducer }; +export interface CustomRouterStateInfo { + url: string; + queryParams: Params; + params: Params; +} + +/** + * Provide a stripped-down serializer to properly get devtools and store-freeze working properly. + */ +export class CustomRouterStateSerializer + implements RouterStateSerializer { + serialize(routerState: RouterStateSnapshot): CustomRouterStateInfo { + return { + url: routerState.url, + params: routerState.root.params, + queryParams: routerState.root.queryParams + }; + } +} + // Generate a reducer to set the root state in dev mode for HMR +/** + * This action is intended for setting the application's state with a single shot. + * See below for further details. + */ +class StateSetterAction implements Action { + readonly type = "SET_ROOT_STATE"; + + constructor(public payload: CoreAppState) {} +} + /** * This reducer is used to provide the ability to set the "root state" with a single call, - * i.e. the payload forwarded to this function contains the whole {AppState}. - * While this is not useful in general, it is essentially required for proper HMR, which is used - * in development mode. - * It will be composed with the reducers listed above. + * i.e. the payload forwarded to this function contains the whole {CoreAppState}. + * As a so-called "meta-reducer", it only reacts on particular actions and forwards every other + * to the nested reducer (which might be another meta-reducer). + * While setting the whole state at once is not useful in general, it is essentially required for proper HMR, + * which is used in development mode. * @param reducer The inner reducer, which will receive the state and action values in case the * outer reducer does not consume the action - i.e. in every case apart from SET_ROOT_STATE. - * @returns {(state:any, action:Action)=>(any|any)} + * @returns {ActionReducer} */ -function stateSetter(reducer: ActionReducer): ActionReducer { +export function stateSetter(reducer: ActionReducer): ActionReducer { "use strict"; - return function(state: any, action: Action) { + return function(state: any, action: StateSetterAction) { if (action.type === "SET_ROOT_STATE") { return action.payload; } @@ -82,33 +139,14 @@ function stateSetter(reducer: ActionReducer): ActionReducer { }; } -// Feel free to add more reducers for development mode to this list, e.g. https://github.com/ngrx/store-log-monitor. -const DEV_REDUCERS = [stateSetter, storeFreeze]; -// This reducer is only used in development mode and is the result of a composition of DEV_REDUCERS and -// the reducers that are related to your {AppState}. -const developmentReducer: ActionReducer = compose( - ...DEV_REDUCERS, - combineReducers -)(reducers) as ActionReducer; -// This reducer is only used in production mode and is just a combination of the once your provided for your {AppState}. -const productionReducer: ActionReducer = combineReducers(reducers); - /** - * Regularly, the "reducer" may a function value. However, due to AoT restrictions, - * we have to explicitly provide a function here. - * @param state The current state. - * @param action The action to evaluate. - * @returns {any} The result state. + * For dev mode, the regular reducers should be used in conjunction with "meta" reducers, + * in our case `stateSetter` and `storeFreeze`. + * TODO: `storeFreeze` temporarily disabled, since it causes errors with zone.js. + * See https://github.com/ngrx/platform/blob/1bbd5bfa1e646eb42a34e8c9d1904f15f9173ed6/docs/store/api.md#meta-reducers + * + * The meta-reducers provided in this list get composed from right to left. If the list is empty, + * no meta-reducer will be generated during runtime. */ -export function rootReducer(state: any, action: any): any { - // It might seem ineffective to evaluate this statement multiple times. - // Don't worry: "process.env.NODE_ENV" is provided by webpack's EnvironmentPlugin, so it is treated as a constant value. - // Thus, the result of this expression is also constant. - // As a result, webpack can use it as a condition to drop some parts of the code w.r.t. to the result - // of this expression. - if ("production" !== process.env.NODE_ENV) { - return developmentReducer(state, action); - } else { - return productionReducer(state, action); - } -} +export const metaReducers: Array> = + process.env.NODE_ENV !== "production" ? [stateSetter, storeFreeze] : []; diff --git a/src/app/i18n/language.store.ts b/src/app/i18n/language.store.ts index 1b9c374..3219b5a 100644 --- a/src/app/i18n/language.store.ts +++ b/src/app/i18n/language.store.ts @@ -1,30 +1,71 @@ -import { Action, ActionReducer } from "@ngrx/store"; +// tslint:disable max-classes-per-file +import { Action } from "@ngrx/store"; -export const LANGUAGE_ACTION_TYPES = { +/** + * Define a set of constants that represent the actions that + * your locally defined reducer can deal with. + * + * Note: As long as you are using strongly-typed actions using + * classes as illustrated in the definitions below, there is + * no need to export this definition. If you ever want to do so, + * it is recommended to freeze it using `Object.freeze`. + */ +const LANGUAGE_ACTION_TYPES = { SET_LANG: "SET_LANG" }; -Object.freeze(LANGUAGE_ACTION_TYPES); +/** + * Since @ngrx/store v4, the `Action` interface no longer contains a `payload` + * field. To get the best out of action definitions, it is recommended + * to use classes for them - this breaking change makes code tend towards + * this practice. + * It is considered best practice to override the `type` field with a readonly + * one, which carries an entry of the definition set on top of this file. + * Using the name "payload" for the data assigned to this action is a convention + * which should not be violated. + * + * Using these kind of actions is pretty straight forward: + * + * store.dispatch(new YourAction(yourOptionalPayload)) + */ +export class SetLanguageAction implements Action { + readonly type = LANGUAGE_ACTION_TYPES.SET_LANG; -export class LangActionCreator { - setLang: (lang: string) => Action = lang => { - return { - type: LANGUAGE_ACTION_TYPES.SET_LANG, - payload: lang - }; - }; + constructor(public payload: string) {} } +/** + * Put all of your actions together in a union type to optimize you reducer's accepted + * action type. It's trivial in this case, but might get more complicated later on. + * + * Feel free to add more actions if required. + */ +export type LanguageActions = SetLanguageAction; + +/** + * Export a type alias or interface describing the type of the store-part you're defining + * here. It's recommended in general to do so for more complex entries, and I recommend + * it as well for more simple structures to ensure consistency. + */ +export type State = string; + +/** + * The initial state for this reducer. Used in the reducer definition below. + * Note that it is also possible to define the initial state globally via `StoreModule.forRoot`. + * It's a matter of personal preference if you prefer that centralized approach or the more + * localized one used in this template. + */ const initialLang = "en"; -export const languageReducer: ActionReducer = ( +export function languageReducer( state: string = initialLang, - action: Action -) => { + action: LanguageActions +): State { switch (action.type) { case LANGUAGE_ACTION_TYPES.SET_LANG: return action.payload as string; default: return state; } -}; +} +// tslint:enable max-classes-per-file diff --git a/src/app/todos/todo.service.spec.ts b/src/app/todos/todo.service.spec.ts index 310fa15..8d381a3 100644 --- a/src/app/todos/todo.service.spec.ts +++ b/src/app/todos/todo.service.spec.ts @@ -1,7 +1,7 @@ import { TestBed, inject } from "@angular/core/testing"; import { TodoService } from "./todo.service"; import { StoreModule, Store } from "@ngrx/store"; -import { todosReducer, TodoActionCreator, ACTION_TYPES } from "./todos.store"; +import { todosReducer, AddTodoAction, CompleteTodoAction } from "./todos.store"; import { Observable } from "rxjs/Observable"; import "rxjs/add/operator/do"; import { List } from "immutable"; @@ -11,9 +11,19 @@ describe("TodoService", () => { beforeEach(() => { TestBed.configureTestingModule({ - providers: [TodoService, TodoActionCreator], + providers: [TodoService], imports: [ - StoreModule.provideStore({ todos: todosReducer }, { todos: List.of() }) + StoreModule.forRoot( + { todos: todosReducer }, + { + initialState: { + todos: { + current: List.of(), + completed: List.of() + } + } + } + ) ] }); }); @@ -46,10 +56,9 @@ describe("TodoService", () => { todoService.add({ text: "Some Task" }); - expect(store.dispatch).toHaveBeenCalledWith({ - type: ACTION_TYPES.ADD_TODO, - payload: { text: "Some Task" } - }); + expect(store.dispatch).toHaveBeenCalledWith( + new AddTodoAction({ text: "Some Task" }) + ); }) ); }); @@ -62,10 +71,9 @@ describe("TodoService", () => { todoService.complete({ text: "Some Task" }); - expect(store.dispatch).toHaveBeenCalledWith({ - type: ACTION_TYPES.COMPLETE_TODO, - payload: { text: "Some Task" } - }); + expect(store.dispatch).toHaveBeenCalledWith( + new CompleteTodoAction({ text: "Some Task" }) + ); }) ); }); diff --git a/src/app/todos/todo.service.ts b/src/app/todos/todo.service.ts index 819c5e0..33cabd1 100644 --- a/src/app/todos/todo.service.ts +++ b/src/app/todos/todo.service.ts @@ -3,28 +3,25 @@ import { Store } from "@ngrx/store"; import { Observable } from "rxjs/Observable"; import { List } from "immutable"; -import { TodoActionCreator } from "./todos.store"; +import { AddTodoAction, CompleteTodoAction } from "./todos.store"; import { Todo } from "./todo.model"; -import { AppState, getCompletedTodos, getCurrentTodos } from "../app.store"; +import { CoreAppState, getCompletedTodos, getCurrentTodos } from "../app.store"; @Injectable() export class TodoService { todos: Observable>; completedTodos: Observable>; - constructor( - private store: Store, - private actionCreator: TodoActionCreator - ) { + constructor(private store: Store) { this.todos = this.store.select(getCurrentTodos); this.completedTodos = this.store.select(getCompletedTodos); } add(todo: Todo) { - this.store.dispatch(this.actionCreator.add(todo)); + this.store.dispatch(new AddTodoAction(todo)); } complete(todo: Todo) { - this.store.dispatch(this.actionCreator.complete(todo)); + this.store.dispatch(new CompleteTodoAction(todo)); } } diff --git a/src/app/todos/todos.module.ts b/src/app/todos/todos.module.ts index eb75622..2d8e312 100644 --- a/src/app/todos/todos.module.ts +++ b/src/app/todos/todos.module.ts @@ -3,12 +3,11 @@ import { NgModule } from "@angular/core"; import { SharedModule } from "../shared/shared.module"; import { TodoListComponent } from "./todo-list.component"; import { TodoService } from "./todo.service"; -import { TodoActionCreator } from "./todos.store"; import { TODO_ROUTES } from "./todos.routes"; @NgModule({ imports: [SharedModule, TODO_ROUTES], declarations: [TodoListComponent], - providers: [TodoService, TodoActionCreator] + providers: [TodoService] }) export class TodosModule {} diff --git a/src/app/todos/todos.store.ts b/src/app/todos/todos.store.ts index 064bcbe..e29a918 100644 --- a/src/app/todos/todos.store.ts +++ b/src/app/todos/todos.store.ts @@ -1,30 +1,80 @@ -import { Action, ActionReducer } from "@ngrx/store"; +// tslint:disable max-classes-per-file +import { Action } from "@ngrx/store"; import { List } from "immutable"; import assign from "lodash-es/assign"; import { Todo } from "./todo.model"; -export const ACTION_TYPES = { +/** + * Define a set of constants that represent the actions that + * your locally defined reducer can deal with. + * + * Note: As long as you are using strongly-typed actions using + * classes as illustrated in the definitions below, there is + * no need to export this definition. If you ever want to do so, + * it is recommended to freeze it using `Object.freeze`. + */ +const ACTION_TYPES = { ADD_TODO: "ADD_TODO", COMPLETE_TODO: "COMPLETE_TODO" }; -Object.freeze(ACTION_TYPES); +/** + * Since @ngrx/store v4, the `Action` interface no longer contains a `payload` + * field. To get the best out of action definitions, it is recommended + * to use classes for them - this breaking change makes code tend towards + * this practice. + * It is considered best practice to override the `type` field with a readonly + * one, which carries an entry of the definition set on top of this file. + * Using the name "payload" for the data assigned to this action is a convention + * which should not be violated. + * + * Using these kind of actions is pretty straight forward: + * + * store.dispatch(new YourAction(yourOptionalPayload)) + */ -export class TodoActionCreator { - add: (todo: Todo) => Action = todo => { - return { type: ACTION_TYPES.ADD_TODO, payload: todo }; - }; +export class AddTodoAction implements Action { + readonly type = ACTION_TYPES.ADD_TODO; + constructor(public payload: Todo) {} +} - complete: (todo: Todo) => Action = todo => { - return { type: ACTION_TYPES.COMPLETE_TODO, payload: todo }; - }; +export class CompleteTodoAction implements Action { + readonly type = ACTION_TYPES.COMPLETE_TODO; + constructor(public payload: Todo) {} } +/** + * Put all of your actions together in a union type to optimize you reducer's accepted + * action type. + * + * Feel free to add more actions if required. + */ +export type TodoActions = AddTodoAction | CompleteTodoAction; + +/** + * Export a type alias or interface describing the type of the store-part you're defining + * here. It's recommended in general to do so for more complex entries, and I recommend + * it as well for more simple structures to ensure consistency. + */ export interface State { current: List; completed: List; } +/** + * These selectors are used for composing selection in more complex states. + * + * @see app.store.ts for a more detailed explanation. + */ +export const currentTodos = (state: State) => state.current; +export const completedTodos = (state: State) => state.completed; + +/** + * The initial state for this reducer. Used in the reducer definition below. + * Note that it is also possible to define the initial state globally via `StoreModule.forRoot`. + * It's a matter of personal preference if you prefer that centralized approach or the more + * localized one used in this template. + */ const initialTodoList: State = { current: List.of(), completed: List.of({ @@ -34,13 +84,10 @@ const initialTodoList: State = { }) }; -export const currentTodos = (state: State) => state.current; -export const completedTodos = (state: State) => state.completed; - -export const todosReducer: ActionReducer = ( +export function todosReducer( state: State = initialTodoList, - action: Action -) => { + action: TodoActions +): State { switch (action.type) { case ACTION_TYPES.ADD_TODO: return assign( @@ -72,4 +119,5 @@ export const todosReducer: ActionReducer = ( default: return state; } -}; +} +// tslint:enable max-classes-per-file diff --git a/webpack/dll.config.js b/webpack/dll.config.js index 364c321..b41e154 100644 --- a/webpack/dll.config.js +++ b/webpack/dll.config.js @@ -53,7 +53,6 @@ module.exports = { "@angular/platform-browser", "@angular/platform-browser-dynamic", "@angular/router", - "@ngrx/core", "@ngrx/store", "@ngrx/router-store", "immutable", diff --git a/yarn.lock b/yarn.lock index 057f93a..7a2daf7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -90,21 +90,17 @@ version "2.1.3" resolved "https://registry.yarnpkg.com/@angularclass/hmr/-/hmr-2.1.3.tgz#34e658ed3da37f23b0a200e2da5a89be92bb209f" -"@ngrx/core@1.2.0": - version "1.2.0" - resolved "https://registry.yarnpkg.com/@ngrx/core/-/core-1.2.0.tgz#882b46abafa2e0e6d887cb71a1b2c2fa3e6d0dc6" - -"@ngrx/router-store@^1.2.6": - version "1.2.6" - resolved "https://registry.yarnpkg.com/@ngrx/router-store/-/router-store-1.2.6.tgz#a2eb0ca515e9b367781f1030250dd64bb73c086b" +"@ngrx/router-store@^4.0.4": + version "4.0.4" + resolved "https://registry.yarnpkg.com/@ngrx/router-store/-/router-store-4.0.4.tgz#ab59f35aae93465088384faf009e21b22edd456a" -"@ngrx/store-devtools@^3.2.4": - version "3.2.4" - resolved "https://registry.yarnpkg.com/@ngrx/store-devtools/-/store-devtools-3.2.4.tgz#2ce4d13bf34848a9e51ec87e3b125ed67b51e550" +"@ngrx/store-devtools@^4.0.0": + version "4.0.0" + resolved "https://registry.yarnpkg.com/@ngrx/store-devtools/-/store-devtools-4.0.0.tgz#b79c24773217df7fd9735ad21f9cbf2533c96e04" -"@ngrx/store@^2.2.3": - version "2.2.3" - resolved "https://registry.yarnpkg.com/@ngrx/store/-/store-2.2.3.tgz#e7bd1149f1c44208f1cc4744353f0f98a0f1f57b" +"@ngrx/store@^4.0.3": + version "4.0.3" + resolved "https://registry.yarnpkg.com/@ngrx/store/-/store-4.0.3.tgz#36abacdfa19bfb8506e40de80bae06050a1e15e9" "@ngtools/webpack@^1.6.0": version "1.6.0" @@ -5499,10 +5495,6 @@ requires-port@1.0.x, requires-port@1.x.x: version "1.0.0" resolved "https://registry.yarnpkg.com/requires-port/-/requires-port-1.0.0.tgz#925d2601d39ac485e091cf0da5c6e694dc3dcaff" -reselect@^3.0.1: - version "3.0.1" - resolved "https://registry.yarnpkg.com/reselect/-/reselect-3.0.1.tgz#efdaa98ea7451324d092b2b2163a6a1d7a9a2147" - resolve-from@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-3.0.0.tgz#b22c7af7d9d6881bc8b6e653335eebcb0a188748"