Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

refactor: Split up MyAccounts component #4671

Merged
merged 9 commits into from
Dec 19, 2024
60 changes: 60 additions & 0 deletions src/features/myAccounts/components/AccountsFilter/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 AccountsFilter = ({ 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} />
Copy link
Contributor

Choose a reason for hiding this comment

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

Took me a minute to find the OrderByButton component here. To me AccountsFilter, suggests it only contains the search. Could we rename to reflect that it has both search and sort? AccountListFilters or something

</Box>
</Paper>
)
}

export default AccountsFilter
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
Loading