From f69ee16da0993cd49c1b750b429bea17c535b69d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Clo=CC=81vis=20Neto?= Date: Thu, 2 Jan 2025 11:52:41 +0100 Subject: [PATCH 01/16] feat: install necessary packages for sortable functionallity --- apps/mobile/app/_layout.tsx | 12 ++++++------ apps/mobile/package.json | 2 ++ yarn.lock | 26 +++++++++++++++++++++++++- 3 files changed, 33 insertions(+), 7 deletions(-) diff --git a/apps/mobile/app/_layout.tsx b/apps/mobile/app/_layout.tsx index 027e159c244..d430524923e 100644 --- a/apps/mobile/app/_layout.tsx +++ b/apps/mobile/app/_layout.tsx @@ -26,9 +26,9 @@ function RootLayout() { store.dispatch(apiSliceWithChainsConfig.endpoints.getChainsConfig.initiate()) return ( - - - + + + @@ -64,9 +64,9 @@ function RootLayout() { - - - + + + ) } diff --git a/apps/mobile/package.json b/apps/mobile/package.json index db6ff429e00..07b6da0a9fd 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -67,6 +67,7 @@ "expo-constants": "~17.0.2", "expo-dev-client": "~5.0.5", "expo-font": "~13.0.1", + "expo-haptics": "~14.0.0", "expo-image": "~2.0.3", "expo-linear-gradient": "^14.0.1", "expo-linking": "~7.0.3", @@ -83,6 +84,7 @@ "react-native-collapsible-tab-view": "^8.0.0", "react-native-device-crypto": "^0.1.7", "react-native-device-info": "^14.0.1", + "react-native-draggable-flatlist": "^4.0.1", "react-native-gesture-handler": "~2.20.2", "react-native-keychain": "^9.2.2", "react-native-mmkv": "^3.1.0", diff --git a/yarn.lock b/yarn.lock index 1a11c6e9ae5..672a22db02c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1613,7 +1613,7 @@ __metadata: languageName: node linkType: hard -"@babel/preset-typescript@npm:^7.13.0, @babel/preset-typescript@npm:^7.16.7, @babel/preset-typescript@npm:^7.21.0, @babel/preset-typescript@npm:^7.23.0, @babel/preset-typescript@npm:^7.24.1, @babel/preset-typescript@npm:^7.24.7": +"@babel/preset-typescript@npm:^7.13.0, @babel/preset-typescript@npm:^7.16.7, @babel/preset-typescript@npm:^7.17.12, @babel/preset-typescript@npm:^7.21.0, @babel/preset-typescript@npm:^7.23.0, @babel/preset-typescript@npm:^7.24.1, @babel/preset-typescript@npm:^7.24.7": version: 7.26.0 resolution: "@babel/preset-typescript@npm:7.26.0" dependencies: @@ -7341,6 +7341,7 @@ __metadata: expo-constants: "npm:~17.0.2" expo-dev-client: "npm:~5.0.5" expo-font: "npm:~13.0.1" + expo-haptics: "npm:~14.0.0" expo-image: "npm:~2.0.3" expo-linear-gradient: "npm:^14.0.1" expo-linking: "npm:~7.0.3" @@ -7360,6 +7361,7 @@ __metadata: react-native-collapsible-tab-view: "npm:^8.0.0" react-native-device-crypto: "npm:^0.1.7" react-native-device-info: "npm:^14.0.1" + react-native-draggable-flatlist: "npm:^4.0.1" react-native-gesture-handler: "npm:~2.20.2" react-native-keychain: "npm:^9.2.2" react-native-mmkv: "npm:^3.1.0" @@ -19019,6 +19021,15 @@ __metadata: languageName: node linkType: hard +"expo-haptics@npm:~14.0.0": + version: 14.0.0 + resolution: "expo-haptics@npm:14.0.0" + peerDependencies: + expo: "*" + checksum: 10/da25773a8f59410fe2066d21404a2cb2a7a93e55b433d21e103dc1cb7ac437926860655672f8f6dfba88d3124c53f911a6b4ef4e935b9f34d2e04c7d4b13d24e + languageName: node + linkType: hard + "expo-image@npm:~2.0.3": version: 2.0.3 resolution: "expo-image@npm:2.0.3" @@ -27647,6 +27658,19 @@ __metadata: languageName: node linkType: hard +"react-native-draggable-flatlist@npm:^4.0.1": + version: 4.0.1 + resolution: "react-native-draggable-flatlist@npm:4.0.1" + dependencies: + "@babel/preset-typescript": "npm:^7.17.12" + peerDependencies: + react-native: ">=0.64.0" + react-native-gesture-handler: ">=2.0.0" + react-native-reanimated: ">=2.8.0" + checksum: 10/4d9c8bfef6185ab51ebef8d00a0a06f8fd57c96a5a9e60cb6e7cd56ec6222d37643c53fc998cc93efcb8be88d32c2e5e43788f1816d8cded4eb2b60076001749 + languageName: node + linkType: hard + "react-native-gesture-handler@npm:~2.20.2": version: 2.20.2 resolution: "react-native-gesture-handler@npm:2.20.2" From 42967a129f00e46a78595198b3b734ef45560ea2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Clo=CC=81vis=20Neto?= Date: Thu, 2 Jan 2025 11:56:37 +0100 Subject: [PATCH 02/16] chore: move active chain information to be under activeSafe to avoid duplicated source of truth and better and domain centralization --- .../components/Balance/Balance.container.tsx | 15 ++--- .../Assets/components/Balance/Balance.tsx | 14 +++-- .../MyAccounts/hooks/useEditMyAccounts.ts | 56 +++++++++++++++++++ .../Assets/components/NFTs/NFTs.container.tsx | 4 +- .../components/Tokens/Tokens.container.tsx | 4 +- .../AppSettings/AppSettings.container.tsx | 7 +-- .../components/Navbar/SettingsMenu.tsx | 7 ++- apps/mobile/src/store/activeChainSlice.ts | 23 -------- apps/mobile/src/store/index.ts | 6 +- 9 files changed, 84 insertions(+), 52 deletions(-) create mode 100644 apps/mobile/src/features/Assets/components/MyAccounts/hooks/useEditMyAccounts.ts delete mode 100644 apps/mobile/src/store/activeChainSlice.ts diff --git a/apps/mobile/src/features/Assets/components/Balance/Balance.container.tsx b/apps/mobile/src/features/Assets/components/Balance/Balance.container.tsx index 23c8b5cb9cb..76a68b8c94b 100644 --- a/apps/mobile/src/features/Assets/components/Balance/Balance.container.tsx +++ b/apps/mobile/src/features/Assets/components/Balance/Balance.container.tsx @@ -1,23 +1,20 @@ -import { selectActiveChain, switchActiveChain } from '@/src/store/activeChainSlice' import { useDispatch, useSelector } from 'react-redux' import { useSafesGetSafeOverviewV1Query } from '@safe-global/store/gateway/AUTO_GENERATED/safes' -import { selectActiveSafe } from '@/src/store/activeSafeSlice' +import { selectActiveSafe, switchActiveChain } from '@/src/store/activeSafeSlice' import { SafeOverviewResult } from '@safe-global/store/gateway/types' import { POLLING_INTERVAL } from '@/src/config/constants' import { getChainsByIds, selectAllChains } from '@/src/store/chains' import { Balance } from './Balance' import { makeSafeId } from '@/src/utils/formatters' import { RootState } from '@/src/store' -import { selectActiveSafeInfo } from '@/src/store/safesSlice' +import { selectSafeInfo } from '@/src/store/safesSlice' export function BalanceContainer() { - const activeChain = useSelector(selectActiveChain) const chains = useSelector(selectAllChains) const activeSafe = useSelector(selectActiveSafe) const dispatch = useDispatch() - const activeSafeInfo = useSelector((state: RootState) => selectActiveSafeInfo(state, activeSafe.address)) + const activeSafeInfo = useSelector((state: RootState) => selectSafeInfo(state, activeSafe.address)) const activeSafeChains = useSelector((state: RootState) => getChainsByIds(state, activeSafeInfo.chains)) - const { data, isLoading } = useSafesGetSafeOverviewV1Query( { safes: chains.map((chain) => makeSafeId(chain.chainId, activeSafe.address)).join(','), @@ -31,8 +28,8 @@ export function BalanceContainer() { }, ) - const handleChainChange = (id: string) => { - dispatch(switchActiveChain({ id })) + const handleChainChange = (chainId: string) => { + dispatch(switchActiveChain({ chainId })) } return ( @@ -40,7 +37,7 @@ export function BalanceContainer() { data={data} chains={activeSafeChains} isLoading={isLoading} - activeChain={activeChain} + activeChainId={activeSafe.chainId} onChainChange={handleChainChange} /> ) diff --git a/apps/mobile/src/features/Assets/components/Balance/Balance.tsx b/apps/mobile/src/features/Assets/components/Balance/Balance.tsx index 3bf7b3c3777..8896651010f 100644 --- a/apps/mobile/src/features/Assets/components/Balance/Balance.tsx +++ b/apps/mobile/src/features/Assets/components/Balance/Balance.tsx @@ -9,26 +9,30 @@ import { Chain } from '@safe-global/store/gateway/AUTO_GENERATED/chains' import { ChainItems } from './ChainItems' import { ChainsDisplay } from '@/src/components/ChainsDisplay' +import { selectChainById } from '@/src/store/chains' +import { useSelector } from 'react-redux' +import { RootState } from '@/src/store' interface BalanceProps { - activeChain: Chain + activeChainId: string data: SafeOverview[] isLoading: boolean chains: Chain[] onChainChange: (chainId: string) => void } -export function Balance({ activeChain, data, chains, isLoading, onChainChange }: BalanceProps) { - const balance = data?.find((chain) => chain.chainId === activeChain.chainId) +export function Balance({ activeChainId, data, chains, isLoading, onChainChange }: BalanceProps) { + const balance = data?.find((chain) => chain.chainId === activeChainId) + const activeChain = useSelector((state: RootState) => selectChainById(state, activeChainId)) return ( - {activeChain && ( + {activeChainId && ( label={activeChain?.chainName} dropdownTitle="Select network:" - leftNode={} + leftNode={} items={data} keyExtractor={({ item }) => item.chainId} renderItem={({ item, onClose }) => ( diff --git a/apps/mobile/src/features/Assets/components/MyAccounts/hooks/useEditMyAccounts.ts b/apps/mobile/src/features/Assets/components/MyAccounts/hooks/useEditMyAccounts.ts new file mode 100644 index 00000000000..6ea79abf91b --- /dev/null +++ b/apps/mobile/src/features/Assets/components/MyAccounts/hooks/useEditMyAccounts.ts @@ -0,0 +1,56 @@ +import { selectActiveSafe, setActiveSafe } from '@/src/store/activeSafeSlice' +import { toggleMode } from '@/src/store/myAccountsSlice' +import { removeSafe, SafesSliceItem, selectAllSafes, setSafes } from '@/src/store/safesSlice' +import { Address } from '@/src/types/address' +import { useCallback, useMemo, useState } from 'react' +import { DragEndParams } from 'react-native-draggable-flatlist' +import { useDispatch, useSelector } from 'react-redux' + +type useEditMyAccountsReturn = { + safes: SafesSliceItem[] + onDragEnd: (params: DragEndParams) => void + onSafeDeleted: (address: Address) => () => void +} + +export const useEditMyAccounts = (): useEditMyAccountsReturn => { + const dispatch = useDispatch() + const safes = useSelector(selectAllSafes) + const activeSafe = useSelector(selectActiveSafe) + const [sortableSafes, setSortableSafes] = useState(() => Object.values(safes)) + + const onDragEnd = useCallback(({ data }: DragEndParams) => { + setSortableSafes([...data]) // Update local state immediately + + // Defer Redux update + setTimeout(() => { + const safes = data.reduce((acc, item) => ({ ...acc, [item.SafeInfo.address.value]: item }), {}) + dispatch(setSafes(safes)) + }, 0) // Ensure this happens after the re-render + }, []) + + const onSafeDeleted = useCallback( + (address: Address) => () => { + if (activeSafe.address === address) { + const safe = sortableSafes.find((item) => item.SafeInfo.address.value !== address) + + if (safe) { + dispatch( + setActiveSafe({ + address: safe.SafeInfo.address.value as Address, + chainId: safe.chains[0], + }), + ) + } + } + + if (sortableSafes.length <= 2) { + dispatch(toggleMode()) + } + + dispatch(removeSafe(address)) + }, + [sortableSafes, activeSafe], + ) + + return { safes: sortableSafes, onDragEnd, onSafeDeleted } +} diff --git a/apps/mobile/src/features/Assets/components/NFTs/NFTs.container.tsx b/apps/mobile/src/features/Assets/components/NFTs/NFTs.container.tsx index d201de1cfe2..10c88689ab9 100644 --- a/apps/mobile/src/features/Assets/components/NFTs/NFTs.container.tsx +++ b/apps/mobile/src/features/Assets/components/NFTs/NFTs.container.tsx @@ -13,17 +13,15 @@ import { import { Fallback } from '../Fallback' import { NFTItem } from './NFTItem' -import { selectActiveChain } from '@/src/store/activeChainSlice' import { useInfiniteScroll } from '@/src/hooks/useInfiniteScroll' export function NFTsContainer() { - const activeChain = useSelector(selectActiveChain) const activeSafe = useSelector(selectActiveSafe) const [pageUrl, setPageUrl] = useState() const { data, isFetching, error, refetch } = useCollectiblesGetCollectiblesV2Query( { - chainId: activeChain.chainId, + chainId: activeSafe.chainId, safeAddress: activeSafe.address, cursor: pageUrl && safelyDecodeURIComponent(pageUrl?.split('cursor=')[1]), }, diff --git a/apps/mobile/src/features/Assets/components/Tokens/Tokens.container.tsx b/apps/mobile/src/features/Assets/components/Tokens/Tokens.container.tsx index 29b5ca90c5a..2f3a671f35f 100644 --- a/apps/mobile/src/features/Assets/components/Tokens/Tokens.container.tsx +++ b/apps/mobile/src/features/Assets/components/Tokens/Tokens.container.tsx @@ -9,17 +9,15 @@ import { POLLING_INTERVAL } from '@/src/config/constants' import { selectActiveSafe } from '@/src/store/activeSafeSlice' import { Balance, useBalancesGetBalancesV1Query } from '@safe-global/store/gateway/AUTO_GENERATED/balances' import { formatValue } from '@/src/utils/formatters' -import { selectActiveChain } from '@/src/store/activeChainSlice' import { Fallback } from '../Fallback' export function TokensContainer() { const activeSafe = useSelector(selectActiveSafe) - const activeChain = useSelector(selectActiveChain) const { data, isFetching, error } = useBalancesGetBalancesV1Query( { - chainId: activeChain.chainId, + chainId: activeSafe.chainId, fiatCode: 'USD', safeAddress: activeSafe.address, excludeSpam: false, diff --git a/apps/mobile/src/features/Settings/components/AppSettings/AppSettings.container.tsx b/apps/mobile/src/features/Settings/components/AppSettings/AppSettings.container.tsx index 977811381aa..c0eec6e4d09 100644 --- a/apps/mobile/src/features/Settings/components/AppSettings/AppSettings.container.tsx +++ b/apps/mobile/src/features/Settings/components/AppSettings/AppSettings.container.tsx @@ -1,19 +1,18 @@ import React, { useState } from 'react' import { useDispatch, useSelector } from 'react-redux' -import { selectActiveChain } from '@/src/store/activeChainSlice' -import { setActiveSafe } from '@/src/store/activeSafeSlice' +import { selectActiveSafe, setActiveSafe } from '@/src/store/activeSafeSlice' import { Address } from '@/src/types/address' import { AppSettings } from './AppSettings' export const AppSettingsContainer = () => { const dispatch = useDispatch() - const activeChain = useSelector(selectActiveChain) + const activeSafe = useSelector(selectActiveSafe) const [safeAddress, setSafeAddress] = useState('') const handleSubmit = () => { dispatch( setActiveSafe({ - chainId: activeChain.chainId, + chainId: activeSafe.chainId, address: safeAddress as Address, }), ) diff --git a/apps/mobile/src/features/Settings/components/Navbar/SettingsMenu.tsx b/apps/mobile/src/features/Settings/components/Navbar/SettingsMenu.tsx index cba28adcb0f..7907e6e293f 100644 --- a/apps/mobile/src/features/Settings/components/Navbar/SettingsMenu.tsx +++ b/apps/mobile/src/features/Settings/components/Navbar/SettingsMenu.tsx @@ -3,18 +3,21 @@ import { MenuView, NativeActionEvent } from '@react-native-menu/menu' import { Linking, Platform } from 'react-native' import { SafeFontIcon } from '@/src/components/SafeFontIcon/SafeFontIcon' import React from 'react' -import { selectActiveChain } from '@/src/store/activeChainSlice' import { useSelector } from 'react-redux' import { getExplorerLink } from '@/src/utils/gateway' import { useCopyAndDispatchToast } from '@/src/hooks/useCopyAndDispatchToast' import { useToastController } from '@tamagui/toast' +import { selectActiveSafe } from '@/src/store/activeSafeSlice' +import { selectChainById } from '@/src/store/chains' +import { RootState } from '@/src/store' type Props = { safeAddress: string | undefined } export const SettingsMenu = ({ safeAddress }: Props) => { const toast = useToastController() - const activeChain = useSelector(selectActiveChain) + const activeSafe = useSelector(selectActiveSafe) + const activeChain = useSelector((state: RootState) => selectChainById(state, activeSafe.chainId)) const copyAndDispatchToast = useCopyAndDispatchToast() const theme = useTheme() const color = theme.color?.get() diff --git a/apps/mobile/src/store/activeChainSlice.ts b/apps/mobile/src/store/activeChainSlice.ts deleted file mode 100644 index db064e6a391..00000000000 --- a/apps/mobile/src/store/activeChainSlice.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { createSelector, createSlice, PayloadAction } from '@reduxjs/toolkit' -import { RootState } from '.' -import { selectChainById } from './chains' -import { mockedActiveAccount } from './constants' - -const initialState = { id: mockedActiveAccount.chainId } - -const activeChainSlice = createSlice({ - name: 'activeChain', - initialState, - reducers: { - switchActiveChain: (state, action: PayloadAction<{ id: string }>) => { - return action.payload - }, - }, -}) - -export const { switchActiveChain } = activeChainSlice.actions - -export const selectActiveChain = (state: RootState) => selectChainById(state, state.activeChain.id) -export const selectNativeCurrency = createSelector([selectActiveChain], (activeChain) => activeChain?.nativeCurrency) - -export default activeChainSlice.reducer diff --git a/apps/mobile/src/store/index.ts b/apps/mobile/src/store/index.ts index 7c580238b21..bc60c33de14 100644 --- a/apps/mobile/src/store/index.ts +++ b/apps/mobile/src/store/index.ts @@ -2,8 +2,8 @@ import { combineReducers, configureStore } from '@reduxjs/toolkit' import { persistStore, persistReducer, FLUSH, REHYDRATE, PAUSE, PERSIST, PURGE, REGISTER } from 'redux-persist' import { reduxStorage } from './storage' import txHistory from './txHistorySlice' -import activeChain from './activeChainSlice' import activeSafe from './activeSafeSlice' +import myAccounts from './myAccountsSlice' import safes from './safesSlice' import { cgwClient, setBaseUrl } from '@safe-global/store/gateway/cgwClient' import devToolsEnhancer from 'redux-devtools-expo-dev-plugin' @@ -14,13 +14,13 @@ const persistConfig = { key: 'root', version: 1, storage: reduxStorage, - blacklist: [cgwClient.reducerPath], + blacklist: [cgwClient.reducerPath, 'myAccounts'], } export const rootReducer = combineReducers({ txHistory, safes, - activeChain, activeSafe, + myAccounts, [cgwClient.reducerPath]: cgwClient.reducer, }) From 82a0e2b074c0413b07267a9df6349a0426551f91 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Clo=CC=81vis=20Neto?= Date: Thu, 2 Jan 2025 11:57:52 +0100 Subject: [PATCH 03/16] feat: allow safeList consumer to remove the horizontal padding if he/she/they wants --- apps/mobile/src/components/Container/Container.tsx | 12 +++++++++--- .../src/components/SafeListItem/SafeListItem.tsx | 3 +++ 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/apps/mobile/src/components/Container/Container.tsx b/apps/mobile/src/components/Container/Container.tsx index 95b7585e001..c92e72ea944 100644 --- a/apps/mobile/src/components/Container/Container.tsx +++ b/apps/mobile/src/components/Container/Container.tsx @@ -21,12 +21,18 @@ const StyledYStack = styled(YStack, { }) export const Container = ( - props: YStackProps & { bordered?: boolean; transparent?: boolean; themeName?: ThemeName }, + props: YStackProps & { bordered?: boolean; spaced?: boolean; transparent?: boolean; themeName?: ThemeName }, ) => { - const { children, bordered, themeName = 'container', ...rest } = props + const { children, bordered, themeName = 'container', spaced = true, ...rest } = props return ( - + {children} diff --git a/apps/mobile/src/components/SafeListItem/SafeListItem.tsx b/apps/mobile/src/components/SafeListItem/SafeListItem.tsx index 9e71f252f82..7b90e01b2e5 100644 --- a/apps/mobile/src/components/SafeListItem/SafeListItem.tsx +++ b/apps/mobile/src/components/SafeListItem/SafeListItem.tsx @@ -16,6 +16,7 @@ interface SafeListItemProps { leftNode?: React.ReactNode bordered?: boolean transparent?: boolean + spaced?: boolean inQueue?: boolean executionInfo?: Transaction['executionInfo'] themeName?: ThemeName @@ -26,6 +27,7 @@ export function SafeListItem({ leftNode, icon, bordered, + spaced, label, transparent, rightNode, @@ -36,6 +38,7 @@ export function SafeListItem({ }: SafeListItemProps) { return ( Date: Thu, 2 Jan 2025 11:59:44 +0100 Subject: [PATCH 04/16] feat: add edit accounts functionality --- .../src/components/Dropdown/Dropdown.tsx | 96 ++++++++++++++----- .../Card/AccountCard/AccountCard.tsx | 23 ++++- .../Card/TxTokenCard/TxTokenCard.tsx | 7 +- .../components/AccountItem/AccountItem.tsx | 44 +++++++-- .../MyAccounts/MyAccounts.container.tsx | 9 +- .../MyAccounts/hooks/useEditMyAccounts.ts | 7 +- .../Assets/components/Navbar/Navbar.tsx | 32 +++++-- .../IdenticonWithBadge/IdenticonWithBadge.tsx | 13 ++- apps/mobile/src/store/activeSafeSlice.ts | 8 +- apps/mobile/src/store/constants.ts | 18 ++++ apps/mobile/src/store/myAccountsSlice.ts | 22 +++++ apps/mobile/src/store/safesSlice.ts | 21 +++- 12 files changed, 242 insertions(+), 58 deletions(-) create mode 100644 apps/mobile/src/store/myAccountsSlice.ts diff --git a/apps/mobile/src/components/Dropdown/Dropdown.tsx b/apps/mobile/src/components/Dropdown/Dropdown.tsx index 160673638ad..c03e9c7f8d1 100644 --- a/apps/mobile/src/components/Dropdown/Dropdown.tsx +++ b/apps/mobile/src/components/Dropdown/Dropdown.tsx @@ -1,23 +1,28 @@ -import React, { useCallback, useRef } from 'react' +import React, { useCallback, useMemo, useRef } from 'react' import { GetThemeValueForKey, H5, ScrollView, Text, View } from 'tamagui' import { SafeFontIcon } from '@/src/components/SafeFontIcon/SafeFontIcon' import { BottomSheetFooterProps, BottomSheetModal, BottomSheetModalProps, BottomSheetView } from '@gorhom/bottom-sheet' import { StyleSheet } from 'react-native' import { BackdropComponent, BackgroundComponent } from './sheetComponents' +import DraggableFlatList, { DragEndParams, RenderItemParams, ScaleDecorator } from 'react-native-draggable-flatlist' + interface DropdownProps { label: string leftNode?: React.ReactNode children?: React.ReactNode dropdownTitle?: string + sortable?: boolean + onDragEnd?: (params: DragEndParams) => void items?: T[] snapPoints?: BottomSheetModalProps['snapPoints'] labelProps?: { fontSize?: '$4' | '$5' | GetThemeValueForKey<'fontSize'> fontWeight: 400 | 500 | 600 } + actions?: React.ReactNode footerComponent?: React.FC - renderItem?: React.FC<{ item: T; onClose: () => void }> + renderItem?: React.FC<{ item: T; isDragging?: boolean; drag?: () => void; onClose: () => void }> keyExtractor?: ({ item, index }: { item: T; index: number }) => string } @@ -31,12 +36,15 @@ export function Dropdown({ leftNode, children, dropdownTitle, + sortable, items, snapPoints = [600, '90%'], keyExtractor, + actions, renderItem: Render, labelProps = defaultLabelProps, footerComponent, + onDragEnd, }: DropdownProps) { const bottomSheetModalRef = useRef(null) @@ -49,6 +57,33 @@ export function Dropdown({ }, []) const hasCustomItems = items && Render + const isSortable = items && sortable + + const renderItem = useCallback( + ({ item, drag, isActive }: RenderItemParams) => { + return ( + + {Render && } + + ) + }, + [handleModalClose, Render], + ) + + const renderDropdownHeader = useMemo( + () => ( + +
{dropdownTitle}
+ + {actions && ( + + {actions} + + )} +
+ ), + [dropdownTitle, actions], + ) return ( <> @@ -80,28 +115,38 @@ export function Dropdown({ backdropComponent={BackdropComponent} footerComponent={footerComponent} > - - - - {dropdownTitle && ( -
- {dropdownTitle} -
- )} - - - {hasCustomItems - ? items.map((item, index) => ( - - )) - : children} + {!isSortable && dropdownTitle && renderDropdownHeader} + + + {isSortable ? ( + + data={items} + extraData={items} + containerStyle={{ height: '100%' }} + ListHeaderComponent={dropdownTitle ? renderDropdownHeader : undefined} + onDragEnd={onDragEnd} + keyExtractor={(item, index) => (keyExtractor ? keyExtractor({ item, index }) : index.toString())} + renderItem={renderItem} + /> + ) : ( + + + + {hasCustomItems + ? items.map((item, index) => ( + + )) + : children} + - -
+ + )}
@@ -110,7 +155,10 @@ export function Dropdown({ const styles = StyleSheet.create({ contentContainer: { - paddingHorizontal: 20, justifyContent: 'space-around', }, }) + +// export const Dropdown = React.memo(DropdownComponent, (prevProps, nextProps) => { +// return JSON.stringify(prevProps.items || []) === JSON.stringify(nextProps.items || []) +// }) diff --git a/apps/mobile/src/components/transactions-list/Card/AccountCard/AccountCard.tsx b/apps/mobile/src/components/transactions-list/Card/AccountCard/AccountCard.tsx index 7409501c456..533a3067117 100644 --- a/apps/mobile/src/components/transactions-list/Card/AccountCard/AccountCard.tsx +++ b/apps/mobile/src/components/transactions-list/Card/AccountCard/AccountCard.tsx @@ -14,12 +14,25 @@ interface AccountCardProps { owners: number threshold: number rightNode?: string | React.ReactNode - chains: Chain[] + leftNode?: React.ReactNode + chains?: Chain[] + spaced?: boolean } -export function AccountCard({ name, chains, owners, balance, address, threshold, rightNode }: AccountCardProps) { +export function AccountCard({ + name, + chains, + spaced, + owners, + leftNode, + balance, + address, + threshold, + rightNode, +}: AccountCardProps) { return ( @@ -31,10 +44,12 @@ export function AccountCard({ name, chains, owners, balance, address, threshold,
} leftNode={ - + + {leftNode} 9 ? 8 : 12} address={address} badgeContent={`${threshold}/${owners}`} /> @@ -42,7 +57,7 @@ export function AccountCard({ name, chains, owners, balance, address, threshold, } rightNode={ - + {chains && } {rightNode} } diff --git a/apps/mobile/src/components/transactions-list/Card/TxTokenCard/TxTokenCard.tsx b/apps/mobile/src/components/transactions-list/Card/TxTokenCard/TxTokenCard.tsx index eef29051e51..49c807fd5b2 100644 --- a/apps/mobile/src/components/transactions-list/Card/TxTokenCard/TxTokenCard.tsx +++ b/apps/mobile/src/components/transactions-list/Card/TxTokenCard/TxTokenCard.tsx @@ -10,10 +10,12 @@ import { } from '@/src/utils/transaction-guards' import { ellipsis, formatValue } from '@/src/utils/formatters' import { useSelector } from 'react-redux' -import { selectNativeCurrency } from '@/src/store/activeChainSlice' import { TransferDirection } from '@safe-global/store/gateway/types' import { TransferTransactionInfo, Transaction } from '@safe-global/store/gateway/AUTO_GENERATED/transactions' import { Logo } from '@/src/components/Logo' +import { selectActiveSafe } from '@/src/store/activeSafeSlice' +import { RootState } from '@/src/store' +import { selectChainById } from '@/src/store/chains' interface TxTokenCardProps { bordered?: boolean @@ -34,7 +36,8 @@ interface tokenDetails { const getTokenDetails = (txInfo: TransferTransactionInfo): tokenDetails => { const transfer = txInfo.transferInfo const unnamedToken = 'Unnamed token' - const nativeCurrency = useSelector(selectNativeCurrency) + const activeSafe = useSelector(selectActiveSafe) + const { nativeCurrency } = useSelector((state: RootState) => selectChainById(state, activeSafe.chainId)) if (isNativeTokenTransfer(transfer)) { return { diff --git a/apps/mobile/src/features/Assets/components/AccountItem/AccountItem.tsx b/apps/mobile/src/features/Assets/components/AccountItem/AccountItem.tsx index 2d3c2d7cec1..2d1e77c2690 100644 --- a/apps/mobile/src/features/Assets/components/AccountItem/AccountItem.tsx +++ b/apps/mobile/src/features/Assets/components/AccountItem/AccountItem.tsx @@ -1,4 +1,4 @@ -import React from 'react' +import React, { useMemo } from 'react' import { TouchableOpacity } from 'react-native' import { View } from 'tamagui' import { SafeFontIcon } from '@/src/components/SafeFontIcon' @@ -7,38 +7,66 @@ import { Chain } from '@safe-global/store/gateway/AUTO_GENERATED/chains' import { Address } from '@/src/types/address' import { SafeOverview } from '@safe-global/store/gateway/AUTO_GENERATED/safes' import { shortenAddress } from '@/src/utils/formatters' +import { RenderItemParams } from 'react-native-draggable-flatlist' +import { useSelector } from 'react-redux' +import { selectMyAccountsMode } from '@/src/store/myAccountsSlice' +import { useEditMyAccounts } from '../MyAccounts/hooks/useEditMyAccounts' interface AccountItemProps { chains: Chain[] account: SafeOverview + drag?: RenderItemParams['drag'] + isDragging?: boolean activeAccount: Address onSelect: (accountAddress: string) => void } -// TODO: These props needs to come from the AccountItem.container component -// remove this comment once it is done -export function AccountItem({ account, chains, activeAccount, onSelect }: AccountItemProps) { +const getRightNodeLayout = (isEdit: boolean, isActive: boolean) => { + if (isEdit) { + return + } + + return isActive ? : null +} + +export function AccountItem({ account, drag, chains, isDragging, activeAccount, onSelect }: AccountItemProps) { const isActive = activeAccount === account.address.value + const isEdit = useSelector(selectMyAccountsMode) + const { onSafeDeleted } = useEditMyAccounts() const handleChainSelect = () => { onSelect(account.address.value) } + const rightNode = useMemo(() => getRightNodeLayout(isEdit, isActive), [isEdit, isActive]) + return ( - + + + + ) + } threshold={account.threshold} owners={account.owners.length} name={account.address.name || shortenAddress(account.address.value)} address={account.address.value as Address} balance={account.fiatTotal} - chains={chains} - rightNode={isActive && } + chains={isEdit ? undefined : chains} + rightNode={rightNode} /> diff --git a/apps/mobile/src/features/Assets/components/MyAccounts/MyAccounts.container.tsx b/apps/mobile/src/features/Assets/components/MyAccounts/MyAccounts.container.tsx index aac4ed881df..ea034426b85 100644 --- a/apps/mobile/src/features/Assets/components/MyAccounts/MyAccounts.container.tsx +++ b/apps/mobile/src/features/Assets/components/MyAccounts/MyAccounts.container.tsx @@ -1,4 +1,5 @@ import React from 'react' +import { RenderItemParams } from 'react-native-draggable-flatlist' import { AccountItem } from '../AccountItem' import { SafesSliceItem } from '@/src/store/safesSlice' import { Address } from '@/src/types/address' @@ -6,15 +7,16 @@ import { useDispatch, useSelector } from 'react-redux' import { selectActiveSafe, setActiveSafe } from '@/src/store/activeSafeSlice' import { getChainsByIds } from '@/src/store/chains' import { RootState } from '@/src/store' -import { switchActiveChain } from '@/src/store/activeChainSlice' import { useMyAccountsService } from './hooks/useMyAccountsService' interface MyAccountsContainerProps { item: SafesSliceItem onClose: () => void + isDragging?: boolean + drag?: RenderItemParams['drag'] } -export function MyAccountsContainer({ item, onClose }: MyAccountsContainerProps) { +export function MyAccountsContainer({ item, isDragging, drag, onClose }: MyAccountsContainerProps) { useMyAccountsService(item) const dispatch = useDispatch() @@ -30,14 +32,15 @@ export function MyAccountsContainer({ item, onClose }: MyAccountsContainerProps) chainId, }), ) - dispatch(switchActiveChain({ id: chainId })) onClose() } return ( { const [sortableSafes, setSortableSafes] = useState(() => Object.values(safes)) const onDragEnd = useCallback(({ data }: DragEndParams) => { - setSortableSafes([...data]) // Update local state immediately + setSortableSafes([...data]) - // Defer Redux update + // Defer Redux update due to incompatibility issues between + // react-native-draggable-flatlist and new architecture. setTimeout(() => { const safes = data.reduce((acc, item) => ({ ...acc, [item.SafeInfo.address.value]: item }), {}) dispatch(setSafes(safes)) diff --git a/apps/mobile/src/features/Assets/components/Navbar/Navbar.tsx b/apps/mobile/src/features/Assets/components/Navbar/Navbar.tsx index f67f4617202..b49a695cba9 100644 --- a/apps/mobile/src/features/Assets/components/Navbar/Navbar.tsx +++ b/apps/mobile/src/features/Assets/components/Navbar/Navbar.tsx @@ -1,17 +1,19 @@ -import { useSelector } from 'react-redux' +import { useDispatch, useSelector } from 'react-redux' import { selectActiveSafe } from '@/src/store/activeSafeSlice' -import { View } from 'tamagui' +import { View, H6 } from 'tamagui' import { BlurredIdenticonBackground } from '@/src/components/BlurredIdenticonBackground' import { SafeAreaView } from 'react-native-safe-area-context' import { Identicon } from '@/src/components/Identicon' import { shortenAddress } from '@/src/utils/formatters' import { SafeFontIcon } from '@/src/components/SafeFontIcon' import { StyleSheet, TouchableOpacity } from 'react-native' -import React, { useMemo } from 'react' +import React from 'react' import { Address } from '@/src/types/address' import { Dropdown } from '@/src/components/Dropdown' import { MyAccountsContainer, MyAccountsFooter } from '../MyAccounts' -import { SafesSliceItem, selectAllSafes } from '@/src/store/safesSlice' +import { SafesSliceItem } from '@/src/store/safesSlice' +import { selectMyAccountsMode, toggleMode } from '@/src/store/myAccountsSlice' +import { useEditMyAccounts } from '../MyAccounts/hooks/useEditMyAccounts' const dropdownLabelProps = { fontSize: '$5', @@ -19,23 +21,37 @@ const dropdownLabelProps = { } as const export const Navbar = () => { + const dispatch = useDispatch() + const isEdit = useSelector(selectMyAccountsMode) const activeSafe = useSelector(selectActiveSafe) - const safes = useSelector(selectAllSafes) - const memoizedSafes = useMemo(() => Object.values(safes), [safes]) + const { safes, onDragEnd } = useEditMyAccounts() + + const toggleEditMode = () => { + dispatch(toggleMode()) + } return ( - + label={shortenAddress(activeSafe.address)} labelProps={dropdownLabelProps} dropdownTitle="My accounts" leftNode={} - items={memoizedSafes} + items={safes} keyExtractor={({ item }) => item.SafeInfo.address.value} footerComponent={MyAccountsFooter} renderItem={MyAccountsContainer} + sortable={isEdit} + onDragEnd={onDragEnd} + actions={ + safes.length > 1 && ( + +
{isEdit ? 'Done' : 'Edit'}
+
+ ) + } /> diff --git a/apps/mobile/src/features/Settings/components/IdenticonWithBadge/IdenticonWithBadge.tsx b/apps/mobile/src/features/Settings/components/IdenticonWithBadge/IdenticonWithBadge.tsx index 7e4ed2c009f..2a1de35dfea 100644 --- a/apps/mobile/src/features/Settings/components/IdenticonWithBadge/IdenticonWithBadge.tsx +++ b/apps/mobile/src/features/Settings/components/IdenticonWithBadge/IdenticonWithBadge.tsx @@ -11,9 +11,16 @@ type IdenticonWithBadgeProps = { badgeContent?: string size?: number testID?: string + fontSize?: number } -export const IdenticonWithBadge = ({ address, testID, badgeContent, size = 56 }: IdenticonWithBadgeProps) => { +export const IdenticonWithBadge = ({ + address, + testID, + badgeContent, + fontSize = 12, + size = 56, +}: IdenticonWithBadgeProps) => { return ( @@ -23,8 +30,8 @@ export const IdenticonWithBadge = ({ address, testID, badgeContent, size = 56 }: { return initialState }, + switchActiveChain: (state, action: PayloadAction<{ chainId: string }>) => { + return { + ...state, + chainId: action.payload.chainId, + } + }, }, }) -export const { setActiveSafe, clearActiveSafe } = activeSafeSlice.actions +export const { setActiveSafe, switchActiveChain, clearActiveSafe } = activeSafeSlice.actions export const selectActiveSafe = (state: RootState) => state.activeSafe diff --git a/apps/mobile/src/store/constants.ts b/apps/mobile/src/store/constants.ts index c71e0af9e31..0646a9476c6 100644 --- a/apps/mobile/src/store/constants.ts +++ b/apps/mobile/src/store/constants.ts @@ -27,6 +27,24 @@ export const mockedAccounts = [ queued: 1, threshold: 1, }, + { + address: { value: '0xF7a47Bf5705572B7EB9cb0F7007C66B770Ea120f', name: null, logoUri: null }, + awaitingConfirmation: null, + chainId: '100', + fiatTotal: '0', + owners: [{ value: '0xF7a47Bf5705572B7EB9cb0F7007C66B770Ea120f', name: null, logoUri: null }], + queued: 1, + threshold: 1, + }, + { + address: { value: '0x7bF7cF1D8375ad2B25B9050FeF93181ec3E15f08', name: null, logoUri: null }, + awaitingConfirmation: null, + chainId: '1', + fiatTotal: '0', + owners: [{ value: '0x7bF7cF1D8375ad2B25B9050FeF93181ec3E15f08', name: null, logoUri: null }], + queued: 1, + threshold: 1, + }, ] export const mockedChains = [ diff --git a/apps/mobile/src/store/myAccountsSlice.ts b/apps/mobile/src/store/myAccountsSlice.ts new file mode 100644 index 00000000000..53de4ffa061 --- /dev/null +++ b/apps/mobile/src/store/myAccountsSlice.ts @@ -0,0 +1,22 @@ +import { createSlice } from '@reduxjs/toolkit' +import { RootState } from '.' + +const initialState = { + isEdit: false, +} + +const myAccountsSlice = createSlice({ + name: 'myAccounts', + initialState, + reducers: { + toggleMode: (state) => { + state.isEdit = !state.isEdit + }, + }, +}) + +export const { toggleMode } = myAccountsSlice.actions + +export const selectMyAccountsMode = (state: RootState) => state.myAccounts.isEdit + +export default myAccountsSlice.reducer diff --git a/apps/mobile/src/store/safesSlice.ts b/apps/mobile/src/store/safesSlice.ts index d78641779cb..4e62999aa7b 100644 --- a/apps/mobile/src/store/safesSlice.ts +++ b/apps/mobile/src/store/safesSlice.ts @@ -20,6 +20,14 @@ const initialState: SafesSlice = { SafeInfo: mockedAccounts[1], chains: [mockedAccounts[1].chainId], }, + [mockedAccounts[2].address.value]: { + SafeInfo: mockedAccounts[2], + chains: [mockedAccounts[2].chainId], + }, + [mockedAccounts[3].address.value]: { + SafeInfo: mockedAccounts[3], + chains: [mockedAccounts[3].chainId], + }, } const activeSafeSlice = createSlice({ @@ -30,13 +38,22 @@ const activeSafeSlice = createSlice({ state[action.payload.address] = action.payload.item return state }, + setSafes: (_state, action: PayloadAction>) => { + return action.payload + }, + removeSafe: (state, action: PayloadAction
) => { + const filteredSafes = Object.values(state).filter((safe) => safe.SafeInfo.address.value !== action.payload) + const newState = filteredSafes.reduce((acc, safe) => ({ ...acc, [safe.SafeInfo.address.value]: safe }), {}) + + return newState + }, }, }) -export const { updateSafeInfo } = activeSafeSlice.actions +export const { updateSafeInfo, setSafes, removeSafe } = activeSafeSlice.actions export const selectAllSafes = (state: RootState) => state.safes -export const selectActiveSafeInfo = createSelector( +export const selectSafeInfo = createSelector( [selectAllSafes, (_state, activeSafeAddress: Address) => activeSafeAddress], (safes: SafesSlice, activeSafeAddress: Address) => safes[activeSafeAddress], ) From 196ae2aeb98c68b332f7322fb585062bc911bd3a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Clo=CC=81vis=20Neto?= Date: Thu, 2 Jan 2025 12:43:23 +0100 Subject: [PATCH 05/16] fix: unit tests --- .../Card/TxGroupedCard/TxGroupedCard.test.tsx | 13 +++++++++++++ .../Card/TxTokenCard/TxTokenCard.tsx | 1 + apps/mobile/src/tests/test-utils.tsx | 19 ++++++++++++++----- 3 files changed, 28 insertions(+), 5 deletions(-) diff --git a/apps/mobile/src/components/transactions-list/Card/TxGroupedCard/TxGroupedCard.test.tsx b/apps/mobile/src/components/transactions-list/Card/TxGroupedCard/TxGroupedCard.test.tsx index 2db0a346631..1feef55b69c 100644 --- a/apps/mobile/src/components/transactions-list/Card/TxGroupedCard/TxGroupedCard.test.tsx +++ b/apps/mobile/src/components/transactions-list/Card/TxGroupedCard/TxGroupedCard.test.tsx @@ -4,6 +4,19 @@ import { mockERC20Transfer, mockListItemByType, mockNFTTransfer, mockSwapTransfe import { TransactionListItemType, TransactionStatus } from '@safe-global/store/gateway/types' import { TransactionItem } from '@safe-global/store/gateway/AUTO_GENERATED/transactions' +jest.mock('@/src/store/chains', () => { + const actualModule = jest.requireActual('@/src/store/chains') // Import the real module + return { + ...actualModule, + selectChainById: jest.fn().mockImplementation(() => ({ + decimals: 8, + logoUri: 'http://safe.com/logo.png', + name: 'mocked currency', + symbol: 'MCC', + })), + } +}) + describe('TxGroupedCard', () => { it('should render the default markup', () => { const { getAllByTestId } = render( diff --git a/apps/mobile/src/components/transactions-list/Card/TxTokenCard/TxTokenCard.tsx b/apps/mobile/src/components/transactions-list/Card/TxTokenCard/TxTokenCard.tsx index 49c807fd5b2..d7bf9627178 100644 --- a/apps/mobile/src/components/transactions-list/Card/TxTokenCard/TxTokenCard.tsx +++ b/apps/mobile/src/components/transactions-list/Card/TxTokenCard/TxTokenCard.tsx @@ -37,6 +37,7 @@ const getTokenDetails = (txInfo: TransferTransactionInfo): tokenDetails => { const transfer = txInfo.transferInfo const unnamedToken = 'Unnamed token' const activeSafe = useSelector(selectActiveSafe) + const { nativeCurrency } = useSelector((state: RootState) => selectChainById(state, activeSafe.chainId)) if (isNativeTokenTransfer(transfer)) { diff --git a/apps/mobile/src/tests/test-utils.tsx b/apps/mobile/src/tests/test-utils.tsx index c2b1e81b2c3..f9c12393152 100644 --- a/apps/mobile/src/tests/test-utils.tsx +++ b/apps/mobile/src/tests/test-utils.tsx @@ -1,13 +1,22 @@ import { render as nativeRender, renderHook } from '@testing-library/react-native' import { SafeThemeProvider } from '@/src/theme/provider/safeTheme' import { Provider } from 'react-redux' -import { makeStore } from '../store' +import { makeStore, rootReducer } from '../store' import { PortalProvider } from 'tamagui' import { BottomSheetModalProvider } from '@gorhom/bottom-sheet' +import { configureStore } from '@reduxjs/toolkit' -const getProviders: () => React.FC<{ children: React.ReactElement }> = () => +export type RootState = ReturnType +type getProvidersArgs = (initialStoreState?: Partial) => React.FC<{ children: React.ReactElement }> + +const getProviders: getProvidersArgs = (initialStoreState) => function ProviderComponent({ children }: { children: React.ReactNode }) { - const store = makeStore() + const store = initialStoreState + ? configureStore({ + reducer: rootReducer, + preloadedState: initialStoreState, + }) + : makeStore() return ( @@ -20,8 +29,8 @@ const getProviders: () => React.FC<{ children: React.ReactElement }> = () => ) } -const customRender = (ui: React.ReactElement) => { - const wrapper = getProviders() +const customRender = (ui: React.ReactElement, { initialStore }: { initialStore?: Partial } = {}) => { + const wrapper = getProviders(initialStore) return nativeRender(ui, { wrapper }) } From 77fae062fccf8a31270f2840d6ed58af8581f270 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Clo=CC=81vis=20Neto?= Date: Thu, 2 Jan 2025 17:10:13 +0100 Subject: [PATCH 06/16] fix: improve seperation of concern between sortable and edit/remove accounts --- apps/mobile/package.json | 1 - .../src/components/Dropdown/Dropdown.tsx | 6 -- .../Card/TxTokenCard/TxTokenCard.tsx | 10 +--- .../components/AccountItem/AccountItem.tsx | 7 +-- .../AccountItem/hooks/useEditAccountItem.ts | 35 ++++++++++++ .../MyAccounts/hooks/useEditMyAccounts.ts | 57 ------------------- .../MyAccounts/hooks/useMyAccountsSortable.ts | 37 ++++++++++++ .../Assets/components/Navbar/Navbar.tsx | 6 +- apps/mobile/src/store/chains/index.ts | 8 +++ 9 files changed, 88 insertions(+), 79 deletions(-) create mode 100644 apps/mobile/src/features/Assets/components/AccountItem/hooks/useEditAccountItem.ts delete mode 100644 apps/mobile/src/features/Assets/components/MyAccounts/hooks/useEditMyAccounts.ts create mode 100644 apps/mobile/src/features/Assets/components/MyAccounts/hooks/useMyAccountsSortable.ts diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 07b6da0a9fd..32c764568a9 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -67,7 +67,6 @@ "expo-constants": "~17.0.2", "expo-dev-client": "~5.0.5", "expo-font": "~13.0.1", - "expo-haptics": "~14.0.0", "expo-image": "~2.0.3", "expo-linear-gradient": "^14.0.1", "expo-linking": "~7.0.3", diff --git a/apps/mobile/src/components/Dropdown/Dropdown.tsx b/apps/mobile/src/components/Dropdown/Dropdown.tsx index c03e9c7f8d1..9ef4ec31a9d 100644 --- a/apps/mobile/src/components/Dropdown/Dropdown.tsx +++ b/apps/mobile/src/components/Dropdown/Dropdown.tsx @@ -47,7 +47,6 @@ export function Dropdown({ onDragEnd, }: DropdownProps) { const bottomSheetModalRef = useRef(null) - const handlePresentModalPress = useCallback(() => { bottomSheetModalRef.current?.present() }, []) @@ -123,7 +122,6 @@ export function Dropdown({ {isSortable ? ( data={items} - extraData={items} containerStyle={{ height: '100%' }} ListHeaderComponent={dropdownTitle ? renderDropdownHeader : undefined} onDragEnd={onDragEnd} @@ -158,7 +156,3 @@ const styles = StyleSheet.create({ justifyContent: 'space-around', }, }) - -// export const Dropdown = React.memo(DropdownComponent, (prevProps, nextProps) => { -// return JSON.stringify(prevProps.items || []) === JSON.stringify(nextProps.items || []) -// }) diff --git a/apps/mobile/src/components/transactions-list/Card/TxTokenCard/TxTokenCard.tsx b/apps/mobile/src/components/transactions-list/Card/TxTokenCard/TxTokenCard.tsx index d7bf9627178..c66158c3dd3 100644 --- a/apps/mobile/src/components/transactions-list/Card/TxTokenCard/TxTokenCard.tsx +++ b/apps/mobile/src/components/transactions-list/Card/TxTokenCard/TxTokenCard.tsx @@ -9,13 +9,11 @@ import { isTxQueued, } from '@/src/utils/transaction-guards' import { ellipsis, formatValue } from '@/src/utils/formatters' -import { useSelector } from 'react-redux' import { TransferDirection } from '@safe-global/store/gateway/types' import { TransferTransactionInfo, Transaction } from '@safe-global/store/gateway/AUTO_GENERATED/transactions' import { Logo } from '@/src/components/Logo' -import { selectActiveSafe } from '@/src/store/activeSafeSlice' -import { RootState } from '@/src/store' -import { selectChainById } from '@/src/store/chains' +import { selectActiveChainCurrency } from '@/src/store/chains' +import { useAppSelector } from '@/src/store/hooks' interface TxTokenCardProps { bordered?: boolean @@ -36,9 +34,7 @@ interface tokenDetails { const getTokenDetails = (txInfo: TransferTransactionInfo): tokenDetails => { const transfer = txInfo.transferInfo const unnamedToken = 'Unnamed token' - const activeSafe = useSelector(selectActiveSafe) - - const { nativeCurrency } = useSelector((state: RootState) => selectChainById(state, activeSafe.chainId)) + const nativeCurrency = useAppSelector(selectActiveChainCurrency) if (isNativeTokenTransfer(transfer)) { return { diff --git a/apps/mobile/src/features/Assets/components/AccountItem/AccountItem.tsx b/apps/mobile/src/features/Assets/components/AccountItem/AccountItem.tsx index 2d1e77c2690..e1086e39f89 100644 --- a/apps/mobile/src/features/Assets/components/AccountItem/AccountItem.tsx +++ b/apps/mobile/src/features/Assets/components/AccountItem/AccountItem.tsx @@ -8,9 +8,7 @@ import { Address } from '@/src/types/address' import { SafeOverview } from '@safe-global/store/gateway/AUTO_GENERATED/safes' import { shortenAddress } from '@/src/utils/formatters' import { RenderItemParams } from 'react-native-draggable-flatlist' -import { useSelector } from 'react-redux' -import { selectMyAccountsMode } from '@/src/store/myAccountsSlice' -import { useEditMyAccounts } from '../MyAccounts/hooks/useEditMyAccounts' +import { useEditAccountItem } from './hooks/useEditAccountItem' interface AccountItemProps { chains: Chain[] @@ -30,9 +28,8 @@ const getRightNodeLayout = (isEdit: boolean, isActive: boolean) => { } export function AccountItem({ account, drag, chains, isDragging, activeAccount, onSelect }: AccountItemProps) { + const { isEdit, onSafeDeleted } = useEditAccountItem() const isActive = activeAccount === account.address.value - const isEdit = useSelector(selectMyAccountsMode) - const { onSafeDeleted } = useEditMyAccounts() const handleChainSelect = () => { onSelect(account.address.value) diff --git a/apps/mobile/src/features/Assets/components/AccountItem/hooks/useEditAccountItem.ts b/apps/mobile/src/features/Assets/components/AccountItem/hooks/useEditAccountItem.ts new file mode 100644 index 00000000000..759ba32a06b --- /dev/null +++ b/apps/mobile/src/features/Assets/components/AccountItem/hooks/useEditAccountItem.ts @@ -0,0 +1,35 @@ +import { selectActiveSafe, setActiveSafe } from '@/src/store/activeSafeSlice' +import { useAppDispatch, useAppSelector } from '@/src/store/hooks' +import { selectMyAccountsMode } from '@/src/store/myAccountsSlice' +import { removeSafe, selectAllSafes } from '@/src/store/safesSlice' +import { Address } from '@/src/types/address' +import { useCallback } from 'react' + +export const useEditAccountItem = () => { + const isEdit = useAppSelector(selectMyAccountsMode) + const activeSafe = useAppSelector(selectActiveSafe) + const safes = useAppSelector(selectAllSafes) + const dispatch = useAppDispatch() + + const onSafeDeleted = useCallback( + (address: Address) => () => { + if (activeSafe.address === address) { + const safe = Object.values(safes).find((item) => item.SafeInfo.address.value !== address) + + if (safe) { + dispatch( + setActiveSafe({ + address: safe.SafeInfo.address.value as Address, + chainId: safe.chains[0], + }), + ) + } + } + + dispatch(removeSafe(address)) + }, + [activeSafe], + ) + + return { isEdit, onSafeDeleted } +} diff --git a/apps/mobile/src/features/Assets/components/MyAccounts/hooks/useEditMyAccounts.ts b/apps/mobile/src/features/Assets/components/MyAccounts/hooks/useEditMyAccounts.ts deleted file mode 100644 index 07987039f87..00000000000 --- a/apps/mobile/src/features/Assets/components/MyAccounts/hooks/useEditMyAccounts.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { selectActiveSafe, setActiveSafe } from '@/src/store/activeSafeSlice' -import { toggleMode } from '@/src/store/myAccountsSlice' -import { removeSafe, SafesSliceItem, selectAllSafes, setSafes } from '@/src/store/safesSlice' -import { Address } from '@/src/types/address' -import { useCallback, useState } from 'react' -import { DragEndParams } from 'react-native-draggable-flatlist' -import { useDispatch, useSelector } from 'react-redux' - -type useEditMyAccountsReturn = { - safes: SafesSliceItem[] - onDragEnd: (params: DragEndParams) => void - onSafeDeleted: (address: Address) => () => void -} - -export const useEditMyAccounts = (): useEditMyAccountsReturn => { - const dispatch = useDispatch() - const safes = useSelector(selectAllSafes) - const activeSafe = useSelector(selectActiveSafe) - const [sortableSafes, setSortableSafes] = useState(() => Object.values(safes)) - - const onDragEnd = useCallback(({ data }: DragEndParams) => { - setSortableSafes([...data]) - - // Defer Redux update due to incompatibility issues between - // react-native-draggable-flatlist and new architecture. - setTimeout(() => { - const safes = data.reduce((acc, item) => ({ ...acc, [item.SafeInfo.address.value]: item }), {}) - dispatch(setSafes(safes)) - }, 0) // Ensure this happens after the re-render - }, []) - - const onSafeDeleted = useCallback( - (address: Address) => () => { - if (activeSafe.address === address) { - const safe = sortableSafes.find((item) => item.SafeInfo.address.value !== address) - - if (safe) { - dispatch( - setActiveSafe({ - address: safe.SafeInfo.address.value as Address, - chainId: safe.chains[0], - }), - ) - } - } - - if (sortableSafes.length <= 2) { - dispatch(toggleMode()) - } - - dispatch(removeSafe(address)) - }, - [sortableSafes, activeSafe], - ) - - return { safes: sortableSafes, onDragEnd, onSafeDeleted } -} diff --git a/apps/mobile/src/features/Assets/components/MyAccounts/hooks/useMyAccountsSortable.ts b/apps/mobile/src/features/Assets/components/MyAccounts/hooks/useMyAccountsSortable.ts new file mode 100644 index 00000000000..bd4e4cd1a6b --- /dev/null +++ b/apps/mobile/src/features/Assets/components/MyAccounts/hooks/useMyAccountsSortable.ts @@ -0,0 +1,37 @@ +import { selectMyAccountsMode, toggleMode } from '@/src/store/myAccountsSlice' +import { SafesSliceItem, selectAllSafes, setSafes } from '@/src/store/safesSlice' +import { useCallback, useEffect, useState } from 'react' +import { DragEndParams } from 'react-native-draggable-flatlist' +import { useDispatch, useSelector } from 'react-redux' + +type useMyAccountsSortableReturn = { + safes: SafesSliceItem[] + onDragEnd: (params: DragEndParams) => void +} + +export const useMyAccountsSortable = (): useMyAccountsSortableReturn => { + const dispatch = useDispatch() + const safes = useSelector(selectAllSafes) + const [sortableSafes, setSortableSafes] = useState(() => Object.values(safes)) + const isEdit = useSelector(selectMyAccountsMode) + + useEffect(() => { + const newSafes = Object.values(safes) + setSortableSafes(newSafes) + + if (newSafes.length <= 1 && isEdit) { + dispatch(toggleMode()) + } + }, [safes, isEdit]) + + const onDragEnd = useCallback(({ data }: DragEndParams) => { + // Defer Redux update due to incompatibility issues between + // react-native-draggable-flatlist and new architecture. + setTimeout(() => { + const safes = data.reduce((acc, item) => ({ ...acc, [item.SafeInfo.address.value]: item }), {}) + dispatch(setSafes(safes)) + }, 0) // Ensure this happens after the re-render + }, []) + + return { safes: sortableSafes, onDragEnd } +} diff --git a/apps/mobile/src/features/Assets/components/Navbar/Navbar.tsx b/apps/mobile/src/features/Assets/components/Navbar/Navbar.tsx index b49a695cba9..3af919ecd13 100644 --- a/apps/mobile/src/features/Assets/components/Navbar/Navbar.tsx +++ b/apps/mobile/src/features/Assets/components/Navbar/Navbar.tsx @@ -10,10 +10,10 @@ import { StyleSheet, TouchableOpacity } from 'react-native' import React from 'react' import { Address } from '@/src/types/address' import { Dropdown } from '@/src/components/Dropdown' -import { MyAccountsContainer, MyAccountsFooter } from '../MyAccounts' import { SafesSliceItem } from '@/src/store/safesSlice' import { selectMyAccountsMode, toggleMode } from '@/src/store/myAccountsSlice' -import { useEditMyAccounts } from '../MyAccounts/hooks/useEditMyAccounts' +import { MyAccountsContainer, MyAccountsFooter } from '../MyAccounts' +import { useMyAccountsSortable } from '../MyAccounts/hooks/useMyAccountsSortable' const dropdownLabelProps = { fontSize: '$5', @@ -24,7 +24,7 @@ export const Navbar = () => { const dispatch = useDispatch() const isEdit = useSelector(selectMyAccountsMode) const activeSafe = useSelector(selectActiveSafe) - const { safes, onDragEnd } = useEditMyAccounts() + const { safes, onDragEnd } = useMyAccountsSortable() const toggleEditMode = () => { dispatch(toggleMode()) diff --git a/apps/mobile/src/store/chains/index.ts b/apps/mobile/src/store/chains/index.ts index 5872031662d..f123ccc142b 100644 --- a/apps/mobile/src/store/chains/index.ts +++ b/apps/mobile/src/store/chains/index.ts @@ -2,6 +2,7 @@ import { apiSliceWithChainsConfig, chainsAdapter, initialState } from '@safe-glo import { createSelector } from '@reduxjs/toolkit' import { RootState } from '..' import { Chain } from '@safe-global/store/gateway/AUTO_GENERATED/chains' +import { selectActiveSafe } from '../activeSafeSlice' const selectChainsResult = apiSliceWithChainsConfig.endpoints.getChainsConfig.select() @@ -15,6 +16,13 @@ export const selectChainById = (state: RootState, chainId: string) => selectById export const selectAllChainsIds = createSelector([selectAllChains], (chains: Chain[]) => chains.map((chain) => chain.chainId), ) +export const selectActiveChainCurrency = createSelector( + [selectActiveSafe, (state: RootState) => state], + (activeSafe, state) => { + const chain = selectChainById(state, activeSafe.chainId) + return chain?.nativeCurrency + }, +) export const getChainsByIds = createSelector( [ From 428d953a00d31cebffd2b038df20e4b846d22a39 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Clo=CC=81vis=20Neto?= Date: Thu, 2 Jan 2025 17:18:02 +0100 Subject: [PATCH 07/16] fix: improve code readability --- .../components/MyAccounts/hooks/useMyAccountsSortable.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/mobile/src/features/Assets/components/MyAccounts/hooks/useMyAccountsSortable.ts b/apps/mobile/src/features/Assets/components/MyAccounts/hooks/useMyAccountsSortable.ts index bd4e4cd1a6b..00cc577a90d 100644 --- a/apps/mobile/src/features/Assets/components/MyAccounts/hooks/useMyAccountsSortable.ts +++ b/apps/mobile/src/features/Assets/components/MyAccounts/hooks/useMyAccountsSortable.ts @@ -17,9 +17,11 @@ export const useMyAccountsSortable = (): useMyAccountsSortableReturn => { useEffect(() => { const newSafes = Object.values(safes) + const shouldGoToListMode = newSafes.length <= 1 && isEdit + setSortableSafes(newSafes) - if (newSafes.length <= 1 && isEdit) { + if (shouldGoToListMode) { dispatch(toggleMode()) } }, [safes, isEdit]) From 1f0447da9d8607216b75f17ea5e64f80523f1c7a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Clo=CC=81vis=20Neto?= Date: Thu, 2 Jan 2025 17:24:10 +0100 Subject: [PATCH 08/16] fix: generated yarn.lock --- yarn.lock | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/yarn.lock b/yarn.lock index 672a22db02c..0ff089dc915 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7341,7 +7341,6 @@ __metadata: expo-constants: "npm:~17.0.2" expo-dev-client: "npm:~5.0.5" expo-font: "npm:~13.0.1" - expo-haptics: "npm:~14.0.0" expo-image: "npm:~2.0.3" expo-linear-gradient: "npm:^14.0.1" expo-linking: "npm:~7.0.3" @@ -19021,15 +19020,6 @@ __metadata: languageName: node linkType: hard -"expo-haptics@npm:~14.0.0": - version: 14.0.0 - resolution: "expo-haptics@npm:14.0.0" - peerDependencies: - expo: "*" - checksum: 10/da25773a8f59410fe2066d21404a2cb2a7a93e55b433d21e103dc1cb7ac437926860655672f8f6dfba88d3124c53f911a6b4ef4e935b9f34d2e04c7d4b13d24e - languageName: node - linkType: hard - "expo-image@npm:~2.0.3": version: 2.0.3 resolution: "expo-image@npm:2.0.3" From 9b637e3b7d3e01f88bcf13052fd68d221801729c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Clo=CC=81vis=20Neto?= Date: Fri, 3 Jan 2025 12:11:15 +0100 Subject: [PATCH 09/16] feat: cover Assets feature with unit tests --- .../react-native-collapsible-tab-view.tsx | 25 ++++++ .../src/components/Tab/TabNameContext.tsx | 11 +++ .../Tokens/Tokens.container.test.tsx | 86 +++++++++++++++++++ apps/mobile/src/tests/handlers.ts | 27 ++++++ apps/mobile/src/tests/jest.setup.tsx | 6 ++ apps/mobile/src/tests/server.ts | 4 + apps/mobile/src/tests/test-utils.tsx | 21 ++++- 7 files changed, 176 insertions(+), 4 deletions(-) create mode 100644 apps/mobile/__mocks__/react-native-collapsible-tab-view.tsx create mode 100644 apps/mobile/src/components/Tab/TabNameContext.tsx create mode 100644 apps/mobile/src/features/Assets/components/Tokens/Tokens.container.test.tsx create mode 100644 apps/mobile/src/tests/handlers.ts create mode 100644 apps/mobile/src/tests/server.ts diff --git a/apps/mobile/__mocks__/react-native-collapsible-tab-view.tsx b/apps/mobile/__mocks__/react-native-collapsible-tab-view.tsx new file mode 100644 index 00000000000..7ec244c49ed --- /dev/null +++ b/apps/mobile/__mocks__/react-native-collapsible-tab-view.tsx @@ -0,0 +1,25 @@ +import React from 'react' +import { View, FlatList } from 'react-native' + +export const Tabs = { + Container: ({ children, renderTabBar }) => ( + + {renderTabBar && renderTabBar({ index: 0, routes: [] })} + {children} + + ), + Tab: ({ children }: { children: React.ReactNode }) => {children}, + FlashList: FlatList, + FlatList: FlatList, + useTabsContext: () => ({ + focusedTab: '', + tabNames: [], + index: 0, + routes: [], + jumpTo: jest.fn(), + }), + useTabNameContext: () => ({ tabName: 'Tokens' }), + ScrollView: ({ children }: { children: React.ReactNode }) => {children}, +} + +export default Tabs diff --git a/apps/mobile/src/components/Tab/TabNameContext.tsx b/apps/mobile/src/components/Tab/TabNameContext.tsx new file mode 100644 index 00000000000..223eb8edb86 --- /dev/null +++ b/apps/mobile/src/components/Tab/TabNameContext.tsx @@ -0,0 +1,11 @@ +import { createContext, useContext } from 'react' + +export const TabNameContext = createContext<{ tabName: string }>({ tabName: '' }) + +export const useTabNameContext = () => { + const context = useContext(TabNameContext) + if (!context) { + throw new Error('useTabNameContext must be inside a TabNameContext') + } + return context +} diff --git a/apps/mobile/src/features/Assets/components/Tokens/Tokens.container.test.tsx b/apps/mobile/src/features/Assets/components/Tokens/Tokens.container.test.tsx new file mode 100644 index 00000000000..072dbbec3b4 --- /dev/null +++ b/apps/mobile/src/features/Assets/components/Tokens/Tokens.container.test.tsx @@ -0,0 +1,86 @@ +import React from 'react' +import { render, screen } from '@/src/tests/test-utils' +import { TokensContainer } from './Tokens.container' +import { server } from '@/src/tests/server' +import { http, HttpResponse } from 'msw' +import { GATEWAY_URL } from '@/src/config/constants' +import { mockBalanceData } from '@/src/tests/handlers' + +// Mock active safe selector with memoized object +const mockActiveSafe = { chainId: '1', address: '0x123' } + +jest.mock('@/src/store/activeSafeSlice', () => ({ + selectActiveSafe: () => mockActiveSafe, +})) + +describe('TokensContainer', () => { + afterEach(() => { + server.resetHandlers() + }) + + afterAll(() => { + server.close() + }) + + it('renders loading state initially', () => { + render() + expect(screen.getByTestId('fallback')).toBeTruthy() + }) + + it('renders error state when API fails', async () => { + server.use( + http.get(`${GATEWAY_URL}/api/v1/chains/:chainId/safes/:safeAddress/balances/usd`, () => { + return HttpResponse.error() + }), + ) + + render() + + expect(await screen.findByTestId('fallback')).toBeTruthy() + }) + + it('renders token list when data is available', async () => { + // Setup response spy + let resolveRequest: (value: unknown) => void + const waitForRequest = new Promise((resolve) => { + resolveRequest = resolve + }) + + server.use( + http.get('https://safe-client.safe.global//v1/chains/:chainId/safes/:safeAddress/balances/USD', async () => { + const response = HttpResponse.json(mockBalanceData) + resolveRequest(true) + return response + }), + ) + + render() + + // First verify we see the loading state + expect(screen.getByTestId('fallback')).toBeTruthy() + + // Wait for the request to complete + await waitForRequest + + // Then check for content + const ethText = await screen.findByText('Ethereum') + const ethAmount = await screen.findByText('1 ETH') + const ethValue = await screen.findByText('$2000') + + expect(ethText).toBeTruthy() + expect(ethAmount).toBeTruthy() + expect(ethValue).toBeTruthy() + }) + + it('renders fallback when data is empty', async () => { + server.use( + http.get(`${GATEWAY_URL}/api/v1/chains/:chainId/safes/:safeAddress/balances/usd`, () => { + return HttpResponse.json({ items: [] }) + }), + ) + + render() + + expect(await screen.findByTestId('fallback')).toBeTruthy() + }) +}) diff --git a/apps/mobile/src/tests/handlers.ts b/apps/mobile/src/tests/handlers.ts new file mode 100644 index 00000000000..4a649ed0b39 --- /dev/null +++ b/apps/mobile/src/tests/handlers.ts @@ -0,0 +1,27 @@ +import { http, HttpResponse } from 'msw' +import { GATEWAY_URL } from '@/src/config/constants' + +export const mockBalanceData = { + items: [ + { + tokenInfo: { + name: 'Ethereum', + symbol: 'ETH', + decimals: 18, + logoUri: 'https://safe-transaction-assets.safe.global/chains/1/chain_logo.png', + }, + balance: '1000000000000000000', + fiatBalance: '2000', + }, + ], +} + +export const handlers = [ + http.get(`${GATEWAY_URL.replace(/\/+$/, '')}/v1/chains/:chainId/safes/:safeAddress/balances/USD`, ({ request }) => { + console.log('Actual request URL:', request.url) + return HttpResponse.json(mockBalanceData) + }), + http.get('https://safe-client.safe.global//v1/chains/1/safes/0x123/balances/USD', () => { + return HttpResponse.json(mockBalanceData) + }), +] diff --git a/apps/mobile/src/tests/jest.setup.tsx b/apps/mobile/src/tests/jest.setup.tsx index f7eed090358..faff2509cbb 100644 --- a/apps/mobile/src/tests/jest.setup.tsx +++ b/apps/mobile/src/tests/jest.setup.tsx @@ -3,6 +3,8 @@ import React from 'react' import '@testing-library/react-native/extend-expect' import mockRNDeviceInfo from 'react-native-device-info/jest/react-native-device-info-mock' +import { server } from './server' + jest.useFakeTimers() /** @@ -127,3 +129,7 @@ jest.mock('@gorhom/bottom-sheet', () => { }), } }) + +beforeAll(() => server.listen()) +afterEach(() => server.resetHandlers()) +afterAll(() => server.close()) diff --git a/apps/mobile/src/tests/server.ts b/apps/mobile/src/tests/server.ts new file mode 100644 index 00000000000..86f7d6154ac --- /dev/null +++ b/apps/mobile/src/tests/server.ts @@ -0,0 +1,4 @@ +import { setupServer } from 'msw/node' +import { handlers } from './handlers' + +export const server = setupServer(...handlers) diff --git a/apps/mobile/src/tests/test-utils.tsx b/apps/mobile/src/tests/test-utils.tsx index f9c12393152..806c6bc1d21 100644 --- a/apps/mobile/src/tests/test-utils.tsx +++ b/apps/mobile/src/tests/test-utils.tsx @@ -7,7 +7,7 @@ import { BottomSheetModalProvider } from '@gorhom/bottom-sheet' import { configureStore } from '@reduxjs/toolkit' export type RootState = ReturnType -type getProvidersArgs = (initialStoreState?: Partial) => React.FC<{ children: React.ReactElement }> +type getProvidersArgs = (initialStoreState?: Partial) => React.FC<{ children: React.ReactNode }> const getProviders: getProvidersArgs = (initialStoreState) => function ProviderComponent({ children }: { children: React.ReactNode }) { @@ -29,10 +29,23 @@ const getProviders: getProvidersArgs = (initialStoreState) => ) } -const customRender = (ui: React.ReactElement, { initialStore }: { initialStore?: Partial } = {}) => { - const wrapper = getProviders(initialStore) +const customRender = ( + ui: React.ReactElement, + { + initialStore, + wrapper: CustomWrapper, + }: { + initialStore?: Partial + wrapper?: React.ComponentType<{ children: React.ReactNode }> + } = {}, +) => { + const Wrapper = getProviders(initialStore) + + function WrapperWithCustom({ children }: { children: React.ReactNode }) { + return {CustomWrapper ? {children} : children} + } - return nativeRender(ui, { wrapper }) + return nativeRender(ui, { wrapper: WrapperWithCustom }) } function customRenderHook(render: (initialProps: Props) => Result) { From e6553f7189328b96274297b381b94e3bffaecb02 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Clo=CC=81vis=20Neto?= Date: Fri, 3 Jan 2025 12:22:39 +0100 Subject: [PATCH 10/16] chore: improve test reliability --- .../components/Tokens/Tokens.container.test.tsx | 17 ----------------- apps/mobile/src/tests/handlers.ts | 4 ---- 2 files changed, 21 deletions(-) diff --git a/apps/mobile/src/features/Assets/components/Tokens/Tokens.container.test.tsx b/apps/mobile/src/features/Assets/components/Tokens/Tokens.container.test.tsx index 072dbbec3b4..d2892830cec 100644 --- a/apps/mobile/src/features/Assets/components/Tokens/Tokens.container.test.tsx +++ b/apps/mobile/src/features/Assets/components/Tokens/Tokens.container.test.tsx @@ -4,7 +4,6 @@ import { TokensContainer } from './Tokens.container' import { server } from '@/src/tests/server' import { http, HttpResponse } from 'msw' import { GATEWAY_URL } from '@/src/config/constants' -import { mockBalanceData } from '@/src/tests/handlers' // Mock active safe selector with memoized object const mockActiveSafe = { chainId: '1', address: '0x123' } @@ -41,27 +40,11 @@ describe('TokensContainer', () => { it('renders token list when data is available', async () => { // Setup response spy - let resolveRequest: (value: unknown) => void - const waitForRequest = new Promise((resolve) => { - resolveRequest = resolve - }) - - server.use( - http.get('https://safe-client.safe.global//v1/chains/:chainId/safes/:safeAddress/balances/USD', async () => { - const response = HttpResponse.json(mockBalanceData) - resolveRequest(true) - return response - }), - ) - render() // First verify we see the loading state expect(screen.getByTestId('fallback')).toBeTruthy() - // Wait for the request to complete - await waitForRequest - // Then check for content const ethText = await screen.findByText('Ethereum') const ethAmount = await screen.findByText('1 ETH') diff --git a/apps/mobile/src/tests/handlers.ts b/apps/mobile/src/tests/handlers.ts index 4a649ed0b39..96f372406ed 100644 --- a/apps/mobile/src/tests/handlers.ts +++ b/apps/mobile/src/tests/handlers.ts @@ -17,10 +17,6 @@ export const mockBalanceData = { } export const handlers = [ - http.get(`${GATEWAY_URL.replace(/\/+$/, '')}/v1/chains/:chainId/safes/:safeAddress/balances/USD`, ({ request }) => { - console.log('Actual request URL:', request.url) - return HttpResponse.json(mockBalanceData) - }), http.get('https://safe-client.safe.global//v1/chains/1/safes/0x123/balances/USD', () => { return HttpResponse.json(mockBalanceData) }), From c7c4a546a660ddcc9ba6ae01f2f59ec0a8db0b60 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Clo=CC=81vis=20Neto?= Date: Fri, 3 Jan 2025 12:24:43 +0100 Subject: [PATCH 11/16] fix: remove inline style --- .../Assets/components/AccountItem/AccountItem.tsx | 10 ++++++++-- apps/mobile/src/tests/handlers.ts | 1 - 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/apps/mobile/src/features/Assets/components/AccountItem/AccountItem.tsx b/apps/mobile/src/features/Assets/components/AccountItem/AccountItem.tsx index e1086e39f89..7961fe3a5a8 100644 --- a/apps/mobile/src/features/Assets/components/AccountItem/AccountItem.tsx +++ b/apps/mobile/src/features/Assets/components/AccountItem/AccountItem.tsx @@ -1,5 +1,5 @@ import React, { useMemo } from 'react' -import { TouchableOpacity } from 'react-native' +import { StyleSheet, TouchableOpacity } from 'react-native' import { View } from 'tamagui' import { SafeFontIcon } from '@/src/components/SafeFontIcon' import { AccountCard } from '@/src/components/transactions-list/Card/AccountCard' @@ -39,7 +39,7 @@ export function AccountItem({ account, drag, chains, isDragging, activeAccount, return ( Date: Fri, 3 Jan 2025 12:27:03 +0100 Subject: [PATCH 12/16] fix: use selectors and dispatch from useApp hooks --- .../Assets/components/Balance/Balance.container.tsx | 11 ++++++----- .../src/features/Assets/components/Navbar/Navbar.tsx | 8 ++++---- .../Settings/components/Navbar/SettingsMenu.tsx | 6 +++--- 3 files changed, 13 insertions(+), 12 deletions(-) diff --git a/apps/mobile/src/features/Assets/components/Balance/Balance.container.tsx b/apps/mobile/src/features/Assets/components/Balance/Balance.container.tsx index 76a68b8c94b..c84c3da24bc 100644 --- a/apps/mobile/src/features/Assets/components/Balance/Balance.container.tsx +++ b/apps/mobile/src/features/Assets/components/Balance/Balance.container.tsx @@ -1,4 +1,4 @@ -import { useDispatch, useSelector } from 'react-redux' +import { useDispatch } from 'react-redux' import { useSafesGetSafeOverviewV1Query } from '@safe-global/store/gateway/AUTO_GENERATED/safes' import { selectActiveSafe, switchActiveChain } from '@/src/store/activeSafeSlice' import { SafeOverviewResult } from '@safe-global/store/gateway/types' @@ -8,13 +8,14 @@ import { Balance } from './Balance' import { makeSafeId } from '@/src/utils/formatters' import { RootState } from '@/src/store' import { selectSafeInfo } from '@/src/store/safesSlice' +import { useAppSelector } from '@/src/store/hooks' export function BalanceContainer() { - const chains = useSelector(selectAllChains) - const activeSafe = useSelector(selectActiveSafe) + const chains = useAppSelector(selectAllChains) + const activeSafe = useAppSelector(selectActiveSafe) const dispatch = useDispatch() - const activeSafeInfo = useSelector((state: RootState) => selectSafeInfo(state, activeSafe.address)) - const activeSafeChains = useSelector((state: RootState) => getChainsByIds(state, activeSafeInfo.chains)) + const activeSafeInfo = useAppSelector((state: RootState) => selectSafeInfo(state, activeSafe.address)) + const activeSafeChains = useAppSelector((state: RootState) => getChainsByIds(state, activeSafeInfo.chains)) const { data, isLoading } = useSafesGetSafeOverviewV1Query( { safes: chains.map((chain) => makeSafeId(chain.chainId, activeSafe.address)).join(','), diff --git a/apps/mobile/src/features/Assets/components/Navbar/Navbar.tsx b/apps/mobile/src/features/Assets/components/Navbar/Navbar.tsx index 3af919ecd13..791ffa7ef11 100644 --- a/apps/mobile/src/features/Assets/components/Navbar/Navbar.tsx +++ b/apps/mobile/src/features/Assets/components/Navbar/Navbar.tsx @@ -1,4 +1,3 @@ -import { useDispatch, useSelector } from 'react-redux' import { selectActiveSafe } from '@/src/store/activeSafeSlice' import { View, H6 } from 'tamagui' import { BlurredIdenticonBackground } from '@/src/components/BlurredIdenticonBackground' @@ -14,6 +13,7 @@ import { SafesSliceItem } from '@/src/store/safesSlice' import { selectMyAccountsMode, toggleMode } from '@/src/store/myAccountsSlice' import { MyAccountsContainer, MyAccountsFooter } from '../MyAccounts' import { useMyAccountsSortable } from '../MyAccounts/hooks/useMyAccountsSortable' +import { useAppDispatch, useAppSelector } from '@/src/store/hooks' const dropdownLabelProps = { fontSize: '$5', @@ -21,9 +21,9 @@ const dropdownLabelProps = { } as const export const Navbar = () => { - const dispatch = useDispatch() - const isEdit = useSelector(selectMyAccountsMode) - const activeSafe = useSelector(selectActiveSafe) + const dispatch = useAppDispatch() + const isEdit = useAppSelector(selectMyAccountsMode) + const activeSafe = useAppSelector(selectActiveSafe) const { safes, onDragEnd } = useMyAccountsSortable() const toggleEditMode = () => { diff --git a/apps/mobile/src/features/Settings/components/Navbar/SettingsMenu.tsx b/apps/mobile/src/features/Settings/components/Navbar/SettingsMenu.tsx index 7907e6e293f..db7e12d2002 100644 --- a/apps/mobile/src/features/Settings/components/Navbar/SettingsMenu.tsx +++ b/apps/mobile/src/features/Settings/components/Navbar/SettingsMenu.tsx @@ -3,21 +3,21 @@ import { MenuView, NativeActionEvent } from '@react-native-menu/menu' import { Linking, Platform } from 'react-native' import { SafeFontIcon } from '@/src/components/SafeFontIcon/SafeFontIcon' import React from 'react' -import { useSelector } from 'react-redux' import { getExplorerLink } from '@/src/utils/gateway' import { useCopyAndDispatchToast } from '@/src/hooks/useCopyAndDispatchToast' import { useToastController } from '@tamagui/toast' import { selectActiveSafe } from '@/src/store/activeSafeSlice' import { selectChainById } from '@/src/store/chains' import { RootState } from '@/src/store' +import { useAppSelector } from '@/src/store/hooks' type Props = { safeAddress: string | undefined } export const SettingsMenu = ({ safeAddress }: Props) => { const toast = useToastController() - const activeSafe = useSelector(selectActiveSafe) - const activeChain = useSelector((state: RootState) => selectChainById(state, activeSafe.chainId)) + const activeSafe = useAppSelector(selectActiveSafe) + const activeChain = useAppSelector((state: RootState) => selectChainById(state, activeSafe.chainId)) const copyAndDispatchToast = useCopyAndDispatchToast() const theme = useTheme() const color = theme.color?.get() From 4a00c70d23c010525931f156b772c396254c35f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Clo=CC=81vis=20Neto?= Date: Fri, 3 Jan 2025 12:42:01 +0100 Subject: [PATCH 13/16] fix: lint --- apps/mobile/tsconfig.json | 3 +- package.json | 1 + yarn.lock | 235 +++++++++++++++++++++++++++++++++++++- 3 files changed, 235 insertions(+), 4 deletions(-) diff --git a/apps/mobile/tsconfig.json b/apps/mobile/tsconfig.json index a4564769f3a..e9389e759ab 100644 --- a/apps/mobile/tsconfig.json +++ b/apps/mobile/tsconfig.json @@ -8,5 +8,6 @@ }, "types": ["jest", "node"] }, - "include": ["**/*.ts", "**/*.tsx", ".expo/types/**/*.ts", "expo-env.d.ts"] + "include": ["**/*.ts", "**/*.tsx", ".expo/types/**/*.ts", "expo-env.d.ts"], + "exclude": ["./__mocks__/**/*"] } diff --git a/package.json b/package.json index 12065299ac8..377a5c1a1cb 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "devDependencies": { "husky": "^9.1.6", "lint-staged": "^15.2.10", + "msw": "^2.7.0", "prettier": "^3.4.2" }, "packageManager": "yarn@4.5.3" diff --git a/yarn.lock b/yarn.lock index 0ff089dc915..a2a872935b8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1695,6 +1695,34 @@ __metadata: languageName: node linkType: hard +"@bundled-es-modules/cookie@npm:^2.0.1": + version: 2.0.1 + resolution: "@bundled-es-modules/cookie@npm:2.0.1" + dependencies: + cookie: "npm:^0.7.2" + checksum: 10/0038a5e82c41bfcd722afedabeb6961a5f15747b3681d7f4b61e35eb1e33130039e10ee9250dc9c9e4d3915ce1aeee717c0fb92225111574f0a030411abc0987 + languageName: node + linkType: hard + +"@bundled-es-modules/statuses@npm:^1.0.1": + version: 1.0.1 + resolution: "@bundled-es-modules/statuses@npm:1.0.1" + dependencies: + statuses: "npm:^2.0.1" + checksum: 10/9bf6a2bcf040a66fb805da0e1446041fd9def7468bb5da29c5ce02adf121a3f7cec123664308059a62a46fcaee666add83094b76df6dce72e5cafa8e6bebe60d + languageName: node + linkType: hard + +"@bundled-es-modules/tough-cookie@npm:^0.1.6": + version: 0.1.6 + resolution: "@bundled-es-modules/tough-cookie@npm:0.1.6" + dependencies: + "@types/tough-cookie": "npm:^4.0.5" + tough-cookie: "npm:^4.1.4" + checksum: 10/4f24a820f02c08c3ca0ff21272317357152093f76f9c8cc182517f61fa426ae53dadc4d68a3d6da5078e8d73f0ff8c0907a9f994c0be756162ba9c7358533e57 + languageName: node + linkType: hard + "@chromatic-com/storybook@npm:^1.3.1": version: 1.9.0 resolution: "@chromatic-com/storybook@npm:1.9.0" @@ -4800,6 +4828,51 @@ __metadata: languageName: node linkType: hard +"@inquirer/confirm@npm:^5.0.0": + version: 5.1.1 + resolution: "@inquirer/confirm@npm:5.1.1" + dependencies: + "@inquirer/core": "npm:^10.1.2" + "@inquirer/type": "npm:^3.0.2" + peerDependencies: + "@types/node": ">=18" + checksum: 10/060acc8b82835efb8950853b4cd226cac2e06c2b2c1a69bcc6e044cfaedd89b2df3d8bbf9ddf31b47cba3dafd8ca6c6e2c4be3f2ea413fad7250aafeab52f1e1 + languageName: node + linkType: hard + +"@inquirer/core@npm:^10.1.2": + version: 10.1.2 + resolution: "@inquirer/core@npm:10.1.2" + dependencies: + "@inquirer/figures": "npm:^1.0.9" + "@inquirer/type": "npm:^3.0.2" + ansi-escapes: "npm:^4.3.2" + cli-width: "npm:^4.1.0" + mute-stream: "npm:^2.0.0" + signal-exit: "npm:^4.1.0" + strip-ansi: "npm:^6.0.1" + wrap-ansi: "npm:^6.2.0" + yoctocolors-cjs: "npm:^2.1.2" + checksum: 10/e92ade5ba7dbcfd83629db2df7fb91877ac777a7f1e03a16b0d5c08621dafe09d321c5f14b37c2dca80a3db2d68e5a478f8eaeafcb62ed42c46e7349b7276094 + languageName: node + linkType: hard + +"@inquirer/figures@npm:^1.0.9": + version: 1.0.9 + resolution: "@inquirer/figures@npm:1.0.9" + checksum: 10/7ced1275a5826cdeb61797d6c068417e7d52aa87894de18cedd259f783f42d731226c3f8b92cab27b8e7b0e31ab1dd3cd77f16935b67ebe1cbb271e5972d7758 + languageName: node + linkType: hard + +"@inquirer/type@npm:^3.0.2": + version: 3.0.2 + resolution: "@inquirer/type@npm:3.0.2" + peerDependencies: + "@types/node": ">=18" + checksum: 10/d1a2879b1baa357421cef441fc7b43181e110243933763ae922c55c2fc9af2d459ceaca8b71ed57e3dabd5077542fa0dd1d0ff0cf362ce054e61202386b545ed + languageName: node + linkType: hard + "@inversifyjs/common@npm:1.4.0": version: 1.4.0 resolution: "@inversifyjs/common@npm:1.4.0" @@ -5659,6 +5732,20 @@ __metadata: languageName: node linkType: hard +"@mswjs/interceptors@npm:^0.37.0": + version: 0.37.4 + resolution: "@mswjs/interceptors@npm:0.37.4" + dependencies: + "@open-draft/deferred-promise": "npm:^2.2.0" + "@open-draft/logger": "npm:^0.3.0" + "@open-draft/until": "npm:^2.0.0" + is-node-process: "npm:^1.2.0" + outvariant: "npm:^1.4.3" + strict-event-emitter: "npm:^0.5.1" + checksum: 10/b6a4ab08a32b61034216ef434e4e97e77b8e8150ecb1d89f6bc71047aa6374e52606ff9758d8296ca4794da6158ebbeaf51a3d68b5581417d404587f210724c7 + languageName: node + linkType: hard + "@mui/core-downloads-tracker@npm:^6.3.0": version: 6.3.0 resolution: "@mui/core-downloads-tracker@npm:6.3.0" @@ -6130,6 +6217,30 @@ __metadata: languageName: node linkType: hard +"@open-draft/deferred-promise@npm:^2.2.0": + version: 2.2.0 + resolution: "@open-draft/deferred-promise@npm:2.2.0" + checksum: 10/bc3bb1668a555bb87b33383cafcf207d9561e17d2ca0d9e61b7ce88e82b66e36a333d3676c1d39eb5848022c03c8145331fcdc828ba297f88cb1de9c5cef6c19 + languageName: node + linkType: hard + +"@open-draft/logger@npm:^0.3.0": + version: 0.3.0 + resolution: "@open-draft/logger@npm:0.3.0" + dependencies: + is-node-process: "npm:^1.2.0" + outvariant: "npm:^1.4.0" + checksum: 10/7a280f170bcd4e91d3eedbefe628efd10c3bd06dd2461d06a7fdbced89ef457a38785847f88cc630fb4fd7dfa176d6f77aed17e5a9b08000baff647433b5ff78 + languageName: node + linkType: hard + +"@open-draft/until@npm:^2.0.0, @open-draft/until@npm:^2.1.0": + version: 2.1.0 + resolution: "@open-draft/until@npm:2.1.0" + checksum: 10/622be42950afc8e89715d0fd6d56cbdcd13e36625e23b174bd3d9f06f80e25f9adf75d6698af93bca1e1bf465b9ce00ec05214a12189b671fb9da0f58215b6f4 + languageName: node + linkType: hard + "@openzeppelin/contracts-upgradeable@npm:^5.0.0": version: 5.1.0 resolution: "@openzeppelin/contracts-upgradeable@npm:5.1.0" @@ -7486,6 +7597,7 @@ __metadata: dependencies: husky: "npm:^9.1.6" lint-staged: "npm:^15.2.10" + msw: "npm:^2.7.0" prettier: "npm:^3.4.2" languageName: unknown linkType: soft @@ -12303,7 +12415,14 @@ __metadata: languageName: node linkType: hard -"@types/tough-cookie@npm:*": +"@types/statuses@npm:^2.0.4": + version: 2.0.5 + resolution: "@types/statuses@npm:2.0.5" + checksum: 10/3f2609f660b45a878c6782f2fb2cef9f08bbd4e89194bf7512e747b8a73b056839be1ad6f64b1353765528cd8a5e93adeffc471cde24d0d9f7b528264e7154e5 + languageName: node + linkType: hard + +"@types/tough-cookie@npm:*, @types/tough-cookie@npm:^4.0.5": version: 4.0.5 resolution: "@types/tough-cookie@npm:4.0.5" checksum: 10/01fd82efc8202670865928629697b62fe9bf0c0dcbc5b1c115831caeb073a2c0abb871ff393d7df1ae94ea41e256cb87d2a5a91fd03cdb1b0b4384e08d4ee482 @@ -15665,6 +15784,13 @@ __metadata: languageName: node linkType: hard +"cli-width@npm:^4.1.0": + version: 4.1.0 + resolution: "cli-width@npm:4.1.0" + checksum: 10/b58876fbf0310a8a35c79b72ecfcf579b354e18ad04e6b20588724ea2b522799a758507a37dfe132fafaf93a9922cafd9514d9e1598e6b2cd46694853aed099f + languageName: node + linkType: hard + "client-only@npm:0.0.1, client-only@npm:^0.0.1": version: 0.0.1 resolution: "client-only@npm:0.0.1" @@ -16061,6 +16187,13 @@ __metadata: languageName: node linkType: hard +"cookie@npm:^0.7.2": + version: 0.7.2 + resolution: "cookie@npm:0.7.2" + checksum: 10/24b286c556420d4ba4e9bc09120c9d3db7d28ace2bd0f8ccee82422ce42322f73c8312441271e5eefafbead725980e5996cc02766dbb89a90ac7f5636ede608f + languageName: node + linkType: hard + "copy-anything@npm:^2.0.1": version: 2.0.6 resolution: "copy-anything@npm:2.0.6" @@ -20381,6 +20514,13 @@ __metadata: languageName: node linkType: hard +"graphql@npm:^16.8.1": + version: 16.10.0 + resolution: "graphql@npm:16.10.0" + checksum: 10/d42cf81ddcf3a61dfb213217576bf33c326f15b02c4cee369b373dc74100cbdcdc4479b3b797e79b654dabd8fddf50ef65ff75420e9ce5596c02e21f24c9126a + languageName: node + linkType: hard + "gray-matter@npm:^4.0.3": version: 4.0.3 resolution: "gray-matter@npm:4.0.3" @@ -20616,6 +20756,13 @@ __metadata: languageName: node linkType: hard +"headers-polyfill@npm:^4.0.2": + version: 4.0.3 + resolution: "headers-polyfill@npm:4.0.3" + checksum: 10/3a008aa2ef71591e2077706efb48db1b2729b90cf646cc217f9b69744e35cca4ba463f39debb6000904aa7de4fada2e5cc682463025d26bcc469c1d99fa5af27 + languageName: node + linkType: hard + "hermes-estree@npm:0.23.1": version: 0.23.1 resolution: "hermes-estree@npm:0.23.1" @@ -21627,6 +21774,13 @@ __metadata: languageName: node linkType: hard +"is-node-process@npm:^1.2.0": + version: 1.2.0 + resolution: "is-node-process@npm:1.2.0" + checksum: 10/930765cdc6d81ab8f1bbecbea4a8d35c7c6d88a3ff61f3630e0fc7f22d624d7661c1df05c58547d0eb6a639dfa9304682c8e342c4113a6ed51472b704cee2928 + languageName: node + linkType: hard + "is-number-object@npm:^1.1.1": version: 1.1.1 resolution: "is-number-object@npm:1.1.1" @@ -25256,6 +25410,39 @@ __metadata: languageName: node linkType: hard +"msw@npm:^2.7.0": + version: 2.7.0 + resolution: "msw@npm:2.7.0" + dependencies: + "@bundled-es-modules/cookie": "npm:^2.0.1" + "@bundled-es-modules/statuses": "npm:^1.0.1" + "@bundled-es-modules/tough-cookie": "npm:^0.1.6" + "@inquirer/confirm": "npm:^5.0.0" + "@mswjs/interceptors": "npm:^0.37.0" + "@open-draft/deferred-promise": "npm:^2.2.0" + "@open-draft/until": "npm:^2.1.0" + "@types/cookie": "npm:^0.6.0" + "@types/statuses": "npm:^2.0.4" + graphql: "npm:^16.8.1" + headers-polyfill: "npm:^4.0.2" + is-node-process: "npm:^1.2.0" + outvariant: "npm:^1.4.3" + path-to-regexp: "npm:^6.3.0" + picocolors: "npm:^1.1.1" + strict-event-emitter: "npm:^0.5.1" + type-fest: "npm:^4.26.1" + yargs: "npm:^17.7.2" + peerDependencies: + typescript: ">= 4.8.x" + peerDependenciesMeta: + typescript: + optional: true + bin: + msw: cli/index.js + checksum: 10/165ccf37d90da0d5271fdb8e01f89f48f7a60fb810038ff73d18c0e5e5ddfdb1266002d19cde61b9ae689ef37c39499b10d9d07e0d16662a31630ce9adce1d77 + languageName: node + linkType: hard + "multibase@npm:^4.0.1": version: 4.0.6 resolution: "multibase@npm:4.0.6" @@ -25324,6 +25511,13 @@ __metadata: languageName: node linkType: hard +"mute-stream@npm:^2.0.0": + version: 2.0.0 + resolution: "mute-stream@npm:2.0.0" + checksum: 10/d2e4fd2f5aa342b89b98134a8d899d8ef9b0a6d69274c4af9df46faa2d97aeb1f2ce83d867880d6de63643c52386579b99139801e24e7526c3b9b0a6d1e18d6c + languageName: node + linkType: hard + "mz@npm:^2.7.0": version: 2.7.0 resolution: "mz@npm:2.7.0" @@ -26210,6 +26404,13 @@ __metadata: languageName: node linkType: hard +"outvariant@npm:^1.4.0, outvariant@npm:^1.4.3": + version: 1.4.3 + resolution: "outvariant@npm:1.4.3" + checksum: 10/3a7582745850cb344d49641867a4c080858c54f4091afd91b9c0765ba6e471c2bc841348f0fff344845ddd0a4db42fd5d68c6f7ebaf32d4b676a3a9987b2488a + languageName: node + linkType: hard + "ox@npm:0.1.2": version: 0.1.2 resolution: "ox@npm:0.1.2" @@ -26548,6 +26749,13 @@ __metadata: languageName: node linkType: hard +"path-to-regexp@npm:^6.3.0": + version: 6.3.0 + resolution: "path-to-regexp@npm:6.3.0" + checksum: 10/6822f686f01556d99538b350722ef761541ec0ce95ca40ce4c29e20a5b492fe8361961f57993c71b2418de12e604478dcf7c430de34b2c31a688363a7a944d9c + languageName: node + linkType: hard + "path-type@npm:^4.0.0": version: 4.0.0 resolution: "path-type@npm:4.0.0" @@ -30198,7 +30406,7 @@ __metadata: languageName: node linkType: hard -"statuses@npm:2.0.1": +"statuses@npm:2.0.1, statuses@npm:^2.0.1": version: 2.0.1 resolution: "statuses@npm:2.0.1" checksum: 10/18c7623fdb8f646fb213ca4051be4df7efb3484d4ab662937ca6fbef7ced9b9e12842709872eb3020cc3504b93bde88935c9f6417489627a7786f24f8031cbcb @@ -30294,6 +30502,13 @@ __metadata: languageName: node linkType: hard +"strict-event-emitter@npm:^0.5.1": + version: 0.5.1 + resolution: "strict-event-emitter@npm:0.5.1" + checksum: 10/25c84d88be85940d3547db665b871bfecea4ea0bedfeb22aae8db48126820cfb2b0bc2fba695392592a09b1aa36b686d6eede499e1ecd151593c03fe5a50d512 + languageName: node + linkType: hard + "strict-uri-encode@npm:^2.0.0": version: 2.0.0 resolution: "strict-uri-encode@npm:2.0.0" @@ -31355,7 +31570,7 @@ __metadata: languageName: node linkType: hard -"tough-cookie@npm:^4.1.2": +"tough-cookie@npm:^4.1.2, tough-cookie@npm:^4.1.4": version: 4.1.4 resolution: "tough-cookie@npm:4.1.4" dependencies: @@ -31792,6 +32007,13 @@ __metadata: languageName: node linkType: hard +"type-fest@npm:^4.26.1": + version: 4.31.0 + resolution: "type-fest@npm:4.31.0" + checksum: 10/e7e849845bf33e1237c3ff0d5ed00a251a807e3321ffe75278dd56a7d3c385badfe09180057c2d0b93cf7429432b8e7061b6ccf4cc468720d8f69073d2b1bed2 + languageName: node + linkType: hard + "type-fest@npm:^4.30.0": version: 4.30.0 resolution: "type-fest@npm:4.30.0" @@ -33894,6 +34116,13 @@ __metadata: languageName: node linkType: hard +"yoctocolors-cjs@npm:^2.1.2": + version: 2.1.2 + resolution: "yoctocolors-cjs@npm:2.1.2" + checksum: 10/d731e3ba776a0ee19021d909787942933a6c2eafb2bbe85541f0c59aa5c7d475ce86fcb860d5803105e32244c3dd5ba875b87c4c6bf2d6f297da416aa54e556f + languageName: node + linkType: hard + "zodiac-roles-deployments@npm:^2.3.4": version: 2.3.4 resolution: "zodiac-roles-deployments@npm:2.3.4" From 9a6a16642e4562152a0ca4dba7b4f5f0a5b3cefb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Clo=CC=81vis=20Neto?= Date: Fri, 3 Jan 2025 13:03:40 +0100 Subject: [PATCH 14/16] feat: cover NFTs container --- .../components/NFTs/NFTs.container.test.tsx | 59 +++++++++++++++++++ .../components/NoFunds/NoFunds.test.tsx | 22 +++++++ .../Assets/components/NoFunds/NoFunds.tsx | 2 +- apps/mobile/src/tests/handlers.ts | 22 ++----- apps/mobile/src/tests/mocks.ts | 46 +++++++++++++++ 5 files changed, 134 insertions(+), 17 deletions(-) create mode 100644 apps/mobile/src/features/Assets/components/NFTs/NFTs.container.test.tsx create mode 100644 apps/mobile/src/features/Assets/components/NoFunds/NoFunds.test.tsx diff --git a/apps/mobile/src/features/Assets/components/NFTs/NFTs.container.test.tsx b/apps/mobile/src/features/Assets/components/NFTs/NFTs.container.test.tsx new file mode 100644 index 00000000000..d3dedda2a23 --- /dev/null +++ b/apps/mobile/src/features/Assets/components/NFTs/NFTs.container.test.tsx @@ -0,0 +1,59 @@ +import React from 'react' +import { render, screen } from '@/src/tests/test-utils' +import { NFTsContainer } from './NFTs.container' +import { server } from '@/src/tests/server' +import { http, HttpResponse } from 'msw' +import { GATEWAY_URL } from '@/src/config/constants' + +// Mock active safe selector with memoized object +const mockActiveSafe = { chainId: '1', address: '0x123' } + +jest.mock('@/src/store/activeSafeSlice', () => ({ + selectActiveSafe: () => mockActiveSafe, +})) + +describe('NFTsContainer', () => { + afterAll(() => { + server.resetHandlers() + }) + it('renders loading state initially', () => { + render() + expect(screen.getByTestId('fallback')).toBeTruthy() + }) + + it('renders error state when API fails', async () => { + server.use( + http.get(`${GATEWAY_URL}//v2/chains/:chainId/safes/:safeAddress/collectibles`, () => { + return HttpResponse.error() + }), + ) + + render() + expect(await screen.findByTestId('fallback')).toBeTruthy() + }) + + it('renders NFT list when data is available', async () => { + render() + + // First verify we see the loading state + expect(screen.getByTestId('fallback')).toBeTruthy() + + // Then check for NFT content + const nft1 = await screen.findByText('NFT #1') + const nft2 = await screen.findByText('NFT #2') + + expect(nft1).toBeTruthy() + expect(nft2).toBeTruthy() + }) + + it('renders fallback when data is empty', async () => { + server.use( + http.get(`${GATEWAY_URL}//v2/chains/:chainId/safes/:safeAddress/collectibles`, () => { + return HttpResponse.json({ results: [] }) + }), + ) + + render() + expect(await screen.findByTestId('fallback')).toBeTruthy() + }) +}) diff --git a/apps/mobile/src/features/Assets/components/NoFunds/NoFunds.test.tsx b/apps/mobile/src/features/Assets/components/NoFunds/NoFunds.test.tsx new file mode 100644 index 00000000000..628372895e6 --- /dev/null +++ b/apps/mobile/src/features/Assets/components/NoFunds/NoFunds.test.tsx @@ -0,0 +1,22 @@ +import React from 'react' +import { render, screen } from '@/src/tests/test-utils' +import { NoFunds } from './NoFunds' + +describe('NoFunds', () => { + it('renders the empty token component', () => { + render() + + // Check for the main elements + expect(screen.getByText('Add funds to get started')).toBeTruthy() + expect( + screen.getByText('Send funds to your Safe Account from another wallet by copying your address.'), + ).toBeTruthy() + }) + + it('renders the EmptyToken component', () => { + render() + + // Check if EmptyToken is rendered by looking for its container + expect(screen.getByTestId('empty-token')).toBeTruthy() + }) +}) diff --git a/apps/mobile/src/features/Assets/components/NoFunds/NoFunds.tsx b/apps/mobile/src/features/Assets/components/NoFunds/NoFunds.tsx index b0cf7f0e9d2..1c5baad6e88 100644 --- a/apps/mobile/src/features/Assets/components/NoFunds/NoFunds.tsx +++ b/apps/mobile/src/features/Assets/components/NoFunds/NoFunds.tsx @@ -4,7 +4,7 @@ import EmptyToken from './EmptyToken' export function NoFunds() { return ( - +

Add funds to get started

diff --git a/apps/mobile/src/tests/handlers.ts b/apps/mobile/src/tests/handlers.ts index fafdae78974..69258153adb 100644 --- a/apps/mobile/src/tests/handlers.ts +++ b/apps/mobile/src/tests/handlers.ts @@ -1,22 +1,12 @@ import { http, HttpResponse } from 'msw' - -export const mockBalanceData = { - items: [ - { - tokenInfo: { - name: 'Ethereum', - symbol: 'ETH', - decimals: 18, - logoUri: 'https://safe-transaction-assets.safe.global/chains/1/chain_logo.png', - }, - balance: '1000000000000000000', - fiatBalance: '2000', - }, - ], -} +import { mockBalanceData, mockNFTData } from './mocks' +import { GATEWAY_URL } from '../config/constants' export const handlers = [ - http.get('https://safe-client.safe.global//v1/chains/1/safes/0x123/balances/USD', () => { + http.get(`${GATEWAY_URL}//v1/chains/1/safes/0x123/balances/USD`, () => { return HttpResponse.json(mockBalanceData) }), + http.get(`${GATEWAY_URL}//v2/chains/:chainId/safes/:safeAddress/collectibles`, () => { + return HttpResponse.json(mockNFTData) + }), ] diff --git a/apps/mobile/src/tests/mocks.ts b/apps/mobile/src/tests/mocks.ts index 7b1b4861ecf..dcbc5e2db95 100644 --- a/apps/mobile/src/tests/mocks.ts +++ b/apps/mobile/src/tests/mocks.ts @@ -20,6 +20,52 @@ import { Transaction, } from '@safe-global/store/gateway/AUTO_GENERATED/transactions' +export const mockBalanceData = { + items: [ + { + tokenInfo: { + name: 'Ethereum', + symbol: 'ETH', + decimals: 18, + logoUri: 'https://safe-transaction-assets.safe.global/chains/1/chain_logo.png', + }, + balance: '1000000000000000000', + fiatBalance: '2000', + }, + ], +} + +export const mockNFTData = { + count: 2, + next: null, + previous: null, + results: [ + { + id: '1', + address: '0x123', + tokenName: 'Cool NFT', + tokenSymbol: 'CNFT', + logoUri: 'https://example.com/nft1.png', + name: 'NFT #1', + description: 'A cool NFT', + tokenId: '1', + uri: 'https://example.com/nft1.json', + imageUri: 'https://example.com/nft1.png', + }, + { + id: '2', + address: '0x456', + tokenName: 'Another NFT', + tokenSymbol: 'ANFT', + logoUri: 'https://example.com/nft2.png', + name: 'NFT #2', + description: 'Another cool NFT', + tokenId: '2', + uri: 'https://example.com/nft2.json', + imageUri: 'https://example.com/nft2.png', + }, + ], +} export const fakeToken = { address: '0x1111111111', decimals: 18, From deda2a4f2b628b3ba2dd38b1009929781491e826 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Clo=CC=81vis=20Neto?= Date: Fri, 3 Jan 2025 13:47:13 +0100 Subject: [PATCH 15/16] chore: cover my accounts functionality with unit tests --- .../AccountItem/AccountItem.test.tsx | 124 ++++++++++++------ .../MyAccounts/MyAccounts.container.test.tsx | 92 +++++++++++++ .../hooks/useMyAccountsService.test.tsx | 116 ++++++++++++++++ .../MyAccounts/hooks/useMyAccountsService.ts | 6 +- 4 files changed, 296 insertions(+), 42 deletions(-) create mode 100644 apps/mobile/src/features/Assets/components/MyAccounts/MyAccounts.container.test.tsx create mode 100644 apps/mobile/src/features/Assets/components/MyAccounts/hooks/useMyAccountsService.test.tsx diff --git a/apps/mobile/src/features/Assets/components/AccountItem/AccountItem.test.tsx b/apps/mobile/src/features/Assets/components/AccountItem/AccountItem.test.tsx index 9f3573e9211..833302409ed 100644 --- a/apps/mobile/src/features/Assets/components/AccountItem/AccountItem.test.tsx +++ b/apps/mobile/src/features/Assets/components/AccountItem/AccountItem.test.tsx @@ -1,59 +1,105 @@ -import { render, userEvent } from '@/src/tests/test-utils' -import AccountItem from './AccountItem' -import { mockedActiveSafeInfo, mockedChains } from '@/src/store/constants' -import { Chain } from '@safe-global/store/gateway/AUTO_GENERATED/chains' -import { shortenAddress } from '@/src/utils/formatters' -import { Address } from '@/src/types/address' +import React from 'react' +import { render, screen, fireEvent } from '@/src/tests/test-utils' +import { AccountItem } from './AccountItem' + +const mockAccount = { + address: { value: '0x123' as `0x${string}`, name: 'Test Account' }, + threshold: 1, + owners: [{ value: '0x456' as `0x${string}` }], + fiatTotal: '1000', + chainId: '1', + queued: 0, +} + +const mockChains = [ + { + chainId: '1', + chainName: 'Ethereum', + shortName: 'eth', + description: 'Ethereum', + l2: false, + isTestnet: false, + nativeCurrency: { symbol: 'ETH', decimals: 18, name: 'Ether' }, + blockExplorerUriTemplate: { address: '', txHash: '', api: '' }, + transactionService: '', + theme: { backgroundColor: '', textColor: '' }, + gasPrice: [], + ensRegistryAddress: '', + features: [], + disabledWallets: [], + rpcUri: { authentication: '', value: '' }, + beaconChainExplorerUriTemplate: { address: '', api: '' }, + balancesProvider: '', + contractAddresses: {}, + publicRpcUri: { authentication: '', value: '' }, + safeAppsRpcUri: { authentication: '', value: '' }, + }, +] describe('AccountItem', () => { - it('should render a unselected AccountItem', () => { - const container = render( + const mockOnSelect = jest.fn() + const mockDrag = jest.fn() + + beforeEach(() => { + jest.clearAllMocks() + }) + + it('renders account details correctly', () => { + render() + + expect(screen.getByText('Test Account')).toBeTruthy() + expect(screen.getByText('1/1')).toBeTruthy() + expect(screen.getByText('$1000')).toBeTruthy() + }) + + it('shows active state when account is selected', () => { + render( , ) - expect(container.getByTestId('account-item-wrapper')).toHaveStyle({ backgroundColor: 'transparent' }) - expect(container.getByText(shortenAddress(mockedActiveSafeInfo.address.value))).toBeDefined() - expect(container.getByText(`${mockedActiveSafeInfo.threshold}/${mockedActiveSafeInfo.owners.length}`)).toBeDefined() - expect(container.getByText(`$${mockedActiveSafeInfo.fiatTotal}`)).toBeVisible() - expect(container.getAllByTestId('chain-display')).toHaveLength(mockedChains.length) + const wrapper = screen.getByTestId('account-item-wrapper') + expect(wrapper.props.style.backgroundColor).toBe('#DCDEE0') + }) + + it('calls onSelect when pressed', () => { + render() + + fireEvent.press(screen.getByTestId('account-item-wrapper')) + expect(mockOnSelect).toHaveBeenCalledWith(mockAccount.address.value) }) - it('should render a selected AccountItem', () => { - const container = render( + it('enables drag functionality when provided', () => { + render( , ) - expect(container.getByTestId('account-item-wrapper')).toHaveStyle({ backgroundColor: '#DCDEE0' }) - expect(container.getByText(shortenAddress(mockedActiveSafeInfo.address.value))).toBeDefined() - expect(container.getByText(`${mockedActiveSafeInfo.threshold}/${mockedActiveSafeInfo.owners.length}`)).toBeDefined() - expect(container.getByText(`$${mockedActiveSafeInfo.fiatTotal}`)).toBeVisible() - expect(container.getAllByTestId('chain-display')).toHaveLength(mockedChains.length) + fireEvent(screen.getByTestId('account-item-wrapper'), 'longPress') + expect(mockDrag).toHaveBeenCalled() }) - it('should trigger an event when user clicks in the account item', async () => { - const spyFn = jest.fn() - const user = userEvent.setup() - const container = render( + it('disables press when dragging', () => { + render( , ) - await user.press(container.getByTestId('account-item-wrapper')) - - expect(spyFn).toHaveBeenNthCalledWith(1, mockedActiveSafeInfo.address.value) + fireEvent.press(screen.getByTestId('account-item-wrapper')) + expect(mockOnSelect).not.toHaveBeenCalled() }) }) diff --git a/apps/mobile/src/features/Assets/components/MyAccounts/MyAccounts.container.test.tsx b/apps/mobile/src/features/Assets/components/MyAccounts/MyAccounts.container.test.tsx new file mode 100644 index 00000000000..cf8084db242 --- /dev/null +++ b/apps/mobile/src/features/Assets/components/MyAccounts/MyAccounts.container.test.tsx @@ -0,0 +1,92 @@ +import React from 'react' +import { render, screen, fireEvent } from '@/src/tests/test-utils' +import { MyAccountsContainer } from './MyAccounts.container' +import { mockedChains } from '@/src/store/constants' +import { server } from '@/src/tests/server' +import { http, HttpResponse } from 'msw' +import { GATEWAY_URL } from '@/src/config/constants' + +// Mock the safe item data +const mockSafeItem = { + SafeInfo: { + address: { value: '0x123' as `0x${string}`, name: 'Test Safe' }, + threshold: 1, + owners: [{ value: '0x456' as `0x${string}` }], + fiatTotal: '1000', + chainId: '1', + queued: 0, + }, + chains: ['1'], +} + +// Create a constant object for the selector result +const mockActiveSafe = { address: '0x789' as `0x${string}`, chainId: '1' } +const mockChainIds = ['1'] as const + +// Mock Redux selectors +jest.mock('@/src/store/activeSafeSlice', () => ({ + selectActiveSafe: () => mockActiveSafe, + setActiveSafe: (payload: { address: `0x${string}`; chainId: string }) => ({ + type: 'activeSafe/setActiveSafe', + payload, + }), +})) + +jest.mock('@/src/store/chains', () => ({ + getChainsByIds: () => mockedChains, + selectAllChainsIds: () => mockChainIds, +})) + +jest.mock('@/src/store/myAccountsSlice', () => ({ + selectMyAccountsMode: () => false, +})) + +describe('MyAccountsContainer', () => { + const mockOnClose = jest.fn() + + beforeEach(() => { + server.use( + http.get(`${GATEWAY_URL}//v1/safes`, () => { + return HttpResponse.json([ + { + address: { value: '0x123', name: 'Test Safe' }, + chainId: '1', + threshold: 1, + owners: [{ value: '0x456' }], + fiatTotal: '1000', + queued: 0, + }, + ]) + }), + ) + }) + + afterEach(() => { + jest.clearAllMocks() + server.resetHandlers() + }) + + it('renders account item with correct data', () => { + render() + + expect(screen.getByText('Test Safe')).toBeTruthy() + expect(screen.getByText('1/1')).toBeTruthy() + expect(screen.getByText('$1000')).toBeTruthy() + }) + + it('calls onClose when account is selected', () => { + render() + + fireEvent.press(screen.getByTestId('account-item-wrapper')) + + expect(mockOnClose).toHaveBeenCalled() + }) + + it('renders with drag functionality when provided', () => { + const mockDrag = jest.fn() + + render() + + expect(screen.getByTestId('account-item-wrapper')).toBeTruthy() + }) +}) diff --git a/apps/mobile/src/features/Assets/components/MyAccounts/hooks/useMyAccountsService.test.tsx b/apps/mobile/src/features/Assets/components/MyAccounts/hooks/useMyAccountsService.test.tsx new file mode 100644 index 00000000000..bf75ecc866d --- /dev/null +++ b/apps/mobile/src/features/Assets/components/MyAccounts/hooks/useMyAccountsService.test.tsx @@ -0,0 +1,116 @@ +import { renderHook, waitFor } from '@/src/tests/test-utils' +import { useMyAccountsService } from './useMyAccountsService' +import { server } from '@/src/tests/server' +import { http, HttpResponse } from 'msw' + +// Mock safe item +const mockSafeItem = { + SafeInfo: { + address: { value: '0x123' as `0x${string}`, name: 'Test Safe' }, + threshold: 1, + owners: [{ value: '0x456' as `0x${string}` }], + fiatTotal: '1000', + chainId: '1', + queued: 0, + }, + chains: ['1'], +} + +// Mock chain IDs selector +const mockChainIds = ['1', '5'] as const + +jest.mock('@/src/store/chains', () => ({ + selectAllChainsIds: () => mockChainIds, +})) + +// Mock Redux dispatch and selector +const mockDispatch = jest.fn() + +jest.mock('@/src/store/hooks', () => ({ + useAppDispatch: () => mockDispatch, + useAppSelector: (selector: (state: unknown) => unknown) => { + if (selector.name === 'selectAllChainsIds') { + return mockChainIds + } + return null + }, +})) + +describe('useMyAccountsService', () => { + beforeEach(() => { + jest.clearAllMocks() + server.use( + http.get('https://safe-client.safe.global//v1/safes', ({ request }) => { + const url = new URL(request.url) + const safes = url.searchParams.get('safes')?.split(',') || [] + + return HttpResponse.json( + safes.map((safe) => ({ + address: { value: '0x123', name: 'Test Safe' }, + chainId: safe.split(':')[0], + threshold: 1, + owners: [{ value: '0x456' }], + fiatTotal: '1000', + queued: 0, + })), + ) + }), + ) + }) + + afterEach(() => { + server.resetHandlers() + }) + + it('should fetch safe overview and update store', async () => { + renderHook(() => useMyAccountsService(mockSafeItem)) + + // Wait for dispatch to be called + await waitFor(() => { + expect(mockDispatch).toHaveBeenCalled() + }) + + expect(mockDispatch).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'safes/updateSafeInfo', + payload: expect.objectContaining({ + address: '0x123', + item: expect.objectContaining({ + chains: ['1', '5'], + SafeInfo: expect.objectContaining({ + fiatTotal: '2000', // Sum of both chain balances + }), + }), + }), + }), + ) + }) + + it('should not update store if no data is returned', async () => { + server.use( + http.get('https://safe-client.safe.global//v1/safes', () => { + return HttpResponse.json([]) + }), + ) + + renderHook(() => useMyAccountsService(mockSafeItem)) + + await waitFor(() => { + expect(mockDispatch).not.toHaveBeenCalled() + }) + }) + + it('should handle API errors gracefully', async () => { + server.use( + http.get('https://safe-client.safe.global//v1/safes', () => { + return HttpResponse.error() + }), + ) + + renderHook(() => useMyAccountsService(mockSafeItem)) + + await waitFor(() => { + expect(mockDispatch).not.toHaveBeenCalled() + }) + }) +}) diff --git a/apps/mobile/src/features/Assets/components/MyAccounts/hooks/useMyAccountsService.ts b/apps/mobile/src/features/Assets/components/MyAccounts/hooks/useMyAccountsService.ts index d590807f766..11cd7525c5a 100644 --- a/apps/mobile/src/features/Assets/components/MyAccounts/hooks/useMyAccountsService.ts +++ b/apps/mobile/src/features/Assets/components/MyAccounts/hooks/useMyAccountsService.ts @@ -1,16 +1,16 @@ import { useSafesGetSafeOverviewV1Query } from '@safe-global/store/gateway/AUTO_GENERATED/safes' import { SafeOverviewResult } from '@safe-global/store/gateway/types' import { useEffect, useMemo } from 'react' -import { useDispatch, useSelector } from 'react-redux' import { selectAllChainsIds } from '@/src/store/chains' import { SafesSliceItem, updateSafeInfo } from '@/src/store/safesSlice' import { Address } from '@/src/types/address' import { makeSafeId } from '@/src/utils/formatters' +import { useAppDispatch, useAppSelector } from '@/src/store/hooks' export const useMyAccountsService = (item: SafesSliceItem) => { - const dispatch = useDispatch() - const chainIds = useSelector(selectAllChainsIds) + const dispatch = useAppDispatch() + const chainIds = useAppSelector(selectAllChainsIds) const safes = useMemo( () => chainIds.map((chainId: string) => makeSafeId(chainId, item.SafeInfo.address.value)).join(','), [chainIds, item.SafeInfo.address.value], From 41611518be0e2d89fd80fc3400c7b341c6c1397f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Clo=CC=81vis=20Neto?= Date: Fri, 3 Jan 2025 14:20:37 +0100 Subject: [PATCH 16/16] fix: AccountItem lint --- .../AccountItem/AccountItem.test.tsx | 25 +++++++++++++++---- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/apps/mobile/src/features/Assets/components/AccountItem/AccountItem.test.tsx b/apps/mobile/src/features/Assets/components/AccountItem/AccountItem.test.tsx index 833302409ed..70b970a342a 100644 --- a/apps/mobile/src/features/Assets/components/AccountItem/AccountItem.test.tsx +++ b/apps/mobile/src/features/Assets/components/AccountItem/AccountItem.test.tsx @@ -1,6 +1,7 @@ import React from 'react' import { render, screen, fireEvent } from '@/src/tests/test-utils' import { AccountItem } from './AccountItem' +import { Chain } from '@safe-global/store/gateway/AUTO_GENERATED/chains' const mockAccount = { address: { value: '0x123' as `0x${string}`, name: 'Test Account' }, @@ -45,7 +46,14 @@ describe('AccountItem', () => { }) it('renders account details correctly', () => { - render() + render( + , + ) expect(screen.getByText('Test Account')).toBeTruthy() expect(screen.getByText('1/1')).toBeTruthy() @@ -56,7 +64,7 @@ describe('AccountItem', () => { render( , @@ -67,7 +75,14 @@ describe('AccountItem', () => { }) it('calls onSelect when pressed', () => { - render() + render( + , + ) fireEvent.press(screen.getByTestId('account-item-wrapper')) expect(mockOnSelect).toHaveBeenCalledWith(mockAccount.address.value) @@ -77,7 +92,7 @@ describe('AccountItem', () => { render( { render(