Skip to content
Merged
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
14 changes: 13 additions & 1 deletion frontend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion frontend/src/app/walletBoot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -187,7 +187,7 @@ export async function runBootDiagnostics(
status: hasWallets ? 'passed' : 'failed',
message: hasWallets
? 'Stellar wallet detected'
: 'No Stellar wallet extension found. Install Freighter, Rabet, or xBull.',
: 'No Stellar wallet extension found. Install Freighter, Rabet, xBull, or Hana.',
})
} catch {
checks.push({
Expand Down
130 changes: 130 additions & 0 deletions frontend/src/components/AssetSearch.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { render, screen, fireEvent, cleanup, waitFor } from '@testing-library/react'
import AssetSearch from './AssetSearch'

const mockAssets = vi.hoisted(() => [
{ symbol: 'XLM', name: 'Stellar', type: 'native', contract: 'CAS3J7GYLGXMF6WDJ7FW6HZYX5N7G4NHG5IQ7QJ6IKGFL4BQK2KX8A' },
{ symbol: 'USDC', name: 'USD Coin', type: 'credit_alphanum4', issuer: 'GBBD47IF6LWK7P7MDEVLCWREC47RBY4PKY3OK4X6JOJ4YFJK3G43Y6K' },
{ symbol: 'BTC', name: 'Bitcoin', type: 'credit_alphanum4' },
])

vi.mock('../hooks/queries/useAssetsQuery', () => ({
useAssets: () => ({ data: mockAssets }),
}))

vi.mock('../config/api', () => ({
api: {
get: vi.fn().mockResolvedValue({ assets: [] }),
},
ENDPOINTS: { ASSETS: '/api/v1/assets' },
}))

let mockStore: Record<string, string> = {}
const lsMock = {
getItem: (key: string) => mockStore[key] ?? null,
setItem: (key: string, value: string) => { mockStore[key] = value },
removeItem: (key: string) => { delete mockStore[key] },
clear: () => { mockStore = {} },
get length() { return Object.keys(mockStore).length },
key: (index: number) => Object.keys(mockStore)[index] ?? null,
}
vi.stubGlobal('localStorage', lsMock)

describe('AssetSearch', () => {
const mockOnChange = vi.fn()

beforeEach(() => {
mockStore = {}
vi.clearAllMocks()
})

afterEach(() => {
cleanup()
mockStore = {}
})

it('persists recent searches to localStorage after selecting an asset', async () => {
render(
<AssetSearch value="" onChange={mockOnChange} supportedContracts={[]} />,
)

const input = screen.getByRole('searchbox')
fireEvent.focus(input)
fireEvent.change(input, { target: { value: 'XLM' } })

await waitFor(() => {
expect(screen.getByText('XLM')).toBeInTheDocument()
})

const xlmOption = screen.getByText('XLM').closest('button')
fireEvent.click(xlmOption!)

const stored = JSON.parse(lsMock.getItem('asset_recent_searches') || '[]')
expect(stored).toContain('XLM')
})

it('shows recent searches when input is focused and empty', () => {
lsMock.setItem('asset_recent_searches', JSON.stringify(['XLM', 'USDC']))

render(
<AssetSearch value="" onChange={mockOnChange} />,
)

const input = screen.getByRole('searchbox')
fireEvent.focus(input)

expect(screen.getByText('Recent searches')).toBeInTheDocument()
expect(screen.getByText('XLM')).toBeInTheDocument()
expect(screen.getByText('USDC')).toBeInTheDocument()
})

it('clears recent searches when clear button is clicked', () => {
lsMock.setItem('asset_recent_searches', JSON.stringify(['XLM', 'USDC']))

render(
<AssetSearch value="" onChange={mockOnChange} />,
)

const input = screen.getByRole('searchbox')
fireEvent.focus(input)

expect(screen.getByText('Recent searches')).toBeInTheDocument()

fireEvent.click(screen.getByText('Clear'))

expect(screen.queryByText('Recent searches')).not.toBeInTheDocument()
expect(lsMock.getItem('asset_recent_searches')).toBeNull()
})

it('recent searches display correctly after multiple simulated searches', () => {
lsMock.setItem('asset_recent_searches', JSON.stringify(['XLM', 'USDC', 'BTC']))

render(
<AssetSearch value="" onChange={mockOnChange} />,
)

const input = screen.getByRole('searchbox')
fireEvent.focus(input)

expect(screen.getByText('Recent searches')).toBeInTheDocument()
expect(screen.getByText('XLM')).toBeInTheDocument()
expect(screen.getByText('USDC')).toBeInTheDocument()
expect(screen.getByText('BTC')).toBeInTheDocument()
})

it('limits recent searches to max 10 items', () => {
const manySearches = Array.from({ length: 15 }, (_, i) => `ASSET${i}`)
lsMock.setItem('asset_recent_searches', JSON.stringify(manySearches))

render(
<AssetSearch value="" onChange={mockOnChange} />,
)

const input = screen.getByRole('searchbox')
fireEvent.focus(input)

expect(screen.getByText('Recent searches')).toBeInTheDocument()
const recentButtons = screen.getAllByRole('option')
expect(recentButtons.length).toBeLessThanOrEqual(10)
})
})
77 changes: 77 additions & 0 deletions frontend/src/components/AssetSearch.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,37 @@ function useDebounce<T>(value: T, delay: number): T {
const CG_CACHE_KEY = 'coingecko_asset_cache'
const CG_CACHE_TTL = 7 * 24 * 60 * 60 * 1000 // 7 days

const RECENT_SEARCHES_KEY = 'asset_recent_searches'
const RECENT_SEARCHES_MAX = 10

function getRecentSearches(): string[] {
try {
const raw = localStorage.getItem(RECENT_SEARCHES_KEY)
if (!raw) return []
const parsed = JSON.parse(raw)
if (!Array.isArray(parsed)) return []
return parsed.slice(0, RECENT_SEARCHES_MAX)
Comment on lines +45 to +51

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Validate individual stored values before rendering.

Array.isArray(parsed) accepts entries such as null or objects, but the dropdown calls symbol.slice(...), causing a render-time crash for malformed localStorage. Filter to non-empty strings before returning; deduplicating here also prevents duplicate persisted rows.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/src/components/AssetSearch.tsx` around lines 45 - 51, Update
getRecentSearches to filter parsed entries to non-empty strings before returning
them, excluding nulls and objects that could break symbol.slice during
rendering. Deduplicate the valid values and preserve the RECENT_SEARCHES_MAX
limit on the final returned list.

} catch {
return []
}
}

function saveRecentSearches(symbols: string[]) {
try {
localStorage.setItem(RECENT_SEARCHES_KEY, JSON.stringify(symbols))
} catch {}
}

function addRecentSearch(symbol: string) {
const current = getRecentSearches()
const updated = [symbol, ...current.filter(s => s !== symbol)].slice(0, RECENT_SEARCHES_MAX)
saveRecentSearches(updated)
}

function clearRecentSearches() {
localStorage.removeItem(RECENT_SEARCHES_KEY)
}

interface CGCacheEntry {
logo: string
description: string
Expand Down Expand Up @@ -78,6 +109,7 @@ const AssetSearch: React.FC<AssetSearchProps> = ({
const [searchQuery, setSearchQuery] = useState('')
const [highlightedIndex, setHighlightedIndex] = useState(-1)
const [isSearching, setIsSearching] = useState(false)
const [recentSearches, setRecentSearches] = useState<string[]>(() => getRecentSearches())
const dropdownRef = useRef<HTMLDivElement>(null)
const searchInputRef = useRef<HTMLInputElement>(null)
const debouncedSearch = useDebounce(searchQuery, 250)
Expand Down Expand Up @@ -260,6 +292,8 @@ const AssetSearch: React.FC<AssetSearchProps> = ({

const handleSelect = useCallback((asset: AssetSearchResult) => {
onChange(asset.symbol)
addRecentSearch(asset.symbol)
setRecentSearches(getRecentSearches())
setIsOpen(false)
setSearchQuery('')
setHighlightedIndex(-1)
Expand All @@ -271,6 +305,11 @@ const AssetSearch: React.FC<AssetSearchProps> = ({
setHighlightedIndex(-1)
}, [onChange])

const handleClearRecentSearches = useCallback(() => {
clearRecentSearches()
setRecentSearches([])
}, [])

return (
<div className={`relative ${className}`} ref={dropdownRef}>
<div
Expand Down Expand Up @@ -335,6 +374,44 @@ const AssetSearch: React.FC<AssetSearchProps> = ({
<div className="p-4 text-center text-sm text-red-500 dark:text-red-400">
{dynamicError}
</div>
) : !debouncedSearch && recentSearches.length > 0 ? (
<div>
<div className="flex items-center justify-between px-3 py-2 border-b border-gray-100 dark:border-gray-700">
<span className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Recent searches
</span>
<button
type="button"
onClick={handleClearRecentSearches}
className="text-xs text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300"
>
Clear
</button>
</div>
{recentSearches.map((symbol, index) => (
<button
key={symbol}
id={`asset-result-${index}`}
role="option"
aria-selected={false}
type="button"
onClick={() => {
const asset = allResults.find(a => a.symbol === symbol)
if (asset) handleSelect(asset)
}}
Comment on lines +377 to +401

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Make recent entries selectable from the same data model used for rendering.

A symbol selected from dynamic API results is persisted, but dynamicResults is cleared once the query clears; allResults.find(...) then returns nothing, so clicking that recent entry does nothing. Keyboard navigation also indexes allResults, not recentSearches, and can select a different asset than the highlighted recent row. Select recents directly by symbol (or persist sufficient asset data) and drive keyboard selection from the displayed rows. Add regressions for a dynamic recent asset and keyboard selection.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/src/components/AssetSearch.tsx` around lines 377 - 401, Update the
recent-search rendering and selection logic in AssetSearch so each recent row is
selectable by its symbol without relying on allResults, including assets whose
dynamic API results have been cleared. Align keyboard navigation and selection
with the currently displayed rows, including recentSearches, so the highlighted
row selects the corresponding recent asset rather than an allResults entry. Add
regression coverage for selecting a dynamically sourced recent asset and
selecting a recent row via keyboard.

className={`w-full flex items-center gap-3 px-3 py-3 text-left hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors ${
highlightedIndex === index ? 'bg-blue-50 dark:bg-blue-900/20' : ''
}`}
>
<div className="w-8 h-8 rounded-full bg-gray-200 dark:bg-gray-600 flex items-center justify-center text-sm font-bold text-gray-500 dark:text-gray-400 flex-shrink-0">
{symbol.slice(0, 2)}
</div>
<div className="min-w-0 flex-1">
<span className="font-medium text-gray-900 dark:text-white">{symbol}</span>
</div>
</button>
))}
</div>
) : allResults.length === 0 ? (
<div className="p-4 text-center text-sm text-gray-500 dark:text-gray-400">
{debouncedSearch ? `No assets matching "${debouncedSearch}"` : 'Start typing to search assets'}
Expand Down
Loading
Loading