Skip to content

Commit

Permalink
add prepareAction option to createAction (#149)
Browse files Browse the repository at this point in the history
* add prepareAction option to createAction, prepare option to reducers/slices

* move logic out of createReducer into createSlice

* Fixed typings to raise an error if the type of the payload returned by prepare and the type accepted by reducer don't agree.

* clean up some type tests
  • Loading branch information
phryneas authored and markerikson committed Jul 29, 2019
1 parent 36c7b7c commit c033b99
Show file tree
Hide file tree
Showing 7 changed files with 347 additions and 59 deletions.
44 changes: 44 additions & 0 deletions src/createAction.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,50 @@ describe('createAction', () => {
expect(`${actionCreator}`).toEqual('A_TYPE')
})
})

describe('when passing a prepareAction method only returning a payload', () => {
it('should use the payload returned from the prepareAction method', () => {
const actionCreator = createAction('A_TYPE', (a: number) => ({
payload: a * 2
}))
expect(actionCreator(5).payload).toBe(10)
})
it('should not have a meta attribute on the resulting Action', () => {
const actionCreator = createAction('A_TYPE', (a: number) => ({
payload: a * 2
}))
expect('meta' in actionCreator(5)).toBeFalsy()
})
})

describe('when passing a prepareAction method returning a payload and meta', () => {
it('should use the payload returned from the prepareAction method', () => {
const actionCreator = createAction('A_TYPE', (a: number) => ({
payload: a * 2,
meta: a / 2
}))
expect(actionCreator(5).payload).toBe(10)
})
it('should use the meta returned from the prepareAction method', () => {
const actionCreator = createAction('A_TYPE', (a: number) => ({
payload: a * 2,
meta: a / 2
}))
expect(actionCreator(10).meta).toBe(5)
})
})

describe('when passing a prepareAction that accepts multiple arguments', () => {
it('should pass all arguments of the resulting actionCreator to prepareAction', () => {
const actionCreator = createAction(
'A_TYPE',
(a: string, b: string, c: string) => ({
payload: a + b + c
})
)
expect(actionCreator('1', '2', '3').payload).toBe('123')
})
})
})

