diff --git a/modules/store-devtools/src/devtools.ts b/modules/store-devtools/src/devtools.ts index 4fc485b374..114aa8741d 100644 --- a/modules/store-devtools/src/devtools.ts +++ b/modules/store-devtools/src/devtools.ts @@ -21,7 +21,12 @@ import * as Actions from './actions'; import { STORE_DEVTOOLS_CONFIG, StoreDevtoolsConfig } from './config'; import { DevtoolsExtension } from './extension'; import { LiftedState, liftInitialState, liftReducerWith } from './reducer'; -import { liftAction, unliftState, shouldFilterActions, filterLiftedState } from './utils'; +import { + liftAction, + unliftState, + shouldFilterActions, + filterLiftedState, +} from './utils'; import { DevtoolsDispatcher } from './devtools-dispatcher'; import { PERFORM_ACTION } from './actions'; @@ -73,25 +78,25 @@ export class StoreDevtools implements Observer { state: LiftedState; action: any; } - >( - ({ state: liftedState }, [action, reducer]) => { - let reducedLiftedState = reducer(liftedState, action); - // On full state update - // If we have actions filters, we must filter completly our lifted state to be sync with the extension - if (action.type !== PERFORM_ACTION && shouldFilterActions(config)) { - reducedLiftedState = filterLiftedState( - reducedLiftedState, - config.predicate, - config.actionsWhitelist, - config.actionsBlacklist - ); - } - // Extension should be sent the sanitized lifted state - extension.notify(action, reducedLiftedState); - return { state: reducedLiftedState, action }; - }, - { state: liftedInitialState, action: null as any } - ) + >( + ({ state: liftedState }, [action, reducer]) => { + let reducedLiftedState = reducer(liftedState, action); + // On full state update + // If we have actions filters, we must filter completly our lifted state to be sync with the extension + if (action.type !== PERFORM_ACTION && shouldFilterActions(config)) { + reducedLiftedState = filterLiftedState( + reducedLiftedState, + config.predicate, + config.actionsWhitelist, + config.actionsBlacklist + ); + } + // Extension should be sent the sanitized lifted state + extension.notify(action, reducedLiftedState); + return { state: reducedLiftedState, action }; + }, + { state: liftedInitialState, action: null as any } + ) ) .subscribe(({ state, action }) => { liftedStateSubject.next(state); @@ -109,7 +114,7 @@ export class StoreDevtools implements Observer { const liftedState$ = liftedStateSubject.asObservable() as Observable< LiftedState - >; + >; const state$ = liftedState$.pipe(map(unliftState)); this.extensionStartSubscription = extensionStartSubscription; @@ -127,9 +132,9 @@ export class StoreDevtools implements Observer { this.dispatcher.next(action); } - error(error: any) { } + error(error: any) {} - complete() { } + complete() {} performAction(action: any) { this.dispatch(new Actions.PerformAction(action, +Date.now())); diff --git a/modules/store/spec/BUILD b/modules/store/spec/BUILD index 729814e0f7..9895a5cd3f 100644 --- a/modules/store/spec/BUILD +++ b/modules/store/spec/BUILD @@ -12,6 +12,7 @@ ts_test_library( "//modules/store", "//modules/store/testing", "@npm//rxjs", + "@npm//ts-snippet", ], ) diff --git a/modules/store/spec/action_creator.spec.ts b/modules/store/spec/action_creator.spec.ts new file mode 100644 index 0000000000..802fffa755 --- /dev/null +++ b/modules/store/spec/action_creator.spec.ts @@ -0,0 +1,145 @@ +import { createAction, props, union } from '@ngrx/store'; +import { expecter } from 'ts-snippet'; + +describe('Action Creators', () => { + let originalTimeout: number; + + beforeEach(() => { + originalTimeout = jasmine.DEFAULT_TIMEOUT_INTERVAL; + jasmine.DEFAULT_TIMEOUT_INTERVAL = 2000; + }); + + afterEach(() => { + jasmine.DEFAULT_TIMEOUT_INTERVAL = originalTimeout; + }); + + const expectSnippet = expecter( + code => ` + // path goes from root + import {createAction, props, union} from './modules/store/src/action_creator'; + ${code}`, + { + moduleResolution: 'node', + target: 'es2015', + } + ); + + describe('createAction', () => { + it('should create an action', () => { + const foo = createAction('FOO', (foo: number) => ({ foo })); + const fooAction = foo(42); + + expect(fooAction).toEqual({ type: 'FOO', foo: 42 }); + }); + + it('should narrow the action', () => { + const foo = createAction('FOO', (foo: number) => ({ foo })); + const bar = createAction('BAR', (bar: number) => ({ bar })); + const both = union({ foo, bar }); + const narrow = (action: typeof both) => { + if (action.type === foo.type) { + expect(action.foo).toEqual(42); + } else { + throw new Error('Should not get here.'); + } + }; + + narrow(foo(42)); + }); + + it('should be serializable', () => { + const foo = createAction('FOO', (foo: number) => ({ foo })); + const fooAction = foo(42); + const text = JSON.stringify(fooAction); + + expect(JSON.parse(text)).toEqual({ type: 'FOO', foo: 42 }); + }); + + it('should enforce ctor parameters', () => { + expectSnippet(` + const foo = createAction('FOO', (foo: number) => ({ foo })); + const fooAction = foo('42'); + `).toFail(/not assignable to parameter of type 'number'/); + }); + + it('should enforce action property types', () => { + expectSnippet(` + const foo = createAction('FOO', (foo: number) => ({ foo })); + const fooAction = foo(42); + const value: string = fooAction.foo; + `).toFail(/'number' is not assignable to type 'string'/); + }); + + it('should enforce action property names', () => { + expectSnippet(` + const foo = createAction('FOO', (foo: number) => ({ foo })); + const fooAction = foo(42); + const value = fooAction.bar; + `).toFail(/'bar' does not exist on type/); + }); + }); + + describe('empty', () => { + it('should allow empty action', () => { + const foo = createAction('FOO'); + const fooAction = foo(); + + expect(fooAction).toEqual({ type: 'FOO' }); + }); + }); + + describe('props', () => { + it('should create an action', () => { + const foo = createAction('FOO', props<{ foo: number }>()); + const fooAction = foo({ foo: 42 }); + + expect(fooAction).toEqual({ type: 'FOO', foo: 42 }); + }); + + it('should narrow the action', () => { + const foo = createAction('FOO', props<{ foo: number }>()); + const bar = createAction('BAR', props<{ bar: number }>()); + const both = union({ foo, bar }); + const narrow = (action: typeof both) => { + if (action.type === foo.type) { + expect(action.foo).toEqual(42); + } else { + throw new Error('Should not get here.'); + } + }; + + narrow(foo({ foo: 42 })); + }); + + it('should be serializable', () => { + const foo = createAction('FOO', props<{ foo: number }>()); + const fooAction = foo({ foo: 42 }); + const text = JSON.stringify(fooAction); + + expect(JSON.parse(text)).toEqual({ foo: 42, type: 'FOO' }); + }); + + it('should enforce ctor parameters', () => { + expectSnippet(` + const foo = createAction('FOO', props<{ foo: number }>()); + const fooAction = foo({ foo: '42' }); + `).toFail(/'string' is not assignable to type 'number'/); + }); + + it('should enforce action property types', () => { + expectSnippet(` + const foo = createAction('FOO', props<{ foo: number }>()); + const fooAction = foo({ foo: 42 }); + const value: string = fooAction.foo; + `).toFail(/'number' is not assignable to type 'string'/); + }); + + it('should enforce action property names', () => { + expectSnippet(` + const foo = createAction('FOO', props<{ foo: number }>()); + const fooAction = foo({ foo: 42 }); + const value = fooAction.bar; + `).toFail(/'bar' does not exist on type/); + }); + }); +}); diff --git a/modules/store/src/action_creator.ts b/modules/store/src/action_creator.ts new file mode 100644 index 0000000000..117a2aff59 --- /dev/null +++ b/modules/store/src/action_creator.ts @@ -0,0 +1,67 @@ +import { + Creator, + ActionCreator, + TypedAction, + FunctionWithParametersType, + ParametersType, +} from './models'; + +/** + * Action creators taken from ts-action library and modified a bit to better + * fit current NgRx usage. Thank you Nicholas Jamieson (@cartant). + */ +export function createAction( + type: T +): ActionCreator TypedAction>; +export function createAction( + type: T, + config: { _as: 'props'; _p: P } +): ActionCreator P & TypedAction>; +export function createAction( + type: T, + creator: C +): FunctionWithParametersType< + ParametersType, + ReturnType & TypedAction +> & + TypedAction; +export function createAction( + type: T, + config?: { _as: 'props' } | Creator +): Creator { + if (typeof config === 'function') { + return defineType(type, (...args: unknown[]) => ({ + ...config(...args), + type, + })); + } + const as = config ? config._as : 'empty'; + switch (as) { + case 'empty': + return defineType(type, () => ({ type })); + case 'props': + return defineType(type, (props: unknown) => ({ + ...(props as object), + type, + })); + default: + throw new Error('Unexpected config.'); + } +} + +export function props

(): { _as: 'props'; _p: P } { + return { _as: 'props', _p: undefined! }; +} + +export function union< + C extends { [key: string]: ActionCreator } +>(creators: C): ReturnType { + return undefined!; +} + +function defineType(type: string, creator: Creator): Creator { + return Object.defineProperty(creator, 'type', { + value: type, + writable: false, + }); +} diff --git a/modules/store/src/index.ts b/modules/store/src/index.ts index ce02914c93..be6f652a88 100644 --- a/modules/store/src/index.ts +++ b/modules/store/src/index.ts @@ -1,12 +1,15 @@ export { Action, + ActionCreator, ActionReducer, ActionReducerMap, ActionReducerFactory, + Creator, MetaReducer, Selector, SelectorWithProps, } from './models'; +export { createAction, props, union } from './action_creator'; export { Store, select } from './store'; export { combineReducers, compose, createReducerFactory } from './utils'; export { ActionsSubject, INIT } from './actions_subject'; diff --git a/modules/store/src/models.ts b/modules/store/src/models.ts index 34413a0139..069fea1093 100644 --- a/modules/store/src/models.ts +++ b/modules/store/src/models.ts @@ -2,6 +2,11 @@ export interface Action { type: string; } +// declare to make it property-renaming safe +export declare interface TypedAction extends Action { + readonly type: T; +} + export type TypeId = () => T; export type InitialState = Partial | TypeId> | void; @@ -39,3 +44,16 @@ export type SelectorWithProps = ( state: State, props: Props ) => Result; + +export type Creator = (...args: any[]) => object; + +export type ActionCreator = C & + TypedAction; + +export type FunctionWithParametersType

= ( + ...args: P +) => R; + +export type ParametersType = T extends (...args: infer U) => unknown + ? U + : never; diff --git a/package.json b/package.json index 581c32e372..088d84450a 100644 --- a/package.json +++ b/package.json @@ -37,7 +37,7 @@ "build:stackblitz": "ts-node ./build/stackblitz.ts && git add ./stackblitz.html" }, "engines": { - "node": ">=10.9.0 <11.2.0", + "node": ">=10.9.0 <=11.12.0", "npm": ">=5.3.0", "yarn": ">=1.9.2 <2.0.0" }, @@ -159,6 +159,7 @@ "sorcery": "^0.10.0", "ts-loader": "^5.3.3", "ts-node": "^5.0.1", + "ts-snippet": "^4.1.0", "tsconfig-paths": "^3.1.3", "tsickle": "^0.34.3", "tslib": "^1.7.1", diff --git a/projects/example-app/src/app/auth/actions/auth-api.actions.ts b/projects/example-app/src/app/auth/actions/auth-api.actions.ts index 48c19b3696..b21260157c 100644 --- a/projects/example-app/src/app/auth/actions/auth-api.actions.ts +++ b/projects/example-app/src/app/auth/actions/auth-api.actions.ts @@ -1,26 +1,20 @@ -import { Action } from '@ngrx/store'; +import { props, createAction } from '@ngrx/store'; import { User } from '@example-app/auth/models/user'; -export enum AuthApiActionTypes { - LoginSuccess = '[Auth/API] Login Success', - LoginFailure = '[Auth/API] Login Failure', - LoginRedirect = '[Auth/API] Login Redirect', -} +export const loginSuccess = createAction( + '[Auth/API] Login Success', + props<{ user: User }>() +); -export class LoginSuccess implements Action { - readonly type = AuthApiActionTypes.LoginSuccess; +export const loginFailure = createAction( + '[Auth/API] Login Failure', + props<{ error: any }>() +); - constructor(public payload: { user: User }) {} -} +export const loginRedirect = createAction('[Auth/API] Login Redirect'); -export class LoginFailure implements Action { - readonly type = AuthApiActionTypes.LoginFailure; - - constructor(public payload: { error: any }) {} -} - -export class LoginRedirect implements Action { - readonly type = AuthApiActionTypes.LoginRedirect; -} - -export type AuthApiActionsUnion = LoginSuccess | LoginFailure | LoginRedirect; +// This is an alternative to union() type export. Work great when you need +// to export only a single Action type. +export type AuthApiActionsUnion = ReturnType< + typeof loginSuccess | typeof loginFailure | typeof loginRedirect +>; diff --git a/projects/example-app/src/app/auth/actions/auth.actions.ts b/projects/example-app/src/app/auth/actions/auth.actions.ts index 5151d08f34..cd75a1ea76 100644 --- a/projects/example-app/src/app/auth/actions/auth.actions.ts +++ b/projects/example-app/src/app/auth/actions/auth.actions.ts @@ -1,24 +1,10 @@ -import { Action } from '@ngrx/store'; +import { createAction, union } from '@ngrx/store'; -export enum AuthActionTypes { - Logout = '[Auth] Logout', - LogoutConfirmation = '[Auth] Logout Confirmation', - LogoutConfirmationDismiss = '[Auth] Logout Confirmation Dismiss', -} +export const logout = createAction('[Auth] Logout'); +export const logoutConfirmation = createAction('[Auth] Logout Confirmation'); +export const logoutConfirmationDismiss = createAction( + '[Auth] Logout Confirmation Dismiss' +); -export class Logout implements Action { - readonly type = AuthActionTypes.Logout; -} - -export class LogoutConfirmation implements Action { - readonly type = AuthActionTypes.LogoutConfirmation; -} - -export class LogoutConfirmationDismiss implements Action { - readonly type = AuthActionTypes.LogoutConfirmationDismiss; -} - -export type AuthActionsUnion = - | Logout - | LogoutConfirmation - | LogoutConfirmationDismiss; +const all = union({ logout, logoutConfirmation, logoutConfirmationDismiss }); +export type AuthActionsUnion = typeof all; diff --git a/projects/example-app/src/app/auth/actions/login-page.actions.ts b/projects/example-app/src/app/auth/actions/login-page.actions.ts index 2a70296d7a..f881552f50 100644 --- a/projects/example-app/src/app/auth/actions/login-page.actions.ts +++ b/projects/example-app/src/app/auth/actions/login-page.actions.ts @@ -1,14 +1,9 @@ -import { Action } from '@ngrx/store'; +import { createAction, props, union } from '@ngrx/store'; import { Credentials } from '@example-app/auth/models/user'; -export enum LoginPageActionTypes { - Login = '[Login Page] Login', -} +export const login = createAction( + '[Login Page] Login', + props<{ credentials: Credentials }>() +); -export class Login implements Action { - readonly type = LoginPageActionTypes.Login; - - constructor(public payload: { credentials: Credentials }) {} -} - -export type LoginPageActionsUnion = Login; +export type LoginPageActionsUnion = ReturnType; 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 9da3433266..5ef167af53 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 @@ -57,7 +57,7 @@ describe('Login Page', () => { it('should dispatch a login event on submit', () => { const credentials: any = {}; - const action = new LoginPageActions.Login({ credentials }); + const action = LoginPageActions.login({ credentials }); instance.onSubmit(credentials); 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 f56a2577f6..4eded3bf77 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 @@ -24,6 +24,6 @@ export class LoginPageComponent implements OnInit { ngOnInit() {} onSubmit(credentials: Credentials) { - this.store.dispatch(new LoginPageActions.Login({ credentials })); + this.store.dispatch(LoginPageActions.login({ credentials })); } } diff --git a/projects/example-app/src/app/auth/effects/auth.effects.spec.ts b/projects/example-app/src/app/auth/effects/auth.effects.spec.ts index 55dd1bb1f4..79818f5675 100644 --- a/projects/example-app/src/app/auth/effects/auth.effects.spec.ts +++ b/projects/example-app/src/app/auth/effects/auth.effects.spec.ts @@ -54,11 +54,11 @@ describe('AuthEffects', () => { }); describe('login$', () => { - it('should return an auth.LoginSuccess action, with user information if login succeeds', () => { + it('should return an auth.loginSuccess action, with user information if login succeeds', () => { const credentials: Credentials = { username: 'test', password: '' }; const user = { name: 'User' } as User; - const action = new LoginPageActions.Login({ credentials }); - const completion = new AuthApiActions.LoginSuccess({ user }); + const action = LoginPageActions.login({ credentials }); + const completion = AuthApiActions.loginSuccess({ user }); actions$ = hot('-a---', { a: action }); const response = cold('-a|', { a: user }); @@ -68,10 +68,10 @@ describe('AuthEffects', () => { expect(effects.login$).toBeObservable(expected); }); - it('should return a new auth.LoginFailure if the login service throws', () => { + it('should return a new auth.loginFailure if the login service throws', () => { const credentials: Credentials = { username: 'someOne', password: '' }; - const action = new LoginPageActions.Login({ credentials }); - const completion = new AuthApiActions.LoginFailure({ + const action = LoginPageActions.login({ credentials }); + const completion = AuthApiActions.loginFailure({ error: 'Invalid username or password', }); const error = 'Invalid username or password'; @@ -88,7 +88,7 @@ describe('AuthEffects', () => { describe('loginSuccess$', () => { it('should dispatch a RouterNavigation action', (done: any) => { const user = { name: 'User' } as User; - const action = new AuthApiActions.LoginSuccess({ user }); + const action = AuthApiActions.loginSuccess({ user }); actions$ = of(action); @@ -100,8 +100,8 @@ describe('AuthEffects', () => { }); describe('loginRedirect$', () => { - it('should dispatch a RouterNavigation action when auth.LoginRedirect is dispatched', (done: any) => { - const action = new AuthApiActions.LoginRedirect(); + it('should dispatch a RouterNavigation action when auth.loginRedirect is dispatched', (done: any) => { + const action = AuthApiActions.loginRedirect(); actions$ = of(action); @@ -111,8 +111,8 @@ describe('AuthEffects', () => { }); }); - it('should dispatch a RouterNavigation action when auth.Logout is dispatched', (done: any) => { - const action = new AuthActions.Logout(); + it('should dispatch a RouterNavigation action when auth.logout is dispatched', (done: any) => { + const action = AuthActions.logout(); actions$ = of(action); @@ -125,8 +125,8 @@ describe('AuthEffects', () => { describe('logoutConfirmation$', () => { it('should dispatch a Logout action if dialog closes with true result', () => { - const action = new AuthActions.LogoutConfirmation(); - const completion = new AuthActions.Logout(); + const action = AuthActions.logoutConfirmation(); + const completion = AuthActions.logout(); actions$ = hot('-a', { a: action }); const expected = cold('-b', { b: completion }); @@ -139,8 +139,8 @@ describe('AuthEffects', () => { }); it('should dispatch a LogoutConfirmationDismiss action if dialog closes with falsy result', () => { - const action = new AuthActions.LogoutConfirmation(); - const completion = new AuthActions.LogoutConfirmationDismiss(); + const action = AuthActions.logoutConfirmation(); + const completion = AuthActions.logoutConfirmationDismiss(); actions$ = hot('-a', { a: action }); const expected = cold('-b', { b: completion }); diff --git a/projects/example-app/src/app/auth/effects/auth.effects.ts b/projects/example-app/src/app/auth/effects/auth.effects.ts index 7db06fe730..1cdbad8e19 100644 --- a/projects/example-app/src/app/auth/effects/auth.effects.ts +++ b/projects/example-app/src/app/auth/effects/auth.effects.ts @@ -17,28 +17,25 @@ import { LogoutConfirmationDialogComponent } from '@example-app/auth/components/ export class AuthEffects { @Effect() login$ = this.actions$.pipe( - ofType(LoginPageActions.LoginPageActionTypes.Login), - map(action => action.payload.credentials), + ofType(LoginPageActions.login.type), + map(action => action.credentials), exhaustMap((auth: Credentials) => this.authService.login(auth).pipe( - map(user => new AuthApiActions.LoginSuccess({ user })), - catchError(error => of(new AuthApiActions.LoginFailure({ error }))) + map(user => AuthApiActions.loginSuccess({ user })), + catchError(error => of(AuthApiActions.loginFailure({ error }))) ) ) ); @Effect({ dispatch: false }) loginSuccess$ = this.actions$.pipe( - ofType(AuthApiActions.AuthApiActionTypes.LoginSuccess), + ofType(AuthApiActions.loginSuccess.type), tap(() => this.router.navigate(['/'])) ); @Effect({ dispatch: false }) loginRedirect$ = this.actions$.pipe( - ofType( - AuthApiActions.AuthApiActionTypes.LoginRedirect, - AuthActions.AuthActionTypes.Logout - ), + ofType(AuthApiActions.loginRedirect.type, AuthActions.logout.type), tap(authed => { this.router.navigate(['/login']); }) @@ -46,7 +43,7 @@ export class AuthEffects { @Effect() logoutConfirmation$ = this.actions$.pipe( - ofType(AuthActions.AuthActionTypes.LogoutConfirmation), + ofType(AuthActions.logoutConfirmation.type), exhaustMap(() => { const dialogRef = this.dialog.open< LogoutConfirmationDialogComponent, @@ -58,9 +55,7 @@ export class AuthEffects { }), map( result => - result - ? new AuthActions.Logout() - : new AuthActions.LogoutConfirmationDismiss() + result ? AuthActions.logout() : AuthActions.logoutConfirmationDismiss() ) ); diff --git a/projects/example-app/src/app/auth/reducers/auth.reducer.spec.ts b/projects/example-app/src/app/auth/reducers/auth.reducer.spec.ts index 6b9b9846c7..1af5f3d1ad 100644 --- a/projects/example-app/src/app/auth/reducers/auth.reducer.spec.ts +++ b/projects/example-app/src/app/auth/reducers/auth.reducer.spec.ts @@ -25,7 +25,7 @@ describe('AuthReducer', () => { describe('LOGIN_SUCCESS', () => { it('should add a user set loggedIn to true in auth state', () => { const user = { name: 'test' } as User; - const createAction = new AuthApiActions.LoginSuccess({ user }); + const createAction = AuthApiActions.loginSuccess({ user }); const expectedResult = { user: { name: 'test' }, @@ -42,7 +42,7 @@ describe('AuthReducer', () => { const initialState = { user: { name: 'test' }, } as fromAuth.State; - const createAction = new AuthActions.Logout(); + const createAction = AuthActions.logout(); const expectedResult = fromAuth.initialState; diff --git a/projects/example-app/src/app/auth/reducers/auth.reducer.ts b/projects/example-app/src/app/auth/reducers/auth.reducer.ts index 797ab9f0af..461c730f55 100644 --- a/projects/example-app/src/app/auth/reducers/auth.reducer.ts +++ b/projects/example-app/src/app/auth/reducers/auth.reducer.ts @@ -14,14 +14,14 @@ export function reducer( action: AuthApiActions.AuthApiActionsUnion | AuthActions.AuthActionsUnion ): State { switch (action.type) { - case AuthApiActions.AuthApiActionTypes.LoginSuccess: { + case AuthApiActions.loginSuccess.type: { return { ...state, - user: action.payload.user, + user: action.user, }; } - case AuthActions.AuthActionTypes.Logout: { + case AuthActions.logout.type: { return initialState; } diff --git a/projects/example-app/src/app/auth/reducers/login-page.reducer.spec.ts b/projects/example-app/src/app/auth/reducers/login-page.reducer.spec.ts index c81149bbcc..628edb73d0 100644 --- a/projects/example-app/src/app/auth/reducers/login-page.reducer.spec.ts +++ b/projects/example-app/src/app/auth/reducers/login-page.reducer.spec.ts @@ -19,7 +19,7 @@ describe('LoginPageReducer', () => { describe('LOGIN', () => { it('should make pending to true', () => { const user = { username: 'test' } as Credentials; - const createAction = new LoginPageActions.Login({ credentials: user }); + const createAction = LoginPageActions.login({ credentials: user }); const expectedResult = { error: null, @@ -35,7 +35,7 @@ describe('LoginPageReducer', () => { describe('LOGIN_SUCCESS', () => { it('should have no error and no pending state', () => { const user = { name: 'test' } as User; - const createAction = new AuthApiActions.LoginSuccess({ user }); + const createAction = AuthApiActions.loginSuccess({ user }); const expectedResult = { error: null, @@ -51,7 +51,7 @@ describe('LoginPageReducer', () => { describe('LOGIN_FAILURE', () => { it('should have an error and no pending state', () => { const error = 'login failed'; - const createAction = new AuthApiActions.LoginFailure({ error }); + const createAction = AuthApiActions.loginFailure({ error }); const expectedResult = { error: error, diff --git a/projects/example-app/src/app/auth/reducers/login-page.reducer.ts b/projects/example-app/src/app/auth/reducers/login-page.reducer.ts index 8e84ab37f7..452d4b5167 100644 --- a/projects/example-app/src/app/auth/reducers/login-page.reducer.ts +++ b/projects/example-app/src/app/auth/reducers/login-page.reducer.ts @@ -17,7 +17,7 @@ export function reducer( | LoginPageActions.LoginPageActionsUnion ): State { switch (action.type) { - case LoginPageActions.LoginPageActionTypes.Login: { + case LoginPageActions.login.type: { return { ...state, error: null, @@ -25,7 +25,7 @@ export function reducer( }; } - case AuthApiActions.AuthApiActionTypes.LoginSuccess: { + case AuthApiActions.loginSuccess.type: { return { ...state, error: null, @@ -33,10 +33,10 @@ export function reducer( }; } - case AuthApiActions.AuthApiActionTypes.LoginFailure: { + case AuthApiActions.loginFailure.type: { return { ...state, - error: action.payload.error, + error: action.error, pending: false, }; } 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 c0402b1670..0537d667bd 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 @@ -17,7 +17,7 @@ export class AuthGuard implements CanActivate { select(fromAuth.getLoggedIn), map(authed => { if (!authed) { - this.store.dispatch(new AuthApiActions.LoginRedirect()); + this.store.dispatch(AuthApiActions.loginRedirect()); return false; } diff --git a/projects/example-app/src/app/books/actions/book.actions.ts b/projects/example-app/src/app/books/actions/book.actions.ts index 9926fc62cf..24c04fc9a3 100644 --- a/projects/example-app/src/app/books/actions/book.actions.ts +++ b/projects/example-app/src/app/books/actions/book.actions.ts @@ -1,14 +1,9 @@ -import { Action } from '@ngrx/store'; +import { createAction, props } from '@ngrx/store'; import { Book } from '@example-app/books/models/book'; -export enum BookActionTypes { - LoadBook = '[Book Exists Guard] Load Book', -} +export const loadBook = createAction( + '[Book Exists Guard] Load Book', + props<{ book: Book }>() +); -export class LoadBook implements Action { - readonly type = BookActionTypes.LoadBook; - - constructor(public payload: Book) {} -} - -export type BookActionsUnion = LoadBook; +export type BookActionsUnion = ReturnType; diff --git a/projects/example-app/src/app/books/actions/books-api.actions.ts b/projects/example-app/src/app/books/actions/books-api.actions.ts index e3a438ad33..4f141a4ba7 100644 --- a/projects/example-app/src/app/books/actions/books-api.actions.ts +++ b/projects/example-app/src/app/books/actions/books-api.actions.ts @@ -1,32 +1,19 @@ -import { Action } from '@ngrx/store'; +import { createAction, union, props } from '@ngrx/store'; import { Book } from '@example-app/books/models/book'; -export enum BooksApiActionTypes { - SearchSuccess = '[Books/API] Search Success', - SearchFailure = '[Books/API] Search Failure', -} +export const searchSuccess = createAction( + '[Books/API] Search Success', + props<{ books: Book[] }>() +); -/** - * Every action is comprised of at least a type and an optional - * payload. Expressing actions as classes enables powerful - * type checking in reducer functions. - * - * See Discriminated Unions: https://www.typescriptlang.org/docs/handbook/advanced-types.html#discriminated-unions - */ -export class SearchSuccess implements Action { - readonly type = BooksApiActionTypes.SearchSuccess; - - constructor(public payload: Book[]) {} -} - -export class SearchFailure implements Action { - readonly type = BooksApiActionTypes.SearchFailure; - - constructor(public payload: string) {} -} +export const searchFailure = createAction( + '[Books/API] Search Failure', + props<{ errorMsg: string }>() +); /** * Export a type alias of all actions in this action group * so that reducers can easily compose action types */ -export type BooksApiActionsUnion = SearchSuccess | SearchFailure; +const all = union({ searchSuccess, searchFailure }); +export type BooksApiActionsUnion = typeof all; diff --git a/projects/example-app/src/app/books/actions/collection-api.actions.ts b/projects/example-app/src/app/books/actions/collection-api.actions.ts index 39412f5a1c..f5165abf6a 100644 --- a/projects/example-app/src/app/books/actions/collection-api.actions.ts +++ b/projects/example-app/src/app/books/actions/collection-api.actions.ts @@ -1,64 +1,51 @@ -import { Action } from '@ngrx/store'; +import { createAction, props, union } from '@ngrx/store'; import { Book } from '@example-app/books/models/book'; -export enum CollectionApiActionTypes { - AddBookSuccess = '[Collection/API] Add Book Success', - AddBookFailure = '[Collection/API] Add Book Failure', - RemoveBookSuccess = '[Collection/API] Remove Book Success', - RemoveBookFailure = '[Collection/API] Remove Book Failure', - LoadBooksSuccess = '[Collection/API] Load Books Success', - LoadBooksFailure = '[Collection/API] Load Books Failure', -} - /** * Add Book to Collection Actions */ -export class AddBookSuccess implements Action { - readonly type = CollectionApiActionTypes.AddBookSuccess; - - constructor(public payload: Book) {} -} - -export class AddBookFailure implements Action { - readonly type = CollectionApiActionTypes.AddBookFailure; +export const addBookSuccess = createAction( + '[Collection/API] Add Book Success', + props<{ book: Book }>() +); - constructor(public payload: Book) {} -} +export const addBookFailure = createAction( + '[Collection/API] Add Book Failure', + props<{ book: Book }>() +); /** * Remove Book from Collection Actions */ -export class RemoveBookSuccess implements Action { - readonly type = CollectionApiActionTypes.RemoveBookSuccess; - - constructor(public payload: Book) {} -} - -export class RemoveBookFailure implements Action { - readonly type = CollectionApiActionTypes.RemoveBookFailure; +export const removeBookSuccess = createAction( + '[Collection/API] Remove Book Success', + props<{ book: Book }>() +); - constructor(public payload: Book) {} -} +export const removeBookFailure = createAction( + '[Collection/API] Remove Book Failure', + props<{ book: Book }>() +); /** * Load Collection Actions */ -export class LoadBooksSuccess implements Action { - readonly type = CollectionApiActionTypes.LoadBooksSuccess; - - constructor(public payload: Book[]) {} -} - -export class LoadBooksFailure implements Action { - readonly type = CollectionApiActionTypes.LoadBooksFailure; - - constructor(public payload: any) {} -} - -export type CollectionApiActionsUnion = - | AddBookSuccess - | AddBookFailure - | RemoveBookSuccess - | RemoveBookFailure - | LoadBooksSuccess - | LoadBooksFailure; +export const loadBooksSuccess = createAction( + '[Collection/API] Load Books Success', + props<{ books: Book[] }>() +); + +export const loadBooksFailure = createAction( + '[Collection/API] Load Books Failure', + props<{ error: any }>() +); + +const all = union({ + addBookSuccess, + addBookFailure, + removeBookSuccess, + removeBookFailure, + loadBooksSuccess, + loadBooksFailure, +}); +export type CollectionApiActionsUnion = typeof all; diff --git a/projects/example-app/src/app/books/actions/collection-page.actions.ts b/projects/example-app/src/app/books/actions/collection-page.actions.ts index 9a789799ea..7bfd6174ca 100644 --- a/projects/example-app/src/app/books/actions/collection-page.actions.ts +++ b/projects/example-app/src/app/books/actions/collection-page.actions.ts @@ -1,14 +1,8 @@ -import { Action } from '@ngrx/store'; - -export enum CollectionPageActionTypes { - LoadCollection = '[Collection Page] Load Collection', -} +import { createAction } from '@ngrx/store'; /** * Load Collection Action */ -export class LoadCollection implements Action { - readonly type = CollectionPageActionTypes.LoadCollection; -} +export const loadCollection = createAction('[Collection Page] Load Collection'); -export type CollectionPageActionsUnion = LoadCollection; +export type CollectionPageActionsUnion = ReturnType; diff --git a/projects/example-app/src/app/books/actions/find-book-page.actions.ts b/projects/example-app/src/app/books/actions/find-book-page.actions.ts index a6387cdf7c..f1ab45d3ff 100644 --- a/projects/example-app/src/app/books/actions/find-book-page.actions.ts +++ b/projects/example-app/src/app/books/actions/find-book-page.actions.ts @@ -1,13 +1,8 @@ -import { Action } from '@ngrx/store'; +import { createAction, props } from '@ngrx/store'; -export enum FindBookPageActionTypes { - SearchBooks = '[Find Book Page] Search Books', -} +export const searchBooks = createAction( + '[Find Book Page] Search Books', + props<{ query: string }>() +); -export class SearchBooks implements Action { - readonly type = FindBookPageActionTypes.SearchBooks; - - constructor(public payload: string) {} -} - -export type FindBookPageActionsUnion = SearchBooks; +export type FindBookPageActionsUnion = ReturnType; diff --git a/projects/example-app/src/app/books/actions/selected-book-page.actions.ts b/projects/example-app/src/app/books/actions/selected-book-page.actions.ts index 1698a909df..353c38431b 100644 --- a/projects/example-app/src/app/books/actions/selected-book-page.actions.ts +++ b/projects/example-app/src/app/books/actions/selected-book-page.actions.ts @@ -1,27 +1,22 @@ -import { Action } from '@ngrx/store'; +import { createAction, union, props } from '@ngrx/store'; import { Book } from '@example-app/books/models/book'; -export enum SelectedBookPageActionTypes { - AddBook = '[Selected Book Page] Add Book', - RemoveBook = '[Selected Book Page] Remove Book', -} - /** * Add Book to Collection Action */ -export class AddBook implements Action { - readonly type = SelectedBookPageActionTypes.AddBook; - - constructor(public payload: Book) {} -} +export const addBook = createAction( + '[Selected Book Page] Add Book', + props<{ book: Book }>() +); /** * Remove Book from Collection Action */ -export class RemoveBook implements Action { - readonly type = SelectedBookPageActionTypes.RemoveBook; +export const removeBook = createAction( + '[Selected Book Page] Remove Book', + props<{ book: Book }>() +); - constructor(public payload: Book) {} -} +const all = union({ addBook, removeBook }); -export type SelectedBookPageActionsUnion = AddBook | RemoveBook; +export type SelectedBookPageActionsUnion = typeof all; diff --git a/projects/example-app/src/app/books/actions/view-book-page.actions.ts b/projects/example-app/src/app/books/actions/view-book-page.actions.ts index 1d15fa2074..38a43b6626 100644 --- a/projects/example-app/src/app/books/actions/view-book-page.actions.ts +++ b/projects/example-app/src/app/books/actions/view-book-page.actions.ts @@ -1,13 +1,8 @@ -import { Action } from '@ngrx/store'; +import { createAction, props } from '@ngrx/store'; -export enum ViewBookPageActionTypes { - SelectBook = '[View Book Page] Select Book', -} +export const selectBook = createAction( + '[View Book Page] Select Book', + props<{ id: string }>() +); -export class SelectBook implements Action { - readonly type = ViewBookPageActionTypes.SelectBook; - - constructor(public payload: string) {} -} - -export type ViewBookPageActionsUnion = SelectBook; +export type ViewBookPageActionsUnion = ReturnType; 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 1047f68381..9db1d0ab4a 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 @@ -52,7 +52,7 @@ describe('Collection Page', () => { }); it('should dispatch a collection.Load on init', () => { - const action = new CollectionPageActions.LoadCollection(); + const action = CollectionPageActions.loadCollection(); fixture.detectChanges(); 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 d238835f0a..f0ebfd39e7 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 @@ -39,6 +39,6 @@ export class CollectionPageComponent implements OnInit { } ngOnInit() { - this.store.dispatch(new CollectionPageActions.LoadCollection()); + this.store.dispatch(CollectionPageActions.loadCollection()); } } 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 126ccc4e99..76652658ec 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 @@ -63,7 +63,7 @@ describe('Find Book Page', () => { it('should dispatch a book.Search action on search', () => { const $event = 'book name'; - const action = new FindBookPageActions.SearchBooks($event); + const action = FindBookPageActions.searchBooks({ query: $event }); instance.search($event); 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 72cc1ea52f..3e260678ed 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 @@ -39,6 +39,6 @@ export class FindBookPageComponent { } search(query: string) { - this.store.dispatch(new FindBookPageActions.SearchBooks(query)); + this.store.dispatch(FindBookPageActions.searchBooks({ query })); } } diff --git a/projects/example-app/src/app/books/containers/selected-book-page.component.spec.ts b/projects/example-app/src/app/books/containers/selected-book-page.component.spec.ts index db23d0738f..e33dfa6919 100644 --- a/projects/example-app/src/app/books/containers/selected-book-page.component.spec.ts +++ b/projects/example-app/src/app/books/containers/selected-book-page.component.spec.ts @@ -48,7 +48,7 @@ describe('Selected Book Page', () => { it('should dispatch a collection.AddBook action when addToCollection is called', () => { const $event: Book = generateMockBook(); - const action = new SelectedBookPageActions.AddBook($event); + const action = SelectedBookPageActions.addBook({ book: $event }); instance.addToCollection($event); @@ -57,7 +57,7 @@ describe('Selected Book Page', () => { it('should dispatch a collection.RemoveBook action on removeFromCollection', () => { const $event: Book = generateMockBook(); - const action = new SelectedBookPageActions.RemoveBook($event); + const action = SelectedBookPageActions.removeBook({ book: $event }); instance.removeFromCollection($event); 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 fb7471ffaf..727f5e4d73 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 @@ -32,10 +32,10 @@ export class SelectedBookPageComponent { } addToCollection(book: Book) { - this.store.dispatch(new SelectedBookPageActions.AddBook(book)); + this.store.dispatch(SelectedBookPageActions.addBook({ book })); } removeFromCollection(book: Book) { - this.store.dispatch(new SelectedBookPageActions.RemoveBook(book)); + this.store.dispatch(SelectedBookPageActions.removeBook({ book })); } } diff --git a/projects/example-app/src/app/books/containers/view-book-page.component.spec.ts b/projects/example-app/src/app/books/containers/view-book-page.component.spec.ts index 4bd5118347..c66c5dba32 100644 --- a/projects/example-app/src/app/books/containers/view-book-page.component.spec.ts +++ b/projects/example-app/src/app/books/containers/view-book-page.component.spec.ts @@ -56,7 +56,7 @@ describe('View Book Page', () => { }); it('should dispatch a book.Select action on init', () => { - const action = new ViewBookPageActions.SelectBook('2'); + const action = ViewBookPageActions.selectBook({ id: '2' }); params.next({ id: '2' }); fixture.detectChanges(); diff --git a/projects/example-app/src/app/books/containers/view-book-page.component.ts b/projects/example-app/src/app/books/containers/view-book-page.component.ts index 5d50407e94..b1e13c353b 100644 --- a/projects/example-app/src/app/books/containers/view-book-page.component.ts +++ b/projects/example-app/src/app/books/containers/view-book-page.component.ts @@ -29,7 +29,7 @@ export class ViewBookPageComponent implements OnDestroy { constructor(store: Store, route: ActivatedRoute) { this.actionsSubscription = route.params - .pipe(map(params => new ViewBookPageActions.SelectBook(params.id))) + .pipe(map(params => ViewBookPageActions.selectBook({ id: params.id }))) .subscribe(store); } diff --git a/projects/example-app/src/app/books/effects/book.effects.spec.ts b/projects/example-app/src/app/books/effects/book.effects.spec.ts index 73b3a37fc3..9147011d6f 100644 --- a/projects/example-app/src/app/books/effects/book.effects.spec.ts +++ b/projects/example-app/src/app/books/effects/book.effects.spec.ts @@ -35,12 +35,12 @@ describe('BookEffects', () => { }); describe('search$', () => { - it('should return a new book.SearchComplete, with the books, on success, after the de-bounce', () => { + it('should return a book.SearchComplete, with the books, on success, after the de-bounce', () => { const book1 = { id: '111', volumeInfo: {} } as Book; const book2 = { id: '222', volumeInfo: {} } as Book; const books = [book1, book2]; - const action = new FindBookPageActions.SearchBooks('query'); - const completion = new BooksApiActions.SearchSuccess(books); + const action = FindBookPageActions.searchBooks({ query: 'query' }); + const completion = BooksApiActions.searchSuccess({ books }); actions$ = hot('-a---', { a: action }); const response = cold('-a|', { a: books }); @@ -55,11 +55,11 @@ describe('BookEffects', () => { ).toBeObservable(expected); }); - it('should return a new book.SearchError if the books service throws', () => { - const action = new FindBookPageActions.SearchBooks('query'); - const completion = new BooksApiActions.SearchFailure( - 'Unexpected Error. Try again later.' - ); + it('should return a book.SearchError if the books service throws', () => { + const action = FindBookPageActions.searchBooks({ query: 'query' }); + const completion = BooksApiActions.searchFailure({ + errorMsg: 'Unexpected Error. Try again later.', + }); const error = 'Unexpected Error. Try again later.'; actions$ = hot('-a---', { a: action }); @@ -76,7 +76,7 @@ describe('BookEffects', () => { }); it(`should not do anything if the query is an empty string`, () => { - const action = new FindBookPageActions.SearchBooks(''); + const action = FindBookPageActions.searchBooks({ query: '' }); actions$ = hot('-a---', { a: action }); const expected = cold('---'); diff --git a/projects/example-app/src/app/books/effects/book.effects.ts b/projects/example-app/src/app/books/effects/book.effects.ts index bee500a9af..d1063c365a 100644 --- a/projects/example-app/src/app/books/effects/book.effects.ts +++ b/projects/example-app/src/app/books/effects/book.effects.ts @@ -36,23 +36,24 @@ export class BookEffects { Action > => this.actions$.pipe( - ofType(FindBookPageActions.FindBookPageActionTypes.SearchBooks), + ofType(FindBookPageActions.searchBooks.type), debounceTime(debounce, scheduler), - map(action => action.payload), - switchMap(query => { + switchMap(({ query }) => { if (query === '') { return empty; } const nextSearch$ = this.actions$.pipe( - ofType(FindBookPageActions.FindBookPageActionTypes.SearchBooks), + ofType(FindBookPageActions.searchBooks.type), skip(1) ); return this.googleBooks.searchBooks(query).pipe( takeUntil(nextSearch$), - map((books: Book[]) => new BooksApiActions.SearchSuccess(books)), - catchError(err => of(new BooksApiActions.SearchFailure(err))) + map((books: Book[]) => BooksApiActions.searchSuccess({ books })), + catchError(err => + of(BooksApiActions.searchFailure({ errorMsg: err })) + ) ); }) ); diff --git a/projects/example-app/src/app/books/effects/collection.effects.spec.ts b/projects/example-app/src/app/books/effects/collection.effects.spec.ts index 62bd9e4ca9..5f6b5fec90 100644 --- a/projects/example-app/src/app/books/effects/collection.effects.spec.ts +++ b/projects/example-app/src/app/books/effects/collection.effects.spec.ts @@ -52,11 +52,10 @@ describe('CollectionEffects', () => { describe('loadCollection$', () => { it('should return a collection.LoadSuccess, with the books, on success', () => { - const action = new CollectionPageActions.LoadCollection(); - const completion = new CollectionApiActions.LoadBooksSuccess([ - book1, - book2, - ]); + const action = CollectionPageActions.loadCollection(); + const completion = CollectionApiActions.loadBooksSuccess({ + books: [book1, book2], + }); actions$ = hot('-a', { a: action }); const response = cold('-a-b|', { a: book1, b: book2 }); @@ -67,9 +66,9 @@ describe('CollectionEffects', () => { }); it('should return a collection.LoadFail, if the query throws', () => { - const action = new CollectionPageActions.LoadCollection(); + const action = CollectionPageActions.loadCollection(); const error = 'Error!'; - const completion = new CollectionApiActions.LoadBooksFailure(error); + const completion = CollectionApiActions.loadBooksFailure({ error }); actions$ = hot('-a', { a: action }); const response = cold('-#', {}, error); @@ -82,8 +81,8 @@ describe('CollectionEffects', () => { describe('addBookToCollection$', () => { it('should return a collection.AddBookSuccess, with the book, on success', () => { - const action = new SelectedBookPageActions.AddBook(book1); - const completion = new CollectionApiActions.AddBookSuccess(book1); + const action = SelectedBookPageActions.addBook({ book: book1 }); + const completion = CollectionApiActions.addBookSuccess({ book: book1 }); actions$ = hot('-a', { a: action }); const response = cold('-b', { b: true }); @@ -95,8 +94,8 @@ describe('CollectionEffects', () => { }); it('should return a collection.AddBookFail, with the book, when the db insert throws', () => { - const action = new SelectedBookPageActions.AddBook(book1); - const completion = new CollectionApiActions.AddBookFailure(book1); + const action = SelectedBookPageActions.addBook({ book: book1 }); + const completion = CollectionApiActions.addBookFailure({ book: book1 }); const error = 'Error!'; actions$ = hot('-a', { a: action }); @@ -109,8 +108,10 @@ describe('CollectionEffects', () => { describe('removeBookFromCollection$', () => { it('should return a collection.RemoveBookSuccess, with the book, on success', () => { - const action = new SelectedBookPageActions.RemoveBook(book1); - const completion = new CollectionApiActions.RemoveBookSuccess(book1); + const action = SelectedBookPageActions.removeBook({ book: book1 }); + const completion = CollectionApiActions.removeBookSuccess({ + book: book1, + }); actions$ = hot('-a', { a: action }); const response = cold('-b', { b: true }); @@ -124,8 +125,10 @@ describe('CollectionEffects', () => { }); it('should return a collection.RemoveBookFail, with the book, when the db insert throws', () => { - const action = new SelectedBookPageActions.RemoveBook(book1); - const completion = new CollectionApiActions.RemoveBookFailure(book1); + const action = SelectedBookPageActions.removeBook({ book: book1 }); + const completion = CollectionApiActions.removeBookFailure({ + book: book1, + }); const error = 'Error!'; actions$ = hot('-a', { a: action }); diff --git a/projects/example-app/src/app/books/effects/collection.effects.ts b/projects/example-app/src/app/books/effects/collection.effects.ts index f38494e20c..4711f2ddd5 100644 --- a/projects/example-app/src/app/books/effects/collection.effects.ts +++ b/projects/example-app/src/app/books/effects/collection.effects.ts @@ -31,15 +31,15 @@ export class CollectionEffects { @Effect() loadCollection$: Observable = this.actions$.pipe( - ofType(CollectionPageActions.CollectionPageActionTypes.LoadCollection), + ofType(CollectionPageActions.loadCollection.type), switchMap(() => this.db.query('books').pipe( toArray(), - map( - (books: Book[]) => new CollectionApiActions.LoadBooksSuccess(books) + map((books: Book[]) => + CollectionApiActions.loadBooksSuccess({ books }) ), catchError(error => - of(new CollectionApiActions.LoadBooksFailure(error)) + of(CollectionApiActions.loadBooksFailure({ error })) ) ) ) @@ -47,24 +47,22 @@ export class CollectionEffects { @Effect() addBookToCollection$: Observable = this.actions$.pipe( - ofType(SelectedBookPageActions.SelectedBookPageActionTypes.AddBook), - map(action => action.payload), - mergeMap(book => + ofType(SelectedBookPageActions.addBook.type), + mergeMap(({ book }) => this.db.insert('books', [book]).pipe( - map(() => new CollectionApiActions.AddBookSuccess(book)), - catchError(() => of(new CollectionApiActions.AddBookFailure(book))) + map(() => CollectionApiActions.addBookSuccess({ book })), + catchError(() => of(CollectionApiActions.addBookFailure({ book }))) ) ) ); @Effect() removeBookFromCollection$: Observable = this.actions$.pipe( - ofType(SelectedBookPageActions.SelectedBookPageActionTypes.RemoveBook), - map(action => action.payload), - mergeMap(book => + ofType(SelectedBookPageActions.removeBook.type), + mergeMap(({ book }) => this.db.executeWrite('books', 'delete', [book.id]).pipe( - map(() => new CollectionApiActions.RemoveBookSuccess(book)), - catchError(() => of(new CollectionApiActions.RemoveBookFailure(book))) + map(() => CollectionApiActions.removeBookSuccess({ book })), + catchError(() => of(CollectionApiActions.removeBookFailure({ book }))) ) ) ); 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 d20856240f..6a33f24615 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 @@ -54,8 +54,8 @@ export class BookExistsGuard implements CanActivate { */ hasBookInApi(id: string): Observable { return this.googleBooks.retrieveBook(id).pipe( - map(bookEntity => new BookActions.LoadBook(bookEntity)), - tap((action: BookActions.LoadBook) => this.store.dispatch(action)), + map(bookEntity => BookActions.loadBook({ book: bookEntity })), + tap(action => this.store.dispatch(action)), map(book => !!book), catchError(() => { this.router.navigate(['/404']); 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 3ebd4bd978..0582de529a 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 @@ -160,7 +160,7 @@ Object { } `; -exports[`BooksReducer SEARCH_COMPLETE & LOAD_SUCCESS should add only new books when books already exist 1`] = ` +exports[`BooksReducer SEARCH_COMPLETE & LOAD_SUCCESS should add only books when books already exist 1`] = ` Object { "entities": Object { "1": Object { @@ -230,7 +230,7 @@ Object { } `; -exports[`BooksReducer SEARCH_COMPLETE & LOAD_SUCCESS should add only new books when books already exist 2`] = ` +exports[`BooksReducer SEARCH_COMPLETE & LOAD_SUCCESS should add only books when books already exist 2`] = ` Object { "entities": Object { "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 13724e8c8e..fc6429e840 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 @@ -30,22 +30,29 @@ describe('BooksReducer', () => { }); describe('SEARCH_COMPLETE & LOAD_SUCCESS', () => { + type BooksActions = + | typeof BooksApiActions.searchSuccess + | typeof CollectionApiActions.loadBooksSuccess; function noExistingBooks( - action: any, + action: BooksActions, booksInitialState: any, books: Book[] ) { - const createAction = new action(books); + const createAction = action({ books }); const result = reducer(booksInitialState, createAction); expect(result).toMatchSnapshot(); } - function existingBooks(action: any, booksInitialState: any, books: Book[]) { + function existingBooks( + action: BooksActions, + booksInitialState: any, + books: Book[] + ) { // should not replace existing books const differentBook2 = { ...books[0], foo: 'bar' }; - const createAction = new action([books[1], differentBook2]); + const createAction = action({ books: [books[1], differentBook2] }); const expectedResult = { ids: [...booksInitialState.ids, books[1].id], @@ -62,24 +69,24 @@ describe('BooksReducer', () => { } it('should add all books in the payload when none exist', () => { - noExistingBooks(BooksApiActions.SearchSuccess, initialState, [ + noExistingBooks(BooksApiActions.searchSuccess, initialState, [ book1, book2, ]); - noExistingBooks(CollectionApiActions.LoadBooksSuccess, initialState, [ + noExistingBooks(CollectionApiActions.loadBooksSuccess, initialState, [ book1, book2, ]); }); - it('should add only new books when books already exist', () => { - existingBooks(BooksApiActions.SearchSuccess, initialState, [ + it('should add only books when books already exist', () => { + existingBooks(BooksApiActions.searchSuccess, initialState, [ book2, book3, ]); - existingBooks(CollectionApiActions.LoadBooksSuccess, initialState, [ + existingBooks(CollectionApiActions.loadBooksSuccess, initialState, [ book2, book3, ]); @@ -96,7 +103,7 @@ describe('BooksReducer', () => { }; it('should add a single book, if the book does not exist', () => { - const action = new BookActions.LoadBook(book1); + const action = BookActions.loadBook({ book: book1 }); const result = reducer(fromBooks.initialState, action); @@ -104,7 +111,7 @@ describe('BooksReducer', () => { }); it('should return the existing state if the book exists', () => { - const action = new BookActions.LoadBook(book1); + const action = BookActions.loadBook({ book: book1 }); const result = reducer(expectedResult, action); @@ -114,7 +121,7 @@ describe('BooksReducer', () => { describe('SELECT', () => { it('should set the selected book id on the state', () => { - const action = new ViewBookPageActions.SelectBook(book1.id); + const action = ViewBookPageActions.selectBook({ id: book1.id }); const result = reducer(initialState, action); 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 847702a719..ec73016799 100644 --- a/projects/example-app/src/app/books/reducers/books.reducer.ts +++ b/projects/example-app/src/app/books/reducers/books.reducer.ts @@ -49,8 +49,8 @@ export function reducer( | CollectionApiActions.CollectionApiActionsUnion ): State { switch (action.type) { - case BooksApiActions.BooksApiActionTypes.SearchSuccess: - case CollectionApiActions.CollectionApiActionTypes.LoadBooksSuccess: { + case BooksApiActions.searchSuccess.type: + case CollectionApiActions.loadBooksSuccess.type: { /** * The addMany function provided by the created adapter * adds many records to the entity dictionary @@ -58,10 +58,10 @@ export function reducer( * the collection is to be sorted, the adapter will * sort each record upon entry into the sorted array. */ - return adapter.addMany(action.payload, state); + return adapter.addMany(action.books, state); } - case BookActions.BookActionTypes.LoadBook: { + case BookActions.loadBook.type: { /** * The addOne function provided by the created adapter * adds one record to the entity dictionary @@ -69,13 +69,13 @@ export function reducer( * exist already. If the collection is to be sorted, the adapter will * insert the new record into the sorted array. */ - return adapter.addOne(action.payload, state); + return adapter.addOne(action.book, state); } - case ViewBookPageActions.ViewBookPageActionTypes.SelectBook: { + case ViewBookPageActions.selectBook.type: { return { ...state, - selectedBookId: action.payload, + selectedBookId: action.id, }; } diff --git a/projects/example-app/src/app/books/reducers/collection.reducer.ts b/projects/example-app/src/app/books/reducers/collection.reducer.ts index 9c246f697e..32889e4f54 100644 --- a/projects/example-app/src/app/books/reducers/collection.reducer.ts +++ b/projects/example-app/src/app/books/reducers/collection.reducer.ts @@ -24,38 +24,38 @@ export function reducer( | CollectionApiActions.CollectionApiActionsUnion ): State { switch (action.type) { - case CollectionPageActions.CollectionPageActionTypes.LoadCollection: { + case CollectionPageActions.loadCollection.type: { return { ...state, loading: true, }; } - case CollectionApiActions.CollectionApiActionTypes.LoadBooksSuccess: { + case CollectionApiActions.loadBooksSuccess.type: { return { loaded: true, loading: false, - ids: action.payload.map(book => book.id), + ids: action.books.map(book => book.id), }; } - case CollectionApiActions.CollectionApiActionTypes.AddBookSuccess: - case CollectionApiActions.CollectionApiActionTypes.RemoveBookFailure: { - if (state.ids.indexOf(action.payload.id) > -1) { + case CollectionApiActions.addBookSuccess.type: + case CollectionApiActions.removeBookFailure.type: { + if (state.ids.indexOf(action.book.id) > -1) { return state; } return { ...state, - ids: [...state.ids, action.payload.id], + ids: [...state.ids, action.book.id], }; } - case CollectionApiActions.CollectionApiActionTypes.RemoveBookSuccess: - case CollectionApiActions.CollectionApiActionTypes.AddBookFailure: { + case CollectionApiActions.removeBookSuccess.type: + case CollectionApiActions.addBookFailure.type: { return { ...state, - ids: state.ids.filter(id => id !== action.payload.id), + ids: state.ids.filter(id => id !== action.book.id), }; } diff --git a/projects/example-app/src/app/books/reducers/search.reducer.ts b/projects/example-app/src/app/books/reducers/search.reducer.ts index 4869fdbbc7..6081e245bd 100644 --- a/projects/example-app/src/app/books/reducers/search.reducer.ts +++ b/projects/example-app/src/app/books/reducers/search.reducer.ts @@ -24,8 +24,8 @@ export function reducer( | FindBookPageActions.FindBookPageActionsUnion ): State { switch (action.type) { - case FindBookPageActions.FindBookPageActionTypes.SearchBooks: { - const query = action.payload; + case FindBookPageActions.searchBooks.type: { + const query = action.query; if (query === '') { return { @@ -44,20 +44,20 @@ export function reducer( }; } - case BooksApiActions.BooksApiActionTypes.SearchSuccess: { + case BooksApiActions.searchSuccess.type: { return { - ids: action.payload.map(book => book.id), + ids: action.books.map(book => book.id), loading: false, error: '', query: state.query, }; } - case BooksApiActions.BooksApiActionTypes.SearchFailure: { + case BooksApiActions.searchFailure.type: { return { ...state, loading: false, - error: action.payload, + error: action.errorMsg, }; } diff --git a/projects/example-app/src/app/core/actions/layout.actions.ts b/projects/example-app/src/app/core/actions/layout.actions.ts index c5f055b1b3..1dd87dc536 100644 --- a/projects/example-app/src/app/core/actions/layout.actions.ts +++ b/projects/example-app/src/app/core/actions/layout.actions.ts @@ -1,16 +1,7 @@ -import { Action } from '@ngrx/store'; +import { createAction, union } from '@ngrx/store'; -export enum LayoutActionTypes { - OpenSidenav = '[Layout] Open Sidenav', - CloseSidenav = '[Layout] Close Sidenav', -} +export const openSidenav = createAction('[Layout] Open Sidenav'); +export const closeSidenav = createAction('[Layout] Close Sidenav'); -export class OpenSidenav implements Action { - readonly type = LayoutActionTypes.OpenSidenav; -} - -export class CloseSidenav implements Action { - readonly type = LayoutActionTypes.CloseSidenav; -} - -export type LayoutActionsUnion = OpenSidenav | CloseSidenav; +const all = union({ openSidenav, closeSidenav }); +export type LayoutActionsUnion = typeof all; 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 81bc56b81e..1797ce9fdb 100644 --- a/projects/example-app/src/app/core/containers/app.component.ts +++ b/projects/example-app/src/app/core/containers/app.component.ts @@ -54,16 +54,16 @@ export class AppComponent { * updates and user interaction through the life of our * application. */ - this.store.dispatch(new LayoutActions.CloseSidenav()); + this.store.dispatch(LayoutActions.closeSidenav()); } openSidenav() { - this.store.dispatch(new LayoutActions.OpenSidenav()); + this.store.dispatch(LayoutActions.openSidenav()); } logout() { this.closeSidenav(); - this.store.dispatch(new AuthActions.LogoutConfirmation()); + this.store.dispatch(AuthActions.logoutConfirmation()); } } 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 5278859bae..b128a041bd 100644 --- a/projects/example-app/src/app/core/reducers/layout.reducer.ts +++ b/projects/example-app/src/app/core/reducers/layout.reducer.ts @@ -13,12 +13,12 @@ export function reducer(state: State = initialState, action: Action): State { const specificAction = action as LayoutActions.LayoutActionsUnion; switch (specificAction.type) { - case LayoutActions.LayoutActionTypes.CloseSidenav: + case LayoutActions.closeSidenav.type: return { showSidenav: false, }; - case LayoutActions.LayoutActionTypes.OpenSidenav: + case LayoutActions.openSidenav.type: return { showSidenav: true, }; diff --git a/yarn.lock b/yarn.lock index 2b3c2e2f68..79df30a0d1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9768,6 +9768,13 @@ ts-node@^5.0.1: source-map-support "^0.5.3" yn "^2.0.0" +ts-snippet@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/ts-snippet/-/ts-snippet-4.1.0.tgz#45228ae151a61dbbf8b8f79e8b9fbfedb4483f23" + integrity sha512-agEI/dema3VNMPFmnLGMJZDBD0DOuWvllSD0szbx5NKdTBqpMomshjdzhGZratVUyiLIE0/kaK9zmBKLnFXilw== + dependencies: + tsutils "^3.0.0" + tsconfig-paths@^3.1.3: version "3.3.2" resolved "https://registry.yarnpkg.com/tsconfig-paths/-/tsconfig-paths-3.3.2.tgz#bb48b845e221a44387be0f9968ee6c37c2a37c4d" @@ -9829,6 +9836,13 @@ tsutils@^2.27.2: dependencies: tslib "^1.8.1" +tsutils@^3.0.0: + version "3.9.1" + resolved "https://registry.yarnpkg.com/tsutils/-/tsutils-3.9.1.tgz#2a40dc742943c71eca6d5c1994fcf999956be387" + integrity sha512-hrxVtLtPqQr//p8/msPT1X1UYXUjizqSit5d9AQ5k38TcV38NyecL5xODNxa73cLe/5sdiJ+w1FqzDhRBA/anA== + dependencies: + tslib "^1.8.1" + tty-browserify@0.0.0: version "0.0.0" resolved "https://registry.yarnpkg.com/tty-browserify/-/tty-browserify-0.0.0.tgz#a157ba402da24e9bf957f9aa69d524eed42901a6"