Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions e2e/TEST_MAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@ Playwright runs 3 projects (`playwright.config.ts`): **smoke** (`@smoke`-tagged,
Desktop Chrome), **full** (everything else, Desktop Chrome), **mobile**
(`@mobile`-tagged, Pixel 7 viewport — off CI, `pnpm e2e:mobile`).

73 specs across 5 top-level dirs: `general/` (8), `index-dtf/` (17),
`yield-dtf/` (6), `smoke/` (12), `flows/` (30). `index-dtf/` and `yield-dtf/`
75 specs across 5 top-level dirs: `general/` (8), `index-dtf/` (18),
`yield-dtf/` (6), `smoke/` (12), `flows/` (31). `index-dtf/` and `yield-dtf/`
hold render/lifecycle/mobile specs per route; `flows/` holds deeper
behavior/write/edge-case specs (desktop only, no `@mobile` tags anywhere in
the directory).
Expand Down Expand Up @@ -53,6 +53,7 @@ the directory).
| Settings / Roles | [settings/lifecycle](tests/index-dtf/settings/lifecycle.spec.ts), [settings/distribute-fees](tests/index-dtf/settings/distribute-fees.spec.ts), [settings/fee-edge](tests/index-dtf/settings/fee-edge.spec.ts), [flows/settings](tests/flows/settings.spec.ts) | roster skeleton→roster; any-wallet `distributeFees()`; platformFee=100 shows Unavailable not a fabricated split; snapshot-scaled fee %s; registry-read-failure→UNAVAILABLE; zero-denominator/zero-numerator edges; public roles roster; governance cards; disconnected hides submit control | partial | yes (lifecycle only) | — |
| Manage | [manage/render](tests/index-dtf/manage/render.spec.ts) | form renders offline | none | yes | SIWE→upload→save write flow |
| Factsheet | [factsheet/render](tests/index-dtf/factsheet/render.spec.ts) | renders offline | none | yes | performance math (CSV, inception clamp) |
| Navigation – DTF switcher | [navigation/dtf-switcher](tests/index-dtf/navigation/dtf-switcher.spec.ts) | cross-DTF switch keeps the current section (governance→governance, cross-chain); issuance-panel switcher keeps `/issuance`; address search narrows to one option; ticker search ignores address hex + no-match empty list; inactive target falls back auctions→overview; mobile menu switch closes the parent sheet | none | yes | keyboard-only selection, unsupported-status filtering |

## Yield DTF (`/:chain/token/:tokenId/*`)

Expand Down
166 changes: 166 additions & 0 deletions e2e/tests/index-dtf/navigation/dtf-switcher.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
import { expect, test } from '../../../harness'
import { REGISTRY } from '../../../helpers/registry'
import { loadSnapshot } from '../../../helpers/snapshots'

// DTF switcher (nav logo / mobile pages menu): opening the popover lists the
// discover catalog and selecting another DTF lands on the SAME section for that
// DTF — the whole point of the control (stay on governance, change DTF). The
// inactive-DTF case re-routes away from Auctions, which inactive DTFs disable.

const base = REGISTRY.find((d) => d.chainId === 8453 && !d.deprecated)! // lcap
const bsc = REGISTRY.find((d) => d.chainId === 56 && d.slug === 'cmc20')!
const deprecated = REGISTRY.find((d) => d.deprecated)! // base/vtf

interface DtfSnapshot {
dtf: { token: { name: string; symbol: string } }
}

const symbolOf = (dir: string) =>
loadSnapshot<DtfSnapshot>(`${dir}/dtf.json`).dtf.token.symbol

const optionFor = (page: import('@playwright/test').Page, address: string) =>
page.locator(
`[data-testid="dtf-switcher-option"][data-address="${address.toLowerCase()}"]`
)

test('dtf switcher: desktop nav keeps the governance section across DTFs @smoke', async ({
harness,
}) => {
const page = harness.page
await harness.goto(base, 'governance')
await expect(page.getByTestId('governance-proposals').first()).toBeVisible({
timeout: 20_000,
})

await page.getByTestId('dtf-switcher-trigger').click()
await expect(page.getByTestId('dtf-switcher-content')).toBeVisible()

const target = optionFor(page, bsc.address)
await expect(target).toBeVisible({ timeout: 15_000 })
await target.click()

// Same section, different DTF — no detour through discover/overview.
await expect(page).toHaveURL(
new RegExp(`/bsc/index-dtf/${bsc.address}/governance$`, 'i')
)
await expect(page.getByTestId('governance-proposals').first()).toBeVisible({
timeout: 20_000,
})
await expect(page.getByTestId('dtf-switcher-content')).toHaveCount(0)
})

