Skip to content

Commit

Permalink
refactor: Split up MyAccounts component (#4671)
Browse files Browse the repository at this point in the history
* refactor: Split up MyAccounts component

* refactor: Move allSafes computation and sort comparator into AccountsList

* fix: Add tests for AccountsList

* fix: Add tests for PinnedSafes

* fix: Add tests for FilteredSafes, adjust maybePlural to account for 0 value

* fix: Failing maybePlural test

* fix: Change QueueActions to a button instead of an a element

* fix: Remove watchlist tracking and use pinned safes tracking instead

* refactor: Rename AccountsFilter to AccountListFilters
  • Loading branch information
usame-algan authored Dec 19, 2024
1 parent ce51771 commit 011e808
Show file tree
Hide file tree
Showing 18 changed files with 655 additions and 323 deletions.
60 changes: 60 additions & 0 deletions src/features/myAccounts/components/AccountListFilters/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { useAppDispatch, useAppSelector } from '@/store'
import { type OrderByOption, selectOrderByPreference, setOrderByPreference } from '@/store/orderByPreferenceSlice'
import debounce from 'lodash/debounce'
import { type Dispatch, type SetStateAction, useCallback } from 'react'
import OrderByButton from '@/features/myAccounts/components/OrderByButton'
import css from '@/features/myAccounts/styles.module.css'
import SearchIcon from '@/public/images/common/search.svg'
import { Box, InputAdornment, Paper, SvgIcon, TextField } from '@mui/material'

const AccountListFilters = ({ setSearchQuery }: { setSearchQuery: Dispatch<SetStateAction<string>> }) => {
const dispatch = useAppDispatch()
const { orderBy } = useAppSelector(selectOrderByPreference)

// eslint-disable-next-line react-hooks/exhaustive-deps
const handleSearch = useCallback(debounce(setSearchQuery, 300), [])

const handleOrderByChange = (orderBy: OrderByOption) => {
dispatch(setOrderByPreference({ orderBy }))
}

return (
<Paper sx={{ px: 2, py: 1 }}>
<Box display="flex" justifyContent="space-between" width="100%" gap={1}>
<TextField
id="search-by-name"
placeholder="Search"
aria-label="Search Safe list by name"
variant="filled"
hiddenLabel
onChange={(e) => {
handleSearch(e.target.value)
}}
className={css.search}
InputProps={{
startAdornment: (
<InputAdornment position="start">
<SvgIcon
component={SearchIcon}
inheritViewBox
fontWeight="bold"
fontSize="small"
sx={{
color: 'var(--color-border-main)',
'.MuiInputBase-root.Mui-focused &': { color: 'var(--color-text-primary)' },
}}
/>
</InputAdornment>
),
disableUnderline: true,
}}
fullWidth
size="small"
/>
<OrderByButton orderBy={orderBy} onOrderByChange={handleOrderByChange} />
</Box>
</Paper>
)
}

export default AccountListFilters
55 changes: 55 additions & 0 deletions src/features/myAccounts/components/AccountsHeader/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import ConnectWalletButton from '@/components/common/ConnectWallet/ConnectWalletButton'
import Track from '@/components/common/Track'
import { AppRoutes } from '@/config/routes'
import CreateButton from '@/features/myAccounts/components/CreateButton'
import css from '@/features/myAccounts/styles.module.css'
import useWallet from '@/hooks/wallets/useWallet'
import AddIcon from '@/public/images/common/add.svg'
import { OVERVIEW_EVENTS, OVERVIEW_LABELS } from '@/services/analytics'
import { Box, Button, Link, SvgIcon, Typography } from '@mui/material'
import classNames from 'classnames'
import { useRouter } from 'next/router'

const AccountsHeader = ({ isSidebar, onLinkClick }: { isSidebar: boolean; onLinkClick?: () => void }) => {
const router = useRouter()
const wallet = useWallet()

const isLoginPage = router.pathname === AppRoutes.welcome.accounts
const trackingLabel = isLoginPage ? OVERVIEW_LABELS.login_page : OVERVIEW_LABELS.sidebar

return (
<Box className={classNames(css.header, { [css.sidebarHeader]: isSidebar })}>
<Typography variant="h1" fontWeight={700} className={css.title}>
My accounts
</Typography>
<Box className={css.headerButtons}>
<Track {...OVERVIEW_EVENTS.ADD_TO_WATCHLIST} label={trackingLabel}>
<Link href={AppRoutes.newSafe.load}>
<Button
disableElevation
variant="outlined"
size="small"
onClick={onLinkClick}
startIcon={<SvgIcon component={AddIcon} inheritViewBox fontSize="small" />}
sx={{ height: '36px', width: '100%', px: 2 }}
>
Add
</Button>
</Link>
</Track>

{wallet ? (
<Track {...OVERVIEW_EVENTS.CREATE_NEW_SAFE} label={trackingLabel}>
<CreateButton isPrimary />
</Track>
) : (
<Box sx={{ '& button': { height: '36px' } }}>
<ConnectWalletButton small={true} />
</Box>
)}
</Box>
</Box>
)
}

export default AccountsHeader
113 changes: 113 additions & 0 deletions src/features/myAccounts/components/AccountsList/index.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import { OrderByOption } from '@/store/orderByPreferenceSlice'
import { safeItemBuilder } from '@/tests/builders/safeItem'
import { render } from '@/tests/test-utils'
import React from 'react'
import { screen } from '@testing-library/react'
import AccountsList from './index'
import FilteredSafes from '@/features/myAccounts/components/FilteredSafes'
import PinnedSafes from '@/features/myAccounts/components/PinnedSafes'
import AllSafes from '@/features/myAccounts/components/AllSafes'
import type { AllSafeItemsGrouped } from '@/features/myAccounts/hooks/useAllSafesGrouped'

// Mock child components to simplify tests, we just need to verify their rendering and props.
jest.mock('@/features/myAccounts/components/FilteredSafes', () => jest.fn(() => <div>FilteredSafes Component</div>))
jest.mock('@/features/myAccounts/components/PinnedSafes', () => jest.fn(() => <div>PinnedSafes Component</div>))
jest.mock('@/features/myAccounts/components/AllSafes', () => jest.fn(() => <div>AllSafes Component</div>))

describe('AccountsList', () => {
const baseSafes: AllSafeItemsGrouped = {
allMultiChainSafes: [
{ name: 'MultiChainSafe1', address: '0xA', isPinned: false, lastVisited: 0, safes: [safeItemBuilder().build()] },
{ name: 'MultiChainSafe2', address: '0xB', isPinned: false, lastVisited: 1, safes: [safeItemBuilder().build()] },
],
allSingleSafes: [
{ name: 'SingleSafe1', address: '0xC', isPinned: true, chainId: '3', isReadOnly: false, lastVisited: 2 },
],
}

afterEach(() => {
jest.clearAllMocks()
})

it('renders FilteredSafes when searchQuery is not empty', () => {
render(<AccountsList searchQuery="Multi" safes={baseSafes} isSidebar={true} />, {
initialReduxState: { orderByPreference: { orderBy: OrderByOption.NAME } },
})

expect(screen.getByText('FilteredSafes Component')).toBeInTheDocument()
expect(screen.queryByText('PinnedSafes Component')).not.toBeInTheDocument()
expect(screen.queryByText('AllSafes Component')).not.toBeInTheDocument()

// Check that FilteredSafes is called with the correct props
const filteredSafesMock = (FilteredSafes as jest.Mock).mock.calls[0][0]
expect(filteredSafesMock.searchQuery).toBe('Multi')
expect(filteredSafesMock.onLinkClick).toBeUndefined()

// The combined allSafes array sorted by name
const expectedSortedSafes = [
{ name: 'MultiChainSafe1', address: '0xA', isPinned: false, lastVisited: 0, safes: expect.anything() },
{ name: 'MultiChainSafe2', address: '0xB', isPinned: false, lastVisited: 1, safes: expect.anything() },
{ name: 'SingleSafe1', address: '0xC', isPinned: true, chainId: '3', isReadOnly: false, lastVisited: 2 },
]
expect(filteredSafesMock.allSafes).toEqual(expectedSortedSafes)
})

it('renders PinnedSafes and AllSafes when searchQuery is empty', () => {
render(<AccountsList searchQuery="" safes={baseSafes} isSidebar={false} />, {
initialReduxState: { orderByPreference: { orderBy: OrderByOption.NAME } },
})

expect(screen.queryByText('FilteredSafes Component')).not.toBeInTheDocument()
expect(screen.getByText('PinnedSafes Component')).toBeInTheDocument()
expect(screen.getByText('AllSafes Component')).toBeInTheDocument()

// Check that PinnedSafes and AllSafes received the correct props
const pinnedSafesMock = (PinnedSafes as jest.Mock).mock.calls[0][0]
const allSafesMock = (AllSafes as jest.Mock).mock.calls[0][0]

// Sorted array as in the previous test
const expectedSortedSafes = [
{ name: 'MultiChainSafe1', address: '0xA', isPinned: false, lastVisited: 0, safes: expect.anything() },
{ name: 'MultiChainSafe2', address: '0xB', isPinned: false, lastVisited: 1, safes: expect.anything() },
{ name: 'SingleSafe1', address: '0xC', isPinned: true, chainId: '3', isReadOnly: false, lastVisited: 2 },
]

expect(pinnedSafesMock.allSafes).toEqual(expectedSortedSafes)
expect(allSafesMock.allSafes).toEqual(expectedSortedSafes)
expect(allSafesMock.isSidebar).toBe(false)
})

it('sorts by lastVisited', () => {
render(<AccountsList searchQuery="" safes={baseSafes} isSidebar={false} />, {
initialReduxState: { orderByPreference: { orderBy: OrderByOption.LAST_VISITED } },
})

expect(screen.queryByText('FilteredSafes Component')).not.toBeInTheDocument()
expect(screen.getByText('PinnedSafes Component')).toBeInTheDocument()
expect(screen.getByText('AllSafes Component')).toBeInTheDocument()

// Check that PinnedSafes and AllSafes received the correct props
const pinnedSafesMock = (PinnedSafes as jest.Mock).mock.calls[0][0]
const allSafesMock = (AllSafes as jest.Mock).mock.calls[0][0]

const expectedSortedSafes = [
{ name: 'SingleSafe1', address: '0xC', isPinned: true, chainId: '3', isReadOnly: false, lastVisited: 2 },
{ name: 'MultiChainSafe2', address: '0xB', isPinned: false, lastVisited: 1, safes: expect.anything() },
{ name: 'MultiChainSafe1', address: '0xA', isPinned: false, lastVisited: 0, safes: expect.anything() },
]

expect(pinnedSafesMock.allSafes).toEqual(expectedSortedSafes)
expect(allSafesMock.allSafes).toEqual(expectedSortedSafes)
})

it('passes onLinkClick prop down to children', () => {
const onLinkClickFn = jest.fn()

render(<AccountsList searchQuery="" safes={baseSafes} onLinkClick={onLinkClickFn} isSidebar={true} />)

const pinnedSafesMock = (PinnedSafes as jest.Mock).mock.calls[0][0]
const allSafesMock = (AllSafes as jest.Mock).mock.calls[0][0]
expect(pinnedSafesMock.onLinkClick).toBe(onLinkClickFn)
expect(allSafesMock.onLinkClick).toBe(onLinkClickFn)
})
})
41 changes: 41 additions & 0 deletions src/features/myAccounts/components/AccountsList/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import FilteredSafes from '@/features/myAccounts/components/FilteredSafes'
import PinnedSafes from '@/features/myAccounts/components/PinnedSafes'
import type { AllSafeItems, AllSafeItemsGrouped } from '@/features/myAccounts/hooks/useAllSafesGrouped'
import AllSafes from '@/features/myAccounts/components/AllSafes'
import { getComparator } from '@/features/myAccounts/utils/utils'
import { useAppSelector } from '@/store'
import { selectOrderByPreference } from '@/store/orderByPreferenceSlice'
import { useMemo } from 'react'

const AccountsList = ({
searchQuery,
safes,
onLinkClick,
isSidebar,
}: {
searchQuery: string
safes: AllSafeItemsGrouped
onLinkClick?: () => void
isSidebar: boolean
}) => {
const { orderBy } = useAppSelector(selectOrderByPreference)
const sortComparator = getComparator(orderBy)

const allSafes = useMemo<AllSafeItems>(
() => [...(safes.allMultiChainSafes ?? []), ...(safes.allSingleSafes ?? [])].sort(sortComparator),
[safes.allMultiChainSafes, safes.allSingleSafes, sortComparator],
)

if (searchQuery) {
return <FilteredSafes searchQuery={searchQuery} allSafes={allSafes} onLinkClick={onLinkClick} />
}

return (
<>
<PinnedSafes allSafes={allSafes} onLinkClick={onLinkClick} />
<AllSafes allSafes={allSafes} onLinkClick={onLinkClick} isSidebar={isSidebar} />
</>
)
}

export default AccountsList
83 changes: 83 additions & 0 deletions src/features/myAccounts/components/AllSafes/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import ConnectWalletButton from '@/components/common/ConnectWallet/ConnectWalletButton'
import Track from '@/components/common/Track'
import { AppRoutes } from '@/config/routes'
import SafesList from '@/features/myAccounts/components/SafesList'
import type { AllSafeItems } from '@/features/myAccounts/hooks/useAllSafesGrouped'
import css from '@/features/myAccounts/styles.module.css'
import useWallet from '@/hooks/wallets/useWallet'
import { OVERVIEW_EVENTS, OVERVIEW_LABELS } from '@/services/analytics'
import ExpandMoreIcon from '@mui/icons-material/ExpandMore'
import { Accordion, AccordionDetails, AccordionSummary, Box, Typography } from '@mui/material'
import { useRouter } from 'next/router'

const AllSafes = ({
allSafes,
onLinkClick,
isSidebar,
}: {
allSafes: AllSafeItems
onLinkClick?: () => void
isSidebar: boolean
}) => {
const wallet = useWallet()
const router = useRouter()

const isLoginPage = router.pathname === AppRoutes.welcome.accounts
const trackingLabel = isLoginPage ? OVERVIEW_LABELS.login_page : OVERVIEW_LABELS.sidebar

return (
<Accordion sx={{ border: 'none' }} defaultExpanded={!isSidebar} slotProps={{ transition: { unmountOnExit: true } }}>
<AccordionSummary
data-testid="expand-safes-list"
expandIcon={<ExpandMoreIcon sx={{ '& path': { fill: 'var(--color-text-secondary)' } }} />}
sx={{
padding: 0,
'& .MuiAccordionSummary-content': { margin: '0 !important', mb: 1, flexGrow: 0 },
}}
>
<div className={css.listHeader}>
<Typography variant="h5" fontWeight={700}>
Accounts
{allSafes && allSafes.length > 0 && (
<Typography component="span" color="text.secondary" fontSize="inherit" fontWeight="normal" mr={1}>
{' '}
({allSafes.length})
</Typography>
)}
</Typography>
</div>
</AccordionSummary>
<AccordionDetails data-testid="accounts-list" sx={{ padding: 0 }}>
{allSafes.length > 0 ? (
<Box mt={1}>
<SafesList safes={allSafes} onLinkClick={onLinkClick} />
</Box>
) : (
<Typography
data-testid="empty-account-list"
component="div"
variant="body2"
color="text.secondary"
textAlign="center"
py={3}
mx="auto"
width={250}
>
{!wallet ? (
<>
<Box mb={2}>Connect a wallet to view your Safe Accounts or to create a new one</Box>
<Track {...OVERVIEW_EVENTS.OPEN_ONBOARD} label={trackingLabel}>
<ConnectWalletButton text="Connect a wallet" contained />
</Track>
</>
) : (
"You don't have any safes yet"
)}
</Typography>
)}
</AccordionDetails>
</Accordion>
)
}

export default AllSafes
Loading

0 comments on commit 011e808

Please sign in to comment.