describe('getType', () => {
Expand Down
89 changes: 63 additions & 26 deletions src/createAction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,33 +7,50 @@ import { Action } from 'redux'
* @template P The type of the action's payload.
* @template T the type used for the action type.
*/
export interface PayloadAction<P = any, T extends string = string>
extends Action<T> {
export type PayloadAction<
P = any,
T extends string = string,
M = void
> = Action<T> & {
payload: P
}
} & ([M] extends [void] ? {} : { meta: M })

export type Diff<T, U> = T extends U ? never : T

export type Diff<T, U> = T extends U ? never : T;
export type PrepareAction<P> =
| ((...args: any[]) => { payload: P })
| ((...args: any[]) => { payload: P; meta: any })

/**
* An action creator that produces actions with a `payload` attribute.
*/
export type PayloadActionCreator<P = any, T extends string = string> = { type: T } & (
/*
* The `P` generic is wrapped with a single-element tuple to prevent the
* conditional from being checked distributively, thus preserving unions
* of contra-variant types.
*/
[undefined] extends [P] ? {
(payload?: undefined): PayloadAction<undefined, T>
<PT extends Diff<P, undefined>>(payload?: PT): PayloadAction<PT, T>
}
: [void] extends [P] ? {
(): PayloadAction<undefined, T>
}
: {
<PT extends P>(payload: PT): PayloadAction<PT, T>
}
);
export type PayloadActionCreator<
P = any,
T extends string = string,
PA extends PrepareAction<P> | void = void
> = {
type: T
} & (PA extends (...args: any[]) => any
? (ReturnType<PA> extends { meta: infer M }
? (...args: Parameters<PA>) => PayloadAction<P, T, M>
: (...args: Parameters<PA>) => PayloadAction<P, T>)
: (/*
* The `P` generic is wrapped with a single-element tuple to prevent the
* conditional from being checked distributively, thus preserving unions
* of contra-variant types.
*/
[undefined] extends [P]
? {
(payload?: undefined): PayloadAction<undefined, T>
<PT extends Diff<P, undefined>>(payload?: PT): PayloadAction<PT, T>
}
: [void] extends [P]
? {
(): PayloadAction<undefined, T>
}
: {
<PT extends P>(payload: PT): PayloadAction<PT, T>
}))

/**
* A utility function to create an action creator for the given action type
Expand All @@ -44,18 +61,38 @@ export type PayloadActionCreator<P = any, T extends string = string> = { type: T
*
* @param type The action type to use for created actions.
*/

export function createAction<P = any, T extends string = string>(
type: T
): PayloadActionCreator<P, T> {
function actionCreator(payload?: P): PayloadAction<undefined | P, T> {
return { type, payload }
): PayloadActionCreator<P, T>

export function createAction<
PA extends PrepareAction<any>,
T extends string = string
>(
type: T,
prepareAction: PA
): PayloadActionCreator<ReturnType<PA>['payload'], T, PA>

export function createAction(type: string, prepareAction?: Function) {
function actionCreator(...args: any[]) {
if (prepareAction) {
let prepared = prepareAction(...args)
if (!prepared) {
throw new Error('prepareAction did not return an object')
}
return 'meta' in prepared
? { type, payload: prepared.payload, meta: prepared.meta }
: { type, payload: prepared.payload }
}
return { type, payload: args[0] }
}

actionCreator.toString = (): T => `${type}` as T
actionCreator.toString = () => `${type}`

actionCreator.type = type

return actionCreator as any
return actionCreator
}

/**
Expand Down
44 changes: 44 additions & 0 deletions src/createSlice.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,4 +128,48 @@ describe('createSlice', () => {
expect(result).toBe(15)
})
})

describe('behaviour with enhanced case reducers', () => {
it('should pass all arguments to the prepare function', () => {
const prepare = jest.fn((payload, somethingElse) => ({ payload }))

const testSlice = createSlice({
slice: 'test',
initialState: 0,
reducers: {
testReducer: {
reducer: s => s,
prepare
}
}
})

expect(testSlice.actions.testReducer('a', 1)).toEqual({
type: 'test/testReducer',
payload: 'a'
})
expect(prepare).toHaveBeenCalledWith('a', 1)
})

it('should call the reducer function', () => {
const reducer = jest.fn()

const testSlice = createSlice({
slice: 'test',
initialState: 0,
reducers: {
testReducer: {
reducer,
prepare: payload => ({ payload })
}
}
})

testSlice.reducer(0, testSlice.actions.testReducer('testPayload'))
expect(reducer).toHaveBeenCalledWith(
0,
expect.objectContaining({ payload: 'testPayload' })
)
})
})
})
72 changes: 56 additions & 16 deletions src/createSlice.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import { Reducer } from 'redux'
import { createAction, PayloadAction, PayloadActionCreator } from './createAction'
import { createReducer, CaseReducers } from './createReducer'
import {
createAction,
PayloadAction,
PayloadActionCreator,
PrepareAction
} from './createAction'
import { createReducer, CaseReducers, CaseReducer } from './createReducer'
import { createSliceSelector, createSelectorName } from './sliceSelector'

/**
Expand All @@ -12,8 +17,8 @@ export type SliceActionCreator<P> = PayloadActionCreator<P>

export interface Slice<
S = any,
AP extends { [key: string]: any } = { [key: string]: any }
> {
AC extends { [key: string]: any } = { [key: string]: any }
> {
/**
* The slice name.
*/
Expand All @@ -28,7 +33,7 @@ export interface Slice<
* Action creators for the types of actions that are handled by the slice
* reducer.
*/
actions: { [type in keyof AP]: PayloadActionCreator<AP[type]> }
actions: AC

/**
* Selectors for the slice reducer state. `createSlice()` inserts a single
Expand All @@ -44,8 +49,8 @@ export interface Slice<
*/
export interface CreateSliceOptions<
S = any,
CR extends CaseReducers<S, any> = CaseReducers<S, any>
> {
CR extends SliceCaseReducers<S, any> = SliceCaseReducers<S, any>
> {
/**
* The slice's name. Used to namespace the generated action types and to
* name the selector for retrieving the reducer's state.
Expand All @@ -72,12 +77,36 @@ export interface CreateSliceOptions<
extraReducers?: CaseReducers<S, any>
}

type CaseReducerActionPayloads<CR extends CaseReducers<any, any>> = {
type PayloadActions<T extends keyof any = string> = Record<T, PayloadAction>

type EnhancedCaseReducer<S, A extends PayloadAction> = {
reducer: CaseReducer<S, A>
prepare: PrepareAction<A['payload']>
}

type SliceCaseReducers<S, PA extends PayloadActions> = {
[T in keyof PA]: CaseReducer<S, PA[T]> | EnhancedCaseReducer<S, PA[T]>
}

type CaseReducerActions<CR extends SliceCaseReducers<any, any>> = {
[T in keyof CR]: CR[T] extends (state: any) => any
? void
: (CR[T] extends (state: any, action: PayloadAction<infer P>) => any
? P
: void)
? PayloadActionCreator<void>
: (CR[T] extends (state: any, action: PayloadAction<infer P>) => any
? PayloadActionCreator<P>
: CR[T] extends { prepare: PrepareAction<infer P> }
? PayloadActionCreator<P, string, CR[T]['prepare']>
: PayloadActionCreator<void>)
}

type NoInfer<T> = [T][T extends any ? 0 : never];
type SliceCaseReducersCheck<S, ACR> = {
[P in keyof ACR] : ACR[P] extends {
reducer(s:S, action?: { payload: infer O }): any
} ? {
prepare(...a:never[]): { payload: O }
} : {

}
}

function getType(slice: string, actionKey: string): string {
Expand All @@ -92,25 +121,36 @@ function getType(slice: string, actionKey: string): string {
*
* The `reducer` argument is passed to `createReducer()`.
*/
export function createSlice<S, CR extends CaseReducers<S, any>>(
export function createSlice<S, CR extends SliceCaseReducers<S, any>>(
options: CreateSliceOptions<S, CR> & { reducers: SliceCaseReducersCheck<S, NoInfer<CR>> }
): Slice<S, CaseReducerActions<CR>>
export function createSlice<S, CR extends SliceCaseReducers<S, any>>(
options: CreateSliceOptions<S, CR>
): Slice<S, CaseReducerActionPayloads<CR>> {
): Slice<S, CaseReducerActions<CR>> {
const { slice = '', initialState } = options
const reducers = options.reducers || {}
const extraReducers = options.extraReducers || {}
const actionKeys = Object.keys(reducers)

const reducerMap = actionKeys.reduce((map, actionKey) => {
map[getType(slice, actionKey)] = reducers[actionKey]
let maybeEnhancedReducer = reducers[actionKey]
map[getType(slice, actionKey)] =
typeof maybeEnhancedReducer === 'function'
? maybeEnhancedReducer
: maybeEnhancedReducer.reducer
return map
}, extraReducers)

const reducer = createReducer(initialState, reducerMap)

const actionMap = actionKeys.reduce(
(map, action) => {
let maybeEnhancedReducer = reducers[action]
const type = getType(slice, action)
map[action] = createAction(type)
map[action] =
typeof maybeEnhancedReducer === 'function'
? createAction(type)
: createAction(type, maybeEnhancedReducer.prepare)
return map
},
{} as any
Expand Down
Loading

0 comments on commit c033b99

Please sign in to comment.