From 9024664655a059bf7f871ebc1c1573d9f8bd42a0 Mon Sep 17 00:00:00 2001 From: Denis Washington Date: Tue, 5 Feb 2019 19:04:09 +0100 Subject: [PATCH] WIP: Revise createSlice() --- src/createSlice.test.ts | 174 +++++++++-------------- src/createSlice.ts | 101 +++++++------ src/sliceSelector.test.ts | 9 -- src/sliceSelector.ts | 30 ---- type-tests/files/createSlice.typetest.ts | 21 +-- 5 files changed, 120 insertions(+), 215 deletions(-) delete mode 100644 src/sliceSelector.test.ts delete mode 100644 src/sliceSelector.ts diff --git a/src/createSlice.test.ts b/src/createSlice.test.ts index ba11e14b0d..bf7ac5a9c5 100644 --- a/src/createSlice.test.ts +++ b/src/createSlice.test.ts @@ -1,130 +1,84 @@ import { createSlice } from './createSlice' -import { createAction } from './createAction' +import { createAction, PayloadAction } from './createAction' describe('createSlice', () => { - describe('when slice is empty', () => { - const { actions, reducer, selectors } = createSlice({ - reducers: { - increment: state => state + 1, - multiply: (state, action) => state * action.payload - }, - initialState: 0 - }) - - it('should create increment action', () => { - expect(actions.hasOwnProperty('increment')).toBe(true) - }) - - it('should create multiply action', () => { - expect(actions.hasOwnProperty('multiply')).toBe(true) - }) - - it('should have the correct action for increment', () => { - expect(actions.increment()).toEqual({ - type: 'increment', - payload: undefined - }) - }) - - it('should have the correct action for multiply', () => { - expect(actions.multiply(3)).toEqual({ - type: 'multiply', - payload: 3 - }) - }) - - describe('when using reducer', () => { - it('should return the correct value from reducer with increment', () => { - expect(reducer(undefined, actions.increment())).toEqual(1) - }) - - it('should return the correct value from reducer with multiply', () => { - expect(reducer(2, actions.multiply(3))).toEqual(6) - }) - }) - - describe('when using selectors', () => { - it('should create selector with correct name', () => { - expect(selectors.hasOwnProperty('getState')).toBe(true) - }) - - it('should return the slice state data', () => { - expect(selectors.getState(2)).toEqual(2) - }) - }) + const slice = createSlice({ + name: 'counter', + initialState: 0, + actions: { + increment: state => state + 1, + multiply: (state, { payload }: PayloadAction) => state * payload + } }) - describe('when passing slice', () => { - const { actions, reducer, selectors } = createSlice({ - reducers: { - increment: state => state + 1 - }, - initialState: 0, - slice: 'cool' - }) - - it('should create increment action', () => { - expect(actions.hasOwnProperty('increment')).toBe(true) - }) - - it('should have the correct action for increment', () => { - expect(actions.increment()).toEqual({ - type: 'cool/increment', - payload: undefined - }) - }) + it('should create action creators for `actions`', () => { + expect(slice.actions).toHaveProperty('increment') + expect(slice.actions).toHaveProperty('multiply') + }) - it('should return the correct value from reducer', () => { - expect(reducer(undefined, actions.increment())).toEqual(1) - }) + it('should namespace action types', () => { + expect(slice.actions.increment().type).toBe('counter/increment') + expect(slice.actions.multiply(2).type).toBe('counter/multiply') + }) - it('should create selector with correct name', () => { - expect(selectors.hasOwnProperty('getCool')).toBe(true) - }) + it('should support action payloads', () => { + expect(slice.actions.multiply(2).payload).toBe(2) + }) - it('should return the slice state data', () => { - expect(selectors.getCool({ cool: 2 })).toEqual(2) - }) + it('should not generate action creators for `extraReducers`', () => { + expect(slice.actions).not.toHaveProperty('RESET_APP') }) - describe('when mutating state object', () => { - const initialState = { user: '' } + it('should apply case reducers passed in `actions`', () => { + const state1 = slice(undefined, slice.actions.increment()) + const state2 = slice(state1, slice.actions.multiply(3)) + expect(state1).toBe(1) + expect(state2).toBe(3) + }) +}) - const { actions, reducer } = createSlice({ - reducers: { - setUserName: (state, action) => { - state.user = action.payload - } - }, - initialState, - slice: 'user' - }) +describe('when mutating state object', () => { + const initialState = { user: '' } + + const slice = createSlice({ + name: 'user', + actions: { + setUserName: (state, action) => { + state.user = action.payload + } + }, + initialState + }) - it('should set the username', () => { - expect(reducer(initialState, actions.setUserName('eric'))).toEqual({ - user: 'eric' - }) + it('should set the username', () => { + expect(slice(initialState, slice.actions.setUserName('eric'))).toEqual({ + user: 'eric' }) }) +}) - describe('when passing extra reducers', () => { - const addMore = createAction('ADD_MORE') +describe('when passing extra reducers', () => { + const addMore = createAction('ADD_MORE') + + const slice = createSlice({ + name: 'counter', + actions: { + increment: state => state + 1, + multiply: (state, action) => state * action.payload + }, + extraReducers: { + [addMore.type]: (state, action) => state + action.payload.amount + }, + initialState: 0 + }) - const { reducer } = createSlice({ - reducers: { - increment: state => state + 1, - multiply: (state, action) => state * action.payload - }, - extraReducers: { - [addMore.type]: (state, action) => state + action.payload.amount - }, - initialState: 0 - }) + it('should call extra reducers when their actions are dispatched', () => { + const result = slice(10, addMore({ amount: 5 })) - it('should call extra reducers when their actions are dispatched', () => { - const result = reducer(10, addMore({ amount: 5 })) + expect(result).toBe(15) + }) - expect(result).toBe(15) - }) + it('should not generate action creators for extra reducers ', () => { + expect(slice.actions).not.toHaveProperty('RESET_APP') }) }) diff --git a/src/createSlice.ts b/src/createSlice.ts index 53b885a6ed..9c69630f38 100644 --- a/src/createSlice.ts +++ b/src/createSlice.ts @@ -1,41 +1,31 @@ import { Action, AnyAction, Reducer } from 'redux' import { createAction, PayloadAction } from './createAction' import { createReducer, CaseReducersMapObject } from './createReducer' -import { createSliceSelector, createSelectorName } from './sliceSelector' /** * An action creator atttached to a slice. */ export type SliceActionCreator

= (payload: P) => PayloadAction

+/** + * A "slice" is a reducer with attached set of action creators. Each of the + * corresponding action types is considered to be "owned" by the slice, and + * is namespaced with the slice's name. + */ export interface Slice< S = any, A extends Action = AnyAction, AP extends { [key: string]: any } = { [key: string]: any } -> { - /** - * The slice name. - */ - slice: string - +> extends Reducer { /** - * The slice's reducer. + * The slice's name. Used as namespace for the slice's action types. */ - reducer: Reducer + sliceName: string /** - * Action creators for the types of actions that are handled by the slice - * reducer. + * The action creators for the action types "owned" by the slice. */ actions: { [type in keyof AP]: SliceActionCreator } - - /** - * Selectors for the slice reducer state. `createSlice()` inserts a single - * selector that returns the entire slice state and whose name is - * automatically derived from the slice name (e.g., `getCounter` for a slice - * named `counter`). - */ - selectors: { [key: string]: (state: any) => S } } /** @@ -48,10 +38,9 @@ export interface CreateSliceOptions< CR2 extends CaseReducersMapObject = CaseReducersMapObject > { /** - * The slice's name. Used to namespace the generated action types and to - * name the selector for retrieving the reducer's state. + * The slice's name. Used to namespace the generated action types. */ - slice?: string + name: string /** * The initial state to be returned by the slice reducer. @@ -59,21 +48,24 @@ export interface CreateSliceOptions< initialState: S /** - * A mapping from action types to action-type-specific *case reducer* - * functions. For every action type, a matching action creator will be - * generated using `createAction()`. + * An object whose keys are names of actions to generate action + * creators for, and whose values are *case reducers* to handle + * these actions. The latter are passed to `createReducer()` + * (together with the case reducers from `extraReducers`, if + * specified) to generate the slice reducer. */ - reducers: CR + actions: CR /** * 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. + * functions. No action creators are generated for these action types. + * The case reducers are passed to `createReducer()` (together with + * the ones from `actions`) to generate the slice reducer. */ extraReducers?: CR2 } -type ExtractPayloads< +type CaseReducerActionPayloads< S, A extends PayloadAction, CR extends CaseReducersMapObject @@ -85,7 +77,7 @@ type ExtractPayloads< : never) } -function getType(slice: string, actionKey: string): string { +function getSliceActionType(slice: string, actionKey: string): string { return slice ? `${slice}/${actionKey}` : actionKey } @@ -103,36 +95,41 @@ export function createSlice< CR extends CaseReducersMapObject = CaseReducersMapObject >( options: CreateSliceOptions -): Slice> { - const { slice = '', initialState } = options - const reducers = options.reducers || {} +): Slice> { + const { name, initialState } = options + const actionCaseReducers = options.actions || {} const extraReducers = options.extraReducers || {} - const actionKeys = Object.keys(reducers) - const reducerMap = actionKeys.reduce((map, actionKey) => { - map[getType(slice, actionKey)] = reducers[actionKey] - return map - }, extraReducers) - - const reducer = createReducer(initialState, reducerMap) + if (!name) { + throw new Error('Missing slice name') + } - const actionMap = actionKeys.reduce( + const actionNames = Object.keys(actionCaseReducers) + + const reducer = createReducer(initialState, { + ...extraReducers, + ...actionNames.reduce( + (map, actionName) => { + const actionType = getSliceActionType(name, actionName) + const caseReducer = actionCaseReducers[actionName] + map[actionType] = caseReducer + return map + }, + {} as CaseReducersMapObject + ) + }) + + const actions = actionNames.reduce( (map, action) => { - const type = getType(slice, action) + const type = getSliceActionType(name, action) map[action] = createAction(type) return map }, {} as any ) - const selectors = { - [createSelectorName(slice)]: createSliceSelector(slice) - } - - return { - slice, - reducer, - actions: actionMap, - selectors - } + return Object.assign(reducer, { + sliceName: name, + actions + }) } diff --git a/src/sliceSelector.test.ts b/src/sliceSelector.test.ts deleted file mode 100644 index 8df49698bf..0000000000 --- a/src/sliceSelector.test.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { createSelectorName } from './sliceSelector' - -describe('createSelectorName', () => { - it('should convert to camel case', () => { - expect(createSelectorName('some')).toEqual('getSome') - expect(createSelectorName('someThing')).toEqual('getSomeThing') - expect(createSelectorName('some-thing')).toEqual('getSomeThing') - }) -}) diff --git a/src/sliceSelector.ts b/src/sliceSelector.ts deleted file mode 100644 index 79813ef6ce..0000000000 --- a/src/sliceSelector.ts +++ /dev/null @@ -1,30 +0,0 @@ -export type Selector = (state: S) => R - -export function createSliceSelector(): Selector -export function createSliceSelector< - S extends { [key: string]: any } = any, - R = any ->(slice: string): Selector - -export function createSliceSelector(slice?: string) { - if (!slice) { - return (state: S): S => state - } - return (state: { [key: string]: any }): R => state[slice] -} - -export function createSelectorName(slice: string): string { - if (!slice) { - return 'getState' - } - return camelize(`get ${slice}`) -} - -function camelize(str: string): string { - return str - .replace(/(?:^\w|[A-Z]|\b\w)/g, (letter, index) => { - return index === 0 ? letter.toLowerCase() : letter.toUpperCase() - }) - .replace(/\s+/g, '') - .replace(/[-_]/g, '') -} diff --git a/type-tests/files/createSlice.typetest.ts b/type-tests/files/createSlice.typetest.ts index a048746361..98b7b0e4cd 100644 --- a/type-tests/files/createSlice.typetest.ts +++ b/type-tests/files/createSlice.typetest.ts @@ -13,9 +13,9 @@ import { const firstAction = createAction<{ count: number }>('FIRST_ACTION') const slice = createSlice({ - slice: 'counter', + name: 'counter', initialState: 0, - reducers: { + actions: { increment: (state: number, action) => state + action.payload, decrement: (state: number, action) => state - action.payload }, @@ -27,12 +27,12 @@ import { /* Reducer */ - const reducer: Reducer = slice.reducer + const reducer: Reducer = slice // typings:expect-error - const stringReducer: Reducer = slice.reducer + const stringReducer: Reducer = slice // typings:expect-error - const anyActionReducer: Reducer = slice.reducer + const anyActionReducer: Reducer = slice /* Actions */ @@ -41,13 +41,6 @@ import { // typings:expect-error slice.actions.other(1) - - /* Selector */ - - const value: number = slice.selectors.getCounter(0) - - // typings:expect-error - const stringValue: string = slice.selectors.getCounter(0) } /* @@ -55,9 +48,9 @@ import { */ { const counter = createSlice({ - slice: 'counter', + name: 'counter', initialState: 0, - reducers: { + actions: { increment: state => state + 1, decrement: state => state - 1, multiply: (state, action: PayloadAction) => state * action.payload