diff --git a/README.md b/README.md index 704cd63..8b037ed 100644 --- a/README.md +++ b/README.md @@ -10,11 +10,20 @@ npx react-native init MyProject --template https://github.com/brains-and-beards/ Project inits with some demo usage files. You can browse them to see the proposed usage patterns. After init feel free to remove them: +- `src/__mocks__/fixtures` +- `src/api/mappers/comicMappers.ts` +- `src/api/types/comic.types.ts` - `src/api/comics.ts` +- `src/components/inputs/DemoTextInput.tsx` - `src/components/surfaces/DemoCard.tsx` - `src/localization/pl` -- `src/model/ComicModel.ts` +- `src/screens/LoginScreen.tsx` - `src/screens/DemoScreen.tsx` - `src/screens/DemoScreen.test.tsx` - `src/screens/TranslationsDemoScreen` - `src/screens/demoSlice.ts` +- `src/screens/__tests__/demoSlice.test.ts` + +## Security + +Project contains basic code for auth token management. It stores sensitive information with [react-native-encrypted-storage](https://github.com/emeraldsanto/react-native-encrypted-storage#readme) library. Although it has been listed in [react-native's official docs](https://reactnative.dev/docs/security#:~:text=react%2Dnative%2Dencrypted%2Dstorage%20%2D%20uses%20Keychain%20on%20iOS%20and%20EncryptedSharedPreferences%20on%20Android.), it's a community-maintained package. **Use at your own risk** or replace it with your own implementation. diff --git a/template/.buckconfig b/template/.buckconfig deleted file mode 100644 index 934256c..0000000 --- a/template/.buckconfig +++ /dev/null @@ -1,6 +0,0 @@ - -[android] - target = Google Inc.:Google APIs:23 - -[maven_repositories] - central = https://repo1.maven.org/maven2 diff --git a/template/.eslintrc.js b/template/.eslintrc.js index 9f48e86..e5bdcd1 100644 --- a/template/.eslintrc.js +++ b/template/.eslintrc.js @@ -15,6 +15,7 @@ module.exports = { 'prettier', 'jest', 'eslint-comments', + '@jambit/typed-redux-saga', ], env: { 'react-native/react-native': true, @@ -65,6 +66,11 @@ module.exports = { // React-Native Plugin 'react-native/no-inline-styles': 1, + + // Typed Redux Saga Plugin + + '@jambit/typed-redux-saga/use-typed-effects': 'error', // ensures, that you import from typed-redux-saga instead of redux-saga/effects + '@jambit/typed-redux-saga/delegate-effects': 'error', // ensures, that you use yield* on effects from typed-redux-saga }, settings: { react: { diff --git a/template/.node-version b/template/.node-version index b6a7d89..3c03207 100644 --- a/template/.node-version +++ b/template/.node-version @@ -1 +1 @@ -16 +18 diff --git a/template/.prettierrc.js b/template/.prettierrc.js index f13d57d..bd329d6 100644 --- a/template/.prettierrc.js +++ b/template/.prettierrc.js @@ -14,7 +14,6 @@ module.exports = { '^@hoc/(.*)$', '^@hooks/(.*)$', '^@localization/(.*)$', - '^@models/(.*)$', '^@navigation/(.*)$', '^@redux/(.*)$', '^@screens/(.*)$', diff --git a/template/README.md b/template/README.md index 7288d92..e42c651 100644 --- a/template/README.md +++ b/template/README.md @@ -2,14 +2,19 @@ This app has been generated using [react-native-template-redbeard](https://github.com/brains-and-beards/react-native-template-redbeard). It includes preconfigured file structure and packages. It also inits with some demo usage files, feel free to remove these: +- `src/__mocks__/fixtures` +- `src/api/mappers/comicMappers.ts` +- `src/api/types/comic.types.ts` - `src/api/comics.ts` +- `src/components/inputs/DemoTextInput.tsx` - `src/components/surfaces/DemoCard.tsx` - `src/localization/pl` -- `src/model/ComicModel.ts` +- `src/screens/LoginScreen.tsx` - `src/screens/DemoScreen.tsx` - `src/screens/DemoScreen.test.tsx` - `src/screens/TranslationsDemoScreen` - `src/screens/demoSlice.ts` +- `src/screens/__tests__/demoSlice.test.ts` ## File tree @@ -28,6 +33,10 @@ This app has been generated using [react-native-template-redbeard](https://githu - `screens/` - App screens - `utils/` - Universal helpers +## Authentication + +App uses preconfigured auth token management. It stores sensitive information via [react-native-encrypted-storage](https://github.com/emeraldsanto/react-native-encrypted-storage#readme) library. + ## Environments There are three different environments preconfigured with [react-native-config](https://github.com/luggit/react-native-config). Use `.env.[development|staging|production]` files to place things like `API_URL`s etc. diff --git a/template/android/app/build.gradle b/template/android/app/build.gradle index acb4fff..dbdbd95 100644 --- a/template/android/app/build.gradle +++ b/template/android/app/build.gradle @@ -34,7 +34,7 @@ react { // The list of variants to that are debuggable. For those we're going to // skip the bundling of the JS bundle and the assets. By default is just 'debug'. // If you add flavors like lite, prod, etc. you'll have to list your debuggableVariants. - // debuggableVariants = ["liteDebug", "prodDebug"] + debuggableVariants = ["developmentDebug", "stagingDebug", "productionDebug"] /* Bundling */ // A list containing the node command and its flags. Default is just 'node'. // nodeExecutableAndArgs = ["node"] @@ -62,12 +62,6 @@ react { // hermesFlags = ["-O", "-output-source-map"] } -project.ext.react = [ - enableHermes: true, // clean and rebuild if changing -] - -apply from: "../../node_modules/react-native/react.gradle" - /** * Set this to true to create four separate APKs instead of one, * one for each native architecture. This is useful if you don't @@ -94,15 +88,6 @@ def enableProguardInReleaseBuilds = false */ def jscFlavor = 'org.webkit:android-jsc:+' -/** - * Whether to enable the Hermes VM. - * -* This should be set on project.ext.react and that value will be read here. If it is not set - * on project.ext.react, JavaScript will not be compiled to Hermes Bytecode - * and the benefits of using Hermes will therefore be sharply reduced. - */ -def enableHermes = project.ext.react.get("enableHermes", false); - /** * Architectures to build native code for. */ diff --git a/template/android/app/src/main/java/com/helloworld/MainActivity.java b/template/android/app/src/main/java/com/helloworld/MainActivity.java index a0455dd..462a492 100644 --- a/template/android/app/src/main/java/com/helloworld/MainActivity.java +++ b/template/android/app/src/main/java/com/helloworld/MainActivity.java @@ -38,26 +38,6 @@ protected ReactActivityDelegate createReactActivityDelegate() { ); } - public static class MainActivityDelegate extends ReactActivityDelegate { - public MainActivityDelegate(ReactActivity activity, String mainComponentName) { - super(activity, mainComponentName); - } - @Override - protected ReactRootView createRootView() { - ReactRootView reactRootView = new ReactRootView(getContext()); - // If you opted-in for the New Architecture, we enable the Fabric Renderer. - reactRootView.setIsFabric(BuildConfig.IS_NEW_ARCHITECTURE_ENABLED); - return reactRootView; - } - - @Override - protected boolean isConcurrentRootEnabled() { - // If you opted-in for the New Architecture, we enable Concurrent Root (i.e. React 18). - // More on this on https://reactjs.org/blog/2022/03/29/react-v18.html - return BuildConfig.IS_NEW_ARCHITECTURE_ENABLED; - } - } - @Override protected void onCreate(Bundle savedInstanceState) { SplashScreen.show(this, R.style.SplashScreen_SecondTheme, true); diff --git a/template/babel.config.js b/template/babel.config.js index 29dcc85..f165bc9 100644 --- a/template/babel.config.js +++ b/template/babel.config.js @@ -29,7 +29,6 @@ module.exports = { '@hoc': './src/hoc', '@hooks': './src/hooks', '@localization': './src/localization', - '@models': './src/models', '@navigation': './src/navigation', '@redux': './src/redux', '@screens': './src/screens', diff --git a/template/jest.setup.ts b/template/jest.setup.ts index db244c5..401d5d4 100644 --- a/template/jest.setup.ts +++ b/template/jest.setup.ts @@ -1,9 +1,8 @@ +import { enableFetchMocks } from 'jest-fetch-mock' import 'react-native-gesture-handler/jestSetup' +import { Middleware } from 'redux' -jest.useFakeTimers() - -const customGlobal = global -customGlobal.fetch = require('jest-fetch-mock') +enableFetchMocks() // react-native-config-node: read env variables from .env.example process.env.NODE_ENV = 'example' @@ -34,3 +33,25 @@ jest.mock('react-native-splash-screen', () => ({ show: jest.fn(), hide: jest.fn(), })) +jest.mock('react-native-encrypted-storage', () => ({ + setItem: jest.fn(() => Promise.resolve(true)), + getItem: jest.fn(() => Promise.resolve('fake-stored-value')), + removeItem: jest.fn(() => Promise.resolve()), + clear: jest.fn(() => Promise.resolve()), +})) +jest.mock('redux-persist', () => { + const real = jest.requireActual('redux-persist') + return { + ...real, + persistReducer: jest.fn().mockImplementation((_config, reducers) => reducers), + } +}) +jest.mock('react-native-mmkv-flipper-plugin', () => ({ + initializeMMKVFlipper: jest.fn(), +})) +jest.mock('redux-flipper', () => { + const fakeMiddleware: Middleware = _store => next => action => next(action) + return { + default: jest.fn(() => fakeMiddleware), + } +}) diff --git a/template/package.json b/template/package.json index e470da0..a1605fc 100644 --- a/template/package.json +++ b/template/package.json @@ -3,11 +3,11 @@ "version": "0.0.1", "private": true, "scripts": { - "android:dev": "react-native run-android --variant=developmentdebug --appIdSuffix development", + "android:dev": "react-native run-android --variant=developmentDebug --appIdSuffix development", "android:devRelease": "react-native run-android --variant=developmentrelease --appIdSuffix development", - "android:stg": "react-native run-android --variant=stagingdebug --appIdSuffix staging", + "android:stg": "react-native run-android --variant=stagingDebug --appIdSuffix staging", "android:stgRelease": "react-native run-android --variant=stagingrelease --appIdSuffix staging", - "android:prod": "react-native run-android --variant=productiondebug", + "android:prod": "react-native run-android --variant=productionDebug", "android:prodRelease": "react-native run-android --variant=productionrelease", "ios:dev": "react-native run-ios --scheme Development", "ios:stg": "react-native run-ios --scheme Staging", @@ -30,8 +30,11 @@ "react-i18next": "^11.11.4", "react-native": "0.71.6", "react-native-config": "^1.4.3", + "react-native-encrypted-storage": "^4.0.3", "react-native-flipper": "^0.178.1", "react-native-gesture-handler": "^1.10.3", + "react-native-mmkv": "^2.8.0", + "react-native-mmkv-flipper-plugin": "^1.0.0", "react-native-reanimated": "^2.8.0", "react-native-safe-area-context": "^3.2.0", "react-native-screens": "^3.4.0", @@ -40,12 +43,15 @@ "react-redux": "^7.2.4", "redux": "^4.1.0", "redux-flipper": "^2.0.1", - "redux-saga": "^1.1.3" + "redux-persist": "^6.0.0", + "redux-saga": "^1.1.3", + "typed-redux-saga": "^1.5.0" }, "devDependencies": { "@babel/core": "^7.20.0", "@babel/preset-env": "^7.20.0", "@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", diff --git a/template/src/__mocks__/mockedApi.ts b/template/src/__mocks__/mockedApi.ts deleted file mode 100644 index 6a4bfc9..0000000 --- a/template/src/__mocks__/mockedApi.ts +++ /dev/null @@ -1,20 +0,0 @@ -interface MockedApiParams { - json: jest.Mock - ok: boolean - status: number - headers: Headers -} - -export const getMockedApiResponse = ( - json = jest.fn(), - ok = true, - status = 200, - headers = new Headers({ - 'Content-Type': 'application/json', - }), -): MockedApiParams => ({ - json, - ok, - status, - headers, -}) diff --git a/template/src/models/RemoteData.ts b/template/src/api/RemoteData.ts similarity index 100% rename from template/src/models/RemoteData.ts rename to template/src/api/RemoteData.ts diff --git a/template/src/api/apiSaga.ts b/template/src/api/apiSaga.ts deleted file mode 100644 index f6b1cbe..0000000 --- a/template/src/api/apiSaga.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { ActionCreatorWithPayload } from '@reduxjs/toolkit' -import { call, delay, put, race } from 'redux-saga/effects' -import { API_TIMEOUT } from '@config/timing' -import { getErrorMessage } from '@utils/getMessageFromError' -import tryJson from '@utils/tryJson' - -export type SuccessResponse = { json: object; headers: Headers } -type ApiCallResponse = Response | undefined - -interface ApiCallOptions { - onError: ActionCreatorWithPayload -} - -export function* makeApiCall

