diff --git a/apps/cowswap-frontend/src/modules/tokensList/containers/TokenSearchResults/index.tsx b/apps/cowswap-frontend/src/modules/tokensList/containers/TokenSearchResults/index.tsx index 4ca54eced2..4bb84a16f8 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/containers/TokenSearchResults/index.tsx +++ b/apps/cowswap-frontend/src/modules/tokensList/containers/TokenSearchResults/index.tsx @@ -3,15 +3,6 @@ import { ReactNode, useCallback, useEffect, useMemo } from 'react' import { TokenWithLogo } from '@cowprotocol/common-const' import { doesTokenMatchSymbolOrAddress } from '@cowprotocol/common-utils' import { getTokenSearchFilter, TokenSearchResponse, useSearchToken } from '@cowprotocol/tokens' -import { - BannerOrientation, - ExternalLink, - InlineBanner, - LINK_GUIDE_ADD_CUSTOM_TOKEN, - StatusColorVariant, -} from '@cowprotocol/ui' - -import { Trans } from '@lingui/react/macro' import { useAddTokenImportCallback } from '../../hooks/useAddTokenImportCallback' import { useUpdateSelectTokenWidgetState } from '../../hooks/useUpdateSelectTokenWidgetState' @@ -77,18 +68,6 @@ export function TokenSearchResults({ return ( - -

- Can't find your token on the list?{' '} - Read our guide on how to add custom tokens. -

-
- +
diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/ImportTokenItem/styled.ts b/apps/cowswap-frontend/src/modules/tokensList/pure/ImportTokenItem/styled.ts index 068ff64547..d528acc946 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/pure/ImportTokenItem/styled.ts +++ b/apps/cowswap-frontend/src/modules/tokensList/pure/ImportTokenItem/styled.ts @@ -2,20 +2,15 @@ import { Media, UI } from '@cowprotocol/ui' import styled from 'styled-components/macro' -export const Wrapper = styled.div` +export const Wrapper = styled.div<{ $isFirst?: boolean; $isLast?: boolean }>` display: flex; flex-direction: row; justify-content: space-between; align-items: center; - padding: 0 20px; - margin-bottom: 20px; + padding: ${({ $isFirst, $isLast }) => `${$isFirst ? '20px' : '0'} 20px ${$isLast ? '0' : '20px'} 20px`}; ${Media.upToSmall()} { - padding: 0 14px; - } - - &:last-child { - margin-bottom: 0; + padding: ${({ $isFirst, $isLast }) => `${$isFirst ? '20px' : '0'} 14px ${$isLast ? '0' : '20px'} 14px`}; } ` diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/TokenSearchContent/index.tsx b/apps/cowswap-frontend/src/modules/tokensList/pure/TokenSearchContent/index.tsx index 5330d9c277..a238ab18d0 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/pure/TokenSearchContent/index.tsx +++ b/apps/cowswap-frontend/src/modules/tokensList/pure/TokenSearchContent/index.tsx @@ -1,12 +1,22 @@ -import { ReactNode, useMemo } from 'react' +import { ReactNode, useCallback, useMemo } from 'react' import { TokenWithLogo } from '@cowprotocol/common-const' import { doesTokenMatchSymbolOrAddress } from '@cowprotocol/common-utils' import { TokenSearchResponse } from '@cowprotocol/tokens' -import { Loader } from '@cowprotocol/ui' +import { + BannerOrientation, + ExternalLink, + InlineBanner, + LINK_GUIDE_ADD_CUSTOM_TOKEN, + Loader, + StatusColorVariant, +} from '@cowprotocol/ui' import { t } from '@lingui/core/macro' import { Trans } from '@lingui/react/macro' +import { VirtualItem } from '@tanstack/react-virtual' + +import { VirtualList } from 'common/pure/VirtualList' import * as styledEl from '../../containers/TokenSearchResults/styled' import { SelectTokenContext } from '../../types' @@ -23,7 +33,6 @@ interface TokenSearchContentProps { importToken: (tokenToImport: TokenWithLogo) => void } -// TODO: Add proper return type annotation export function TokenSearchContent({ searchInput, searchResults, @@ -47,8 +56,6 @@ export function TokenSearchContent({ for (const t of activeListsResult) { if (doesTokenMatchSymbolOrAddress(t, searchInput)) { - // There should ever be only 1 token with a given address - // There can be multiple with the same symbol matched.push(t) } else { remaining.push(t) @@ -58,59 +65,211 @@ export function TokenSearchContent({ return [matched, remaining] }, [activeListsResult, searchInput]) - return isLoading ? ( - - - - ) : isTokenNotFound ? ( - - No tokens found - - ) : ( - <> - {/*Matched tokens first, followed by tokens from active lists*/} - {matchedTokens.concat(activeList).map((token) => { - return - })} - - {/*Tokens from blockchain*/} - {blockchainResult?.length ? ( - - {blockchainResult.slice(0, SEARCH_RESULTS_LIMIT).map((token) => { - return - })} - - ) : null} - - {/*Tokens from inactive lists*/} - {inactiveListsResult?.length ? ( -
- - {t`Expanded results from inactive Token Lists`} - -
- {inactiveListsResult.slice(0, SEARCH_RESULTS_LIMIT).map((token) => { - return - })} -
-
- ) : null} - - {/*Tokens from external sources*/} - {externalApiResult?.length ? ( -
- - {t`Additional Results from External Sources`} - -
- {externalApiResult.map((token) => { - return - })} -
-
- ) : null} - + const rows = useSearchRows({ + isLoading, + matchedTokens, + activeList, + blockchainResult, + inactiveListsResult, + externalApiResult, + }) + + const renderRow = useCallback( + (items: TokenSearchRow[], virtualItem: VirtualItem) => ( + + ), + [importToken, selectTokenContext], + ) + + if (isLoading) + return ( + + + + ) + + if (isTokenNotFound) + return ( + + No tokens found + + ) + + return +} + +type TokenImportSection = 'blockchain' | 'inactive' | 'external' + +type TokenSearchRow = + | { type: 'banner' } + | { type: 'token'; token: TokenWithLogo } + | { type: 'section-title'; text: string; tooltip?: string } + | { + type: 'import-token' + token: TokenWithLogo + shadowed?: boolean + section: TokenImportSection + isFirstInSection: boolean + isLastInSection: boolean + wrapperId?: string + } + +interface UseSearchRowsParams { + isLoading: boolean + matchedTokens: TokenWithLogo[] + activeList: TokenWithLogo[] + blockchainResult?: TokenWithLogo[] + inactiveListsResult?: TokenWithLogo[] + externalApiResult?: TokenWithLogo[] +} + +function useSearchRows({ + isLoading, + matchedTokens, + activeList, + blockchainResult, + inactiveListsResult, + externalApiResult, +}: UseSearchRowsParams): TokenSearchRow[] { + return useMemo(() => { + const entries: TokenSearchRow[] = [] + + if (isLoading) { + return entries + } + + entries.push({ type: 'banner' }) + + for (const token of matchedTokens) { + entries.push({ type: 'token', token }) + } + + for (const token of activeList) { + entries.push({ type: 'token', token }) + } + + appendImportSection(entries, { + tokens: blockchainResult, + section: 'blockchain', + limit: SEARCH_RESULTS_LIMIT, + sectionTitle: undefined, + tooltip: undefined, + shadowed: false, + wrapperId: 'currency-import', + }) + + appendImportSection(entries, { + tokens: inactiveListsResult, + section: 'inactive', + limit: SEARCH_RESULTS_LIMIT, + sectionTitle: t`Expanded results from inactive Token Lists`, + tooltip: t`Tokens from inactive lists. Import specific tokens below or click Manage to activate more lists.`, + shadowed: true, + }) + + appendImportSection(entries, { + tokens: externalApiResult, + section: 'external', + limit: SEARCH_RESULTS_LIMIT, + sectionTitle: t`Additional Results from External Sources`, + tooltip: t`Tokens from external sources.`, + shadowed: true, + }) + + return entries + }, [isLoading, matchedTokens, activeList, blockchainResult, inactiveListsResult, externalApiResult]) +} + +interface AppendImportSectionParams { + tokens?: TokenWithLogo[] + section: TokenImportSection + limit: number + sectionTitle?: string + tooltip?: string + shadowed?: boolean + wrapperId?: string +} + +function appendImportSection(rows: TokenSearchRow[], params: AppendImportSectionParams): void { + const { tokens, section, limit, sectionTitle, tooltip, shadowed, wrapperId } = params + + if (!tokens?.length) { + return + } + + if (sectionTitle) { + rows.push({ type: 'section-title', text: sectionTitle, tooltip }) + } + + const limitedTokens = tokens.slice(0, limit) + + limitedTokens.forEach((token, index) => { + rows.push({ + type: 'import-token', + token, + section, + shadowed, + isFirstInSection: index === 0, + isLastInSection: index === limitedTokens.length - 1, + wrapperId: index === 0 ? wrapperId : undefined, + }) + }) +} + +interface TokenSearchRowRendererProps { + row: TokenSearchRow + selectTokenContext: SelectTokenContext + importToken(token: TokenWithLogo): void +} + +function TokenSearchRowRenderer({ row, selectTokenContext, importToken }: TokenSearchRowRendererProps): ReactNode { + switch (row.type) { + case 'banner': + return + case 'token': + return + case 'section-title': { + const tooltip = row.tooltip ?? '' + return ( + + {row.text} + + ) + } + case 'import-token': + return ( + + ) + default: + return null + } +} + +function GuideBanner(): ReactNode { + return ( + +

+ + Can't find your token on the list? Read our guide{' '} + on how to add custom tokens. + +

+
) }