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 0000000000..7ec244c49e --- /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/app/_layout.tsx b/apps/mobile/app/_layout.tsx index 027e159c24..d430524923 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 db6ff429e0..32c764568a 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -83,6 +83,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/apps/mobile/src/components/Container/Container.tsx b/apps/mobile/src/components/Container/Container.tsx index 95b7585e00..c92e72ea94 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/Dropdown/Dropdown.tsx b/apps/mobile/src/components/Dropdown/Dropdown.tsx index 160673638a..9ef4ec31a9 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,15 +36,17 @@ export function Dropdown({ leftNode, children, dropdownTitle, + sortable, items, snapPoints = [600, '90%'], keyExtractor, + actions, renderItem: Render, labelProps = defaultLabelProps, footerComponent, + onDragEnd, }: DropdownProps) { const bottomSheetModalRef = useRef(null) - const handlePresentModalPress = useCallback(() => { bottomSheetModalRef.current?.present() }, []) @@ -49,6 +56,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 +114,37 @@ export function Dropdown({ backdropComponent={BackdropComponent} footerComponent={footerComponent} > - - - - {dropdownTitle && ( -
- {dropdownTitle} -
- )} + {!isSortable && dropdownTitle && renderDropdownHeader} - - {hasCustomItems - ? items.map((item, index) => ( - - )) - : children} + + {isSortable ? ( + + data={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 +153,6 @@ export function Dropdown({ const styles = StyleSheet.create({ contentContainer: { - paddingHorizontal: 20, justifyContent: 'space-around', }, }) diff --git a/apps/mobile/src/components/SafeListItem/SafeListItem.tsx b/apps/mobile/src/components/SafeListItem/SafeListItem.tsx index 9e71f252f8..7b90e01b2e 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 ( ({ 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/components/transactions-list/Card/AccountCard/AccountCard.tsx b/apps/mobile/src/components/transactions-list/Card/AccountCard/AccountCard.tsx index 7409501c45..533a306711 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/TxGroupedCard/TxGroupedCard.test.tsx b/apps/mobile/src/components/transactions-list/Card/TxGroupedCard/TxGroupedCard.test.tsx index 2db0a34663..1feef55b69 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 eef29051e5..c66158c3dd 100644 --- a/apps/mobile/src/components/transactions-list/Card/TxTokenCard/TxTokenCard.tsx +++ b/apps/mobile/src/components/transactions-list/Card/TxTokenCard/TxTokenCard.tsx @@ -9,11 +9,11 @@ import { isTxQueued, } 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 { selectActiveChainCurrency } from '@/src/store/chains' +import { useAppSelector } from '@/src/store/hooks' interface TxTokenCardProps { bordered?: boolean @@ -34,7 +34,7 @@ interface tokenDetails { const getTokenDetails = (txInfo: TransferTransactionInfo): tokenDetails => { const transfer = txInfo.transferInfo const unnamedToken = 'Unnamed token' - const nativeCurrency = useSelector(selectNativeCurrency) + const nativeCurrency = useAppSelector(selectActiveChainCurrency) if (isNativeTokenTransfer(transfer)) { return { 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 9f3573e921..70b970a342 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,120 @@ -import { render, userEvent } from '@/src/tests/test-utils' -import AccountItem from './AccountItem' -import { mockedActiveSafeInfo, mockedChains } from '@/src/store/constants' +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' -import { shortenAddress } from '@/src/utils/formatters' -import { Address } from '@/src/types/address' + +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('should render a selected AccountItem', () => { - const container = render( + it('calls onSelect when pressed', () => { + 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.press(screen.getByTestId('account-item-wrapper')) + expect(mockOnSelect).toHaveBeenCalledWith(mockAccount.address.value) }) - 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('enables drag functionality when provided', () => { + render( , ) - await user.press(container.getByTestId('account-item-wrapper')) + fireEvent(screen.getByTestId('account-item-wrapper'), 'longPress') + expect(mockDrag).toHaveBeenCalled() + }) + + it('disables press when dragging', () => { + render( + , + ) - 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/AccountItem/AccountItem.tsx b/apps/mobile/src/features/Assets/components/AccountItem/AccountItem.tsx index 2d3c2d7cec..7961fe3a5a 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 from 'react' -import { TouchableOpacity } from 'react-native' +import React, { useMemo } from 'react' +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' @@ -7,42 +7,73 @@ 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 { useEditAccountItem } from './hooks/useEditAccountItem' 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 { isEdit, onSafeDeleted } = useEditAccountItem() const isActive = activeAccount === account.address.value 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} /> ) } +const styles = StyleSheet.create({ + container: { + width: '100%', + }, +}) + export default AccountItem 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 0000000000..759ba32a06 --- /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/Balance/Balance.container.tsx b/apps/mobile/src/features/Assets/components/Balance/Balance.container.tsx index 23c8b5cb9c..c84c3da24b 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,21 @@ -import { selectActiveChain, switchActiveChain } from '@/src/store/activeChainSlice' -import { useDispatch, useSelector } from 'react-redux' +import { useDispatch } 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' +import { useAppSelector } from '@/src/store/hooks' export function BalanceContainer() { - const activeChain = useSelector(selectActiveChain) - const chains = useSelector(selectAllChains) - const activeSafe = useSelector(selectActiveSafe) + const chains = useAppSelector(selectAllChains) + const activeSafe = useAppSelector(selectActiveSafe) const dispatch = useDispatch() - const activeSafeInfo = useSelector((state: RootState) => selectActiveSafeInfo(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(','), @@ -31,8 +29,8 @@ export function BalanceContainer() { }, ) - const handleChainChange = (id: string) => { - dispatch(switchActiveChain({ id })) + const handleChainChange = (chainId: string) => { + dispatch(switchActiveChain({ chainId })) } return ( @@ -40,7 +38,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 3bf7b3c377..8896651010 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/MyAccounts.container.test.tsx b/apps/mobile/src/features/Assets/components/MyAccounts/MyAccounts.container.test.tsx new file mode 100644 index 0000000000..cf8084db24 --- /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/MyAccounts.container.tsx b/apps/mobile/src/features/Assets/components/MyAccounts/MyAccounts.container.tsx index aac4ed881d..ea034426b8 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 ( ({ + 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 d590807f76..11cd7525c5 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], 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 0000000000..00cc577a90 --- /dev/null +++ b/apps/mobile/src/features/Assets/components/MyAccounts/hooks/useMyAccountsSortable.ts @@ -0,0 +1,39 @@ +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) + const shouldGoToListMode = newSafes.length <= 1 && isEdit + + setSortableSafes(newSafes) + + if (shouldGoToListMode) { + 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/NFTs/NFTs.container.test.tsx b/apps/mobile/src/features/Assets/components/NFTs/NFTs.container.test.tsx new file mode 100644 index 0000000000..d3dedda2a2 --- /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/NFTs/NFTs.container.tsx b/apps/mobile/src/features/Assets/components/NFTs/NFTs.container.tsx index d201de1cfe..10c88689ab 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/Navbar/Navbar.tsx b/apps/mobile/src/features/Assets/components/Navbar/Navbar.tsx index f67f461720..791ffa7ef1 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 { 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 { SafesSliceItem } from '@/src/store/safesSlice' +import { selectMyAccountsMode, toggleMode } from '@/src/store/myAccountsSlice' import { MyAccountsContainer, MyAccountsFooter } from '../MyAccounts' -import { SafesSliceItem, selectAllSafes } from '@/src/store/safesSlice' +import { useMyAccountsSortable } from '../MyAccounts/hooks/useMyAccountsSortable' +import { useAppDispatch, useAppSelector } from '@/src/store/hooks' const dropdownLabelProps = { fontSize: '$5', @@ -19,23 +21,37 @@ const dropdownLabelProps = { } as const export const Navbar = () => { - const activeSafe = useSelector(selectActiveSafe) - const safes = useSelector(selectAllSafes) - const memoizedSafes = useMemo(() => Object.values(safes), [safes]) + const dispatch = useAppDispatch() + const isEdit = useAppSelector(selectMyAccountsMode) + const activeSafe = useAppSelector(selectActiveSafe) + const { safes, onDragEnd } = useMyAccountsSortable() + + 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/Assets/components/NoFunds/NoFunds.test.tsx b/apps/mobile/src/features/Assets/components/NoFunds/NoFunds.test.tsx new file mode 100644 index 0000000000..628372895e --- /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 b0cf7f0e9d..1c5baad6e8 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/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 0000000000..d2892830ce --- /dev/null +++ b/apps/mobile/src/features/Assets/components/Tokens/Tokens.container.test.tsx @@ -0,0 +1,69 @@ +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' + +// 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 + render() + + // First verify we see the loading state + expect(screen.getByTestId('fallback')).toBeTruthy() + + // 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/features/Assets/components/Tokens/Tokens.container.tsx b/apps/mobile/src/features/Assets/components/Tokens/Tokens.container.tsx index 29b5ca90c5..2f3a671f35 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 977811381a..c0eec6e4d0 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/IdenticonWithBadge/IdenticonWithBadge.tsx b/apps/mobile/src/features/Settings/components/IdenticonWithBadge/IdenticonWithBadge.tsx index 7e4ed2c009..2a1de35dfe 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 }: { const toast = useToastController() - const activeChain = useSelector(selectActiveChain) + const activeSafe = useAppSelector(selectActiveSafe) + const activeChain = useAppSelector((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 db064e6a39..0000000000 --- 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/activeSafeSlice.ts b/apps/mobile/src/store/activeSafeSlice.ts index adaf98cb14..6a1bc19cbc 100644 --- a/apps/mobile/src/store/activeSafeSlice.ts +++ b/apps/mobile/src/store/activeSafeSlice.ts @@ -18,10 +18,16 @@ const activeSafeSlice = createSlice({ clearActiveSafe: () => { 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/chains/index.ts b/apps/mobile/src/store/chains/index.ts index 5872031662..f123ccc142 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( [ diff --git a/apps/mobile/src/store/constants.ts b/apps/mobile/src/store/constants.ts index c71e0af9e3..0646a9476c 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/index.ts b/apps/mobile/src/store/index.ts index 7c580238b2..bc60c33de1 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, }) diff --git a/apps/mobile/src/store/myAccountsSlice.ts b/apps/mobile/src/store/myAccountsSlice.ts new file mode 100644 index 0000000000..53de4ffa06 --- /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 d78641779c..4e62999aa7 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], ) diff --git a/apps/mobile/src/tests/handlers.ts b/apps/mobile/src/tests/handlers.ts new file mode 100644 index 0000000000..69258153ad --- /dev/null +++ b/apps/mobile/src/tests/handlers.ts @@ -0,0 +1,12 @@ +import { http, HttpResponse } from 'msw' +import { mockBalanceData, mockNFTData } from './mocks' +import { GATEWAY_URL } from '../config/constants' + +export const handlers = [ + 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/jest.setup.tsx b/apps/mobile/src/tests/jest.setup.tsx index f7eed09035..faff2509cb 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/mocks.ts b/apps/mobile/src/tests/mocks.ts index 7b1b4861ec..dcbc5e2db9 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, diff --git a/apps/mobile/src/tests/server.ts b/apps/mobile/src/tests/server.ts new file mode 100644 index 0000000000..86f7d6154a --- /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 c2b1e81b2c..806c6bc1d2 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.ReactNode }> + +const getProviders: getProvidersArgs = (initialStoreState) => function ProviderComponent({ children }: { children: React.ReactNode }) { - const store = makeStore() + const store = initialStoreState + ? configureStore({ + reducer: rootReducer, + preloadedState: initialStoreState, + }) + : makeStore() return ( @@ -20,10 +29,23 @@ const getProviders: () => React.FC<{ children: React.ReactElement }> = () => ) } -const customRender = (ui: React.ReactElement) => { - const wrapper = getProviders() +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) { diff --git a/apps/mobile/tsconfig.json b/apps/mobile/tsconfig.json index a4564769f3..e9389e759a 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 12065299ac..377a5c1a1c 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 1a11c6e9ae..a2a872935b 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: @@ -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" @@ -7360,6 +7471,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" @@ -7485,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 @@ -12302,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 @@ -15664,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" @@ -16060,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" @@ -20380,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" @@ -20615,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" @@ -21626,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" @@ -25255,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" @@ -25323,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" @@ -26209,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" @@ -26547,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" @@ -27647,6 +27856,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" @@ -30184,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 @@ -30280,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" @@ -31341,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: @@ -31778,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" @@ -33880,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"