( - apiRequest: (payload?: P) => Promise, - options: ApiCallOptions & { payload?: P }, -): Generator { - const { payload, onError } = options - - try { - const { apiResponse, timeout } = (yield race({ - apiResponse: call(apiRequest, payload), - timeout: delay(API_TIMEOUT), - })) as { apiResponse?: ApiCallResponse; timeout?: boolean } - - if (timeout) { - throw new Error('Request timed out.') - } - - const headers = apiResponse?.headers - if (!apiResponse || !headers) { - const error = new Error('no API response or headers') - throw error - } - - if (!apiResponse.ok) { - const message = `Server returned ${apiResponse.status} code! Response: ${tryJson( - apiResponse, - )}` - throw new Error(message) - } - - const parsedResponse = (yield apiResponse.json()) as object | { errors: string[] } - - // eslint-disable-next-line no-prototype-builtins - if (parsedResponse.hasOwnProperty('errors')) { - const errorJSON = parsedResponse as { errors: string[] } - throw new Error(errorJSON.errors.join(', ')) - } - - const json = parsedResponse as object - return { json, headers } - } catch (error) { - console.error('[makeApiCall] Error:', getErrorMessage(error)) - yield put(onError(getErrorMessage(error))) - - return null - } -} diff --git a/template/src/api/auth.ts b/template/src/api/auth.ts new file mode 100644 index 0000000..9676838 --- /dev/null +++ b/template/src/api/auth.ts @@ -0,0 +1,21 @@ +import { AuthTokens } from './common' +import { Credentials } from './types/auth.types' + +// This is the place where you want to call your auth provider API + +export const logIn = (_credentials: Credentials) => { + return new Promise<{ accessToken: string; refreshToken: string }>(resolve => { + const fakeAuthTokens = { accessToken: 'FAKE_ACCESS_TOKEN', refreshToken: 'FAKE_REFRESH_TOKEN' } + setTimeout(() => resolve(fakeAuthTokens), 500) + }) +} + +export const refreshTokens = (_refreshToken: AuthTokens['refreshToken']) => { + return new Promise<{ accessToken: string; refreshToken: string }>(resolve => { + const fakeRefreshedAuthTokens = { + accessToken: 'REFRESHED_ACCESS_TOKEN', + refreshToken: 'REFRESHED_REFRESH_TOKEN', + } + setTimeout(() => resolve(fakeRefreshedAuthTokens), 500) + }) +} diff --git a/template/src/api/authSlice.test.ts b/template/src/api/authSlice.test.ts new file mode 100644 index 0000000..bca0843 --- /dev/null +++ b/template/src/api/authSlice.test.ts @@ -0,0 +1,107 @@ +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 { Failure, Loading, RemoteDataType, Success } from '@api/RemoteData' +import { resetStore } from '@redux/rootActions' +import { logIn } from './auth' +import { + authSlice, + logInAsync, + logInAsyncFailure, + logInAsyncSuccess, + watchAuthTokens, + watchLogInSaga, +} 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: RemoteDataType.Success, + }, + _persist: { + rehydrated: true, + version: -1, + }, + }, + } + return expectSaga(watchAuthTokens) + .call(setAuthConfig, fakeAuthTokens) + .dispatch(rehydrateAction) + .silentRun() + }) + + it('should clear API auth tokens on store reset action', () => { + const fakeBlankAuthTokens = { + accessToken: undefined, + refreshToken: undefined, + } + return expectSaga(watchAuthTokens) + .call(setAuthConfig, fakeBlankAuthTokens) + .dispatch(resetStore()) + .silentRun() + }) +}) + +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)) + }) + }) +}) diff --git a/template/src/api/authSlice.ts b/template/src/api/authSlice.ts new file mode 100644 index 0000000..b1d1d01 --- /dev/null +++ b/template/src/api/authSlice.ts @@ -0,0 +1,104 @@ +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 { Failure, Loading, NotRequested, RemoteData, Success, isSuccess } from '@api/RemoteData' +import { logIn as logInRequest } from '@api/auth' +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 { getErrorMessage } from '@utils/error' + +type TopLevelStoreStates = { + [K in keyof RootState]: RootState[K] +}[keyof RootState] + +type SeparatelyPersistedStates = T extends PersistPartial ? T : never + +// Payload depends on persist config, it can be any of separately persisted state chunks +type RehydratePayload = Partial | SeparatelyPersistedStates + +interface RehydrateAction { + type: typeof REHYDRATE + key: string + payload?: RehydratePayload +} + +function* setApiAuthConfig( + action: ReturnType | ReturnType | RehydrateAction, +) { + const isLoginAction = logInAsyncSuccess.match(action) + const isResetStoreAction = resetStore.match(action) + + if (isLoginAction) { + yield* call(setAuthConfig, action.payload) + } else 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) + ) { + yield* call(setAuthConfig, action.payload.tokens.data) + } +} + +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 +} + +const initialState: AuthState = { + tokens: NotRequested, +} + +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) + }, + }, +}) + +export const { logInAsync, logInAsyncSuccess, logInAsyncFailure } = 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) +} + +const authPersistConfig = { + key: authSlice.name, + storage: safeStorage, +} + +export default persistReducer(authPersistConfig, authSlice.reducer) diff --git a/template/src/api/comics.ts b/template/src/api/comics.ts index 09ed542..fa8111f 100644 --- a/template/src/api/comics.ts +++ b/template/src/api/comics.ts @@ -1,7 +1,4 @@ -import { makeRequest } from './common' +import { getRequest } from './common' +import { ComicBE } from './types/comic.types' -export const getLatestComic = () => - makeRequest({ - path: 'info.0.json', - method: 'GET', - }) +export const getLatestComic = () => getRequest({ path: 'info.0.json' }) diff --git a/template/src/api/common.test.ts b/template/src/api/common.test.ts new file mode 100644 index 0000000..5f00ec7 --- /dev/null +++ b/template/src/api/common.test.ts @@ -0,0 +1,296 @@ +import { comicMockResponse } from '__mocks__/fixtures' +import Config from 'react-native-config' +import { HttpError } from '@utils/error' +import { refreshTokens } from './auth' +import { + authDeleteRequest, + authGetRequest, + authPatchRequest, + authPostRequest, + authPutRequest, + deleteRequest, + getRequest, + patchRequest, + postRequest, + putRequest, + setAuthConfig, + testExports, +} from './common' + +jest.mock('./auth', () => ({ + refreshTokens: jest.fn().mockResolvedValue(fakeRefreshedAuthTokens), +})) + +const fakeAuthTokens = { + accessToken: 'FAKE_ACCESS_TOKEN', + refreshToken: 'FAKE_REFRESH_TOKEN', +} +const fakeRefreshedAuthTokens = { + accessToken: 'REFRESHED_ACCESS_TOKEN', + refreshToken: 'REFRESHED_REFRESH_TOKEN', +} +const fakeRequestPath = 'path/mock' + +afterEach(() => { + jest.clearAllMocks() + fetchMock.resetMocks() +}) + +describe('#makeRequest', () => { + const { makeRequest } = testExports + + const fakeRequestParams = { + path: fakeRequestPath, + method: 'POST', + body: { + fakeProperty1: 'fakeValue1', + fakeProperty2: 'fakeValue2', + }, + } + + it('should call the API with proper headers and body', async () => { + await makeRequest(fakeRequestParams) + + expect(fetchMock).toHaveBeenCalledWith( + `${Config.API_URL}/${fakeRequestParams.path}`, + expect.objectContaining({ + body: JSON.stringify(fakeRequestParams.body), + headers: expect.objectContaining({ + Accept: 'application/json', + 'Content-Type': 'application/json', + }), + }), + ) + }) + + it('should return response body from successfull calls', async () => { + fetchMock.once(JSON.stringify(comicMockResponse)) + const result = await makeRequest(fakeRequestParams) + + expect(result).toEqual(comicMockResponse) + }) + + it('should throw an HttpError on responses that are not ok', async () => { + const errorMessage = 'Not found' + const errorStatus = 404 + const errorResponse = { error: errorMessage } + fetchMock.once(JSON.stringify(errorResponse), { status: errorStatus }) + + try { + await makeRequest(fakeRequestParams) + // Fail the test if makeRequest doesn't throw an error + fail('#makeRequest did not throw an error') + } catch (err) { + const error = err as HttpError + expect(error).toBeInstanceOf(HttpError) + expect(error.body).toEqual(errorResponse) + expect(error.status).toBe(404) + expect(error.message).toBe( + `Server returned ${errorStatus} and {\n "error": "${errorMessage}"\n}`, + ) + } + }) + + it('should rethrow any catched error', async () => { + const randomError = new Error('Random error') + fetchMock.mockRejectOnce(randomError) + await expect(makeRequest(fakeRequestParams)).rejects.toThrow(randomError) + }) + + describe('when token expired', () => { + const fakeAuthRequestParams = { + ...fakeRequestParams, + headers: { + authorization: `Bearer ${fakeAuthTokens.accessToken}`, + }, + } + const authErrorResponse = { + body: JSON.stringify({ error: 'Unauthorized' }), + init: { status: 401 }, + } + + const persistNewTokensMock = jest.fn() + beforeEach(() => { + setAuthConfig({ + ...fakeAuthTokens, + persistNewTokens: persistNewTokensMock, + }) + fetchMock.mockResponse(req => + req.headers.get('authorization') === `Bearer ${fakeAuthTokens.accessToken}` || + req.headers.get('authorization') === null + ? Promise.resolve(authErrorResponse) + : Promise.resolve({}), + ) + }) + + it('should retry request with fresh token', async () => { + await makeRequest(fakeAuthRequestParams) + + expect(refreshTokens).toHaveBeenCalledWith(fakeAuthTokens.refreshToken) + expect(persistNewTokensMock).toHaveBeenCalledWith(fakeRefreshedAuthTokens) + expect(fetchMock).toHaveBeenCalledTimes(2) + expect(fetchMock).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + headers: expect.objectContaining({ + authorization: `Bearer ${fakeAuthTokens.accessToken}`, + }), + }), + ) + expect(fetchMock).toHaveBeenLastCalledWith( + expect.anything(), + expect.objectContaining({ + headers: expect.objectContaining({ + authorization: `Bearer ${fakeRefreshedAuthTokens.accessToken}`, + }), + }), + ) + }) + + it('it should not attempt to refresh token when doing unauthenticated calls', async () => { + await expect(makeRequest(fakeRequestParams)).rejects.toThrow('Unauthorized') + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(refreshTokens).not.toHaveBeenCalled() + }) + + it('it should attempt to refresh token only once per call', async () => { + fetchMock.mockResponse(authErrorResponse.body, authErrorResponse.init) + + await expect(makeRequest(fakeAuthRequestParams)).rejects.toThrow('Unauthorized') + expect(fetchMock).toHaveBeenCalledTimes(2) + expect(refreshTokens).toHaveBeenCalledTimes(1) + }) + + it('should throw on auth errors when missing correct auth config', async () => { + setAuthConfig({ + persistNewTokens: undefined, + }) + await expect(makeRequest(fakeAuthRequestParams)).rejects.toThrow( + "Tokens won't be persisted without redux handler for it", + ) + + setAuthConfig({ + refreshToken: undefined, + }) + await expect(makeRequest(fakeAuthRequestParams)).rejects.toThrow( + 'Tokens cannot be refreshed without the refresh token', + ) + }) + }) +}) + +describe('basic request creators', () => { + it('should create a request with "method" property', async () => { + await getRequest({ path: fakeRequestPath }) + await postRequest({ path: fakeRequestPath }) + await putRequest({ path: fakeRequestPath }) + await patchRequest({ path: fakeRequestPath }) + await deleteRequest({ path: fakeRequestPath }) + + expect(fetchMock).toHaveBeenNthCalledWith( + 1, + expect.anything(), + expect.objectContaining({ method: 'GET' }), + ) + expect(fetchMock).toHaveBeenNthCalledWith( + 2, + expect.anything(), + expect.objectContaining({ method: 'POST' }), + ) + expect(fetchMock).toHaveBeenNthCalledWith( + 3, + expect.anything(), + expect.objectContaining({ method: 'PUT' }), + ) + expect(fetchMock).toHaveBeenNthCalledWith( + 4, + expect.anything(), + expect.objectContaining({ method: 'PATCH' }), + ) + expect(fetchMock).toHaveBeenNthCalledWith( + 5, + expect.anything(), + expect.objectContaining({ method: 'DELETE' }), + ) + expect(fetchMock).toBeCalledTimes(5) + }) +}) + +describe('auth request creators', () => { + it('should create a request with "method" property and "authorization" header', async () => { + setAuthConfig({ + accessToken: fakeAuthTokens.accessToken, + }) + + await authGetRequest({ path: fakeRequestPath }) + await authPostRequest({ path: fakeRequestPath }) + await authPutRequest({ path: fakeRequestPath }) + await authPatchRequest({ path: fakeRequestPath }) + await authDeleteRequest({ path: fakeRequestPath }) + + expect(fetchMock).toHaveBeenNthCalledWith( + 1, + expect.anything(), + expect.objectContaining({ + method: 'GET', + headers: expect.objectContaining({ + authorization: `Bearer ${fakeAuthTokens.accessToken}`, + }), + }), + ) + expect(fetchMock).toHaveBeenNthCalledWith( + 2, + expect.anything(), + expect.objectContaining({ + method: 'POST', + headers: expect.objectContaining({ + authorization: `Bearer ${fakeAuthTokens.accessToken}`, + }), + }), + ) + expect(fetchMock).toHaveBeenNthCalledWith( + 3, + expect.anything(), + expect.objectContaining({ + method: 'PUT', + headers: expect.objectContaining({ + authorization: `Bearer ${fakeAuthTokens.accessToken}`, + }), + }), + ) + expect(fetchMock).toHaveBeenNthCalledWith( + 4, + expect.anything(), + expect.objectContaining({ + method: 'PATCH', + headers: expect.objectContaining({ + authorization: `Bearer ${fakeAuthTokens.accessToken}`, + }), + }), + ) + expect(fetchMock).toHaveBeenNthCalledWith( + 5, + expect.anything(), + expect.objectContaining({ + method: 'DELETE', + headers: expect.objectContaining({ + authorization: `Bearer ${fakeAuthTokens.accessToken}`, + }), + }), + ) + expect(fetchMock).toBeCalledTimes(5) + }) + + it('should throw when used withous correct config', () => { + setAuthConfig({ + accessToken: undefined, + }) + const missingTokenError = 'Authenticated request cannot be made without the token' + + expect(() => authGetRequest({ path: fakeRequestPath })).toThrow(missingTokenError) + expect(() => authPostRequest({ path: fakeRequestPath })).toThrow(missingTokenError) + expect(() => authPutRequest({ path: fakeRequestPath })).toThrow(missingTokenError) + expect(() => authPatchRequest({ path: fakeRequestPath })).toThrow(missingTokenError) + expect(() => authDeleteRequest({ path: fakeRequestPath })).toThrow(missingTokenError) + }) +}) diff --git a/template/src/api/common.ts b/template/src/api/common.ts index c26f73f..f64451b 100644 --- a/template/src/api/common.ts +++ b/template/src/api/common.ts @@ -1,21 +1,174 @@ import Config from 'react-native-config' +import { API_TIMEOUT } from '@config/timing' +import { HttpError, getErrorMessage, isTimeoutError } from '@utils/error' +import { refreshTokens } from './auth' -interface RequestParams extends RequestInit { +interface RequestParams extends Omit { path: string + body?: Record } -export const makeRequest = async (params: RequestParams) => { +type SimplifiedRequestParams = Omit + +export interface AuthTokens { + accessToken: string + refreshToken: string +} +interface AuthConfig extends Partial { + persistNewTokens?: (tokens: AuthTokens) => void +} + +const authConfig: AuthConfig = { + accessToken: undefined, + refreshToken: undefined, + persistNewTokens: undefined, +} + +export function setAuthConfig(newConfig: AuthConfig) { + Object.assign(authConfig, newConfig) +} + +async function makeRequest(params: RequestParams, isFirstTry = true): Promise { + // Change the default request timeout + const controller = new AbortController() + const timeoutId = setTimeout(() => controller.abort(), API_TIMEOUT) + const { path, ...restParams } = params try { const response = await fetch(`${Config.API_URL}/${path}`, { ...restParams, + body: JSON.stringify(restParams.body), + signal: controller.signal, headers: { Accept: 'application/json', + 'Content-Type': 'application/json', ...restParams.headers, }, }) - return response + + const isAuthenticatedRequest = restParams.headers && 'authorization' in restParams.headers + if (response.status === 401 && isFirstTry && isAuthenticatedRequest) { + return refreshTokensAndRetryRequest(params) + } + + const hasBody = !!(await response.clone().text()) + const body = hasBody ? await response.json() : null + + if (!response.ok) { + const readableBody = body ? ` and ${JSON.stringify(body, null, 2)}` : '' + throw new HttpError( + `Server returned ${response.status}${readableBody}`, + response.status, + body, + ) + } + + return body } catch (error) { - console.error('[makeRequest] Error:', error) + // Log all the errors and rethrow to parent handlers + const errorMessage = isTimeoutError(error) ? 'Request timed out' : getErrorMessage(error) + console.warn(`#makeRequest failed on /${path}: ${errorMessage}`) + + throw error + } finally { + clearTimeout(timeoutId) + } +} + +async function refreshTokensAndRetryRequest(params: RequestParams) { + if (!authConfig.refreshToken) { + throw new Error('Tokens cannot be refreshed without the refresh token') + } + + if (!authConfig.persistNewTokens) { + throw new Error("Tokens won't be persisted without redux handler for it") + } + + let newTokens: AuthTokens + + try { + newTokens = await refreshTokens(authConfig.refreshToken) + } catch (error) { + throw new Error(`Failed to refresh expired auth tokens - ${getErrorMessage(error)}`) + } + + authConfig.persistNewTokens(newTokens) + const refreshedParams: RequestParams = { + ...params, + headers: { + ...params.headers, + authorization: `Bearer ${newTokens.accessToken}`, + }, } + + return makeRequest(refreshedParams, false) +} + +export const getRequest = (params: SimplifiedRequestParams) => { + const getParams: RequestParams = { + ...params, + method: 'GET', + } + return makeRequest(getParams) +} + +export const postRequest = (params: SimplifiedRequestParams) => { + const postParams: RequestParams = { + ...params, + method: 'POST', + } + return makeRequest(postParams) +} + +export const putRequest = (params: SimplifiedRequestParams) => { + const putParams: RequestParams = { + ...params, + method: 'PUT', + } + return makeRequest(putParams) +} + +export const patchRequest = (params: SimplifiedRequestParams) => { + const patchParams: RequestParams = { + ...params, + method: 'PATCH', + } + return makeRequest(patchParams) +} + +export const deleteRequest = (params: SimplifiedRequestParams) => { + const deleteParams: RequestParams = { + ...params, + method: 'DELETE', + } + return makeRequest(deleteParams) +} + +const withAuth = (params: RequestParams): RequestParams => { + if (!authConfig.accessToken) { + throw new Error('Authenticated request cannot be made without the token') + } + + return { + ...params, + headers: { + ...params.headers, + authorization: `Bearer ${authConfig.accessToken}`, + }, + } +} + +export const authGetRequest = (params: SimplifiedRequestParams) => + getRequest(withAuth(params)) +export const authPostRequest = (params: SimplifiedRequestParams) => + postRequest(withAuth(params)) +export const authPutRequest = (params: SimplifiedRequestParams) => + putRequest(withAuth(params)) +export const authPatchRequest = (params: SimplifiedRequestParams) => + patchRequest(withAuth(params)) +export const authDeleteRequest = (params: SimplifiedRequestParams) => + deleteRequest(withAuth(params)) + +export const testExports = { + makeRequest, } diff --git a/template/src/api/mappers/comicMappers.ts b/template/src/api/mappers/comicMappers.ts new file mode 100644 index 0000000..9923356 --- /dev/null +++ b/template/src/api/mappers/comicMappers.ts @@ -0,0 +1,8 @@ +import { Comic, ComicBE } from '@api/types/comic.types' + +export const mapComic = (comic: ComicBE): Comic => ({ + id: comic.num, + title: comic.title, + description: comic.alt, + imageUrl: comic.img, +}) diff --git a/template/src/api/types/auth.types.ts b/template/src/api/types/auth.types.ts new file mode 100644 index 0000000..3d0e91d --- /dev/null +++ b/template/src/api/types/auth.types.ts @@ -0,0 +1,4 @@ +export interface Credentials { + username: string + password: string +} diff --git a/template/src/api/types/comic.types.ts b/template/src/api/types/comic.types.ts new file mode 100644 index 0000000..feda8e1 --- /dev/null +++ b/template/src/api/types/comic.types.ts @@ -0,0 +1,20 @@ +export interface ComicBE { + month: string + num: number + link: string + year: string + news: string + safe_title: string + transcript: string + alt: string + img: string + title: string + day: string +} + +export interface Comic { + id: number + title: string + description: string + imageUrl: string +} diff --git a/template/src/components/inputs/.gitkeep b/template/src/components/inputs/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/template/src/components/inputs/DemoTextInput.tsx b/template/src/components/inputs/DemoTextInput.tsx new file mode 100644 index 0000000..8db399f --- /dev/null +++ b/template/src/components/inputs/DemoTextInput.tsx @@ -0,0 +1,19 @@ +import React from 'react' +import { StyleSheet, TextInput, TextInputProps } from 'react-native' +import Colors from '@config/ui/colors' +import typography from '@config/ui/typography' + +const DemoTextInput: React.FC = ({ style, ...props }) => { + return +} + +const styles = StyleSheet.create({ + demoTextInput: { + backgroundColor: Colors.inputBackground, + color: Colors.onInputBackground, + fontSize: 32, + ...typography.textInput, + }, +}) + +export default DemoTextInput diff --git a/template/src/config/ui/colors.ts b/template/src/config/ui/colors.ts index 7a19a5c..96aa1e8 100644 --- a/template/src/config/ui/colors.ts +++ b/template/src/config/ui/colors.ts @@ -20,6 +20,8 @@ const Colors = { onBackground: Palette.WHITE, surface: Palette.OFF_WHITE, onSurface: Palette.BLACK, + inputBackground: Palette.WHITE, + onInputBackground: Palette.BLACK, error: Palette.ANGRY, onError: Palette.WHITE, transparent: Palette.TRANSPARENT, diff --git a/template/src/config/ui/typography.ts b/template/src/config/ui/typography.ts new file mode 100644 index 0000000..780e58f --- /dev/null +++ b/template/src/config/ui/typography.ts @@ -0,0 +1,15 @@ +import { StyleSheet, TextStyle } from 'react-native' + +// You can modify properties required in your typography object in here +type Typography = { [K in keyof T]: TextStyle } + +function createTypography>(values: Typography): T { + return StyleSheet.create(values) +} + +// Add your app text styles in here +export default createTypography({ + textInput: { + fontSize: 24, + }, +}) diff --git a/template/src/localization/en/translation.json b/template/src/localization/en/translation.json index 4b439af..79ca24b 100644 --- a/template/src/localization/en/translation.json +++ b/template/src/localization/en/translation.json @@ -1,6 +1,9 @@ { + "loginScreen": { + "logInButton": "Log in" + }, "demoScreen": { - "hermesEnabled": "Hermes enabled -> {{enabled}}", + "logOutButton": "Log out", "incrementButton": "Increment counter by 5", "decrementButton": "Decrement counter by 15", "counter": "Counter:", diff --git a/template/src/models/ComicModel.ts b/template/src/models/ComicModel.ts deleted file mode 100644 index 69dab00..0000000 --- a/template/src/models/ComicModel.ts +++ /dev/null @@ -1,14 +0,0 @@ -export interface Comic { - readonly id: number - readonly title: string - readonly description: string - readonly imageUrl: string -} - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export const parseComic = (json: any): Comic => ({ - id: json.num, - title: json.title, - description: json.alt, - imageUrl: json.img, -}) diff --git a/template/src/navigation/navigators/RootStackNavigator.tsx b/template/src/navigation/navigators/RootStackNavigator.tsx index 5007243..1f04e37 100644 --- a/template/src/navigation/navigators/RootStackNavigator.tsx +++ b/template/src/navigation/navigators/RootStackNavigator.tsx @@ -1,12 +1,16 @@ import { NativeStackScreenProps, createNativeStackNavigator } from '@react-navigation/native-stack' import React from 'react' +import { selectIsLoggedIn } from '@api/authSlice' +import useAppSelector from '@hooks/useAppSelector' import Routes from '@navigation/routes' import DemoScreen, { DemoScreenParams } from '@screens/DemoScreen' +import LoginScreen, { LoginScreenParams } from '@screens/LoginScreen' import TranslationsDemoScreen, { TranslationsDemoScreenParams, } from '@screens/TranslationsDemoScreen' export type RootStackParamList = { + [Routes.LOGIN_SCREEN]: LoginScreenParams [Routes.DEMO_SCREEN]: DemoScreenParams [Routes.TRANSLATIONS_DEMO_SCREEN]: TranslationsDemoScreenParams } @@ -18,10 +22,20 @@ export type RootStackScreenProps = NativeSta const RootStack = createNativeStackNavigator() const RootStackNavigator = () => { + const isLoggedIn = useAppSelector(selectIsLoggedIn) return ( - - + {!isLoggedIn ? ( + + ) : ( + + + + + )} ) } diff --git a/template/src/navigation/routes.ts b/template/src/navigation/routes.ts index ae6f17d..82826db 100644 --- a/template/src/navigation/routes.ts +++ b/template/src/navigation/routes.ts @@ -1,4 +1,5 @@ enum Routes { + LOGIN_SCREEN = 'LOGIN_SCREEN', DEMO_SCREEN = 'DEMO_SCREEN', TRANSLATIONS_DEMO_SCREEN = 'TRANSLATIONS_DEMO_SCREEN', } diff --git a/template/src/providers/StoreProvider.tsx b/template/src/providers/StoreProvider.tsx index 297a781..5caa11a 100644 --- a/template/src/providers/StoreProvider.tsx +++ b/template/src/providers/StoreProvider.tsx @@ -1,9 +1,14 @@ import React from 'react' import { Provider } from 'react-redux' -import store from '@redux/store' +import { PersistGate } from 'redux-persist/integration/react' +import store, { persistor } from '@redux/store' import { FC } from '@utils/types' const StoreProvider: FC = ({ children }) => { - return {children} + return ( + + {children} + + ) } export default StoreProvider diff --git a/template/src/redux/persistence.ts b/template/src/redux/persistence.ts new file mode 100644 index 0000000..11a8f25 --- /dev/null +++ b/template/src/redux/persistence.ts @@ -0,0 +1,33 @@ +import EncryptedStorage from 'react-native-encrypted-storage' +import { MMKV } from 'react-native-mmkv' +import { initializeMMKVFlipper } from 'react-native-mmkv-flipper-plugin' +import { Storage } from 'redux-persist' + +const storage = new MMKV() + +if (__DEV__) { + initializeMMKVFlipper({ default: storage }) +} + +const reduxStorage: Storage = { + setItem: (key, value) => { + storage.set(key, value) + return Promise.resolve(true) + }, + getItem: key => { + const value = storage.getString(key) + return Promise.resolve(value) + }, + removeItem: key => { + storage.delete(key) + return Promise.resolve() + }, +} + +export { reduxStorage as storage } +export { EncryptedStorage as safeStorage } + +export function clearPersistence() { + storage.clearAll() + return EncryptedStorage.clear() +} diff --git a/template/src/redux/rootActions.ts b/template/src/redux/rootActions.ts new file mode 100644 index 0000000..2a5ea36 --- /dev/null +++ b/template/src/redux/rootActions.ts @@ -0,0 +1,3 @@ +import { createAction } from '@reduxjs/toolkit' + +export const resetStore = createAction('root/RESET_STORE') diff --git a/template/src/redux/rootReducer.ts b/template/src/redux/rootReducer.ts index 5d66733..c6cf651 100644 --- a/template/src/redux/rootReducer.ts +++ b/template/src/redux/rootReducer.ts @@ -1,7 +1,25 @@ +import { combineReducers } from 'redux' +import { persistReducer } from 'redux-persist' +import authReducer from '@api/authSlice' import demoReducer from '@screens/demoSlice' +import { storage } from './persistence' +import { resetStore } from './rootActions' -const rootReducer = { +const rootReducer = combineReducers({ + auth: authReducer, demo: demoReducer, +}) + +const rootReducerWithReset: typeof rootReducer = (state, action) => { + if (resetStore.match(action)) { + return rootReducer(undefined, action) + } + return rootReducer(state, action) } -export default rootReducer +const rootPersistConfig = { + key: 'root', + storage, + blacklist: ['auth'], +} +export default persistReducer(rootPersistConfig, rootReducerWithReset) diff --git a/template/src/redux/rootSaga.ts b/template/src/redux/rootSaga.ts index d054cba..859c6cf 100644 --- a/template/src/redux/rootSaga.ts +++ b/template/src/redux/rootSaga.ts @@ -1,6 +1,7 @@ -import { all } from 'redux-saga/effects' -import { watchFetchLatestComicSaga } from '@screens/demoSlice' +import { all } from 'typed-redux-saga' +import { watchAuthTokens, watchLogInSaga } from '@api/authSlice' +import { watchGetLatestComicSaga } from '@screens/demoSlice' export default function* rootSaga() { - yield all([watchFetchLatestComicSaga()]) + yield* all([watchAuthTokens(), watchLogInSaga(), watchGetLatestComicSaga()]) } diff --git a/template/src/redux/store.ts b/template/src/redux/store.ts index fb9c76f..1479608 100644 --- a/template/src/redux/store.ts +++ b/template/src/redux/store.ts @@ -1,5 +1,8 @@ import { configureStore } from '@reduxjs/toolkit' +import { FLUSH, PAUSE, PERSIST, PURGE, REGISTER, REHYDRATE, persistStore } from 'redux-persist' import createSagaMiddleware from 'redux-saga' +import { logInAsyncSuccess } from '@api/authSlice' +import { setAuthConfig } from '@api/common' import rootReducer from './rootReducer' import rootSaga from './rootSaga' @@ -14,11 +17,21 @@ if (__DEV__) { const store = configureStore({ reducer: rootReducer, - middleware: getDefaultMiddleware => getDefaultMiddleware({ thunk: false }).concat(middlewares), + middleware: getDefaultMiddleware => + getDefaultMiddleware({ + serializableCheck: { + ignoredActions: [FLUSH, REHYDRATE, PAUSE, PERSIST, PURGE, REGISTER], + }, + thunk: false, + }).concat(middlewares), }) sagaMiddleware.run(rootSaga) +setAuthConfig({ persistNewTokens: tokens => store.dispatch(logInAsyncSuccess(tokens)) }) + +export const persistor = persistStore(store) + export type RootState = ReturnType export type AppDispatch = typeof store.dispatch diff --git a/template/src/screens/DemoScreen.test.tsx b/template/src/screens/DemoScreen.test.tsx index c8c3809..bf56e08 100644 --- a/template/src/screens/DemoScreen.test.tsx +++ b/template/src/screens/DemoScreen.test.tsx @@ -1,6 +1,6 @@ import React from 'react' +import { RemoteDataType } from '@api/RemoteData' import { TestIDs } from '@config/testIDs' -import { RemoteDataType } from '@models/RemoteData' import Routes from '@navigation/routes' import { createNavigationProps, fireEvent, render } from '@utils/testing' import DemoScreen from './DemoScreen' diff --git a/template/src/screens/DemoScreen.tsx b/template/src/screens/DemoScreen.tsx index 9ff1bf9..c274053 100644 --- a/template/src/screens/DemoScreen.tsx +++ b/template/src/screens/DemoScreen.tsx @@ -1,15 +1,18 @@ -import React, { useEffect } from 'react' +import React, { useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import { ActivityIndicator, Button, Image, StyleSheet, Text } from 'react-native' +import { hasData, isNotRequested } from '@api/RemoteData' import MainScreenLayout from '@components/layouts/MainScreenLayout' import DemoCard from '@components/surfaces/DemoCard' import { TestIDs } from '@config/testIDs' import Colors from '@config/ui/colors' import useAppDispatch from '@hooks/useAppDispatch' import useAppSelector from '@hooks/useAppSelector' -import { hasData } from '@models/RemoteData' 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 { decrementCounterBy, getLatestComicAsync, @@ -26,14 +29,31 @@ 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(() => { - dispatch(getLatestComicAsync()) - }, []) + if (isNotRequested(comicRequest)) { + dispatch(getLatestComicAsync()) + } + }, [comicRequest.type]) + + 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 @@ -72,6 +92,13 @@ const DemoScreen = ({ navigation }: DemoScreenProps) => { title={t('demoScreen.goToTranslationsDemo')} /> + + {isLogoutLoading ? ( + + ) : ( +