test('dtf switcher: search narrows the list to the typed DTF @smoke', async ({
harness,
}) => {
const page = harness.page
await harness.goto(base, 'overview')
await expect(page.getByTestId('overview-dtf-symbol')).toHaveText(
`$${symbolOf(base.snapshotDir)}`,
{ timeout: 20_000 }
)

await page.getByTestId('dtf-switcher-trigger').click()
const options = page.getByTestId('dtf-switcher-option')
await expect(options.first()).toBeVisible({ timeout: 15_000 })
const total = await options.count()
expect(total).toBeGreaterThan(1)

// Filter by the destination's address: cmdk matches the item keywords.
await page.getByTestId('dtf-switcher-search').fill(bsc.address)
await expect(options).toHaveCount(1)
await expect(optionFor(page, bsc.address)).toBeVisible()
})

test('dtf switcher: ticker search ignores address hex noise', async ({
harness,
}) => {
const page = harness.page
await harness.goto(base, 'overview')
await page.getByTestId('dtf-switcher-trigger').click()

const options = page.getByTestId('dtf-switcher-option')
await expect(options.first()).toBeVisible({ timeout: 15_000 })

// Ticker queries match symbols/names only — addresses would fuzzy-match every
// short hex-ish query and drown the intended row.
await page.getByTestId('dtf-switcher-search').fill(symbolOf(bsc.snapshotDir))
await expect(options).toHaveCount(1)
await expect(optionFor(page, bsc.address)).toBeVisible()

await page.getByTestId('dtf-switcher-search').fill('zzzznotadtf')
await expect(options).toHaveCount(0)
})

test('dtf switcher: issuance panel switcher keeps the issuance section', async ({
harness,
}) => {
const page = harness.page
await harness.goto(base, 'issuance')
await expect(page.getByTestId('dtf-issuance')).toBeVisible({
timeout: 20_000,
})

await page.getByTestId('dtf-switcher-trigger-issuance').click()
const target = optionFor(page, bsc.address)
await expect(target).toBeVisible({ timeout: 15_000 })
await target.click()

await expect(page).toHaveURL(
new RegExp(`/bsc/index-dtf/${bsc.address}/issuance$`, 'i')
)
await expect(page.getByTestId('dtf-issuance')).toBeVisible({
timeout: 20_000,
})
})

test('dtf switcher: inactive DTF falls back to overview from auctions', async ({
harness,
}) => {
const page = harness.page
await harness.goto(base, 'auctions')
await page.getByTestId('dtf-switcher-trigger').click()

const target = optionFor(page, deprecated.address)
await expect(target).toBeVisible({ timeout: 15_000 })
await target.click()

// Auctions are disabled for inactive DTFs, so the switch lands on overview
// rather than a route the destination refuses to render.
await expect(page).toHaveURL(
new RegExp(`/base/index-dtf/${deprecated.address}/overview$`, 'i')
)
await expect(page.getByTestId('overview-dtf-symbol')).toHaveText(
`$${symbolOf(deprecated.snapshotDir)}`,
{ timeout: 20_000 }
)
})

test('dtf switcher: mobile pages menu switches DTF in place @mobile', async ({
harness,
}, testInfo) => {
test.skip(
testInfo.project.name !== 'mobile',
'mobile chrome only (bottom-nav portal menu)'
)
const page = harness.page
await harness.goto(base, 'governance')
await expect(page.getByTestId('governance-proposals').first()).toBeVisible({
timeout: 20_000,
})

await page.getByTestId('dtf-nav-mobile-menu').click()
await page.getByTestId('dtf-switcher-trigger-mobile').click()

const target = optionFor(page, bsc.address)
await expect(target).toBeVisible({ timeout: 15_000 })
await target.click()

await expect(page).toHaveURL(
new RegExp(`/bsc/index-dtf/${bsc.address}/governance$`, 'i')
)
// Parent menu closes with the popover — no stale overlay over the new page.
await expect(page.getByTestId('dtf-switcher-trigger-mobile')).toHaveCount(0)
await expect(page.getByTestId('governance-proposals').first()).toBeVisible({
timeout: 20_000,
})
})
213 changes: 213 additions & 0 deletions src/views/index-dtf/components/dtf-switcher/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
import ChainLogo from '@/components/icons/ChainLogo'
import TokenLogo from '@/components/token-logo'
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '@/components/ui/command'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover'
import { Skeleton } from '@/components/ui/skeleton'
import useIndexDTFList, { IndexDTFItem } from '@/hooks/useIndexDTFList'
import { cn } from '@/lib/utils'
import { indexDTFAtom } from '@/state/dtf/atoms'
import { getFolioRoute } from '@/utils'
import { ROUTES } from '@/utils/constants'
import { useTrackIndexDTFClick } from '@/views/index-dtf/hooks/useTrackIndexDTFPage'
import { Trans, useLingui } from '@lingui/react/macro'
import { useAtomValue } from 'jotai'
import { Check } from 'lucide-react'
import { ReactNode, useMemo, useState } from 'react'
import { useLocation, useNavigate } from 'react-router-dom'

const DTF_SECTIONS: string[] = [
ROUTES.OVERVIEW,
ROUTES.ISSUANCE,
ROUTES.GOVERNANCE,
ROUTES.AUCTIONS,
ROUTES.SETTINGS,
]

