| 1 | import type { PayloadAction } from '@reduxjs/toolkit'
|
|---|
| 2 | import {
|
|---|
| 3 | createAsyncThunk,
|
|---|
| 4 | createAction,
|
|---|
| 5 | createSlice,
|
|---|
| 6 | configureStore,
|
|---|
| 7 | createEntityAdapter,
|
|---|
| 8 | } from '@reduxjs/toolkit'
|
|---|
| 9 | import type { EntityAdapter } from '@internal/entities/models'
|
|---|
| 10 | import type { BookModel } from '@internal/entities/tests/fixtures/book'
|
|---|
| 11 |
|
|---|
| 12 | describe('Combined entity slice', () => {
|
|---|
| 13 | let adapter: EntityAdapter<BookModel, string>
|
|---|
| 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 | })
|
|---|