-
Notifications
You must be signed in to change notification settings - Fork 232
feat: implement 4 open source contributions #1613
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
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| 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) | ||
| }) | ||
| }) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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) | ||
| } 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 | ||
|
|
@@ -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) | ||
|
|
@@ -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) | ||
|
|
@@ -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 | ||
|
|
@@ -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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 🤖 Prompt for AI Agents |
||
| 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'} | ||
|
|
||
There was a problem hiding this comment.
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 asnullor objects, but the dropdown callssymbol.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