// Keeps the user on the section they were browsing when they switch DTF
export const useCurrentDTFSection = () => {
const { pathname } = useLocation()

return useMemo(() => {
const segments = pathname.split('/').filter(Boolean)
const dtfIndex = segments.indexOf('index-dtf')
const section = dtfIndex >= 0 ? segments[dtfIndex + 2] : undefined

return section && DTF_SECTIONS.includes(section) ? section : ROUTES.OVERVIEW
}, [pathname])
}

const isActiveDTF = (dtf: IndexDTFItem) => dtf.status === 'active'

// Addresses only match address-shaped queries, otherwise their hex characters
// fuzzy-match every short ticker search
const filterDTF = (_value: string, search: string, keywords?: string[]) => {
const query = search.trim().toLowerCase()

if (!query) return 1

const [address = '', symbol = '', name = ''] = keywords ?? []

if (query.startsWith('0x')) {
return address.toLowerCase().startsWith(query) ? 1 : 0
}

if (symbol.toLowerCase().startsWith(query)) return 1
if (name.toLowerCase().startsWith(query)) return 0.9

return symbol.toLowerCase().includes(query) ||
name.toLowerCase().includes(query)
? 0.5
: 0
}

const DTFOption = ({
dtf,
section,
isCurrent,
onSelect,
}: {
dtf: IndexDTFItem
section: string
isCurrent: boolean
onSelect: (dtf: IndexDTFItem, route: string) => void
}) => {
// Auctions are unavailable on inactive DTFs, so land on overview instead
const route =
section === ROUTES.AUCTIONS && !isActiveDTF(dtf) ? ROUTES.OVERVIEW : section

return (
<CommandItem
value={`${dtf.chainId}-${dtf.address}`}
keywords={[dtf.address, dtf.symbol, dtf.name]}
data-testid="dtf-switcher-option"
data-address={dtf.address.toLowerCase()}
data-chain={dtf.chainId}
className="flex cursor-pointer items-center gap-2"
onSelect={() =>
onSelect(dtf, getFolioRoute(dtf.address, dtf.chainId, route))
}
>
<div className="relative shrink-0">
<TokenLogo
src={dtf.brand?.icon}
symbol={dtf.symbol}
address={dtf.address}
chain={dtf.chainId}
size="lg"
/>
<ChainLogo
chain={dtf.chainId}
className="absolute -bottom-1 -right-1 h-2 w-4"
/>
</div>
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-semibold">${dtf.symbol}</div>
<div className="truncate text-xs text-muted-foreground">{dtf.name}</div>
</div>
{isCurrent && <Check className="h-4 w-4 shrink-0 text-primary" />}
</CommandItem>
)
}

const DTFSwitcher = ({
children,
align = 'start',
className,
onNavigate,
}: {
children: ReactNode
align?: 'start' | 'center' | 'end'
className?: string
onNavigate?: () => void
}) => {
const { t } = useLingui()
const [open, setOpen] = useState(false)
const navigate = useNavigate()
const currentDTF = useAtomValue(indexDTFAtom)
const section = useCurrentDTFSection()
const { data } = useIndexDTFList()
const { trackClick } = useTrackIndexDTFClick(section, 'navigation')

const dtfs = useMemo(() => {
if (!data) return undefined

return data
.filter((dtf) => dtf.status !== 'unsupported')
.sort((a, b) => {
if (isActiveDTF(a) !== isActiveDTF(b)) return isActiveDTF(a) ? -1 : 1
return (b.marketCap || 0) - (a.marketCap || 0)
})
}, [data])

const onSelect = (dtf: IndexDTFItem, route: string) => {
trackClick('switch_dtf', {
target_ca: dtf.address,
target_ticker: dtf.symbol,
target_chain: dtf.chainId,
})
setOpen(false)
onNavigate?.()
navigate(route)
}

return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>{children}</PopoverTrigger>
<PopoverContent
align={align}
className={cn('w-[320px] p-0', className)}
data-testid="dtf-switcher-content"
>
<Command loop filter={filterDTF}>
<CommandInput
placeholder={t`Switch DTF...`}
data-testid="dtf-switcher-search"
/>
{!dtfs ? (
<div
className="flex flex-col gap-2 p-2"
data-testid="dtf-switcher-skeleton"
>
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-full" />
</div>
) : (
<CommandList className="max-h-[320px]">
<CommandEmpty>
<Trans>No DTFs found.</Trans>
</CommandEmpty>
<CommandGroup>
{dtfs.map((dtf) => (
<DTFOption
key={`${dtf.chainId}-${dtf.address}`}
dtf={dtf}
section={section}
isCurrent={
!!currentDTF &&
currentDTF.id.toLowerCase() === dtf.address.toLowerCase()
}
onSelect={onSelect}
/>
))}
</CommandGroup>
</CommandList>
)}
</Command>
</PopoverContent>
</Popover>
)
}

export default DTFSwitcher
Loading
Loading