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

feat(TransactionFeedV2): Add RTK-Query, add api slice for Transaction Feed V2 #6134

Merged
merged 16 commits into from
Oct 11, 2024
Merged
Show file tree
Hide file tree
Changes from 13 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion knip.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,12 @@ const config: KnipConfig = {
'@types/jest',
'husky',
],
ignore: ['src/utils/inputValidation.ts', 'src/utils/country.json'],
ignore: [
'src/utils/inputValidation.ts',
'src/utils/country.json',
'src/redux/reducersForSchemaGeneration.ts',
'src/transactions/apiTestHelpers.ts',
Copy link
Member

Choose a reason for hiding this comment

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

We'll use it in a follow up PR?

Copy link
Contributor Author

@sviderock sviderock Oct 9, 2024

Choose a reason for hiding this comment

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

@jeanregisser yes, already removed it in the next PR! Only added here to fix the knip error.

],
}

export default config
2 changes: 1 addition & 1 deletion scripts/update_root_state_schema.sh
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ set -euo pipefail

root_state_schema="test/RootStateSchema.json"

typescript-json-schema ./tsconfig.json RootState --include src/redux/reducers.ts --ignoreErrors --required --noExtraProps > "$root_state_schema"
typescript-json-schema ./tsconfig.json RootStateForSchemaGeneration --include src/redux/reducersForSchemaGeneration.ts --ignoreErrors --required --noExtraProps > "$root_state_schema"

if git diff --exit-code "$root_state_schema"; then
echo "$root_state_schema is up to date"
Expand Down
9 changes: 9 additions & 0 deletions src/redux/api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { fetchBaseQuery } from '@reduxjs/toolkit/query/react'
import networkConfig from 'src/web3/networkConfig'

export const baseQuery = fetchBaseQuery({
baseUrl: networkConfig.blockchainApiUrl,
headers: {
Accept: 'application/json',
},
})
9 changes: 9 additions & 0 deletions src/redux/apiReducersList.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { transactionFeedV2Api } from 'src/transactions/api'

export type ApiReducersKeys = keyof typeof apiReducersList

export const apiReducersList = {
[transactionFeedV2Api.reducerPath]: transactionFeedV2Api.reducer,
} as const

export const apiMiddlewares = [transactionFeedV2Api.middleware]
1 change: 1 addition & 0 deletions src/redux/migrations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1916,4 +1916,5 @@ export const migrations = {
...state,
app: _.omit(state.app, 'numberVerified'),
}),
233: (state: any) => state,
}
62 changes: 6 additions & 56 deletions src/redux/reducers.ts
Original file line number Diff line number Diff line change
@@ -1,61 +1,11 @@
import { Action, combineReducers } from '@reduxjs/toolkit'
import { PersistState } from 'redux-persist'
import { Actions, ClearStoredAccountAction } from 'src/account/actions'
import { reducer as account } from 'src/account/reducer'
import { reducer as alert } from 'src/alert/reducer'
import { appReducer as app } from 'src/app/reducers'
import dappsReducer from 'src/dapps/slice'
import earnReducer from 'src/earn/slice'
import { reducer as fiatExchanges } from 'src/fiatExchanges/reducer'
import fiatConnectReducer from 'src/fiatconnect/slice'
import { homeReducer as home } from 'src/home/reducers'
import i18nReducer from 'src/i18n/slice'
import { type Action, combineReducers } from '@reduxjs/toolkit'
import { type PersistState } from 'redux-persist'
import { Actions, type ClearStoredAccountAction } from 'src/account/actions'
import { reducer as identity } from 'src/identity/reducer'
import { reducer as imports } from 'src/import/reducer'
import jumpstartReducer from 'src/jumpstart/slice'
import keylessBackupReducer from 'src/keylessBackup/slice'
import { reducer as localCurrency } from 'src/localCurrency/reducer'
import { reducer as networkInfo } from 'src/networkInfo/reducer'
import nftsReducer from 'src/nfts/slice'
import pointsReducer from 'src/points/slice'
import positionsReducer from 'src/positions/slice'
import priceHistoryReducer from 'src/priceHistory/slice'
import { recipientsReducer as recipients } from 'src/recipients/reducer'
import { sendReducer as send } from 'src/send/reducers'
import swapReducer from 'src/swap/slice'
import tokenReducer from 'src/tokens/slice'
import { reducer as transactions } from 'src/transactions/reducer'
import { reducer as walletConnect } from 'src/walletConnect/reducer'
import { reducer as web3 } from 'src/web3/reducer'
import { apiReducersList } from 'src/redux/apiReducersList'
import { reducersList } from 'src/redux/reducersList'

