From 1fc2f3673be0ae0845c574d62bfad170f8458a72 Mon Sep 17 00:00:00 2001 From: Lenz Weber Date: Sun, 1 Dec 2019 22:23:01 +0100 Subject: [PATCH] alternative callback-builder-style notation for actionsMap (#262) * alternative callback-builder-style notation for actionsMap * add alternative api for extraReducers, move shared type tests to mapBuilders.typetest.ts * add/update docblocks --- src/createReducer.test.ts | 69 +++++++++++++++++++++- src/createReducer.ts | 37 +++++++++++- src/createSlice.test.ts | 51 ++++++++++++++++ src/createSlice.ts | 18 +++++- src/index.ts | 1 + src/mapBuilders.ts | 57 ++++++++++++++++++ type-tests/files/createReducer.typetest.ts | 15 ++++- type-tests/files/createSlice.typetest.ts | 19 +++++- type-tests/files/mapBuilders.typetest.ts | 54 +++++++++++++++++ 9 files changed, 315 insertions(+), 6 deletions(-) create mode 100644 src/mapBuilders.ts create mode 100644 type-tests/files/mapBuilders.typetest.ts diff --git a/src/createReducer.test.ts b/src/createReducer.test.ts index ff0f6b09b..a0b98bf99 100644 --- a/src/createReducer.test.ts +++ b/src/createReducer.test.ts @@ -1,5 +1,5 @@ import { createReducer, CaseReducer } from './createReducer' -import { PayloadAction } from './createAction' +import { PayloadAction, createAction } from './createAction' import { Reducer } from 'redux' interface Todo { @@ -74,6 +74,73 @@ describe('createReducer', () => { behavesLikeReducer(todosReducer) }) + + describe('alternative builder callback for actionMap', () => { + const increment = createAction('increment') + const decrement = createAction('decrement') + + test('can be used with ActionCreators', () => { + const reducer = createReducer(0, builder => + builder + .addCase(increment, (state, action) => state + action.payload) + .addCase(decrement, (state, action) => state - action.payload) + ) + expect(reducer(0, increment(5))).toBe(5) + expect(reducer(5, decrement(5))).toBe(0) + }) + test('can be used with string types', () => { + const reducer = createReducer(0, builder => + builder + .addCase( + 'increment', + (state, action: { type: 'increment'; payload: number }) => + state + action.payload + ) + .addCase( + 'decrement', + (state, action: { type: 'decrement'; payload: number }) => + state - action.payload + ) + ) + expect(reducer(0, increment(5))).toBe(5) + expect(reducer(5, decrement(5))).toBe(0) + }) + test('can be used with ActionCreators and string types combined', () => { + const reducer = createReducer(0, builder => + builder + .addCase(increment, (state, action) => state + action.payload) + .addCase( + 'decrement', + (state, action: { type: 'decrement'; payload: number }) => + state - action.payload + ) + ) + expect(reducer(0, increment(5))).toBe(5) + expect(reducer(5, decrement(5))).toBe(0) + }) + test('will throw if the same type is used twice', () => { + expect(() => + createReducer(0, builder => + builder + .addCase(increment, (state, action) => state + action.payload) + .addCase(increment, (state, action) => state + action.payload) + .addCase(decrement, (state, action) => state - action.payload) + ) + ).toThrowErrorMatchingInlineSnapshot( + `"addCase cannot be called with two reducers for the same action type"` + ) + expect(() => + createReducer(0, builder => + builder + .addCase(increment, (state, action) => state + action.payload) + .addCase('increment', state => state + 1) + .addCase(decrement, (state, action) => state - action.payload) + ) + ).toThrowErrorMatchingInlineSnapshot( + `"addCase cannot be called with two reducers for the same action type"` + ) + }) + }) }) function behavesLikeReducer(todosReducer: TodosReducer) { diff --git a/src/createReducer.ts b/src/createReducer.ts index cdc721b3d..6c3d16c87 100644 --- a/src/createReducer.ts +++ b/src/createReducer.ts @@ -1,5 +1,9 @@ import createNextState, { Draft } from 'immer' import { AnyAction, Action, Reducer } from 'redux' +import { + executeReducerBuilderCallback, + ActionReducerMapBuilder +} from './mapBuilders' /** * Defines a mapping from action types to corresponding action object shapes. @@ -51,7 +55,38 @@ export type CaseReducers = { export function createReducer< S, CR extends CaseReducers = CaseReducers ->(initialState: S, actionsMap: CR): Reducer { +>(initialState: S, actionsMap: CR): Reducer +/** + * A utility function that allows defining a reducer as a mapping from action + * type to *case reducer* functions that handle these action types. The + * reducer's initial state is passed as the first argument. + * + * The body of every case reducer is implicitly wrapped with a call to + * `produce()` from the [immer](https://github.com/mweststrate/immer) library. + * This means that rather than returning a new state object, you can also + * mutate the passed-in state object directly; these mutations will then be + * automatically and efficiently translated into copies, giving you both + * convenience and immutability. + * @param initialState The initial state to be returned by the reducer. + * @param builderCallback A callback that receives a *builder* object to define + * case reducers via calls to `builder.addCase(actionCreatorOrType, reducer)`. + */ +export function createReducer( + initialState: S, + builderCallback: (builder: ActionReducerMapBuilder) => void +): Reducer + +export function createReducer( + initialState: S, + mapOrBuilderCallback: + | CaseReducers + | ((builder: ActionReducerMapBuilder) => void) +): Reducer { + let actionsMap = + typeof mapOrBuilderCallback === 'function' + ? executeReducerBuilderCallback(mapOrBuilderCallback) + : mapOrBuilderCallback + return function(state = initialState, action): S { // @ts-ignore createNextState() produces an Immutable> rather // than an Immutable, and TypeScript cannot find out how to reconcile diff --git a/src/createSlice.test.ts b/src/createSlice.test.ts index 476f184e1..81092b7bd 100644 --- a/src/createSlice.test.ts +++ b/src/createSlice.test.ts @@ -105,6 +105,57 @@ describe('createSlice', () => { expect(result).toBe(15) }) + + describe('alternative builder callback for extraReducers', () => { + const increment = createAction('increment') + + test('can be used with actionCreators', () => { + const slice = createSlice({ + name: 'counter', + initialState: 0, + reducers: {}, + extraReducers: builder => + builder.addCase( + increment, + (state, action) => state + action.payload + ) + }) + expect(slice.reducer(0, increment(5))).toBe(5) + }) + + test('can be used with string action types', () => { + const slice = createSlice({ + name: 'counter', + initialState: 0, + reducers: {}, + extraReducers: builder => + builder.addCase( + 'increment', + (state, action: { type: 'increment'; payload: number }) => + state + action.payload + ) + }) + expect(slice.reducer(0, increment(5))).toBe(5) + }) + + test('prevents the same action type from being specified twice', () => { + expect(() => + createSlice({ + name: 'counter', + initialState: 0, + reducers: {}, + extraReducers: builder => + builder + .addCase('increment', state => state + 1) + .addCase('increment', state => state + 1) + }) + ).toThrowErrorMatchingInlineSnapshot( + `"addCase cannot be called with two reducers for the same action type"` + ) + }) + + // for further tests, see the test of createReducer that goes way more into depth on this + }) }) describe('behaviour with enhanced case reducers', () => { diff --git a/src/createSlice.ts b/src/createSlice.ts index c7b1804e1..14636226a 100644 --- a/src/createSlice.ts +++ b/src/createSlice.ts @@ -8,6 +8,10 @@ import { ActionCreatorWithPreparedPayload } from './createAction' import { createReducer, CaseReducers, CaseReducer } from './createReducer' +import { + ActionReducerMapBuilder, + executeReducerBuilderCallback +} from './mapBuilders' /** * An action creator atttached to a slice. @@ -72,8 +76,12 @@ export interface CreateSliceOptions< * A mapping from action types to action-type-specific *case reducer* * functions. These reducers should have existing action types used * as the keys, and action creators will _not_ be generated. + * Alternatively, a callback that receives a *builder* object to define + * case reducers via calls to `builder.addCase(actionCreatorOrType, reducer)`. */ - extraReducers?: CaseReducers, any> + extraReducers?: + | CaseReducers, any> + | ((builder: ActionReducerMapBuilder>) => void) } type PayloadActions = Record< @@ -201,7 +209,13 @@ export function createSlice< throw new Error('`name` is a required option for createSlice') } const reducers = options.reducers || {} - const extraReducers = options.extraReducers || {} + const extraReducers = + typeof options.extraReducers === 'undefined' + ? {} + : typeof options.extraReducers === 'function' + ? executeReducerBuilderCallback(options.extraReducers) + : options.extraReducers + const reducerNames = Object.keys(reducers) const sliceCaseReducersByName: Record = {} diff --git a/src/index.ts b/src/index.ts index 330c68584..015d0475c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,3 +8,4 @@ export * from './createReducer' export * from './createSlice' export * from './serializableStateInvariantMiddleware' export * from './getDefaultMiddleware' +export { ActionReducerMapBuilder } from './mapBuilders' diff --git a/src/mapBuilders.ts b/src/mapBuilders.ts new file mode 100644 index 000000000..6b53d0af3 --- /dev/null +++ b/src/mapBuilders.ts @@ -0,0 +1,57 @@ +import { Action } from 'redux' +import { CaseReducer, CaseReducers } from './createReducer' + +export interface TypedActionCreator { + (...args: any[]): Action + type: Type +} + +/** + * A builder for an action <-> reducer map. + */ +export interface ActionReducerMapBuilder { + /** + * Add a case reducer for actions created by this action creator. + * @param actionCreator + * @param reducer + */ + addCase>( + actionCreator: ActionCreator, + reducer: CaseReducer> + ): ActionReducerMapBuilder + /** + * Add a case reducer for actions with the specified type. + * @param type + * @param reducer + */ + addCase>( + type: Type, + reducer: CaseReducer + ): ActionReducerMapBuilder +} + +export function executeReducerBuilderCallback( + builderCallback: (builder: ActionReducerMapBuilder) => void +): CaseReducers { + const actionsMap: CaseReducers = {} + const builder = { + addCase( + typeOrActionCreator: string | TypedActionCreator, + reducer: CaseReducer + ) { + const type = + typeof typeOrActionCreator === 'string' + ? typeOrActionCreator + : typeOrActionCreator.type + if (type in actionsMap) { + throw new Error( + 'addCase cannot be called with two reducers for the same action type' + ) + } + actionsMap[type] = reducer + return builder + } + } + builderCallback(builder) + return actionsMap +} diff --git a/type-tests/files/createReducer.typetest.ts b/type-tests/files/createReducer.typetest.ts index de5aef088..e3a594c4e 100644 --- a/type-tests/files/createReducer.typetest.ts +++ b/type-tests/files/createReducer.typetest.ts @@ -1,5 +1,5 @@ import { Reducer } from 'redux' -import { createReducer } from '../../src' +import { createReducer, createAction, ActionReducerMapBuilder } from '../../src' function expectType(p: T) {} @@ -63,3 +63,16 @@ function expectType(p: T) {} } }) } + +/** Test: alternative builder callback for actionMap */ +{ + const increment = createAction('increment') + + const reducer = createReducer(0, builder => + expectType>(builder) + ) + + expectType(reducer(0, increment(5))) + // typings:expect-error + expectType(reducer(0, increment(5))) +} diff --git a/type-tests/files/createSlice.typetest.ts b/type-tests/files/createSlice.typetest.ts index 440e46123..fd2c0ee70 100644 --- a/type-tests/files/createSlice.typetest.ts +++ b/type-tests/files/createSlice.typetest.ts @@ -1,5 +1,10 @@ import { AnyAction, Reducer, Action } from 'redux' -import { createSlice, PayloadAction, createAction } from '../../src' +import { + createSlice, + PayloadAction, + createAction, + ActionReducerMapBuilder +} from '../../src' function expectType(t: T) { return t @@ -272,3 +277,15 @@ function expectType(t: T) { expectType(x.payload) } } + +/** Test: alternative builder callback for extraReducers */ +{ + createSlice({ + name: 'test', + initialState: 0, + reducers: {}, + extraReducers: builder => { + expectType>(builder) + } + }) +} diff --git a/type-tests/files/mapBuilders.typetest.ts b/type-tests/files/mapBuilders.typetest.ts new file mode 100644 index 000000000..27a5b91b8 --- /dev/null +++ b/type-tests/files/mapBuilders.typetest.ts @@ -0,0 +1,54 @@ +import { executeReducerBuilderCallback } from 'src/mapBuilders' +import { createAction, CaseReducers } from 'src' + +function expectType(t: T) { + return t +} + +/** Test: alternative builder callback for actionMap */ +{ + const increment = createAction('increment') + const decrement = createAction('decrement') + + executeReducerBuilderCallback(builder => { + builder.addCase(increment, (state, action) => { + expectType(state) + expectType<{ type: 'increment'; payload: number }>(action) + // typings:expect-error + expectType(state) + // typings:expect-error + expectType<{ type: 'increment'; payload: string }>(action) + // typings:expect-error + expectType<{ type: 'decrement'; payload: number }>(action) + }) + + builder.addCase('increment', (state, action) => { + expectType(state) + expectType<{ type: 'increment' }>(action) + // typings:expect-error + expectType<{ type: 'decrement' }>(action) + // typings:expect-error - this cannot be inferred and has to be manually specified + expectType<{ type: 'increment'; payload: number }>(action) + }) + + builder.addCase( + increment, + (state, action: ReturnType) => state + ) + // typings:expect-error + builder.addCase( + increment, + (state, action: ReturnType) => state + ) + + builder.addCase( + 'increment', + (state, action: ReturnType) => state + ) + // typings:expect-error + builder.addCase( + 'decrement', + (state, action: ReturnType) => state + ) + }) +}