Skip to content

Commit

Permalink
feat(store): add createReducer function (#1746)
Browse files Browse the repository at this point in the history
Closes #1724
  • Loading branch information
alex-okrushko authored and brandonroberts committed Apr 17, 2019
1 parent dbfdbaf commit f954e14
Show file tree
Hide file tree
Showing 25 changed files with 341 additions and 284 deletions.
93 changes: 93 additions & 0 deletions modules/store/spec/reducer_creator.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { on, createReducer, createAction, props, union } from '@ngrx/store';
import { expecter } from 'ts-snippet';

describe('classes/reducer', function(): void {
const expectSnippet = expecter(
code => `
// path goes from root
import {createAction, props} from './modules/store/src/action_creator';
import {on} from './modules/store/src/reducer_creator';
${code}`,
{
moduleResolution: 'node',
target: 'es2015',
}
);

describe('base', () => {
const bar = createAction('[foobar] BAR', props<{ bar: number }>());
const foo = createAction('[foobar] FOO', props<{ foo: number }>());

describe('on', () => {
it('should enforce action property types', () => {
expectSnippet(`
const foo = createAction('FOO', props<{ foo: number }>());
on(foo, (state, action) => { const foo: string = action.foo; return state; });
`).toFail(/'number' is not assignable to type 'string'/);
});

it('should enforce action property names', () => {
expectSnippet(`
const foo = createAction('FOO', props<{ foo: number }>());
on(foo, (state, action) => { const bar: string = action.bar; return state; });
`).toFail(/'bar' does not exist on type/);
});

it('should support reducers with multiple actions', () => {
const both = union({ bar, foo });
const func = (state: {}, action: typeof both) => ({});
const result = on(foo, bar, func);
expect(result.types).toContain(bar.type);
expect(result.types).toContain(foo.type);
});
});

describe('createReducer', () => {
it('should create a reducer', () => {
interface State {
foo?: number;
bar?: number;
}

const fooBarReducer = createReducer<State>(
[
on(foo, (state, { foo }) => ({ ...state, foo })),
on(bar, (state, { bar }) => ({ ...state, bar })),
],
{}
);

expect(typeof fooBarReducer).toEqual('function');

let state = fooBarReducer(undefined, { type: 'UNKNOWN' });
expect(state).toEqual({});

state = fooBarReducer(state, foo({ foo: 42 }));
expect(state).toEqual({ foo: 42 });

state = fooBarReducer(state, bar({ bar: 54 }));
expect(state).toEqual({ foo: 42, bar: 54 });
});

it('should support reducers with multiple actions', () => {
type State = string[];

const fooBarReducer = createReducer<State>(
[on(foo, bar, (state, { type }) => [...state, type])],
[]
);

expect(typeof fooBarReducer).toEqual('function');

let state = fooBarReducer(undefined, { type: 'UNKNOWN' });
expect(state).toEqual([]);

state = fooBarReducer(state, foo({ foo: 42 }));
expect(state).toEqual(['[foobar] FOO']);

state = fooBarReducer(state, bar({ bar: 54 }));
expect(state).toEqual(['[foobar] FOO', '[foobar] BAR']);
});
});
});
});
1 change: 1 addition & 0 deletions modules/store/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,3 +47,4 @@ export {
StoreRootModule,
StoreFeatureModule,
} from './store_module';
export { on, createReducer } from './reducer_creator';
10 changes: 8 additions & 2 deletions modules/store/src/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ export declare interface TypedAction<T extends string> extends Action {
readonly type: T;
}

export type ActionType<A> = A extends ActionCreator<infer T, infer C>
? ReturnType<C> & { type: T }
: never;

export type TypeId<T> = () => T;

export type InitialState<T> = Partial<T> | TypeId<Partial<T>> | void;
Expand Down Expand Up @@ -47,8 +51,10 @@ export type SelectorWithProps<State, Props, Result> = (

export type Creator = (...args: any[]) => object;

export type ActionCreator<T extends string, C extends Creator> = C &
TypedAction<T>;
export type ActionCreator<
T extends string = string,
C extends Creator = Creator
> = C & TypedAction<T>;

export type FunctionWithParametersType<P extends unknown[], R = void> = (
...args: P
Expand Down
63 changes: 63 additions & 0 deletions modules/store/src/reducer_creator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { ActionCreator, ActionReducer, ActionType, Action } from './models';

// Return type of the `on` fn.
export interface On<S> {
reducer: ActionReducer<S>;
types: string[];
}

// Specialized Reducer that is aware of the Action type it needs to handle
export interface OnReducer<S, C extends ActionCreator[]> {
(state: S, action: ActionType<C[number]>): S;
}

export function on<C1 extends ActionCreator, S>(
creator1: C1,
reducer: OnReducer<S, [C1]>
): On<S>;
export function on<C1 extends ActionCreator, C2 extends ActionCreator, S>(
creator1: C1,
creator2: C2,
reducer: OnReducer<S, [C1, C2]>
): On<S>;
export function on<
C1 extends ActionCreator,
C2 extends ActionCreator,
C3 extends ActionCreator,
S
>(
creator1: C1,
creator2: C2,
creator3: C3,
reducer: OnReducer<S, [C1, C2, C3]>
): On<S>;
export function on<S>(
creator: ActionCreator,
...rest: (ActionCreator | OnReducer<S, [ActionCreator]>)[]
): On<S>;
export function on(
...args: (ActionCreator | Function)[]
): { reducer: Function; types: string[] } {
const reducer = args.pop() as Function;
const types = args.reduce(
(result, creator) => [...result, (creator as ActionCreator).type],
[] as string[]
);
return { reducer, types };
}

export function createReducer<S>(
ons: On<S>[],
initialState: S
): ActionReducer<S> {
const map = new Map<string, ActionReducer<S>>();
for (let on of ons) {
for (let type of on.types) {
map.set(type, on.reducer);
}
}
return function(state: S = initialState, action: Action): S {
const reducer = map.get(action.type);
return reducer ? reducer(state, action) : state;
};
}
4 changes: 2 additions & 2 deletions projects/example-app/src/app/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import { StoreDevtoolsModule } from '@ngrx/store-devtools';
import { CoreModule } from '@example-app/core/core.module';
import { AuthModule } from '@example-app/auth/auth.module';

import { reducers, metaReducers } from '@example-app/reducers';
import { ROOT_REDUCERS, metaReducers } from '@example-app/reducers';

import { AppComponent } from '@example-app/core/containers/app.component';
import { AppRoutingModule } from '@example-app/app-routing.module';
Expand All @@ -33,7 +33,7 @@ import { AppRoutingModule } from '@example-app/app-routing.module';
* meta-reducer. This returns all providers for an @ngrx/store
* based application.
*/
StoreModule.forRoot(reducers, {
StoreModule.forRoot(ROOT_REDUCERS, {
metaReducers,
runtimeChecks: {
strictImmutability: true,
Expand Down
6 changes: 0 additions & 6 deletions projects/example-app/src/app/auth/actions/auth-api.actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,3 @@ export const loginFailure = createAction(
);

export const loginRedirect = createAction('[Auth/API] Login Redirect');

// 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
>;
5 changes: 1 addition & 4 deletions projects/example-app/src/app/auth/actions/auth.actions.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,7 @@
import { createAction, union } from '@ngrx/store';
import { createAction } from '@ngrx/store';

export const logout = createAction('[Auth] Logout');
export const logoutConfirmation = createAction('[Auth] Logout Confirmation');
export const logoutConfirmationDismiss = createAction(
'[Auth] Logout Confirmation Dismiss'
);

const all = union({ logout, logoutConfirmation, logoutConfirmationDismiss });
export type AuthActionsUnion = typeof all;
Original file line number Diff line number Diff line change
@@ -1,9 +1,7 @@
import { createAction, props, union } from '@ngrx/store';
import { createAction, props } from '@ngrx/store';
import { Credentials } from '@example-app/auth/models/user';

export const login = createAction(
'[Login Page] Login',
props<{ credentials: Credentials }>()
);

export type LoginPageActionsUnion = ReturnType<typeof login>;
29 changes: 8 additions & 21 deletions projects/example-app/src/app/auth/reducers/auth.reducer.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { createReducer, on } from '@ngrx/store';
import { AuthApiActions, AuthActions } from '@example-app/auth/actions';
import { User } from '@example-app/auth/models/user';

Expand All @@ -9,26 +10,12 @@ export const initialState: State = {
user: null,
};

export function reducer(
state = initialState,
action: AuthApiActions.AuthApiActionsUnion | AuthActions.AuthActionsUnion
): State {
switch (action.type) {
case AuthApiActions.loginSuccess.type: {
return {
...state,
user: action.user,
};
}

case AuthActions.logout.type: {
return initialState;
}

default: {
return state;
}
}
}
export const reducer = createReducer<State>(
[
on(AuthApiActions.loginSuccess, (state, { user }) => ({ ...state, user })),
on(AuthActions.logout, () => initialState),
],
initialState
);

export const getUser = (state: State) => state.user;
13 changes: 8 additions & 5 deletions projects/example-app/src/app/auth/reducers/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import {
createSelector,
createFeatureSelector,
ActionReducerMap,
Action,
combineReducers,
} from '@ngrx/store';
import * as fromRoot from '@example-app/reducers';
import * as fromAuth from '@example-app/auth/reducers/auth.reducer';
Expand All @@ -16,10 +17,12 @@ export interface State extends fromRoot.State {
auth: AuthState;
}

export const reducers: ActionReducerMap<AuthState, any> = {
status: fromAuth.reducer,
loginPage: fromLoginPage.reducer,
};
export function reducers(state: AuthState | undefined, action: Action) {
return combineReducers({
status: fromAuth.reducer,
loginPage: fromLoginPage.reducer,
})(state, action);
}

export const selectAuthState = createFeatureSelector<State, AuthState>('auth');

Expand Down
56 changes: 21 additions & 35 deletions projects/example-app/src/app/auth/reducers/login-page.reducer.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { AuthApiActions, LoginPageActions } from '@example-app/auth/actions';
import { createReducer, on } from '@ngrx/store';

export interface State {
error: string | null;
Expand All @@ -10,42 +11,27 @@ export const initialState: State = {
pending: false,
};

export function reducer(
state = initialState,
action:
| AuthApiActions.AuthApiActionsUnion
| LoginPageActions.LoginPageActionsUnion
): State {
switch (action.type) {
case LoginPageActions.login.type: {
return {
...state,
error: null,
pending: true,
};
}
export const reducer = createReducer<State>(
[
on(LoginPageActions.login, state => ({
...state,
error: null,
pending: true,
})),

case AuthApiActions.loginSuccess.type: {
return {
...state,
error: null,
pending: false,
};
}

case AuthApiActions.loginFailure.type: {
return {
...state,
error: action.error,
pending: false,
};
}

default: {
return state;
}
}
}
on(AuthApiActions.loginSuccess, state => ({
...state,
error: null,
pending: false,
})),
on(AuthApiActions.loginFailure, (state, { error }) => ({
...state,
error,
pending: false,
})),
],
initialState
);

export const getError = (state: State) => state.error;
export const getPending = (state: State) => state.pending;
2 changes: 0 additions & 2 deletions projects/example-app/src/app/books/actions/book.actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,3 @@ export const loadBook = createAction(
'[Book Exists Guard] Load Book',
props<{ book: Book }>()
);

export type BookActionsUnion = ReturnType<typeof loadBook>;
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { createAction, union, props } from '@ngrx/store';
import { createAction, props } from '@ngrx/store';
import { Book } from '@example-app/books/models/book';

export const searchSuccess = createAction(
Expand All @@ -10,10 +10,3 @@ 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
*/
const all = union({ searchSuccess, searchFailure });
export type BooksApiActionsUnion = typeof all;
Loading

0 comments on commit f954e14

Please sign in to comment.