UNPKG

4.39 kBPlain TextView Raw
1import { createAsyncThunk } from './createAsyncThunk'
2import { createAction, PayloadAction } from './createAction'
3import { createSlice } from './createSlice'
4import { configureStore } from './configureStore'
5import { createEntityAdapter } from './entities/create_adapter'
6import { EntityAdapter } from './entities/models'
7import { BookModel } from './entities/fixtures/book'
8
9describe('Combined entity slice', () => {
10 let adapter: EntityAdapter<BookModel>
11
12 beforeEach(() => {
13 adapter = createEntityAdapter({
14 selectId: (book: BookModel) => book.id,
15 sortComparer: (a, b) => a.title.localeCompare(b.title)
16 })
17 })
18
19 it('Entity and async features all works together', async () => {
20 const upsertBook = createAction<BookModel>('otherBooks/upsert')
21
22 type BooksState = ReturnType<typeof adapter.getInitialState> & {
23 loading: 'initial' | 'pending' | 'finished' | 'failed'
24 lastRequestId: string | null
25 }
26
27 const initialState: BooksState = adapter.getInitialState({
28 loading: 'initial',
29 lastRequestId: null
30 })
31
32 const fakeBooks: BookModel[] = [
33 { id: 'b', title: 'Second' },
34 { id: 'a', title: 'First' }
35 ]
36
37 const fetchBooksTAC = createAsyncThunk<
38 BookModel[],
39 void,
40 {
41 state: { books: BooksState }
42 }
43 >(
44 'books/fetch',
45 async (arg, { getState, dispatch, extra, requestId, signal }) => {
46 const state = getState()
47 return fakeBooks
48 }
49 )
50
51 const booksSlice = createSlice({
52 name: 'books',
53 initialState,
54 reducers: {
55 addOne: adapter.addOne,
56 removeOne(state, action: PayloadAction<string>) {
57 const sizeBefore = state.ids.length
58 // Originally, having nested `produce` calls don't mutate `state` here as I would have expected.
59 // (note that `state` here is actually an Immer Draft<S>, from `createReducer`)
60 // One woarkound was to return the new plain result value instead
61 // See https://github.com/immerjs/immer/issues/533
62 // However, after tweaking `createStateOperator` to check if the argument is a draft,
63 // we can just treat the operator as strictly mutating, without returning a result,
64 // and the result should be correct.
65 const result = adapter.removeOne(state, action)
66
67 const sizeAfter = state.ids.length
68 if (sizeBefore > 0) {
69 expect(sizeAfter).toBe(sizeBefore - 1)
70 }
71
72 //Deliberately _don't_ return result
73 }
74 },
75 extraReducers: builder => {
76 builder.addCase(upsertBook, (state, action) => {
77 return adapter.upsertOne(state, action)
78 })
79 builder.addCase(fetchBooksTAC.pending, (state, action) => {
80 state.loading = 'pending'
81 state.lastRequestId = action.meta.requestId
82 })
83 builder.addCase(fetchBooksTAC.fulfilled, (state, action) => {
84 if (
85 state.loading === 'pending' &&
86 action.meta.requestId === state.lastRequestId
87 ) {
88 adapter.setAll(state, action.payload)
89 state.loading = 'finished'
90 state.lastRequestId = null
91 }
92 })
93 }
94 })
95
96 const { addOne, removeOne } = booksSlice.actions
97 const { reducer } = booksSlice
98
99 const store = configureStore({
100 reducer: {
101 books: reducer
102 }
103 })
104
105 await store.dispatch(fetchBooksTAC())
106
107 const { books: booksAfterLoaded } = store.getState()
108 // Sorted, so "First" goes first
109 expect(booksAfterLoaded.ids).toEqual(['a', 'b'])
110 expect(booksAfterLoaded.lastRequestId).toBe(null)
111 expect(booksAfterLoaded.loading).toBe('finished')
112
113 store.dispatch(addOne({ id: 'd', title: 'Remove Me' }))
114 store.dispatch(removeOne('d'))
115
116 store.dispatch(addOne({ id: 'c', title: 'Middle' }))
117
118 const { books: booksAfterAddOne } = store.getState()
119
120 // Sorted, so "Middle" goes in the middle
121 expect(booksAfterAddOne.ids).toEqual(['a', 'c', 'b'])
122
123 store.dispatch(upsertBook({ id: 'c', title: 'Zeroth' }))
124
125 const { books: booksAfterUpsert } = store.getState()
126
127 // Sorted, so "Zeroth" goes last
128 expect(booksAfterUpsert.ids).toEqual(['a', 'b', 'c'])
129 })
130})