mirror of
https://github.com/luxfi/safe-wallet.git
synced 2026-07-26 22:53:37 +00:00
feat(web): redesign accounts page (#7678)
* feat(web): redesign accounts page * feat(web): add unit tests and use isLoading instead of isFetching for the chainsConfigV2
This commit is contained in:
@@ -63,11 +63,12 @@ Organize page object files in clear sections with this order:
|
||||
### Function Rules
|
||||
|
||||
- **Action functions** (`click*`, `open*`, `expand*`, `type*`, `visit*`): perform one user action AND wait for the result to be ready. An action that opens a popover must wait for the popover to appear. An action that navigates must wait for the target page to load. This prevents flaky tests where the next step runs before the UI has settled:
|
||||
|
||||
```js
|
||||
// ✅ Good: action waits for result
|
||||
export function clickOnExpandWalletBtn() {
|
||||
cy.get(expandWalletBtn).should('be.visible').click()
|
||||
cy.get(sentinelStart).next().should('exist') // wait for popover
|
||||
cy.get(sentinelStart).next().should('exist') // wait for popover
|
||||
}
|
||||
|
||||
// ❌ Bad: action with no wait — next step may fail
|
||||
@@ -75,12 +76,14 @@ Organize page object files in clear sections with this order:
|
||||
cy.get(expandWalletBtn).click()
|
||||
}
|
||||
```
|
||||
|
||||
- **Verify functions** (`verify*`): assert state only, no user actions
|
||||
- Functions used only within the page object: no `export`
|
||||
- Functions used by test files: `export`
|
||||
- General functions (3+ page files): put in `main.page.js`
|
||||
- Page-specific functions: put in that page's `.pages.js`
|
||||
- **Wallet/navigation functions belong in `navigation.page.js`** — not in feature page objects. Feature page objects import and call them. When the same action has different UI across contexts (e.g. legacy vs spaces wallet button), create separate functions in `navigation.page.js` rather than duplicating selectors in feature page objects:
|
||||
|
||||
```js
|
||||
// navigation.page.js — both wallet expand variants
|
||||
export function clickOnWalletExpandMoreIcon() { ... } // legacy
|
||||
@@ -92,6 +95,7 @@ Organize page object files in clear sections with this order:
|
||||
navigation.clickOnDisconnectBtn()
|
||||
}
|
||||
```
|
||||
|
||||
- **Prefer one parameterized function over multiple similar functions** — use a type/variant parameter with a selector lookup table when the same verification applies to different component variants:
|
||||
```js
|
||||
const selectors = {
|
||||
@@ -128,6 +132,7 @@ export function createSpaceViaOnboardingWithSkip(name) {
|
||||
```
|
||||
|
||||
Rules for composite flows:
|
||||
|
||||
- Each step is a private function with a descriptive name
|
||||
- The exported function reads as a plain-language sequence
|
||||
- Step functions handle their own waits (URL checks, element visibility)
|
||||
@@ -146,14 +151,14 @@ Rules for composite flows:
|
||||
|
||||
### Function Naming Convention
|
||||
|
||||
| Prefix | Purpose | Example |
|
||||
| ----------- | -------------------------------- | -------------------------------------- |
|
||||
| `click*` | Click an element | `clickAccountItemByIndex(index)` |
|
||||
| `open*` | Open a dropdown/modal/panel | `openSpaceSelector()` |
|
||||
| `expand*` | Expand a collapsible section | `expandAccountRow(index)` |
|
||||
| `type*` | Type into an input | `typeSpaceName(name)` |
|
||||
| `visit*` | Navigate to a URL | `visitSpaceDashboard(id)` |
|
||||
| `verify*` | Assert state (visibility, URL) | `verifySpaceSidebarItemsVisible()` |
|
||||
| Prefix | Purpose | Example |
|
||||
| --------- | ------------------------------ | ---------------------------------- |
|
||||
| `click*` | Click an element | `clickAccountItemByIndex(index)` |
|
||||
| `open*` | Open a dropdown/modal/panel | `openSpaceSelector()` |
|
||||
| `expand*` | Expand a collapsible section | `expandAccountRow(index)` |
|
||||
| `type*` | Type into an input | `typeSpaceName(name)` |
|
||||
| `visit*` | Navigate to a URL | `visitSpaceDashboard(id)` |
|
||||
| `verify*` | Assert state (visibility, URL) | `verifySpaceSidebarItemsVisible()` |
|
||||
|
||||
## Phase 3: Write Tests
|
||||
|
||||
@@ -208,6 +213,7 @@ When a component needs a `data-testid` for E2E tests:
|
||||
Keep test data and UI selectors in separate places — never mix them.
|
||||
|
||||
### Fixture files (`cypress/fixtures/`) — test data only
|
||||
|
||||
- Space IDs, names
|
||||
- Account addresses, names, chain info, row indices
|
||||
- Counts (row counts, sub-accounts)
|
||||
@@ -215,6 +221,7 @@ Keep test data and UI selectors in separate places — never mix them.
|
||||
- Any data that varies per environment or test scenario
|
||||
|
||||
### Page object files (`e2e/pages/`) — UI selectors and labels only
|
||||
|
||||
- `data-testid` selectors
|
||||
- `aria-label` selectors
|
||||
- Static UI text labels ("Getting started", "Add member", etc.)
|
||||
@@ -222,6 +229,7 @@ Keep test data and UI selectors in separate places — never mix them.
|
||||
- Functions (actions, verifiers)
|
||||
|
||||
### Rules
|
||||
|
||||
- **Never duplicate fixture data in page objects** — if an address or name is in a fixture, don't also define it as a `const` in the page object
|
||||
- **Never put selectors in fixtures** — selectors belong in page objects
|
||||
- **Never re-export fixtures from page objects** — tests should import fixtures directly (`import staticSpaces from '../../fixtures/spaces/staticSpaces.js'`)
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
import { Button } from '@mui/material'
|
||||
import useConnectWallet from '@/components/common/ConnectWallet/useConnectWallet'
|
||||
import { cn } from '@/utils/cn'
|
||||
|
||||
const ConnectWalletButton = ({
|
||||
onConnect,
|
||||
contained = true,
|
||||
small = false,
|
||||
text,
|
||||
className,
|
||||
}: {
|
||||
onConnect?: () => void
|
||||
contained?: boolean
|
||||
small?: boolean
|
||||
text?: string
|
||||
className?: string
|
||||
}): React.ReactElement => {
|
||||
const connectWallet = useConnectWallet()
|
||||
|
||||
@@ -26,7 +29,7 @@ const ConnectWalletButton = ({
|
||||
variant={contained ? 'contained' : 'text'}
|
||||
size={small ? 'small' : 'medium'}
|
||||
disableElevation
|
||||
fullWidth
|
||||
className={cn(className)}
|
||||
sx={{ fontSize: small ? ['12px', '13px'] : '' }}
|
||||
>
|
||||
{text || 'Connect'}
|
||||
|
||||
@@ -3,32 +3,31 @@ import Track from '@/components/common/Track'
|
||||
import { AppRoutes } from '@/config/routes'
|
||||
import AccountsNavigation from '../AccountsNavigation'
|
||||
import CreateButton from '../CreateButton'
|
||||
import css from '../../styles.module.css'
|
||||
import { useHasFeature } from '@/hooks/useChains'
|
||||
import useWallet from '@/hooks/wallets/useWallet'
|
||||
import AddIcon from '@/public/images/common/add.svg'
|
||||
import { OVERVIEW_EVENTS, OVERVIEW_LABELS } from '@/services/analytics'
|
||||
import { FEATURES } from '@safe-global/utils/utils/chains'
|
||||
import { Box, Button, Link, SvgIcon, Typography } from '@mui/material'
|
||||
import classNames from 'classnames'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Typography } from '@/components/ui/typography'
|
||||
import { cn } from '@/utils/cn'
|
||||
import NextLink from 'next/link'
|
||||
import { useRouter } from 'next/router'
|
||||
|
||||
const AddSafeButton = ({ trackingLabel, onLinkClick }: { trackingLabel: string; onLinkClick?: () => void }) => {
|
||||
return (
|
||||
<Track {...OVERVIEW_EVENTS.ADD_TO_WATCHLIST} label={trackingLabel}>
|
||||
<Link href={AppRoutes.newSafe.load}>
|
||||
<Button
|
||||
data-testid="add-safe-button"
|
||||
disableElevation
|
||||
variant="outlined"
|
||||
size="small"
|
||||
onClick={onLinkClick}
|
||||
startIcon={<SvgIcon component={AddIcon} inheritViewBox fontSize="small" />}
|
||||
sx={{ height: '36px', width: '100%', px: 2 }}
|
||||
>
|
||||
<Box mt="1px">Add</Box>
|
||||
</Button>
|
||||
</Link>
|
||||
<Button
|
||||
data-testid="add-safe-button"
|
||||
variant="outline"
|
||||
size="lg"
|
||||
onClick={onLinkClick}
|
||||
className="w-full rounded-lg h-full text-base"
|
||||
render={<NextLink href={AppRoutes.newSafe.load} />}
|
||||
>
|
||||
<AddIcon color="currentColor" className="fill-primary" />
|
||||
Add account
|
||||
</Button>
|
||||
</Track>
|
||||
)
|
||||
}
|
||||
@@ -41,29 +40,27 @@ const AccountsHeader = ({ isSidebar, onLinkClick }: { isSidebar: boolean; onLink
|
||||
const trackingLabel = isLoginPage ? OVERVIEW_LABELS.login_page : OVERVIEW_LABELS.sidebar
|
||||
|
||||
return (
|
||||
<Box className={classNames(css.header, { [css.sidebarHeader]: isSidebar })}>
|
||||
<div
|
||||
className={cn('flex justify-between gap-4 py-6 max-[599px]:flex-col', isSidebar && 'border-border border-b px-4')}
|
||||
>
|
||||
{isSidebar || !isSpacesFeatureEnabled ? (
|
||||
<Typography variant="h1" fontWeight={700} className={css.title}>
|
||||
Accounts
|
||||
</Typography>
|
||||
<Typography variant={isSidebar ? 'h3' : 'h1'}>Accounts</Typography>
|
||||
) : (
|
||||
<AccountsNavigation />
|
||||
)}
|
||||
|
||||
<Box className={css.headerButtons}>
|
||||
<div className="flex flex-row gap-2 max-[599px]:[&>span]:flex-1">
|
||||
<AddSafeButton trackingLabel={trackingLabel} onLinkClick={onLinkClick} />
|
||||
|
||||
{wallet ? (
|
||||
<Track {...OVERVIEW_EVENTS.CREATE_NEW_SAFE} label={trackingLabel}>
|
||||
<CreateButton isPrimary />
|
||||
<CreateButton isPrimary className="h-full text-base" />
|
||||
</Track>
|
||||
) : (
|
||||
<Box sx={{ '& button': { height: '36px' } }}>
|
||||
<ConnectWalletButton small={true} />
|
||||
</Box>
|
||||
<ConnectWalletButton small={true} className="h-full rounded-lg text-base" />
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,47 +1,80 @@
|
||||
import { useState } from 'react'
|
||||
import { AppRoutes } from '@/config/routes'
|
||||
import css from '../../styles.module.css'
|
||||
import css from './styles.module.css'
|
||||
import { SPACE_EVENTS, SPACE_LABELS } from '@/services/analytics/events/spaces'
|
||||
import { Chip, Stack, Typography } from '@mui/material'
|
||||
import { Chip } from '@mui/material'
|
||||
import classNames from 'classnames'
|
||||
import { useRouter } from 'next/router'
|
||||
import Link from 'next/link'
|
||||
import { motion } from 'motion/react'
|
||||
import { trackEvent } from '@/services/analytics'
|
||||
import type { AnalyticsEvent } from '@/services/analytics/types'
|
||||
|
||||
type Item = {
|
||||
label: string
|
||||
url: string
|
||||
trackEvent?: AnalyticsEvent
|
||||
beta?: boolean
|
||||
}
|
||||
|
||||
type NavItems = Item[]
|
||||
|
||||
const navItems: NavItems = [
|
||||
{
|
||||
label: 'Accounts',
|
||||
url: AppRoutes.welcome.accounts,
|
||||
},
|
||||
{
|
||||
label: 'Spaces',
|
||||
url: AppRoutes.welcome.spaces,
|
||||
trackEvent: { ...SPACE_EVENTS.OPEN_SPACE_LIST_PAGE, label: SPACE_LABELS.accounts_page },
|
||||
beta: true,
|
||||
},
|
||||
]
|
||||
|
||||
const INDICATOR_LAYOUT_ID = 'accounts-nav-indicator'
|
||||
|
||||
const AccountsNavigation = () => {
|
||||
const router = useRouter()
|
||||
const [hoveredUrl, setHoveredUrl] = useState<string | null>(null)
|
||||
|
||||
const isActiveNavigation = (pathname: string) => {
|
||||
return router.pathname === pathname
|
||||
}
|
||||
|
||||
const trackSpacesClick = () => {
|
||||
if (!isActiveNavigation(AppRoutes.welcome.spaces)) {
|
||||
trackEvent({ ...SPACE_EVENTS.OPEN_SPACE_LIST_PAGE, label: SPACE_LABELS.accounts_page })
|
||||
const activeUrl = navItems.find((item) => isActiveNavigation(item.url))?.url
|
||||
const highlightedUrl = hoveredUrl ?? activeUrl
|
||||
|
||||
const handleClick = (item: Item) => () => {
|
||||
if (item.trackEvent && !isActiveNavigation(item.url)) {
|
||||
trackEvent(item.trackEvent)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack direction="row" gap={2} flexWrap="wrap">
|
||||
<Typography variant="h1" fontWeight={700} className={css.title}>
|
||||
<nav className={css.nav} onMouseLeave={() => setHoveredUrl(null)}>
|
||||
{navItems.map((item) => (
|
||||
<Link
|
||||
href={AppRoutes.welcome.accounts}
|
||||
className={classNames(css.link, { [css.active]: isActiveNavigation(AppRoutes.welcome.accounts) })}
|
||||
key={item.url}
|
||||
href={item.url}
|
||||
onClick={handleClick(item)}
|
||||
onMouseEnter={() => setHoveredUrl(item.url)}
|
||||
className={classNames(css.tab, { [css.active]: isActiveNavigation(item.url) })}
|
||||
>
|
||||
Accounts
|
||||
{highlightedUrl === item.url && (
|
||||
<motion.div
|
||||
layoutId={INDICATOR_LAYOUT_ID}
|
||||
className={css.indicator}
|
||||
transition={{ type: 'spring', duration: 1, stiffness: 400, damping: 32 }}
|
||||
/>
|
||||
)}
|
||||
<span className={css.label}>
|
||||
{item.label}
|
||||
{item.beta && <Chip label="Beta" size="small" sx={{ ml: 1, fontWeight: 'normal', borderRadius: '4px' }} />}
|
||||
</span>
|
||||
</Link>
|
||||
</Typography>
|
||||
|
||||
<Typography variant="h1" fontWeight={700} className={css.title}>
|
||||
<Link
|
||||
onClick={trackSpacesClick}
|
||||
href={AppRoutes.welcome.spaces}
|
||||
className={classNames(css.link, { [css.active]: isActiveNavigation(AppRoutes.welcome.spaces) })}
|
||||
>
|
||||
Spaces
|
||||
<Chip label="Beta" size="small" sx={{ ml: 1, fontWeight: 'normal', borderRadius: '4px' }} />
|
||||
</Link>
|
||||
</Typography>
|
||||
</Stack>
|
||||
))}
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
.nav {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px;
|
||||
background: var(--color-background-paper);
|
||||
border-radius: 16px;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.tab {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 36px;
|
||||
padding: 8px 24px;
|
||||
border-radius: 16px;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
line-height: 24px;
|
||||
color: var(--color-text-secondary);
|
||||
text-decoration: none;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
.tab:hover {
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.active {
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.indicator {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: var(--color-background-secondary);
|
||||
border-radius: 16px;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.label {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
@@ -1,16 +1,18 @@
|
||||
import { Button } from '@mui/material'
|
||||
import Link from 'next/link'
|
||||
import { AppRoutes } from '@/config/routes'
|
||||
import { cn } from '@/utils/cn'
|
||||
|
||||
const buttonSx = { width: ['100%', 'auto'], height: '36px', px: 2 }
|
||||
|
||||
const CreateButton = ({ isPrimary }: { isPrimary: boolean }) => {
|
||||
const CreateButton = ({ isPrimary, className }: { isPrimary: boolean; className?: string }) => {
|
||||
return (
|
||||
<Link href={AppRoutes.newSafe.create} passHref legacyBehavior>
|
||||
<Button
|
||||
data-testid="create-safe-btn"
|
||||
disableElevation
|
||||
size="small"
|
||||
className={cn('rounded-lg font-medium', className)}
|
||||
variant={isPrimary ? 'contained' : 'outlined'}
|
||||
sx={buttonSx}
|
||||
component="a"
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { useState } from 'react'
|
||||
import AccountsHeader from '../AccountsHeader'
|
||||
import AccountsList from './components/AccountsList'
|
||||
import AccountsSearch from './components/AccountsSearch'
|
||||
import madProps from '@/utils/mad-props'
|
||||
import css from '../../styles.module.css'
|
||||
import useWallet from '@/hooks/wallets/useWallet'
|
||||
import { type AllSafeItemsGrouped, useAllSafesGrouped } from '@/hooks/safes'
|
||||
import classNames from 'classnames'
|
||||
import useTrackSafesCount from '../../hooks/useTrackedSafesCount'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { DataWidget } from '../DataWidget'
|
||||
|
||||
type MyAccountsProps = {
|
||||
safes: AllSafeItemsGrouped
|
||||
isSidebar?: boolean
|
||||
onLinkClick?: () => void
|
||||
}
|
||||
|
||||
const MyAccountsV2 = ({ safes, onLinkClick, isSidebar = false }: MyAccountsProps) => {
|
||||
const wallet = useWallet()
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
useTrackSafesCount(safes, wallet)
|
||||
|
||||
return (
|
||||
<div data-testid="sidebar-safe-container" className={css.container}>
|
||||
<div className={classNames(css.myAccounts, { [css.sidebarAccounts]: isSidebar })}>
|
||||
<AccountsHeader isSidebar={isSidebar} onLinkClick={onLinkClick} />
|
||||
|
||||
<div className="bg-background/50 text-card-foreground overflow-hidden rounded-xl">
|
||||
<AccountsSearch setSearchQuery={setSearchQuery} />
|
||||
|
||||
{isSidebar && <Separator />}
|
||||
|
||||
<div className={classNames(css.safeList)}>
|
||||
<AccountsList searchQuery={searchQuery} safes={safes} isSidebar={isSidebar} onLinkClick={onLinkClick} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DataWidget />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default madProps(MyAccountsV2, {
|
||||
safes: useAllSafesGrouped,
|
||||
})
|
||||
+212
@@ -0,0 +1,212 @@
|
||||
import { useState } from 'react'
|
||||
import NextLink from 'next/link'
|
||||
import Identicon from '@/components/common/Identicon'
|
||||
import FiatBalance from '@/features/spaces/components/SelectSafesOnboarding/components/FiatBalance'
|
||||
import MultiAccountContextMenu from '@/components/sidebar/SafeListContextMenu/MultiAccountContextMenu'
|
||||
import { AccountItem as BaseAccountItem } from '../../../AccountItem'
|
||||
import { AddNetworkButton } from '../../../AddNetworkButton'
|
||||
import { useMultiAccountItemData } from '../../../../hooks/useMultiAccountItemData'
|
||||
import { useSafeItemData } from '../../../../hooks/useSafeItemData'
|
||||
import { OVERVIEW_EVENTS, OVERVIEW_LABELS, trackEvent } from '@/services/analytics'
|
||||
import type { MultiChainSafeItem, SafeItem } from '@/hooks/safes'
|
||||
import type { SafeOverview } from '@safe-global/store/gateway/AUTO_GENERATED/safes'
|
||||
import { sameAddress } from '@safe-global/utils/utils/addresses'
|
||||
import { shortenAddress } from '@safe-global/utils/utils/formatters'
|
||||
import { Typography } from '@/components/ui/typography'
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import Track from '@/components/common/Track'
|
||||
import { cn } from '@/utils/cn'
|
||||
|
||||
const SubItem = ({
|
||||
safeItem,
|
||||
safeOverview,
|
||||
onLinkClick,
|
||||
}: {
|
||||
safeItem: SafeItem
|
||||
safeOverview?: SafeOverview
|
||||
onLinkClick?: () => void
|
||||
}) => {
|
||||
const { chain, href, threshold, owners, elementRef, trackingLabel, isCurrentSafe, undeployedSafe, isActivating } =
|
||||
useSafeItemData(safeItem, { safeOverview })
|
||||
|
||||
const hasQueuedItems =
|
||||
!safeItem.isReadOnly &&
|
||||
safeOverview &&
|
||||
((safeOverview.queued ?? 0) > 0 || (safeOverview.awaitingConfirmation ?? 0) > 0)
|
||||
|
||||
return (
|
||||
<Track {...OVERVIEW_EVENTS.OPEN_SAFE} label={trackingLabel}>
|
||||
<NextLink
|
||||
ref={elementRef as React.Ref<HTMLAnchorElement>}
|
||||
href={href}
|
||||
onClick={onLinkClick}
|
||||
className={cn(
|
||||
'hover:bg-muted/40 flex w-full items-center justify-between gap-4 rounded-2xl py-3 pl-4 pr-6 transition-colors',
|
||||
isCurrentSafe && 'bg-muted/50',
|
||||
)}
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-3">
|
||||
<BaseAccountItem.Icon
|
||||
address={safeItem.address}
|
||||
chainId={safeItem.chainId}
|
||||
threshold={threshold}
|
||||
owners={owners.length}
|
||||
isMultiChainItem
|
||||
/>
|
||||
<div className="flex min-w-0 flex-col gap-1">
|
||||
<Typography variant="paragraph-small-medium" className="text-foreground truncate">
|
||||
{chain?.chainName ?? shortenAddress(safeItem.address)}
|
||||
</Typography>
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
<BaseAccountItem.StatusChip
|
||||
isActivating={isActivating}
|
||||
isReadOnly={safeItem.isReadOnly}
|
||||
undeployedSafe={!!undeployedSafe}
|
||||
/>
|
||||
{hasQueuedItems && (
|
||||
<BaseAccountItem.QueueActions
|
||||
safeAddress={safeOverview.address.value}
|
||||
chainShortName={chain?.shortName || ''}
|
||||
queued={safeOverview.queued ?? 0}
|
||||
awaitingConfirmation={safeOverview.awaitingConfirmation ?? 0}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center justify-end">
|
||||
{safeOverview || undeployedSafe ? (
|
||||
<FiatBalance value={safeOverview?.fiatTotal} />
|
||||
) : (
|
||||
<Skeleton className="h-4 w-16" />
|
||||
)}
|
||||
</div>
|
||||
</NextLink>
|
||||
</Track>
|
||||
)
|
||||
}
|
||||
|
||||
type MultiAccountItemProps = {
|
||||
multiSafeAccountItem: MultiChainSafeItem
|
||||
onLinkClick?: () => void
|
||||
isSpaceSafe?: boolean
|
||||
}
|
||||
|
||||
const MultiAccountItem = ({ multiSafeAccountItem, onLinkClick, isSpaceSafe = false }: MultiAccountItemProps) => {
|
||||
const {
|
||||
address,
|
||||
name,
|
||||
sortedSafes,
|
||||
safeOverviews,
|
||||
totalFiatValue,
|
||||
hasReplayableSafe,
|
||||
isCurrentSafe,
|
||||
isReadOnly,
|
||||
isWelcomePage,
|
||||
deployedChainIds,
|
||||
isSpaceRoute,
|
||||
} = useMultiAccountItemData(multiSafeAccountItem)
|
||||
|
||||
const [expanded, setExpanded] = useState(isCurrentSafe)
|
||||
const trackingLabel = isWelcomePage ? OVERVIEW_LABELS.login_page : OVERVIEW_LABELS.sidebar
|
||||
|
||||
const toggleExpand = () => {
|
||||
setExpanded((prev) => {
|
||||
if (!prev && !isSpaceRoute) {
|
||||
trackEvent({ ...OVERVIEW_EVENTS.EXPAND_MULTI_SAFE, label: trackingLabel })
|
||||
}
|
||||
return !prev
|
||||
})
|
||||
}
|
||||
|
||||
const displayName = multiSafeAccountItem.name || name || shortenAddress(address)
|
||||
|
||||
return (
|
||||
<Collapsible open={expanded} onOpenChange={toggleExpand} data-testid="safe-list-item">
|
||||
<div
|
||||
className={cn(
|
||||
'bg-card flex w-full flex-col rounded-3xl border-2 transition-colors',
|
||||
isCurrentSafe ? 'border-primary/30' : 'border-card',
|
||||
)}
|
||||
>
|
||||
<CollapsibleTrigger
|
||||
render={
|
||||
<button
|
||||
type="button"
|
||||
data-testid="multichain-item-summary"
|
||||
className="hover:bg-muted/50 flex w-full items-center justify-between gap-4 rounded-3xl py-4 pl-4 pr-6 text-left transition-colors cursor-pointer"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-3">
|
||||
<span className="inline-flex shrink-0">
|
||||
<Identicon address={address} />
|
||||
</span>
|
||||
<div className="flex min-w-0 flex-col gap-1" data-testid="group-address">
|
||||
<Typography variant="paragraph-medium" className="text-foreground truncate">
|
||||
{displayName}
|
||||
</Typography>
|
||||
<Typography variant="paragraph-mini" color="muted" className="truncate">
|
||||
{shortenAddress(address)}
|
||||
</Typography>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center">
|
||||
<BaseAccountItem.ChainBadge safes={sortedSafes} />
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center justify-end" data-testid="group-balance">
|
||||
<FiatBalance value={totalFiatValue?.toString()} />
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-1" onClick={(e) => e.stopPropagation()}>
|
||||
{!isSpaceSafe && (
|
||||
<BaseAccountItem.PinButton safeItems={sortedSafes} safeOverviews={safeOverviews} name={name} />
|
||||
)}
|
||||
{isSpaceSafe ? null : (
|
||||
<MultiAccountContextMenu
|
||||
name={multiSafeAccountItem.name ?? ''}
|
||||
address={address}
|
||||
chainIds={deployedChainIds}
|
||||
addNetwork={hasReplayableSafe}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</CollapsibleTrigger>
|
||||
|
||||
<CollapsibleContent>
|
||||
<div data-testid="subacounts-container" className="flex flex-col gap-1 px-3 pb-3">
|
||||
{sortedSafes.map((safeItem) => {
|
||||
const overview = safeOverviews?.find(
|
||||
(o) => o.chainId === safeItem.chainId && sameAddress(o.address.value, safeItem.address),
|
||||
)
|
||||
return (
|
||||
<SubItem
|
||||
key={`${safeItem.chainId}:${safeItem.address}`}
|
||||
safeItem={safeItem}
|
||||
safeOverview={overview}
|
||||
onLinkClick={onLinkClick}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
|
||||
{!isReadOnly && hasReplayableSafe && !isSpaceSafe && (
|
||||
<div className="border-border mt-1 flex items-center justify-center border-t pt-2">
|
||||
<AddNetworkButton
|
||||
currentName={multiSafeAccountItem.name ?? ''}
|
||||
safeAddress={address}
|
||||
deployedChains={sortedSafes.map((s) => s.chainId)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</div>
|
||||
</Collapsible>
|
||||
)
|
||||
}
|
||||
|
||||
export default MultiAccountItem
|
||||
+112
@@ -0,0 +1,112 @@
|
||||
import NextLink from 'next/link'
|
||||
import Identicon from '@/components/common/Identicon'
|
||||
import Track from '@/components/common/Track'
|
||||
import FiatBalance from '@/features/spaces/components/SelectSafesOnboarding/components/FiatBalance'
|
||||
import { AccountItem as BaseAccountItem } from '../../../AccountItem'
|
||||
import { useSafeItemData } from '../../../../hooks/useSafeItemData'
|
||||
import { OVERVIEW_EVENTS } from '@/services/analytics'
|
||||
import type { SafeItem } from '@/hooks/safes'
|
||||
import { shortenAddress } from '@safe-global/utils/utils/formatters'
|
||||
import { Typography } from '@/components/ui/typography'
|
||||
import { cn } from '@/utils/cn'
|
||||
|
||||
type SingleAccountItemProps = {
|
||||
safeItem: SafeItem
|
||||
onLinkClick?: () => void
|
||||
isSpaceSafe?: boolean
|
||||
}
|
||||
|
||||
const SingleAccountItem = ({ safeItem, onLinkClick, isSpaceSafe = false }: SingleAccountItemProps) => {
|
||||
const {
|
||||
chain,
|
||||
name,
|
||||
href,
|
||||
safeOverview,
|
||||
isCurrentSafe,
|
||||
isActivating,
|
||||
isReplayable,
|
||||
threshold,
|
||||
owners,
|
||||
undeployedSafe,
|
||||
elementRef,
|
||||
trackingLabel,
|
||||
} = useSafeItemData(safeItem, { isSpaceSafe })
|
||||
|
||||
const displayName = (isSpaceSafe ? safeItem.name : name) || shortenAddress(safeItem.address)
|
||||
|
||||
const hasQueuedItems =
|
||||
!safeItem.isReadOnly &&
|
||||
safeOverview &&
|
||||
((safeOverview.queued ?? 0) > 0 || (safeOverview.awaitingConfirmation ?? 0) > 0)
|
||||
|
||||
return (
|
||||
<Track {...OVERVIEW_EVENTS.OPEN_SAFE} label={trackingLabel}>
|
||||
<NextLink
|
||||
ref={elementRef as React.Ref<HTMLAnchorElement>}
|
||||
href={href}
|
||||
onClick={onLinkClick}
|
||||
data-testid="safe-list-item"
|
||||
className={cn(
|
||||
'bg-card hover:bg-muted/50 flex w-full items-center justify-between gap-4 rounded-3xl border-2 py-4 pl-4 pr-6 transition-colors',
|
||||
isCurrentSafe ? 'border-primary/30' : 'border-card',
|
||||
)}
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-3">
|
||||
<span className="inline-flex shrink-0">
|
||||
<Identicon address={safeItem.address} />
|
||||
</span>
|
||||
|
||||
<div className="flex min-w-0 flex-col gap-1">
|
||||
<Typography variant="paragraph-medium" className="text-foreground truncate">
|
||||
{displayName}
|
||||
</Typography>
|
||||
<Typography variant="paragraph-mini" color="muted" className="truncate">
|
||||
{shortenAddress(safeItem.address)}
|
||||
</Typography>
|
||||
|
||||
<div className="mt-1 flex flex-wrap items-center gap-1">
|
||||
<BaseAccountItem.StatusChip
|
||||
isActivating={isActivating}
|
||||
isReadOnly={safeItem.isReadOnly}
|
||||
undeployedSafe={!!undeployedSafe}
|
||||
/>
|
||||
{hasQueuedItems && (
|
||||
<BaseAccountItem.QueueActions
|
||||
safeAddress={safeOverview.address.value}
|
||||
chainShortName={chain?.shortName || ''}
|
||||
queued={safeOverview.queued ?? 0}
|
||||
awaitingConfirmation={safeOverview.awaitingConfirmation ?? 0}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center">
|
||||
<BaseAccountItem.ChainBadge chainId={safeItem.chainId} />
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center justify-end">
|
||||
<FiatBalance value={safeOverview?.fiatTotal} />
|
||||
</div>
|
||||
|
||||
{!isSpaceSafe && (
|
||||
<div className="flex shrink-0 items-center gap-1" onClick={(e) => e.stopPropagation()}>
|
||||
<BaseAccountItem.PinButton safeItem={safeItem} threshold={threshold} owners={owners} name={name} />
|
||||
<BaseAccountItem.ContextMenu
|
||||
address={safeItem.address}
|
||||
chainId={safeItem.chainId}
|
||||
name={name}
|
||||
isReplayable={isReplayable}
|
||||
undeployedSafe={!!undeployedSafe}
|
||||
hideNestedSafes
|
||||
onClose={onLinkClick}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</NextLink>
|
||||
</Track>
|
||||
)
|
||||
}
|
||||
|
||||
export default SingleAccountItem
|
||||
+430
@@ -0,0 +1,430 @@
|
||||
import { render, screen, fireEvent } from '@/tests/test-utils'
|
||||
import { OVERVIEW_EVENTS, OVERVIEW_LABELS, trackEvent } from '@/services/analytics'
|
||||
import type { MultiChainSafeItem, SafeItem } from '@/hooks/safes'
|
||||
import type { SafeOverview } from '@safe-global/store/gateway/AUTO_GENERATED/safes'
|
||||
import MultiAccountItem from '../MultiAccountItem'
|
||||
import { useMultiAccountItemData } from '@/features/myAccounts/hooks/useMultiAccountItemData'
|
||||
import { useSafeItemData } from '@/features/myAccounts/hooks/useSafeItemData'
|
||||
|
||||
jest.mock('@/services/analytics', () => ({
|
||||
...jest.requireActual('@/services/analytics'),
|
||||
trackEvent: jest.fn(),
|
||||
}))
|
||||
|
||||
jest.mock('@/features/myAccounts/hooks/useMultiAccountItemData')
|
||||
jest.mock('@/features/myAccounts/hooks/useSafeItemData')
|
||||
|
||||
jest.mock('@/components/common/Identicon', () => ({
|
||||
__esModule: true,
|
||||
default: ({ address }: { address: string }) => <div data-testid="identicon">{address}</div>,
|
||||
}))
|
||||
|
||||
jest.mock('@/features/spaces/components/SelectSafesOnboarding/components/FiatBalance', () => ({
|
||||
__esModule: true,
|
||||
default: ({ value }: { value?: string }) => <div data-testid="fiat-balance">{value ?? ''}</div>,
|
||||
}))
|
||||
|
||||
jest.mock('@/components/sidebar/SafeListContextMenu/MultiAccountContextMenu', () => ({
|
||||
__esModule: true,
|
||||
default: ({ name, address }: { name: string; address: string }) => (
|
||||
<div data-testid="multi-account-context-menu" data-name={name} data-address={address} />
|
||||
),
|
||||
}))
|
||||
|
||||
jest.mock('@/features/myAccounts/components/AddNetworkButton', () => ({
|
||||
AddNetworkButton: ({
|
||||
currentName,
|
||||
safeAddress,
|
||||
deployedChains,
|
||||
}: {
|
||||
currentName: string
|
||||
safeAddress: string
|
||||
deployedChains: string[]
|
||||
}) => (
|
||||
<div
|
||||
data-testid="add-network-button"
|
||||
data-name={currentName}
|
||||
data-address={safeAddress}
|
||||
data-chains={deployedChains.join(',')}
|
||||
/>
|
||||
),
|
||||
}))
|
||||
|
||||
jest.mock('@/features/myAccounts/components/AccountItem', () => ({
|
||||
AccountItem: {
|
||||
Icon: ({ address, chainId }: { address: string; chainId: string }) => (
|
||||
<div data-testid="sub-icon" data-address={address} data-chain={chainId} />
|
||||
),
|
||||
ChainBadge: ({ safes }: { safes: Array<{ chainId: string }> }) => (
|
||||
<div data-testid="chain-badge" data-chain-count={safes.length} />
|
||||
),
|
||||
PinButton: () => <div data-testid="pin-button" />,
|
||||
StatusChip: () => <div data-testid="status-chip" />,
|
||||
QueueActions: ({ queued, awaitingConfirmation }: { queued: number; awaitingConfirmation: number }) => (
|
||||
<div data-testid="queue-actions" data-queued={queued} data-awaiting={awaitingConfirmation} />
|
||||
),
|
||||
},
|
||||
}))
|
||||
|
||||
const mockedUseMultiAccountItemData = useMultiAccountItemData as jest.MockedFunction<typeof useMultiAccountItemData>
|
||||
const mockedUseSafeItemData = useSafeItemData as jest.MockedFunction<typeof useSafeItemData>
|
||||
|
||||
const buildSafeItem = (overrides: Partial<SafeItem> = {}): SafeItem => ({
|
||||
chainId: '1',
|
||||
address: '0x0000000000000000000000000000000000000001',
|
||||
isReadOnly: false,
|
||||
isPinned: false,
|
||||
lastVisited: 0,
|
||||
name: '',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const buildMultiChainSafeItem = (overrides: Partial<MultiChainSafeItem> = {}): MultiChainSafeItem => ({
|
||||
address: '0x1234567890abcdef1234567890abcdef12345678',
|
||||
name: 'Group Name',
|
||||
isPinned: false,
|
||||
lastVisited: 0,
|
||||
safes: [buildSafeItem({ chainId: '1' }), buildSafeItem({ chainId: '137' })],
|
||||
...overrides,
|
||||
})
|
||||
|
||||
type MultiAccountHookReturn = ReturnType<typeof useMultiAccountItemData>
|
||||
type SafeItemHookReturn = ReturnType<typeof useSafeItemData>
|
||||
|
||||
const buildMultiAccountHookReturn = (overrides: Partial<MultiAccountHookReturn> = {}): MultiAccountHookReturn => {
|
||||
const safes = [buildSafeItem({ chainId: '1' }), buildSafeItem({ chainId: '137' })]
|
||||
return {
|
||||
address: '0x1234567890abcdef1234567890abcdef12345678',
|
||||
name: 'Group Name',
|
||||
sortedSafes: safes,
|
||||
safeOverviews: undefined,
|
||||
sharedSetup: undefined,
|
||||
totalFiatValue: 100,
|
||||
hasReplayableSafe: false,
|
||||
isPinned: false,
|
||||
isCurrentSafe: false,
|
||||
isReadOnly: false,
|
||||
isWelcomePage: false,
|
||||
deployedChainIds: safes.map((s) => s.chainId),
|
||||
isSpaceRoute: false,
|
||||
...overrides,
|
||||
} as MultiAccountHookReturn
|
||||
}
|
||||
|
||||
const buildSafeItemHookReturn = (overrides: Partial<SafeItemHookReturn> = {}): SafeItemHookReturn =>
|
||||
({
|
||||
chain: undefined,
|
||||
name: undefined,
|
||||
href: '/home?safe=eth:0x0000000000000000000000000000000000000001',
|
||||
safeOverview: undefined,
|
||||
isCurrentSafe: false,
|
||||
isActivating: false,
|
||||
isReplayable: false,
|
||||
isWelcomePage: false,
|
||||
threshold: 1,
|
||||
owners: [{ value: '0xowner1' }],
|
||||
undeployedSafe: undefined,
|
||||
counterfactualSetup: undefined,
|
||||
elementRef: { current: null },
|
||||
isVisible: true,
|
||||
trackingLabel: OVERVIEW_LABELS.sidebar,
|
||||
...overrides,
|
||||
}) as SafeItemHookReturn
|
||||
|
||||
describe('MultiAccountItem (MyAccountsV2)', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
mockedUseMultiAccountItemData.mockReturnValue(buildMultiAccountHookReturn())
|
||||
mockedUseSafeItemData.mockReturnValue(buildSafeItemHookReturn())
|
||||
})
|
||||
|
||||
describe('display name', () => {
|
||||
it('displays the name from multiSafeAccountItem when provided', () => {
|
||||
const item = buildMultiChainSafeItem({ name: 'My Explicit Name' })
|
||||
|
||||
render(<MultiAccountItem multiSafeAccountItem={item} />)
|
||||
|
||||
expect(screen.getByText('My Explicit Name')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('falls back to the hook-derived name when multiSafeAccountItem.name is empty', () => {
|
||||
mockedUseMultiAccountItemData.mockReturnValue(buildMultiAccountHookReturn({ name: 'Hook Name' }))
|
||||
const item = buildMultiChainSafeItem({ name: undefined })
|
||||
|
||||
render(<MultiAccountItem multiSafeAccountItem={item} />)
|
||||
|
||||
expect(screen.getByText('Hook Name')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('falls back to shortened address when no name is available', () => {
|
||||
const address = '0x1234567890abcdef1234567890abcdef12345678'
|
||||
mockedUseMultiAccountItemData.mockReturnValue(buildMultiAccountHookReturn({ address, name: undefined }))
|
||||
const item = buildMultiChainSafeItem({ address, name: undefined })
|
||||
|
||||
render(<MultiAccountItem multiSafeAccountItem={item} />)
|
||||
|
||||
// Shortened form appears both in the display name and in the address row
|
||||
expect(screen.getAllByText('0x1234...5678').length).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
|
||||
it('always displays the shortened address below the name', () => {
|
||||
const address = '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd'
|
||||
mockedUseMultiAccountItemData.mockReturnValue(buildMultiAccountHookReturn({ address, name: 'Some Name' }))
|
||||
const item = buildMultiChainSafeItem({ address, name: 'Some Name' })
|
||||
|
||||
render(<MultiAccountItem multiSafeAccountItem={item} />)
|
||||
|
||||
expect(screen.getByText('0xabcd...abcd')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('expansion state', () => {
|
||||
it('starts collapsed when the group is not the current safe', () => {
|
||||
mockedUseMultiAccountItemData.mockReturnValue(buildMultiAccountHookReturn({ isCurrentSafe: false }))
|
||||
|
||||
render(<MultiAccountItem multiSafeAccountItem={buildMultiChainSafeItem()} />)
|
||||
|
||||
expect(screen.queryByTestId('subacounts-container')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('starts expanded when the group is the current safe', () => {
|
||||
mockedUseMultiAccountItemData.mockReturnValue(buildMultiAccountHookReturn({ isCurrentSafe: true }))
|
||||
|
||||
render(<MultiAccountItem multiSafeAccountItem={buildMultiChainSafeItem()} />)
|
||||
|
||||
expect(screen.getByTestId('subacounts-container')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('expands when the summary is clicked', () => {
|
||||
mockedUseMultiAccountItemData.mockReturnValue(buildMultiAccountHookReturn({ isCurrentSafe: false }))
|
||||
|
||||
render(<MultiAccountItem multiSafeAccountItem={buildMultiChainSafeItem()} />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('multichain-item-summary'))
|
||||
|
||||
expect(screen.getByTestId('subacounts-container')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('collapses when the summary is clicked while expanded', () => {
|
||||
mockedUseMultiAccountItemData.mockReturnValue(buildMultiAccountHookReturn({ isCurrentSafe: true }))
|
||||
|
||||
render(<MultiAccountItem multiSafeAccountItem={buildMultiChainSafeItem()} />)
|
||||
|
||||
expect(screen.getByTestId('subacounts-container')).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByTestId('multichain-item-summary'))
|
||||
expect(screen.queryByTestId('subacounts-container')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('analytics tracking', () => {
|
||||
it('fires EXPAND_MULTI_SAFE with sidebar label when expanding outside the welcome page', () => {
|
||||
mockedUseMultiAccountItemData.mockReturnValue(
|
||||
buildMultiAccountHookReturn({ isCurrentSafe: false, isWelcomePage: false, isSpaceRoute: false }),
|
||||
)
|
||||
|
||||
render(<MultiAccountItem multiSafeAccountItem={buildMultiChainSafeItem()} />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('multichain-item-summary'))
|
||||
|
||||
expect(trackEvent).toHaveBeenCalledWith({
|
||||
...OVERVIEW_EVENTS.EXPAND_MULTI_SAFE,
|
||||
label: OVERVIEW_LABELS.sidebar,
|
||||
})
|
||||
})
|
||||
|
||||
it('fires EXPAND_MULTI_SAFE with login_page label on the welcome accounts page', () => {
|
||||
mockedUseMultiAccountItemData.mockReturnValue(
|
||||
buildMultiAccountHookReturn({ isCurrentSafe: false, isWelcomePage: true, isSpaceRoute: false }),
|
||||
)
|
||||
|
||||
render(<MultiAccountItem multiSafeAccountItem={buildMultiChainSafeItem()} />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('multichain-item-summary'))
|
||||
|
||||
expect(trackEvent).toHaveBeenCalledWith({
|
||||
...OVERVIEW_EVENTS.EXPAND_MULTI_SAFE,
|
||||
label: OVERVIEW_LABELS.login_page,
|
||||
})
|
||||
})
|
||||
|
||||
it('does not fire an expand event when collapsing', () => {
|
||||
mockedUseMultiAccountItemData.mockReturnValue(buildMultiAccountHookReturn({ isCurrentSafe: true }))
|
||||
|
||||
render(<MultiAccountItem multiSafeAccountItem={buildMultiChainSafeItem()} />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('multichain-item-summary'))
|
||||
|
||||
expect(trackEvent).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({ action: OVERVIEW_EVENTS.EXPAND_MULTI_SAFE.action }),
|
||||
)
|
||||
})
|
||||
|
||||
it('does not fire an expand event on a space route', () => {
|
||||
mockedUseMultiAccountItemData.mockReturnValue(
|
||||
buildMultiAccountHookReturn({ isCurrentSafe: false, isSpaceRoute: true }),
|
||||
)
|
||||
|
||||
render(<MultiAccountItem multiSafeAccountItem={buildMultiChainSafeItem()} />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('multichain-item-summary'))
|
||||
|
||||
expect(trackEvent).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({ action: OVERVIEW_EVENTS.EXPAND_MULTI_SAFE.action }),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('space-safe mode', () => {
|
||||
it('hides the pin button and context menu when isSpaceSafe is true', () => {
|
||||
render(<MultiAccountItem multiSafeAccountItem={buildMultiChainSafeItem()} isSpaceSafe />)
|
||||
|
||||
expect(screen.queryByTestId('pin-button')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('multi-account-context-menu')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows the pin button and context menu when isSpaceSafe is false', () => {
|
||||
render(<MultiAccountItem multiSafeAccountItem={buildMultiChainSafeItem()} isSpaceSafe={false} />)
|
||||
|
||||
expect(screen.getByTestId('pin-button')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('multi-account-context-menu')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('passes the name and address through to the context menu', () => {
|
||||
const item = buildMultiChainSafeItem({
|
||||
address: '0xaaa1111111111111111111111111111111111aaa',
|
||||
name: 'Context Menu Name',
|
||||
})
|
||||
mockedUseMultiAccountItemData.mockReturnValue(buildMultiAccountHookReturn({ address: item.address }))
|
||||
|
||||
render(<MultiAccountItem multiSafeAccountItem={item} />)
|
||||
|
||||
const menu = screen.getByTestId('multi-account-context-menu')
|
||||
expect(menu).toHaveAttribute('data-name', 'Context Menu Name')
|
||||
expect(menu).toHaveAttribute('data-address', item.address)
|
||||
})
|
||||
})
|
||||
|
||||
describe('sub-items', () => {
|
||||
it('renders one sub-item per safe in sortedSafes', () => {
|
||||
const safes = [
|
||||
buildSafeItem({ chainId: '1', address: '0xaaa' }),
|
||||
buildSafeItem({ chainId: '137', address: '0xaaa' }),
|
||||
buildSafeItem({ chainId: '10', address: '0xaaa' }),
|
||||
]
|
||||
mockedUseMultiAccountItemData.mockReturnValue(
|
||||
buildMultiAccountHookReturn({ sortedSafes: safes, isCurrentSafe: true }),
|
||||
)
|
||||
|
||||
render(<MultiAccountItem multiSafeAccountItem={buildMultiChainSafeItem({ safes })} />)
|
||||
|
||||
expect(screen.getAllByTestId('sub-icon')).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('matches a sub-item with its matching overview by chainId and address', () => {
|
||||
const safe = buildSafeItem({ chainId: '1', address: '0xaaa' })
|
||||
const overview = {
|
||||
address: { value: '0xaaa' },
|
||||
chainId: '1',
|
||||
fiatTotal: '500',
|
||||
queued: 2,
|
||||
awaitingConfirmation: 1,
|
||||
} as unknown as SafeOverview
|
||||
mockedUseMultiAccountItemData.mockReturnValue(
|
||||
buildMultiAccountHookReturn({
|
||||
sortedSafes: [safe],
|
||||
safeOverviews: [overview],
|
||||
isCurrentSafe: true,
|
||||
}),
|
||||
)
|
||||
|
||||
render(<MultiAccountItem multiSafeAccountItem={buildMultiChainSafeItem({ safes: [safe] })} />)
|
||||
|
||||
expect(mockedUseSafeItemData).toHaveBeenCalledWith(safe, { safeOverview: overview })
|
||||
})
|
||||
|
||||
it('does not attach an overview when no match is found', () => {
|
||||
const safe = buildSafeItem({ chainId: '1', address: '0xaaa' })
|
||||
mockedUseMultiAccountItemData.mockReturnValue(
|
||||
buildMultiAccountHookReturn({
|
||||
sortedSafes: [safe],
|
||||
safeOverviews: [
|
||||
{
|
||||
address: { value: '0xbbb' },
|
||||
chainId: '1',
|
||||
fiatTotal: '500',
|
||||
} as unknown as SafeOverview,
|
||||
],
|
||||
isCurrentSafe: true,
|
||||
}),
|
||||
)
|
||||
|
||||
render(<MultiAccountItem multiSafeAccountItem={buildMultiChainSafeItem({ safes: [safe] })} />)
|
||||
|
||||
expect(mockedUseSafeItemData).toHaveBeenCalledWith(safe, { safeOverview: undefined })
|
||||
})
|
||||
})
|
||||
|
||||
describe('AddNetworkButton', () => {
|
||||
const expandedMulti = (overrides: Partial<MultiAccountHookReturn> = {}) =>
|
||||
buildMultiAccountHookReturn({ isCurrentSafe: true, ...overrides })
|
||||
|
||||
it('renders when the user can add a network (not read-only, has replayable safe, not a space safe)', () => {
|
||||
mockedUseMultiAccountItemData.mockReturnValue(expandedMulti({ isReadOnly: false, hasReplayableSafe: true }))
|
||||
|
||||
render(<MultiAccountItem multiSafeAccountItem={buildMultiChainSafeItem()} />)
|
||||
|
||||
expect(screen.getByTestId('add-network-button')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not render when the group is read-only', () => {
|
||||
mockedUseMultiAccountItemData.mockReturnValue(expandedMulti({ isReadOnly: true, hasReplayableSafe: true }))
|
||||
|
||||
render(<MultiAccountItem multiSafeAccountItem={buildMultiChainSafeItem()} />)
|
||||
|
||||
expect(screen.queryByTestId('add-network-button')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not render when there is no replayable safe', () => {
|
||||
mockedUseMultiAccountItemData.mockReturnValue(expandedMulti({ isReadOnly: false, hasReplayableSafe: false }))
|
||||
|
||||
render(<MultiAccountItem multiSafeAccountItem={buildMultiChainSafeItem()} />)
|
||||
|
||||
expect(screen.queryByTestId('add-network-button')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not render in space-safe mode', () => {
|
||||
mockedUseMultiAccountItemData.mockReturnValue(expandedMulti({ isReadOnly: false, hasReplayableSafe: true }))
|
||||
|
||||
render(<MultiAccountItem multiSafeAccountItem={buildMultiChainSafeItem()} isSpaceSafe />)
|
||||
|
||||
expect(screen.queryByTestId('add-network-button')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('passes current name, address and deployed chain ids to AddNetworkButton', () => {
|
||||
const safes = [
|
||||
buildSafeItem({ chainId: '1', address: '0xaaa' }),
|
||||
buildSafeItem({ chainId: '137', address: '0xaaa' }),
|
||||
]
|
||||
const item = buildMultiChainSafeItem({
|
||||
address: '0xaaa',
|
||||
name: 'Shared Name',
|
||||
safes,
|
||||
})
|
||||
mockedUseMultiAccountItemData.mockReturnValue(
|
||||
expandedMulti({
|
||||
address: '0xaaa',
|
||||
sortedSafes: safes,
|
||||
isReadOnly: false,
|
||||
hasReplayableSafe: true,
|
||||
}),
|
||||
)
|
||||
|
||||
render(<MultiAccountItem multiSafeAccountItem={item} />)
|
||||
|
||||
const button = screen.getByTestId('add-network-button')
|
||||
expect(button).toHaveAttribute('data-name', 'Shared Name')
|
||||
expect(button).toHaveAttribute('data-address', '0xaaa')
|
||||
expect(button).toHaveAttribute('data-chains', '1,137')
|
||||
})
|
||||
})
|
||||
})
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
import { isMultiChainSafeItem, type MultiChainSafeItem, type SafeItem } from '@/hooks/safes'
|
||||
import SingleAccountItem from './SingleAccountItem'
|
||||
import MultiAccountItem from './MultiAccountItem'
|
||||
|
||||
type AccountItemProps = {
|
||||
safe: SafeItem | MultiChainSafeItem
|
||||
onLinkClick?: () => void
|
||||
isSpaceSafe?: boolean
|
||||
}
|
||||
|
||||
const AccountItem = ({ safe, onLinkClick, isSpaceSafe = false }: AccountItemProps) => {
|
||||
if (isMultiChainSafeItem(safe)) {
|
||||
return <MultiAccountItem multiSafeAccountItem={safe} onLinkClick={onLinkClick} isSpaceSafe={isSpaceSafe} />
|
||||
}
|
||||
return <SingleAccountItem safeItem={safe} onLinkClick={onLinkClick} isSpaceSafe={isSpaceSafe} />
|
||||
}
|
||||
|
||||
export default AccountItem
|
||||
+153
@@ -0,0 +1,153 @@
|
||||
import { useCallback, useEffect, useMemo } from 'react'
|
||||
import useWallet from '@/hooks/wallets/useWallet'
|
||||
import { type AllSafeItems, type AllSafeItemsGrouped, getComparator, useSafesSearch } from '@/hooks/safes'
|
||||
import { sameAddress } from '@safe-global/utils/utils/addresses'
|
||||
import useSafeInfo from '@/hooks/useSafeInfo'
|
||||
import useAddressBook from '@/hooks/useAddressBook'
|
||||
import { useAppSelector } from '@/store'
|
||||
import { selectOrderByPreference } from '@/store/orderByPreferenceSlice'
|
||||
import { maybePlural } from '@safe-global/utils/utils/formatters'
|
||||
import { trackEvent, OVERVIEW_EVENTS } from '@/services/analytics'
|
||||
import { Typography } from '@/components/ui/typography'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import BookmarkIcon from '@/public/images/apps/bookmark.svg'
|
||||
|
||||
import ConnectWalletPrompt from '../../../ConnectWalletPrompt'
|
||||
import MigrationPrompt from '../../../MigrationPrompt'
|
||||
import SafeSelectionModal from '../../../SafeSelectionModal'
|
||||
import useSafeSelectionModal from '../../../../hooks/useSafeSelectionModal'
|
||||
import useMigrationPrompt from '../../../../hooks/useMigrationPrompt'
|
||||
import AccountItem from '../AccountItem'
|
||||
|
||||
type AccountsListProps = {
|
||||
searchQuery: string
|
||||
safes: AllSafeItemsGrouped
|
||||
onLinkClick?: () => void
|
||||
isSidebar?: boolean
|
||||
}
|
||||
|
||||
const AccountsList = ({ searchQuery, safes, onLinkClick }: AccountsListProps) => {
|
||||
const wallet = useWallet()
|
||||
const isConnected = Boolean(wallet)
|
||||
|
||||
const { orderBy } = useAppSelector(selectOrderByPreference)
|
||||
const sortComparator = getComparator(orderBy)
|
||||
|
||||
const modal = useSafeSelectionModal()
|
||||
const migration = useMigrationPrompt()
|
||||
|
||||
const { safe: currentSafe, safeAddress } = useSafeInfo()
|
||||
const addressBook = useAddressBook()
|
||||
|
||||
const allSafes = useMemo<AllSafeItems>(
|
||||
() => [...(safes.allMultiChainSafes ?? []), ...(safes.allSingleSafes ?? [])].sort(sortComparator),
|
||||
[safes.allMultiChainSafes, safes.allSingleSafes, sortComparator],
|
||||
)
|
||||
|
||||
const filteredSafes = useSafesSearch(allSafes, searchQuery)
|
||||
|
||||
const pinnedSafes = useMemo<AllSafeItems>(() => allSafes.filter((s) => s.isPinned), [allSafes])
|
||||
|
||||
const currentSafeInList = useMemo(
|
||||
() => (safeAddress ? allSafes.find((s) => sameAddress(s.address, safeAddress)) : undefined),
|
||||
[allSafes, safeAddress],
|
||||
)
|
||||
|
||||
const currentSafeItem = useMemo(
|
||||
() =>
|
||||
safeAddress
|
||||
? {
|
||||
chainId: currentSafe.chainId,
|
||||
address: safeAddress,
|
||||
isReadOnly: !currentSafeInList,
|
||||
isPinned: false,
|
||||
lastVisited: -1,
|
||||
name: addressBook[safeAddress],
|
||||
}
|
||||
: undefined,
|
||||
[currentSafe.chainId, safeAddress, currentSafeInList, addressBook],
|
||||
)
|
||||
|
||||
const handleMigrationProceed = useCallback(() => modal.open(), [modal])
|
||||
|
||||
useEffect(() => {
|
||||
if (searchQuery) {
|
||||
trackEvent({ category: OVERVIEW_EVENTS.SEARCH.category, action: OVERVIEW_EVENTS.SEARCH.action })
|
||||
}
|
||||
}, [searchQuery])
|
||||
|
||||
if (searchQuery) {
|
||||
return (
|
||||
<>
|
||||
<Typography variant="paragraph-small" color="muted" className="mb-2">
|
||||
Found {filteredSafes.length} result{maybePlural(filteredSafes)}
|
||||
</Typography>
|
||||
<div className="flex flex-col gap-2">
|
||||
{filteredSafes.map((item) => (
|
||||
<AccountItem key={item.address} safe={item} onLinkClick={onLinkClick} />
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
if (!isConnected && !migration.hasPinnedSafes) {
|
||||
return <ConnectWalletPrompt />
|
||||
}
|
||||
|
||||
const showCurrentSafe = safeAddress && currentSafeItem && !currentSafeInList?.isPinned
|
||||
const showEmptyState = !migration.hasPinnedSafes && !migration.shouldShowPrompt
|
||||
|
||||
return (
|
||||
<>
|
||||
{migration.shouldShowPrompt && <MigrationPrompt onProceed={handleMigrationProceed} />}
|
||||
|
||||
{showCurrentSafe && (
|
||||
<section data-testid="current-safe-section" className="mb-6">
|
||||
<Typography variant="paragraph-small-bold" className="mb-2">
|
||||
Current Safe Account
|
||||
</Typography>
|
||||
<AccountItem safe={currentSafeItem} onLinkClick={onLinkClick} />
|
||||
</section>
|
||||
)}
|
||||
|
||||
{pinnedSafes.length > 0 && (
|
||||
<div className="mb-4 flex items-center gap-1.5">
|
||||
<BookmarkIcon className="text-foreground size-4 [&_path]:stroke-current" />
|
||||
<Typography variant="paragraph-small-bold">Trusted Safes</Typography>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{pinnedSafes.length > 0 && (
|
||||
<section data-testid="pinned-accounts" className="mb-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
{pinnedSafes.map((item) => (
|
||||
<AccountItem key={item.address} safe={item} onLinkClick={onLinkClick} />
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-3 flex justify-center">
|
||||
<Button variant="outline" size="sm" onClick={modal.open} data-testid="add-more-safes-button">
|
||||
Manage trusted Safes
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{showEmptyState && (
|
||||
<Typography
|
||||
data-testid="empty-safe-list"
|
||||
variant="paragraph-small"
|
||||
color="muted"
|
||||
align="center"
|
||||
className="py-6"
|
||||
>
|
||||
You don't have any safes yet
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
<SafeSelectionModal modal={modal} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default AccountsList
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
import { type Dispatch, type SetStateAction, useCallback } from 'react'
|
||||
import debounce from 'lodash/debounce'
|
||||
import { Search } from 'lucide-react'
|
||||
import { InputGroup, InputGroupAddon, InputGroupInput } from '@/components/ui/input-group'
|
||||
|
||||
type AccountsSearchProps = {
|
||||
setSearchQuery: Dispatch<SetStateAction<string>>
|
||||
}
|
||||
|
||||
const AccountsSearch = ({ setSearchQuery }: AccountsSearchProps) => {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
const handleSearch = useCallback(debounce(setSearchQuery, 300), [])
|
||||
|
||||
return (
|
||||
<div className="w-full px-4 py-3">
|
||||
<InputGroup className="bg-card px-3 rounded-lg">
|
||||
<InputGroupAddon align="inline-start">
|
||||
<Search />
|
||||
</InputGroupAddon>
|
||||
<InputGroupInput
|
||||
id="search-by-name"
|
||||
placeholder="Search for safes"
|
||||
aria-label="Search Safe list by name"
|
||||
onChange={(e) => handleSearch(e.target.value)}
|
||||
/>
|
||||
</InputGroup>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default AccountsSearch
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './MyAccountsV2'
|
||||
@@ -27,6 +27,7 @@ import type AccountItemContent from './components/AccountItem/AccountItemContent
|
||||
import type SafesList from './components/SafesList'
|
||||
import type AccountsNavigation from './components/AccountsNavigation'
|
||||
import type MyAccounts from './components/MyAccounts'
|
||||
import type MyAccountsV2 from './components/MyAccountsV2'
|
||||
import type SafeSelectionModal from './components/SafeSelectionModal'
|
||||
import type NonPinnedWarning from './components/NonPinnedWarning'
|
||||
import type AccountsWidget from './components/AccountsWidget/AccountsWidget'
|
||||
@@ -34,6 +35,7 @@ import type AccountsWidget from './components/AccountsWidget/AccountsWidget'
|
||||
export interface MyAccountsContract {
|
||||
// Main component
|
||||
MyAccounts: typeof MyAccounts
|
||||
MyAccountsV2: typeof MyAccountsV2
|
||||
|
||||
// Externally used components (PascalCase → stub renders null)
|
||||
AccountItemButton: typeof AccountItemButton
|
||||
|
||||
@@ -12,6 +12,7 @@ import type { MyAccountsContract } from './contract'
|
||||
|
||||
// Direct component imports (already lazy-loaded at feature level)
|
||||
import MyAccounts from './components/MyAccounts'
|
||||
import MyAccountsV2 from './components/MyAccountsV2'
|
||||
import AccountItemButton from './components/AccountItem/AccountItemButton'
|
||||
import AccountItemLink from './components/AccountItem/AccountItemLink'
|
||||
import AccountItemCheckbox from './components/AccountItem/AccountItemCheckbox'
|
||||
@@ -35,6 +36,7 @@ import AccountsWidget from './components/AccountsWidget/AccountsWidget'
|
||||
const feature: MyAccountsContract = {
|
||||
// Main component
|
||||
MyAccounts,
|
||||
MyAccountsV2,
|
||||
|
||||
// Externally used components (individual exports to avoid compound component issues)
|
||||
AccountItemButton,
|
||||
|
||||
@@ -5,7 +5,8 @@ import SignInOptions from '../SignInOptions'
|
||||
import SpacesIcon from '@/public/images/spaces/spaces.svg'
|
||||
import { useAppSelector } from '@/store'
|
||||
import { isAuthenticated } from '@/store/authSlice'
|
||||
import { Box, Button, Card, Grid2, Link, Typography } from '@mui/material'
|
||||
import { Box, Card, Grid2, Link, Typography } from '@mui/material'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { type GetSpaceResponse, useSpacesGetV1Query } from '@safe-global/store/gateway/AUTO_GENERATED/spaces'
|
||||
import { useUsersGetWithWalletsV1Query } from '@safe-global/store/gateway/AUTO_GENERATED/users'
|
||||
import SpaceListInvite from '../InviteBanner'
|
||||
@@ -19,19 +20,19 @@ import { filterSpacesByStatus } from '@/features/spaces/utils'
|
||||
import { AppRoutes } from '@/config/routes'
|
||||
import NextLink from 'next/link'
|
||||
import { useSignInRedirect } from '@/components/welcome/WelcomeLogin/hooks/useSignInRedirect'
|
||||
import AddIcon from '@/public/images/common/add.svg'
|
||||
|
||||
const AddSpaceButton = () => {
|
||||
return (
|
||||
<Button
|
||||
data-testid="create-space-button"
|
||||
disableElevation
|
||||
variant="contained"
|
||||
size="small"
|
||||
href={AppRoutes.welcome.createSpace}
|
||||
LinkComponent={NextLink}
|
||||
sx={{ height: '36px' }}
|
||||
variant="default"
|
||||
size="lg"
|
||||
className="h-full rounded-lg px-6 text-base"
|
||||
render={<NextLink href={AppRoutes.welcome.createSpace} />}
|
||||
>
|
||||
<Box mt="1px">Create space</Box>
|
||||
<AddIcon className="fill-current" />
|
||||
Create space
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,11 +1,30 @@
|
||||
import type { NextPage } from 'next'
|
||||
import Head from 'next/head'
|
||||
import { useGetChainsConfigV2Query } from '@safe-global/store/gateway'
|
||||
import { useLoadFeature } from '@/features/__core__'
|
||||
import { MyAccountsFeature } from '@/features/myAccounts'
|
||||
import { BRAND_NAME } from '@/config/constants'
|
||||
import { useHasFeature } from '@/hooks/useChains'
|
||||
import { FEATURES } from '@safe-global/utils/utils/chains'
|
||||
import { BRAND_NAME, CONFIG_SERVICE_KEY } from '@/config/constants'
|
||||
import { Spinner } from '@/components/ui/spinner'
|
||||
|
||||
const Accounts: NextPage = () => {
|
||||
const { MyAccounts } = useLoadFeature(MyAccountsFeature)
|
||||
const { MyAccounts, MyAccountsV2 } = useLoadFeature(MyAccountsFeature)
|
||||
const { isLoading } = useGetChainsConfigV2Query(CONFIG_SERVICE_KEY)
|
||||
const isRedesignEnabled = useHasFeature(FEATURES.WELCOME_ACCOUNTS_REDESIGN)
|
||||
|
||||
const isFlagResolved = !isLoading && isRedesignEnabled !== undefined
|
||||
|
||||
const renderAccounts = () => {
|
||||
if (!isFlagResolved) {
|
||||
return (
|
||||
<div className="flex w-full justify-center py-16">
|
||||
<Spinner className="text-muted-foreground size-6" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return isRedesignEnabled ? <MyAccountsV2 /> : <MyAccounts />
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -13,7 +32,7 @@ const Accounts: NextPage = () => {
|
||||
<title>{`${BRAND_NAME} – My accounts`}</title>
|
||||
</Head>
|
||||
|
||||
<MyAccounts />
|
||||
{renderAccounts()}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -64,6 +64,7 @@ export enum FEATURES {
|
||||
SUPPORT_CHAT = 'SUPPORT_CHAT',
|
||||
GTF = 'GTF',
|
||||
SAFE_STAKING = 'SAFE_STAKING',
|
||||
WELCOME_ACCOUNTS_REDESIGN = 'WELCOME_ACCOUNTS_REDESIGN',
|
||||
}
|
||||
|
||||
const MIN_SAFE_VERSION = '1.3.0'
|
||||
|
||||
Reference in New Issue
Block a user