Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

add prepareAction option to createAction #149

Merged
merged 4 commits into from
Jul 29, 2019
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 }
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just so you know, my eyes are still bleeding here :)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, that one is wild. But it defines many different behaviours :D

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FWIW here's how I understand this code.

PayloadActionCreator always has a type property of type T

If PA is a function we check if it has a meta field. If yes the final type is

{
  type: T extends string,
  meta: M,
  payload: P
}

If the return value does not have a meta field it's

{
  type: T extends string,
  payload: P
}

Now the else, if PA is not a function we default back to the older behavior which terrifies me.

@phryneas as some practical feedback, it might be helpful to make it clear to the reader

  1. where the if/else statements are
  2. what the final type will be in each scenario.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Almost, but no.

All Action creators share the { type: T }, but the rest of the functionality differs.

If a PA argument is passed, the first branch will be taken.

Within that branch, if PA returns something with { meta }, again take the first branch. Resulting type is a function (...args: Parameters<PA>) => PayloadAction<P, T, M> (with the additional type from above - all that follow will, too). Otherwise it's a function (...args: Parameters<PA>) => PayloadAction<P, T>).

Now, on to the other branch - no PA given. This has to handle some special cases first and then falls back to the most used case.

If P is undefined, it returns a method that might take 0 or 1 argument. If P is void, it's a method with 0 arguments. Otherwise it is a method with 1 argument. All these methods infer their argument and return that inferred argument as the payload of the returned action.

Formatting this is in a language that knows only ternaries and in a file that will be shuffled around with prettier is unfortunately something I did not achieve :/

? (...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']>
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

wat

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh, come on, THAT one isn't even so much different than before =D

Big difference is that it builds the complete ActionCreator type instead of just the payload, and also it now has 4 cases instead of 3.

: PayloadActionCreator<void>)
}

type NoInfer<T> = [T][T extends any ? 0 : never];
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

voodoo

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I'm still "wat" here, too :)

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