const appReducer = combineReducers({
app,
i18n: i18nReducer,
networkInfo,
alert,
send,
home,
transactions,
web3,
identity,
account,
recipients,
localCurrency,
imports,
fiatExchanges,
walletConnect,
tokens: tokenReducer,
dapps: dappsReducer,
fiatConnect: fiatConnectReducer,
swap: swapReducer,
positions: positionsReducer,
keylessBackup: keylessBackupReducer,
nfts: nftsReducer,
priceHistory: priceHistoryReducer,
jumpstart: jumpstartReducer,
points: pointsReducer,
earn: earnReducer,
})
const appReducer = combineReducers({ ...reducersList, ...apiReducersList })

const rootReducer = (state: RootState | undefined, action: Action): RootState => {
if (action.type === Actions.CLEAR_STORED_ACCOUNT && state) {
Expand Down
8 changes: 8 additions & 0 deletions src/redux/reducersForSchemaGeneration.ts
Copy link
Member

Choose a reason for hiding this comment

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

nit: Maybe we should add a little comment about why we're doing this?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@jeanregisser yes, will add!

Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { combineReducers } from 'redux'
import { PersistState } from 'redux-persist'
import { reducersList } from 'src/redux/reducersList'

const reducerForSchemaGeneration = combineReducers(reducersList)
export type RootStateForSchemaGeneration = ReturnType<typeof reducerForSchemaGeneration> & {
_persist: PersistState
}
55 changes: 55 additions & 0 deletions src/redux/reducersList.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { reducer as account } from 'src/account/reducer'
import { reducer as alert } from 'src/alert/reducer'
import { appReducer as app } from 'src/app/reducers'
import dappsReducer from 'src/dapps/slice'
import earnReducer from 'src/earn/slice'
import { reducer as fiatExchanges } from 'src/fiatExchanges/reducer'
import fiatConnectReducer from 'src/fiatconnect/slice'
import { homeReducer as home } from 'src/home/reducers'
import i18nReducer from 'src/i18n/slice'
import { reducer as identity } from 'src/identity/reducer'
import { reducer as imports } from 'src/import/reducer'
import jumpstartReducer from 'src/jumpstart/slice'
import keylessBackupReducer from 'src/keylessBackup/slice'
import { reducer as localCurrency } from 'src/localCurrency/reducer'
import { reducer as networkInfo } from 'src/networkInfo/reducer'
import nftsReducer from 'src/nfts/slice'
import pointsReducer from 'src/points/slice'
import positionsReducer from 'src/positions/slice'
import priceHistoryReducer from 'src/priceHistory/slice'
import { recipientsReducer as recipients } from 'src/recipients/reducer'
import { sendReducer as send } from 'src/send/reducers'
import swapReducer from 'src/swap/slice'
import tokenReducer from 'src/tokens/slice'
import { reducer as transactions } from 'src/transactions/reducer'
import { reducer as walletConnect } from 'src/walletConnect/reducer'
import { reducer as web3 } from 'src/web3/reducer'

export const reducersList = {
app,
i18n: i18nReducer,
networkInfo,
alert,
send,
home,
transactions,
web3,
identity,
account,
recipients,
localCurrency,
imports,
fiatExchanges,
walletConnect,
tokens: tokenReducer,
dapps: dappsReducer,
fiatConnect: fiatConnectReducer,
swap: swapReducer,
positions: positionsReducer,
keylessBackup: keylessBackupReducer,
nfts: nftsReducer,
priceHistory: priceHistoryReducer,
jumpstart: jumpstartReducer,
points: pointsReducer,
earn: earnReducer,
} as const
18 changes: 15 additions & 3 deletions src/redux/store.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import Ajv from 'ajv'
import { spawn, takeEvery } from 'redux-saga/effects'
import { ApiReducersKeys, apiReducersList } from 'src/redux/apiReducersList'
import * as createMigrateModule from 'src/redux/createMigrate'
import { migrations } from 'src/redux/migrations'
import { RootState } from 'src/redux/reducers'
import { rootSaga } from 'src/redux/sagas'
import { _persistConfig, setupStore } from 'src/redux/store'
import * as accountCheckerModule from 'src/utils/accountChecker'
Expand All @@ -25,6 +27,16 @@ const resetStateOnInvalidStoredAccount = jest.spyOn(

const loggerErrorSpy = jest.spyOn(Logger, 'error')

function getNonApiReducers<R = Omit<RootState, ApiReducersKeys>>(state: RootState): R {
const apiReducersKeys: string[] = Object.keys(apiReducersList)
return Object.entries(state).reduce((acc, [reducerKey, value]) => {
const key = reducerKey as keyof R
if (apiReducersKeys.includes(reducerKey)) return acc
acc[key] = value as unknown as any
return acc
}, {} as R)
Copy link
Member

Choose a reason for hiding this comment

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

nit: probably ok here because it's short, but I try to avoid reduce as it can often lead to hard to read code.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@jeanregisser there are also a few .reduce usages in the follow-up PRs as well. Though the reason is not as applicable in this particular case, but I'm trying to reduce the amount of iterations the paginated data gets processed over. Considering, the Transaction Feed screen is one of the main screens with potentially lots of data I thought it made sense to try to minimize iterations so it is as optimized as possible.

I might be overthinking this so please let me know if that's the case. I've tried to make all the .reduce usages (the ones you'll see later in other PRs) fully descriptive but please let me know if there's anything that can be improved in that regard!

Copy link
Member

Choose a reason for hiding this comment

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

Ok, I think we should optimize for readability first, and avoid reduce in general. Given the number of elements we're dealing with, iterating once, twice or more times usually won't have much real world impact.
And we can usually refactor to iterate once with a for loop, if performance is measured to be a problem.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@jeanregisser Got it, I'll tweak code in the other PRs to follow this approach!

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@jeanregisser refactored to for loop

}

beforeEach(() => {
jest.clearAllMocks()
// For some reason createMigrate.mockRestore doesn't work, so instead we manually reset it to the original implementation
Expand Down Expand Up @@ -81,9 +93,9 @@ describe('store state', () => {
})
})

const data = store.getState()
const data = getNonApiReducers(store.getState())

const ajv = new Ajv({ allErrors: true })
const ajv = new Ajv({ allErrors: true, allowUnionTypes: true })
const schema = require('test/RootStateSchema.json')
const validate = ajv.compile(schema)
const isValid = validate(data)
Expand All @@ -98,7 +110,7 @@ describe('store state', () => {
{
"_persist": {
"rehydrated": true,
"version": 232,
"version": 233,
},
"account": {
"acceptedTerms": false,
Expand Down
19 changes: 16 additions & 3 deletions src/redux/store.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,18 @@
import AsyncStorage from '@react-native-async-storage/async-storage'
import { configureStore, Middleware } from '@reduxjs/toolkit'
import { setupListeners } from '@reduxjs/toolkit/query'
import { getStoredState, PersistConfig, persistReducer, persistStore } from 'redux-persist'
import FSStorage from 'redux-persist-fs-storage'
import autoMergeLevel2 from 'redux-persist/lib/stateReconciler/autoMergeLevel2'
import createSagaMiddleware from 'redux-saga'
import AppAnalytics from 'src/analytics/AppAnalytics'
import { PerformanceEvents } from 'src/analytics/Events'
import { apiMiddlewares } from 'src/redux/apiReducersList'
import { createMigrate } from 'src/redux/createMigrate'
import { migrations } from 'src/redux/migrations'
import rootReducer, { RootState as ReducersRootState } from 'src/redux/reducers'
import { rootSaga } from 'src/redux/sagas'
import { transactionFeedV2Api } from 'src/transactions/api'
import { resetStateOnInvalidStoredAccount } from 'src/utils/accountChecker'
import Logger from 'src/utils/Logger'
import { ONE_DAY_IN_MILLIS } from 'src/utils/time'
Expand All @@ -21,10 +24,17 @@ const persistConfig: PersistConfig<ReducersRootState> = {
key: 'root',
// default is -1, increment as we make migrations
// See https://github.com/valora-inc/wallet/tree/main/WALLET.md#redux-state-migration
version: 232,
version: 233,
keyPrefix: `reduxStore-`, // the redux-persist default is `persist:` which doesn't work with some file systems.
storage: FSStorage(),
blacklist: ['networkInfo', 'alert', 'imports', 'keylessBackup', 'jumpstart'],
blacklist: [
'networkInfo',
'alert',
'imports',
'keylessBackup',
'jumpstart',
transactionFeedV2Api.reducerPath,
],
stateReconciler: autoMergeLevel2,
migrate: async (...args) => {
const migrate = createMigrate(migrations)
Expand Down Expand Up @@ -103,7 +113,8 @@ export const setupStore = (initialState?: ReducersRootState, config = persistCon
)
},
})
const middlewares: Middleware[] = [sagaMiddleware]

const middlewares: Middleware[] = [sagaMiddleware, ...apiMiddlewares]

if (__DEV__ && !process.env.JEST_WORKER_ID) {
const createDebugger = require('redux-flipper').default
Expand Down Expand Up @@ -172,3 +183,5 @@ export { persistor, store }

export type RootState = ReturnType<typeof store.getState>
export type AppDispatch = typeof store.dispatch

setupListeners(store.dispatch)
6 changes: 6 additions & 0 deletions src/swap/SwapScreen.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1328,6 +1328,9 @@ describe('SwapScreen', () => {
status: 'started',
},
},

// as per test/utils.ts, line 105
transactionFeedV2Api: undefined,
})

update(
Expand Down Expand Up @@ -1373,6 +1376,9 @@ describe('SwapScreen', () => {
status: 'error',
},
},

// as per test/utils.ts, line 105
transactionFeedV2Api: undefined,
})

update(
Expand Down
23 changes: 23 additions & 0 deletions src/transactions/api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { createApi } from '@reduxjs/toolkit/query/react'
import { baseQuery } from 'src/redux/api'
import type { TokenTransaction } from 'src/transactions/types'

type TransactionFeedV2Response = {
transactions: TokenTransaction[]
pageInfo: {
hasNextPage: boolean
}
}

export const transactionFeedV2Api = createApi({
reducerPath: 'transactionFeedV2Api',
baseQuery,
endpoints: (builder) => ({
transactionFeedV2: builder.query<
TransactionFeedV2Response,
{ address: string; endCursor: number }
>({
query: ({ address, endCursor }) => `/wallet/${address}/transactions?endCursor=${endCursor}`,

Check warning on line 20 in src/transactions/api.ts

View check run for this annotation

Codecov / codecov/patch

src/transactions/api.ts#L20

Added line #L20 was not covered by tests
}),
}),
})
56 changes: 56 additions & 0 deletions src/transactions/apiTestHelpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import type { EnhancedStore, Middleware, Reducer, UnknownAction } from '@reduxjs/toolkit'
import { combineReducers, configureStore } from '@reduxjs/toolkit'
import { ApiReducersKeys } from 'src/redux/apiReducersList'
import { RootState } from 'src/redux/reducers'
import { getMockStoreData, RecursivePartial } from 'test/utils'

/**
* This function is taken from the Redux team. It creates a testable store that is compatible with RTK-Query.
* It is slightly modified to also include the preloaded state.
* https://github.com/reduxjs/redux-toolkit/blob/e7540a5594b0d880037f2ff41a83a32c629d3117/packages/toolkit/src/tests/utils/helpers.tsx#L186
*
* For more info on why this is needed and how it works - here's an article that answers some of the questions:
* https://medium.com/@johnmcdowell0801/testing-rtk-query-with-jest-cdfa5aaf3dc1
*/
export function setupApiStore<
Copy link
Member

Choose a reason for hiding this comment

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

Could we use/extend our existing mockStore instead?

wallet/test/utils.ts

Lines 76 to 82 in 1aa94d6

const mockStore = configureMockStore<RootState>()
/* Create a mock store with some reasonable default values */
export type RecursivePartial<T> = { [P in keyof T]?: RecursivePartial<T[P]> }
export function createMockStore(overrides: RecursivePartial<RootState> = {}) {
return mockStore(getMockStoreData(overrides))
}

So it's not specific to this reducer.

This way we'll respect one of the Redux testing principle:

Prefer writing integration tests with everything working together.

https://redux.js.org/usage/writing-tests

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@jeanregisser Initially, I've tried to use this store and I couldn't get it to work.

To test RTK-Query it is required to have api middlewares in the mocked store. configureMockStore accepts an array of middlewares as an argument and I've tried to pass it was triggering for all the other tests to fail with error saying something similar to: "Actions must not be dispatched but passed as a plain object". Considering, that RTK-Query was released in 2021 and the last update for redux-mock-store was in 2018, it seems like there were no changes made to the library to make is able to work with RTK-Query.

I was looking for the solution and found this article (I've also put it in a comment there so there's some reference) that pointed me in a direction to try to use the helper function Redux team uses themselves to create a testable store. I've slightly modified it but it seemed to do the job.

P.S. Here's the quote from the article explaining the potential reason of why redux-mock-store doesn't work with RTK-Query:
"Typically we might reach for a tool like redux-mock-store but this library doesn’t work when testing RTK Query because dispatching RTK Query actions relies on there being a reducer for the API service and redux-mock-store doesn’t allow the use of reducers."

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@jeanregisser I've now added all the data from getMockStoreData to it so it's identical. Sorry, definitely missed this initially!
The two mocked stores should be identical now in terms of preloaded data.

A extends {
reducer: Reducer<any, any>
reducerPath: string
middleware: Middleware
util: { resetApiState(): any }
},
Preloaded extends RecursivePartial<Omit<RootState, ApiReducersKeys>>,
R extends Record<string, Reducer<any, any>> = Record<never, never>,
>(api: A, preloadedState: Preloaded, extraReducers?: R) {
const getStore = () =>
configureStore({
preloadedState: getMockStoreData(preloadedState),
reducer: combineReducers({
[api.reducerPath]: api.reducer,
...extraReducers,
}),
middleware: (getDefaultMiddleware) => {
return getDefaultMiddleware({
serializableCheck: false,
immutableCheck: false,
}).concat(api.middleware)
},
})

type StoreType = EnhancedStore<
{
api: ReturnType<A['reducer']>
} & {
[K in keyof R]: ReturnType<R[K]>
},
UnknownAction,
ReturnType<typeof getStore> extends EnhancedStore<any, any, infer M> ? M : never
>

const initialStore = getStore() as StoreType
const refObj = { api, store: initialStore }
const store = getStore() as StoreType
refObj.store = store

return refObj
}
2 changes: 1 addition & 1 deletion test/schema.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ describe(getLatestSchema, () => {
it('validates against the RootState schema', async () => {
const data = getLatestSchema()

const ajv = new Ajv({ allErrors: true })
const ajv = new Ajv({ allErrors: true, allowUnionTypes: true })
const schema = require('./RootStateSchema.json')
const validate = ajv.compile(schema)
const isValid = validate(data)
Expand Down
Loading
Loading