UNPKG

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