diff --git a/template/.prettierrc.js b/template/.prettierrc.js
index bd329d6..065698b 100644
--- a/template/.prettierrc.js
+++ b/template/.prettierrc.js
@@ -16,6 +16,7 @@ module.exports = {
'^@localization/(.*)$',
'^@navigation/(.*)$',
'^@redux/(.*)$',
+ '^@remote/(.*)$',
'^@screens/(.*)$',
'^@utils/(.*)$',
'^[./]',
diff --git a/template/README.md b/template/README.md
index e42c651..ed1e2b3 100644
--- a/template/README.md
+++ b/template/README.md
@@ -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)
- `screens/` - App screens
- `utils/` - Universal helpers
diff --git a/template/babel.config.js b/template/babel.config.js
index f165bc9..c87b96f 100644
--- a/template/babel.config.js
+++ b/template/babel.config.js
@@ -31,6 +31,7 @@ module.exports = {
'@localization': './src/localization',
'@navigation': './src/navigation',
'@redux': './src/redux',
+ '@remote': './src/remote',
'@screens': './src/screens',
'@utils': './src/utils',
},
diff --git a/template/package.json b/template/package.json
index b6e347d..43bfb91 100644
--- a/template/package.json
+++ b/template/package.json
@@ -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",
@@ -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",
diff --git a/template/src/App.tsx b/template/src/App.tsx
index c93c04f..e295174 100644
--- a/template/src/App.tsx
+++ b/template/src/App.tsx
@@ -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'
@@ -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 (
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
)
}
diff --git a/template/src/api/authSlice.test.ts b/template/src/api/authSlice.test.ts
index 05db540..430e51b 100644
--- a/template/src/api/authSlice.test.ts
+++ b/template/src/api/authSlice.test.ts
@@ -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,
- },
+ tokens: fakeAuthTokens,
_persist: {
rehydrated: true,
version: -1,
@@ -63,45 +40,13 @@ describe('#watchAuthTokens', () => {
})
})
-describe('#watchLogInSaga', () => {
- 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))
+ expect(state.tokens).toEqual(fakeAuthTokens)
})
})
})
diff --git a/template/src/api/authSlice.ts b/template/src/api/authSlice.ts
index dc9c14a..ad2daff 100644
--- a/template/src/api/authSlice.ts
+++ b/template/src/api/authSlice.ts
@@ -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]
@@ -26,23 +22,18 @@ interface RehydrateAction {
payload?: RehydratePayload
}
-function* setApiAuthConfig(
- action: ReturnType | ReturnType | RehydrateAction,
-) {
- const isLoginAction = logInAsyncSuccess.match(action)
+function* setApiAuthConfig(action: ReturnType | 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)
}
}
@@ -50,50 +41,31 @@ export function* watchAuthTokens() {
yield* takeLatest([logInAsyncSuccess, REHYDRATE, resetStore], setApiAuthConfig)
}
-function* logIn(action: ReturnType) {
- 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
+ tokens: AuthTokens | undefined
}
const initialState: AuthState = {
- tokens: NotRequested,
+ tokens: undefined,
}
export const authSlice = createSlice({
name: 'auth',
initialState,
reducers: {
- logInAsync: (state, _action: PayloadAction) => {
- state.tokens = Loading
- },
logInAsyncSuccess: (state, action: PayloadAction) => {
- state.tokens = Success(action.payload)
- },
- logInAsyncFailure: (state, action: PayloadAction) => {
- 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 = {
diff --git a/template/src/redux/rootSaga.ts b/template/src/redux/rootSaga.ts
index 859c6cf..451004f 100644
--- a/template/src/redux/rootSaga.ts
+++ b/template/src/redux/rootSaga.ts
@@ -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()])
}
diff --git a/template/src/remote/auth.test.ts b/template/src/remote/auth.test.ts
new file mode 100644
index 0000000..50d6e14
--- /dev/null
+++ b/template/src/remote/auth.test.ts
@@ -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
+mockLogIn.mockResolvedValue(mockTokens)
+
+jest.mock('@api/common', () => ({
+ setAuthConfig: jest.fn(),
+}))
+const mockSetAuthConfig = setAuthConfig as jest.MockedFunction
+
+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
+
+ 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)
+ })
+ })
+})
diff --git a/template/src/remote/auth.ts b/template/src/remote/auth.ts
new file mode 100644
index 0000000..df3f699
--- /dev/null
+++ b/template/src/remote/auth.ts
@@ -0,0 +1,43 @@
+import { useMutation, useQueryClient } from '@tanstack/react-query'
+import { logIn } from '@api/auth'
+import { logInAsyncSuccess } from '@api/authSlice'
+import { setAuthConfig } from '@api/common'
+import useAppDispatch from '@hooks/useAppDispatch'
+import { clearPersistence } from '@redux/persistence'
+import { resetStore } from '@redux/rootActions'
+import { persistor } from '@redux/store'
+
+export const useLogInMutation = () => {
+ const dispatch = useAppDispatch()
+
+ return useMutation({
+ mutationKey: ['auth', 'logIn'],
+ mutationFn: logIn,
+ onSuccess: tokens => {
+ setAuthConfig(tokens)
+ dispatch(logInAsyncSuccess(tokens))
+ },
+ })
+}
+
+export const useLogOutMutation = () => {
+ const dispatch = useAppDispatch()
+ const client = useQueryClient()
+
+ return useMutation({
+ mutationKey: ['auth', 'logOut'],
+ mutationFn: async () => {
+ Promise.resolve()
+ },
+ onSuccess: async () => {
+ // typical logout for apps that require user to be logged in
+ // before giving any further access
+ persistor.pause()
+ await clearPersistence()
+ setAuthConfig({})
+ dispatch(resetStore())
+ client.clear()
+ persistor.persist()
+ },
+ })
+}
diff --git a/template/src/remote/comics.test.ts b/template/src/remote/comics.test.ts
new file mode 100644
index 0000000..db897f6
--- /dev/null
+++ b/template/src/remote/comics.test.ts
@@ -0,0 +1,64 @@
+import { renderHook, waitFor } from '@testing-library/react-native'
+import { getLatestComic } from '@api/comics'
+import { Comic, ComicBE } from '@api/types/comic.types'
+import { createTestEnvWrapper } from '@utils/testing'
+import { useLatestComicQuery } from './comics'
+
+const mockComicBE: ComicBE = {
+ num: 1,
+ title: 'test title',
+ alt: 'test description',
+ img: 'test image url',
+} as ComicBE
+
+const mockComic: Comic = {
+ id: 1,
+ title: 'test title',
+ description: 'test description',
+ imageUrl: 'test image url',
+}
+
+const mockGetLatestComic = getLatestComic as jest.MockedFunction
+
+jest.mock('@api/comics', () => ({
+ getLatestComic: jest.fn().mockRejectedValue(mockComicBE),
+}))
+
+jest.useFakeTimers()
+
+describe('comics', () => {
+ let wrapper: ReturnType
+
+ beforeEach(() => {
+ jest.clearAllMocks()
+ wrapper = createTestEnvWrapper({})
+ })
+
+ describe('useLatestComicQuery', () => {
+ it('is initially loading', async () => {
+ const { result } = renderHook(() => useLatestComicQuery(), { wrapper })
+
+ expect(result.current).toMatchObject({
+ isLoading: true,
+ isSuccess: false,
+ data: undefined,
+ })
+ })
+
+ it('should fetch the latest comic', async () => {
+ mockGetLatestComic.mockResolvedValueOnce(mockComicBE)
+
+ const { result } = renderHook(() => useLatestComicQuery(), { wrapper })
+
+ await waitFor(() => expect(result.current.isSuccess).toBe(true))
+
+ expect(getLatestComic).toHaveBeenCalledTimes(1)
+
+ expect(result.current).toMatchObject({
+ data: mockComic,
+ isLoading: false,
+ isSuccess: true,
+ })
+ })
+ })
+})
diff --git a/template/src/remote/comics.ts b/template/src/remote/comics.ts
new file mode 100644
index 0000000..2d71894
--- /dev/null
+++ b/template/src/remote/comics.ts
@@ -0,0 +1,11 @@
+import { useQuery } from '@tanstack/react-query'
+import { getLatestComic } from '@api/comics'
+import { mapComic } from '@api/mappers/comicMappers'
+
+export const useLatestComicQuery = () => {
+ return useQuery({
+ queryKey: ['comic', 'latest'],
+ queryFn: getLatestComic,
+ select: comicBE => mapComic(comicBE),
+ })
+}
diff --git a/template/src/screens/DemoScreen.test.tsx b/template/src/screens/DemoScreen.test.tsx
index 2932dfd..f559f41 100644
--- a/template/src/screens/DemoScreen.test.tsx
+++ b/template/src/screens/DemoScreen.test.tsx
@@ -1,13 +1,26 @@
+import { comicMockParsed } from '__mocks__/fixtures'
import React from 'react'
import { TestIDs } from '@config/testIDs'
import Routes from '@navigation/routes'
-import { RemoteDataStates } from '@utils/api'
-import { createNavigationProps, fireEvent, render } from '@utils/testing'
+import * as remoteComics from '@remote/comics'
+import { act, createNavigationProps, fireEvent, render } from '@utils/testing'
import DemoScreen from './DemoScreen'
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const navPropsMock = createNavigationProps() as any
+const mockUseLatestComicQuery = jest.spyOn(remoteComics, 'useLatestComicQuery').mockReturnValue({
+ isLoading: false,
+ data: undefined,
+} as ReturnType)
+
+jest.mock('@remote/auth', () => ({
+ useLogOutMutation: jest.fn().mockImplementation(() => ({
+ mutate: jest.fn(),
+ isPending: false,
+ })),
+}))
+
describe('when increment button pressed', () => {
it('should increment counter by 5', () => {
const { getByText } = render()
@@ -15,7 +28,10 @@ describe('when increment button pressed', () => {
getByText(/demoScreen.counter/).props.children.split(' ')[1],
10,
)
- fireEvent.press(getByText(/demoScreen.incrementButton/))
+ act(() => {
+ fireEvent.press(getByText(/demoScreen.incrementButton/))
+ })
+
const counterValue = parseInt(getByText(/demoScreen.counter/).props.children.split(' ')[1], 10)
expect(counterValue).toBe(prevCounterValue + 5)
@@ -29,7 +45,10 @@ describe('when decrement button pressed', () => {
getByText(/demoScreen.counter/).props.children.split(' ')[1],
10,
)
- fireEvent.press(getByText(/demoScreen.decrementButton/))
+
+ act(() => {
+ fireEvent.press(getByText(/demoScreen.decrementButton/))
+ })
const counterValue = parseInt(getByText(/demoScreen.counter/).props.children.split(' ')[1], 10)
expect(counterValue).toBe(prevCounterValue - 15)
@@ -38,42 +57,41 @@ describe('when decrement button pressed', () => {
describe('Comic card', () => {
describe('when comic is available', () => {
- it('renders the comic', () => {
- const comicMock = {
- id: 1,
- title: 'Some mock title',
- imageUrl: 'http://example.com/test.jpg',
- description: 'Some mock description',
- }
+ it('renders the comic', async () => {
+ mockUseLatestComicQuery.mockReturnValueOnce({
+ isLoading: false,
+ data: comicMockParsed,
+ } as ReturnType)
+
const preloadedState = {
demo: {
counter: 420,
- comic: {
- state: RemoteDataStates.SUCCESS as const,
- data: comicMock,
- },
},
}
+
const { getByText, getByTestId } = render(, {
preloadedState,
})
- expect(getByText(comicMock.title)).toBeDefined()
- expect(getByText(comicMock.description)).toBeDefined()
+ expect(getByText(comicMockParsed.title)).toBeDefined()
+ expect(getByText(comicMockParsed.description)).toBeDefined()
expect(getByTestId(TestIDs.DEMO_COMIC_IMAGE)).toBeDefined()
})
})
describe('when NO comic is available', () => {
+ mockUseLatestComicQuery.mockReturnValue({
+ isLoading: true,
+ data: undefined,
+ } as ReturnType)
+
it('renders the loading spinner', () => {
const preloadedState = {
demo: {
counter: 420,
- comic: {
- state: RemoteDataStates.LOADING as const,
- },
},
}
+
const { getByTestId } = render(, {
preloadedState,
})
@@ -86,7 +104,9 @@ describe('Comic card', () => {
describe('when "go to translations demo" pressed', () => {
it('should navigate to translations demo screen', () => {
const { getByText } = render()
- fireEvent.press(getByText(/demoScreen.goToTranslationsDemo/))
+ act(() => {
+ fireEvent.press(getByText(/demoScreen.goToTranslationsDemo/))
+ })
expect(navPropsMock.navigation.navigate).toBeCalledWith(Routes.TRANSLATIONS_DEMO_SCREEN)
})
diff --git a/template/src/screens/DemoScreen.tsx b/template/src/screens/DemoScreen.tsx
index 90a3ddd..bebbf62 100644
--- a/template/src/screens/DemoScreen.tsx
+++ b/template/src/screens/DemoScreen.tsx
@@ -1,4 +1,4 @@
-import React, { useEffect, useState } from 'react'
+import React from 'react'
import { useTranslation } from 'react-i18next'
import { ActivityIndicator, Button, Image, StyleSheet, Text } from 'react-native'
import MainScreenLayout from '@components/layouts/MainScreenLayout'
@@ -9,17 +9,9 @@ import useAppDispatch from '@hooks/useAppDispatch'
import useAppSelector from '@hooks/useAppSelector'
import type { RootStackScreenProps } from '@navigation/navigators/RootStackNavigator'
import Routes from '@navigation/routes'
-import { clearPersistence } from '@redux/persistence'
-import { resetStore } from '@redux/rootActions'
-import { persistor } from '@redux/store'
-import { hasData, isNotRequested } from '@utils/api'
-import {
- decrementCounterBy,
- getLatestComicAsync,
- incrementCounterBy,
- selectComic,
- selectCounter,
-} from './demoSlice'
+import { useLogOutMutation } from '@remote/auth'
+import { useLatestComicQuery } from '@remote/comics'
+import { decrementCounterBy, incrementCounterBy, selectCounter } from './demoSlice'
export type DemoScreenParams = undefined
@@ -29,33 +21,13 @@ interface DemoScreenProps {
}
const DemoScreen = ({ navigation }: DemoScreenProps) => {
- const [isLogoutLoading, setIsLogoutLoading] = useState(false)
const counter = useAppSelector(selectCounter)
- const comicRequest = useAppSelector(selectComic)
const dispatch = useAppDispatch()
const { t } = useTranslation()
- useEffect(() => {
- if (isNotRequested(comicRequest)) {
- dispatch(getLatestComicAsync())
- }
- }, [comicRequest.state])
+ const { isLoading, data: comicData } = useLatestComicQuery()
- const logOut = async () => {
- try {
- setIsLogoutLoading(true)
- // typical logout for apps that require user to be logged in
- // before giving any further access
- persistor.pause()
- await clearPersistence()
- dispatch(resetStore())
- persistor.persist()
- } finally {
- setIsLogoutLoading(false)
- }
- }
-
- const comicData = hasData(comicRequest) ? comicRequest.data : null
+ const { mutate: logOut, isPending: isLogoutLoading } = useLogOutMutation()
return (
@@ -71,7 +43,7 @@ const DemoScreen = ({ navigation }: DemoScreenProps) => {
{`${t('demoScreen.counter')} ${counter}`}
- {comicData ? (
+ {!isLoading && comicData ? (
<>
{comicData.title}
{
{isLogoutLoading ? (
) : (
-
+
diff --git a/template/src/screens/LoginScreen.tsx b/template/src/screens/LoginScreen.tsx
index b3235bd..b55b999 100644
--- a/template/src/screens/LoginScreen.tsx
+++ b/template/src/screens/LoginScreen.tsx
@@ -1,15 +1,12 @@
import React from 'react'
import { useTranslation } from 'react-i18next'
import { ActivityIndicator, Button, StyleSheet } from 'react-native'
-import { logInAsync, selectAuthTokens } from '@api/authSlice'
import DemoTextInput from '@components/inputs/DemoTextInput'
import MainScreenLayout from '@components/layouts/MainScreenLayout'
import DemoCard from '@components/surfaces/DemoCard'
-import useAppDispatch from '@hooks/useAppDispatch'
-import useAppSelector from '@hooks/useAppSelector'
import { RootStackScreenProps } from '@navigation/navigators/RootStackNavigator'
import Routes from '@navigation/routes'
-import { isLoading } from '@utils/api'
+import { useLogInMutation } from '@remote/auth'
const username = 'FAKE_USERNAME'
const password = 'FAKE_PASSWORD'
@@ -17,20 +14,19 @@ const password = 'FAKE_PASSWORD'
export type LoginScreenParams = undefined
const LoginScreen: React.FC> = () => {
const { t } = useTranslation()
- const dispatch = useAppDispatch()
- const authTokensRequest = useAppSelector(selectAuthTokens)
+ const { isPending, mutate } = useLogInMutation()
return (
- {isLoading(authTokensRequest) ? (
+ {isPending ? (
) : (
diff --git a/template/src/screens/__tests__/demoSlice.test.ts b/template/src/screens/__tests__/demoSlice.test.ts
index 8b7a54f..ad86319 100644
--- a/template/src/screens/__tests__/demoSlice.test.ts
+++ b/template/src/screens/__tests__/demoSlice.test.ts
@@ -1,38 +1,6 @@
-import { comicMockParsed, comicMockResponse } from '__mocks__/fixtures'
-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 {
- decrementCounterBy,
- demoSlice,
- fetchLatestComic,
- getLatestComicAsync,
- getLatestComicAsyncFailure,
- getLatestComicAsyncSuccess,
- incrementCounterBy,
-} from '@screens/demoSlice'
-import { getLatestComic } from '../../api/comics'
-import { Failure, Loading, Refreshing, Success } from '../../utils/api'
+import { decrementCounterBy, demoSlice, incrementCounterBy } from '@screens/demoSlice'
describe('DemoSlice', () => {
- describe('Demo saga fetchLatestComic', () => {
- it('dispatches success action when the request succeed', () => {
- return expectSaga(fetchLatestComic)
- .provide([[matchers.call.fn(getLatestComic), comicMockResponse]])
- .put(getLatestComicAsyncSuccess(comicMockParsed))
- .run()
- })
-
- it('dispatches error action when the request fails', () => {
- const error = new Error('Demo error')
-
- return expectSaga(fetchLatestComic)
- .provide([[matchers.call.fn(getLatestComic), throwError(error)]])
- .put(getLatestComicAsyncFailure(error.message))
- .run()
- })
- })
-
const initialState = demoSlice.getInitialState()
describe('Demo reducer', () => {
@@ -58,47 +26,5 @@ describe('DemoSlice', () => {
expect(result.counter).toEqual(410)
})
-
- it('set getLatestComicAsync to Loading if there is no comic in store', () => {
- const result = demoSlice.reducer(initialState, {
- type: getLatestComicAsync,
- payload: comicMockResponse,
- })
-
- expect(result.comic).toEqual(Loading)
- })
-
- it('set getLatestComicAsync to Refreshing if there is already comic in store', () => {
- const newInitialState = {
- ...initialState,
- comic: Success(comicMockParsed),
- }
-
- const result = demoSlice.reducer(newInitialState, {
- type: getLatestComicAsync,
- payload: comicMockResponse,
- })
-
- expect(result.comic).toEqual(Refreshing(comicMockParsed))
- })
-
- it('set getLatestComicAsyncSuccess state', () => {
- const result = demoSlice.reducer(initialState, {
- type: getLatestComicAsyncSuccess,
- payload: comicMockResponse,
- })
-
- expect(result.comic).toEqual(Success(comicMockResponse))
- })
-
- it('set getLatestComicAsyncFailure state', () => {
- const error = 'Error message'
- const result = demoSlice.reducer(initialState, {
- type: getLatestComicAsyncFailure,
- payload: error,
- })
-
- expect(result.comic).toEqual(Failure(error))
- })
})
})
diff --git a/template/src/screens/demoSlice.ts b/template/src/screens/demoSlice.ts
index 04afb03..464f0ca 100644
--- a/template/src/screens/demoSlice.ts
+++ b/template/src/screens/demoSlice.ts
@@ -1,34 +1,12 @@
import { PayloadAction, createSlice } from '@reduxjs/toolkit'
-import { call, put, takeEvery } from 'typed-redux-saga'
-import { getLatestComic } from '@api/comics'
-import { mapComic } from '@api/mappers/comicMappers'
-import { Comic } from '@api/types/comic.types'
import { RootState } from '@redux/store'
-import { Failure, NotRequested, Pending, RemoteData, Success } from '@utils/api'
-import { getErrorMessage } from '@utils/error'
-
-export function* fetchLatestComic() {
- try {
- const comic = yield* call(getLatestComic)
- yield* put(getLatestComicAsyncSuccess(mapComic(comic)))
- } catch (error) {
- const errorMessage = getErrorMessage(error)
- yield* put(getLatestComicAsyncFailure(errorMessage))
- }
-}
-
-export function* watchGetLatestComicSaga() {
- yield* takeEvery(getLatestComicAsync, fetchLatestComic)
-}
interface DemoState {
counter: number
- comic: RemoteData
}
const initialState: DemoState = {
counter: 420,
- comic: NotRequested,
}
export const demoSlice = createSlice({
@@ -41,28 +19,11 @@ export const demoSlice = createSlice({
decrementCounterBy: (state, action: PayloadAction) => {
state.counter -= action.payload
},
- getLatestComicAsync: state => {
- state.comic = Pending(state.comic)
- },
- getLatestComicAsyncSuccess: (state, action: PayloadAction) => {
- state.comic = Success(action.payload)
- },
- getLatestComicAsyncFailure: (state, action: PayloadAction) => {
- state.comic = Failure(action.payload)
- },
},
})
-export const {
- incrementCounterBy,
- decrementCounterBy,
- getLatestComicAsync,
- getLatestComicAsyncSuccess,
- getLatestComicAsyncFailure,
-} = demoSlice.actions
+export const { incrementCounterBy, decrementCounterBy } = demoSlice.actions
export const selectCounter = (state: RootState) => state.demo.counter
-export const selectComic = (state: RootState) => state.demo.comic
-
export default demoSlice.reducer
diff --git a/template/src/utils/api.ts b/template/src/utils/api.ts
deleted file mode 100644
index 654a3b3..0000000
--- a/template/src/utils/api.ts
+++ /dev/null
@@ -1,87 +0,0 @@
-export enum RemoteDataStates {
- NOT_REQUESTED = 'NOT_REQUESTED',
- LOADING = 'LOADING',
- REFRESHING = 'REFRESHING',
- FAILURE = 'FAILURE',
- SUCCESS = 'SUCCESS',
-}
-
-type NotRequestedType = {
- state: RemoteDataStates.NOT_REQUESTED
-}
-
-type LoadingType = {
- state: RemoteDataStates.LOADING
-}
-
-type RefreshingType = {
- state: RemoteDataStates.REFRESHING
- data: T
-}
-
-type FailureType = {
- state: RemoteDataStates.FAILURE
- error: E
- data?: T
-}
-
-type SuccessType = {
- state: RemoteDataStates.SUCCESS
- data: T
-}
-
-// Constructors
-
-export const NotRequested: NotRequestedType = {
- state: RemoteDataStates.NOT_REQUESTED,
-}
-
-export const Loading: LoadingType = { state: RemoteDataStates.LOADING }
-
-export const Refreshing = (data: T): RefreshingType => ({
- state: RemoteDataStates.REFRESHING,
- data,
-})
-
-export const Pending = (remoteData: RemoteData) => {
- return hasData(remoteData) ? Refreshing(remoteData.data) : Loading
-}
-
-export const Failure = (error: E, data?: T): FailureType => ({
- state: RemoteDataStates.FAILURE,
- error,
- ...(data && { data }),
-})
-
-export const Success = (data: T): SuccessType => ({
- state: RemoteDataStates.SUCCESS,
- data,
-})
-
-export type RemoteData =
- | NotRequestedType
- | LoadingType
- | RefreshingType
- | FailureType
- | SuccessType
-
-export const isNotRequested = (
- remoteData: RemoteData,
-): remoteData is NotRequestedType => remoteData.state === RemoteDataStates.NOT_REQUESTED
-
-export const isLoading = (remoteData: RemoteData): remoteData is LoadingType =>
- remoteData.state === RemoteDataStates.LOADING
-
-export const isRefreshing = (remoteData: RemoteData): remoteData is RefreshingType =>
- remoteData.state === RemoteDataStates.REFRESHING
-
-export const isFailure = (remoteData: RemoteData): remoteData is FailureType =>
- remoteData.state === RemoteDataStates.FAILURE
-
-export const isSuccess = (remoteData: RemoteData): remoteData is SuccessType =>
- remoteData.state === RemoteDataStates.SUCCESS
-
-export const hasData = (
- remoteData: RemoteData,
-): remoteData is SuccessType | RefreshingType | Required> =>
- !!(remoteData as SuccessType | RefreshingType | Required>).data
diff --git a/template/src/utils/testing.tsx b/template/src/utils/testing.tsx
index 375b36d..a598b24 100644
--- a/template/src/utils/testing.tsx
+++ b/template/src/utils/testing.tsx
@@ -1,4 +1,5 @@
import { configureStore } from '@reduxjs/toolkit'
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { RenderOptions, render as rtlRender } from '@testing-library/react-native'
import React from 'react'
import { Provider } from 'react-redux'
@@ -10,6 +11,29 @@ interface Options extends RenderOptions {
store?: ReturnType
}
+export const createTestEnvWrapper = ({
+ preloadedState = {},
+ store = configureStore({ reducer, preloadedState }),
+}: Options) => {
+ const TestEnvWrapper = ({ children }: { children: JSX.Element }) => {
+ const queryClient = new QueryClient({
+ defaultOptions: {
+ queries: {
+ retry: false,
+ },
+ },
+ })
+
+ return (
+
+ {children}
+
+ )
+ }
+
+ return TestEnvWrapper
+}
+
function render(
ui: JSX.Element,
{
@@ -18,10 +42,10 @@ function render(
...renderOptions
}: Options = {},
) {
- function Wrapper({ children }: { children: JSX.Element }) {
- return {children}
- }
- return rtlRender(ui, { wrapper: Wrapper, ...renderOptions })
+ return rtlRender(ui, {
+ wrapper: createTestEnvWrapper({ preloadedState, store }),
+ ...renderOptions,
+ })
}
export * from '@testing-library/react-native'
export { render }
diff --git a/template/tsconfig.json b/template/tsconfig.json
index a836892..c632317 100644
--- a/template/tsconfig.json
+++ b/template/tsconfig.json
@@ -48,6 +48,7 @@
"@localization/*": ["./localization/*"],
"@navigation/*": ["./navigation/*"],
"@redux/*": ["./redux/*"],
+ "@remote/*": ["./remote/*"],
"@screens/*": ["./screens/*"],
"@utils/*": ["./utils/*"]
},