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

Experiment: TanStack Query #39

Draft
wants to merge 13 commits into
base: develop
Choose a base branch
from
1 change: 1 addition & 0 deletions template/.prettierrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ module.exports = {
'^@localization/(.*)$',
'^@navigation/(.*)$',
'^@redux/(.*)$',
'^@remote/(.*)$',
'^@screens/(.*)$',
'^@utils/(.*)$',
'^[./]',
Expand Down
1 change: 1 addition & 0 deletions template/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ This app has been generated using [react-native-template-redbeard](https://githu
- `localization/` - Things related to user locale
- `navigation/` - Navigators, routes
- `redux/` - Actions, reducers, sagas
- `remote/` - Remote state (via TanStack Query)
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This indicates remote state (server state) in terms of reflecting inside the app what's stored on some remote server (backend). It's keeping networking state - loading, errors and data from requests and responses.

It's different than redux/, which is client local state, that doesn't need to by synchronised with server. It's updated by local user's action or some effects from remote state updates.

This structure allows to keep them separate.

- `screens/` - App screens
- `utils/` - Universal helpers

Expand Down
1 change: 1 addition & 0 deletions template/babel.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ module.exports = {
'@localization': './src/localization',
'@navigation': './src/navigation',
'@redux': './src/redux',
'@remote': './src/remote',
'@screens': './src/screens',
'@utils': './src/utils',
},
Expand Down
5 changes: 3 additions & 2 deletions template/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
"@react-navigation/native": "^6.0.10",
"@react-navigation/native-stack": "^6.6.2",
"@reduxjs/toolkit": "^1.6.1",
"@tanstack/react-query": "5.0.0-beta.15",
"dayjs": "^1.10.6",
"i18next": "^20.3.5",
"react": "18.2.0",
Expand Down Expand Up @@ -53,8 +54,8 @@
"@babel/runtime": "^7.20.0",
"@jambit/eslint-plugin-typed-redux-saga": "^0.4.0",
"@react-native-community/eslint-config": "^3.2.0",
"@testing-library/jest-native": "^4.0.1",
"@testing-library/react-native": "^7.2.0",
"@testing-library/jest-native": "^5.4.2",
"@testing-library/react-native": "^12.2.2",
"@trivago/prettier-plugin-sort-imports": "^4.1.1",
"@tsconfig/react-native": "^2.0.2",
"@types/jest": "^29.2.1",
Expand Down
19 changes: 12 additions & 7 deletions template/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { NavigationContainer } from '@react-navigation/native'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import StoreProvider from 'providers/StoreProvider'
import React from 'react'
import 'react-native-gesture-handler'
Expand All @@ -7,15 +8,19 @@ import SplashScreen from 'react-native-splash-screen'
import '@localization/i18n'
import RootStackNavigator from '@navigation/navigators/RootStackNavigator'

const queryClient = new QueryClient()

const App = () => {
return (
<StoreProvider>
<SafeAreaProvider>
<NavigationContainer onReady={SplashScreen.hide}>
<RootStackNavigator />
</NavigationContainer>
</SafeAreaProvider>
</StoreProvider>
<QueryClientProvider client={queryClient}>
<StoreProvider>
<SafeAreaProvider>
<NavigationContainer onReady={SplashScreen.hide}>
<RootStackNavigator />
</NavigationContainer>
</SafeAreaProvider>
</StoreProvider>
</QueryClientProvider>
)
}

Expand Down
61 changes: 3 additions & 58 deletions template/src/api/authSlice.test.ts
Original file line number Diff line number Diff line change
@@ -1,44 +1,21 @@
import { REHYDRATE } from 'redux-persist'
import { expectSaga } from 'redux-saga-test-plan'
import * as matchers from 'redux-saga-test-plan/matchers'
import { throwError } from 'redux-saga-test-plan/providers'
import { resetStore } from '@redux/rootActions'
import { Failure, Loading, RemoteDataStates, Success } from '@utils/api'
import { logIn } from './auth'
import {
authSlice,
logInAsync,
logInAsyncFailure,
logInAsyncSuccess,
watchAuthTokens,
watchLogInSaga,
} from './authSlice'
import { authSlice, logInAsyncSuccess, watchAuthTokens } from './authSlice'
import { setAuthConfig } from './common'

const fakeAuthTokens = {
accessToken: 'FAKE_ACCESS_TOKEN',
refreshToken: 'FAKE_REFRESH_TOKEN',
}
const fakeCredentials = { username: 'FAKE_USERNAME', password: 'FAKE_PASSWORD' }
const logInErrorMessage = 'Login failed'

describe('#watchAuthTokens', () => {
it('should set API auth tokens on successful login', () => {
return expectSaga(watchAuthTokens)
.call(setAuthConfig, fakeAuthTokens)
.dispatch(logInAsyncSuccess(fakeAuthTokens))
.silentRun()
})

it('should restore API auth tokens on REHYDRATE auth action', () => {
const rehydrateAction = {
type: REHYDRATE,
key: 'auth',
payload: {
tokens: {
data: fakeAuthTokens,
type: RemoteDataStates.SUCCESS,
Copy link
Contributor Author

Choose a reason for hiding this comment

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

no need to store and test request state, because it's implemented and tested in useQuery and useMutation

},
tokens: fakeAuthTokens,
_persist: {
rehydrated: true,
version: -1,
Expand All @@ -63,45 +40,13 @@ describe('#watchAuthTokens', () => {
})
})

describe('#watchLogInSaga', () => {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Tests of logging in logic are moved to tests of useLogInMutation

it('should log user in and put success action with tokens', () => {
return expectSaga(watchLogInSaga)
.provide([[matchers.call.fn(logIn), fakeAuthTokens]])
.put(logInAsyncSuccess(fakeAuthTokens))
.dispatch(logInAsync(fakeCredentials))
.silentRun()
})

it('should put log in error action wit error message on auth error', () => {
return expectSaga(watchLogInSaga)
.provide([[matchers.call.fn(logIn), throwError(new Error(logInErrorMessage))]])
.put(logInAsyncFailure(logInErrorMessage))
.dispatch(logInAsync(fakeCredentials))
.silentRun()
})
})

describe('#authSlice', () => {
const initialState = authSlice.getInitialState()

describe('#loginAsync', () => {
it('should change tokens state to Loading', () => {
const state = authSlice.reducer(initialState, logInAsync(fakeCredentials))
expect(state.tokens).toEqual(Loading)
})
})

describe('#logInAsyncSuccess', () => {
it('should store auth tokens', () => {
const state = authSlice.reducer(initialState, logInAsyncSuccess(fakeAuthTokens))
expect(state.tokens).toEqual(Success(fakeAuthTokens))
})
})

describe('#logInAsyncFailure', () => {
it('should store auth error message', () => {
const state = authSlice.reducer(initialState, logInAsyncFailure(logInErrorMessage))
expect(state.tokens).toEqual(Failure(logInErrorMessage))
Copy link
Contributor Author

@szymonkoper szymonkoper Aug 25, 2023

Choose a reason for hiding this comment

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

Removed those tests because there is no need to test switching states: Loading, Success and Failure, because it's handled by useQuery/useMutation in library

expect(state.tokens).toEqual(fakeAuthTokens)
})
})
})
48 changes: 10 additions & 38 deletions template/src/api/authSlice.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,11 @@
import { PayloadAction, createSlice } from '@reduxjs/toolkit'
import { REHYDRATE, persistReducer } from 'redux-persist'
import { PersistPartial } from 'redux-persist/es/persistReducer'
import { call, put, takeLatest, takeLeading } from 'typed-redux-saga'
import { logIn as logInRequest } from '@api/auth'
import { call, takeLatest } from 'typed-redux-saga'
import { AuthTokens, setAuthConfig } from '@api/common'
import { Credentials } from '@api/types/auth.types'
import { safeStorage } from '@redux/persistence'
import { resetStore } from '@redux/rootActions'
import { RootState } from '@redux/store'
import { Failure, Loading, NotRequested, RemoteData, Success, isSuccess } from '@utils/api'
import { getErrorMessage } from '@utils/error'

type TopLevelStoreStates = {
[K in keyof RootState]: RootState[K]
Expand All @@ -26,74 +22,50 @@ interface RehydrateAction {
payload?: RehydratePayload
}

function* setApiAuthConfig(
action: ReturnType<typeof logInAsyncSuccess> | ReturnType<typeof resetStore> | RehydrateAction,
) {
const isLoginAction = logInAsyncSuccess.match(action)
function* setApiAuthConfig(action: ReturnType<typeof resetStore> | RehydrateAction) {
const isResetStoreAction = resetStore.match(action)

if (isLoginAction) {
yield* call(setAuthConfig, action.payload)
} else if (isResetStoreAction) {
if (isResetStoreAction) {
yield* call(setAuthConfig, { accessToken: undefined, refreshToken: undefined })
} else if (
action.key === authPersistConfig.key &&
action.payload &&
'tokens' in action.payload &&
isSuccess(action.payload.tokens)
action.payload.tokens
) {
yield* call(setAuthConfig, action.payload.tokens.data)
yield* call(setAuthConfig, action.payload.tokens)
}
}

export function* watchAuthTokens() {
yield* takeLatest([logInAsyncSuccess, REHYDRATE, resetStore], setApiAuthConfig)
}

function* logIn(action: ReturnType<typeof logInAsync>) {
try {
const { accessToken, refreshToken } = yield* call(logInRequest, action.payload)
yield* put(logInAsyncSuccess({ accessToken, refreshToken }))
} catch (error) {
yield* put(logInAsyncFailure(getErrorMessage(error)))
}
}

export function* watchLogInSaga() {
yield* takeLeading(logInAsync, logIn)
}

interface AuthState {
tokens: RemoteData<AuthTokens, Error['message']>
tokens: AuthTokens | undefined
}

const initialState: AuthState = {
tokens: NotRequested,
tokens: undefined,
}

export const authSlice = createSlice({
name: 'auth',
initialState,
reducers: {
logInAsync: (state, _action: PayloadAction<Credentials>) => {
state.tokens = Loading
},
logInAsyncSuccess: (state, action: PayloadAction<AuthTokens>) => {
state.tokens = Success(action.payload)
},
logInAsyncFailure: (state, action: PayloadAction<Error['message']>) => {
state.tokens = Failure(action.payload)
state.tokens = action.payload
},
},
})

export const { logInAsync, logInAsyncSuccess, logInAsyncFailure } = authSlice.actions
export const { logInAsyncSuccess } = authSlice.actions

export const selectAuthTokens = (state: RootState) => state.auth.tokens

export const selectIsLoggedIn = (state: RootState) => {
const { tokens } = state.auth
return isSuccess(tokens) && Boolean(tokens.data.accessToken)
return !!tokens?.accessToken
}

const authPersistConfig = {
Expand Down
5 changes: 2 additions & 3 deletions template/src/redux/rootSaga.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { all } from 'typed-redux-saga'
import { watchAuthTokens, watchLogInSaga } from '@api/authSlice'
import { watchGetLatestComicSaga } from '@screens/demoSlice'
import { watchAuthTokens } from '@api/authSlice'

export default function* rootSaga() {
yield* all([watchAuthTokens(), watchLogInSaga(), watchGetLatestComicSaga()])
yield* all([watchAuthTokens()])
}
101 changes: 101 additions & 0 deletions template/src/remote/auth.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { renderHook, waitFor } from '@testing-library/react-native'
import { logIn } from '@api/auth'
import { logInAsyncSuccess } from '@api/authSlice'
import { setAuthConfig } from '@api/common'
import * as persistence from '@redux/persistence'
import { resetStore } from '@redux/rootActions'
import { persistor } from '@redux/store'
import { createTestEnvWrapper } from '@utils/testing'
import { useLogInMutation, useLogOutMutation } from './auth'

const mockCredentials = {
username: 'testUsername',
password: 'testPassword',
}

const mockTokens = {
accessToken: 'testAccessToken',
refreshToken: 'testRefreshToken',
}

jest.mock('@api/auth', () => ({
logIn: jest.fn(),
}))
const mockLogIn = logIn as jest.MockedFunction<typeof logIn>
mockLogIn.mockResolvedValue(mockTokens)

jest.mock('@api/common', () => ({
setAuthConfig: jest.fn(),
}))
const mockSetAuthConfig = setAuthConfig as jest.MockedFunction<typeof setAuthConfig>

const mockDispatch = jest.fn()
jest.mock('@hooks/useAppDispatch', () => ({
__esModule: true,
default: jest.fn(() => {
return mockDispatch
}),
}))

jest.mock('@redux/store', () => ({
persistor: {
pause: jest.fn(),
persist: jest.fn(),
},
}))

jest.useFakeTimers()

describe('auth', () => {
let wrapper: ReturnType<typeof createTestEnvWrapper>

beforeEach(() => {
jest.clearAllMocks()
wrapper = createTestEnvWrapper({})
})

describe('useLogInMutation', () => {
it('calls onSuccess', async () => {
const { result } = renderHook(() => useLogInMutation(), { wrapper })

result.current.mutate(mockCredentials)

await waitFor(() => expect(result.current.isSuccess).toBe(true))

expect(result.current).toMatchObject({
isSuccess: true,
data: mockTokens,
})

expect(mockSetAuthConfig).toHaveBeenCalledTimes(1)
expect(mockSetAuthConfig).toHaveBeenLastCalledWith(mockTokens)
expect(mockDispatch).toHaveBeenCalledTimes(1)
expect(mockDispatch).toHaveBeenLastCalledWith(logInAsyncSuccess(mockTokens))
})
})

describe('useLogOutMutation', () => {
it('calls onSuccess', async () => {
const mockClearPersistence = jest.spyOn(persistence, 'clearPersistence')

const { result } = renderHook(() => useLogOutMutation(), { wrapper })

result.current.mutate()

await waitFor(() => expect(result.current.isSuccess).toBe(true))

expect(result.current).toMatchObject({
isSuccess: true,
data: undefined,
})

expect(persistor.pause).toHaveBeenCalledTimes(1)
expect(mockClearPersistence).toHaveBeenCalledTimes(1)
expect(mockSetAuthConfig).toHaveBeenCalledTimes(1)
expect(mockSetAuthConfig).toHaveBeenLastCalledWith({})
expect(mockDispatch).toHaveBeenCalledTimes(1)
expect(mockDispatch).toHaveBeenLastCalledWith(resetStore())
expect(persistor.persist).toHaveBeenCalledTimes(1)
})
})
})
Loading