From a6909791dd0f03e91782c86e2e3a526ae6d46933 Mon Sep 17 00:00:00 2001 From: fairlighteth <31534717+fairlighteth@users.noreply.github.com> Date: Thu, 13 Nov 2025 18:09:45 +0000 Subject: [PATCH 001/100] feat(tokenselector): enhance VirtualList component --- .../src/common/pure/VirtualList/index.tsx | 122 +++++++++--- apps/cowswap-frontend/src/locales/en-US.po | 27 +-- apps/cowswap-frontend/src/theme/consts.tsx | 1 + libs/tokens/src/pure/TokenLogo/index.tsx | 174 +++++++++++------- libs/ui/src/enum.ts | 35 ++++ libs/ui/src/pure/Input/index.tsx | 13 +- libs/ui/src/pure/Popover/index.tsx | 166 ++++++++++++----- libs/ui/src/theme/ThemeColorVars.tsx | 149 +++++++++++++++ 8 files changed, 537 insertions(+), 150 deletions(-) diff --git a/apps/cowswap-frontend/src/common/pure/VirtualList/index.tsx b/apps/cowswap-frontend/src/common/pure/VirtualList/index.tsx index a5f10673af8..2134dc41297 100644 --- a/apps/cowswap-frontend/src/common/pure/VirtualList/index.tsx +++ b/apps/cowswap-frontend/src/common/pure/VirtualList/index.tsx @@ -1,4 +1,4 @@ -import { ReactNode, useCallback, useRef } from 'react' +import { ReactNode, useCallback, useLayoutEffect, useRef } from 'react' import { useVirtualizer, VirtualItem } from '@tanstack/react-virtual' import ms from 'ms.macro' @@ -7,15 +7,72 @@ import { ListInner, ListScroller, ListWrapper, LoadingRows } from './styled' const scrollDelay = ms`400ms` -// TODO: Add proper return type annotation -// eslint-disable-next-line @typescript-eslint/explicit-function-return-type -const threeDivs = () => ( - <> -
-
-
- -) +const LoadingPlaceholder: () => ReactNode = () => { + return ( + <> +
+
+
+ + ) +} + +interface VirtualListRowProps { + item: VirtualItem + loading?: boolean + items: T[] + getItemView(items: T[], item: VirtualItem): ReactNode + measureElement(element: Element | null): void +} + +function VirtualListRow({ item, loading, items, getItemView, measureElement }: VirtualListRowProps): ReactNode { + if (loading) { + return ( + + + + ) + } + + return ( +
+ {getItemView(items, item)} +
+ ) +} + +interface VirtualListRowsProps { + virtualItems: VirtualItem[] + loading?: boolean + items: T[] + getItemView(items: T[], item: VirtualItem): ReactNode + measureElement(element: Element | null): void +} + +function renderVirtualListRows({ + virtualItems, + loading, + items, + getItemView, + measureElement, +}: VirtualListRowsProps): ReactNode[] { + const elements: ReactNode[] = [] + + for (const item of virtualItems) { + elements.push( + , + ) + } + + return elements +} interface VirtualListProps { id?: string @@ -26,10 +83,9 @@ interface VirtualListProps { loading?: boolean estimateSize?: () => number children?: ReactNode + scrollResetKey?: string | number | boolean } -// TODO: Add proper return type annotation -// eslint-disable-next-line @typescript-eslint/explicit-function-return-type export function VirtualList({ id, items, @@ -37,7 +93,8 @@ export function VirtualList({ getItemView, children, estimateSize = () => 56, -}: VirtualListProps) { + scrollResetKey, +}: VirtualListProps): ReactNode { const parentRef = useRef(null) const wrapperRef = useRef(null) const scrollTimeoutRef = useRef(undefined) @@ -53,6 +110,7 @@ export function VirtualList({ }, scrollDelay) }, []) + // eslint-disable-next-line react-hooks/incompatible-library const virtualizer = useVirtualizer({ getScrollElement: () => parentRef.current, count: items.length, @@ -60,24 +118,40 @@ export function VirtualList({ overscan: 5, }) + useLayoutEffect(() => { + if (scrollResetKey === undefined) { + return + } + + const scrollContainer = parentRef.current + + if (scrollContainer) { + scrollContainer.scrollTop = 0 + scrollContainer.scrollLeft = 0 + + if (typeof scrollContainer.scrollTo === 'function') { + scrollContainer.scrollTo({ top: 0, left: 0, behavior: 'auto' }) + } + } + + virtualizer.scrollToOffset(0, { align: 'start' }) + }, [scrollResetKey, virtualizer]) + const virtualItems = virtualizer.getVirtualItems() + const virtualRows = renderVirtualListRows({ + virtualItems, + loading, + items, + getItemView, + measureElement: virtualizer.measureElement, + }) return ( {children} - {virtualItems.map((item) => { - if (loading) { - return {threeDivs()} - } - - return ( -
- {getItemView(items, item)} -
- ) - })} + {virtualRows}
diff --git a/apps/cowswap-frontend/src/locales/en-US.po b/apps/cowswap-frontend/src/locales/en-US.po index 85dd692ccd6..ea987f8f0b2 100644 --- a/apps/cowswap-frontend/src/locales/en-US.po +++ b/apps/cowswap-frontend/src/locales/en-US.po @@ -455,7 +455,6 @@ msgid "View details" msgstr "View details" #: apps/cowswap-frontend/src/modules/application/containers/App/menuConsts.tsx -#: apps/cowswap-frontend/src/modules/tokensList/pure/ChainsSelector/index.tsx msgid "More" msgstr "More" @@ -827,8 +826,8 @@ msgid "Copied" msgstr "Copied" #: apps/cowswap-frontend/src/modules/tokensList/containers/TokenSearchResults/index.tsx -msgid "Can't find your token on the list?" -msgstr "Can't find your token on the list?" +#~ msgid "Can't find your token on the list?" +#~ msgstr "Can't find your token on the list?" #: apps/cowswap-frontend/src/modules/trade/pure/ReceiveAmountTitle/index.tsx msgid "icon" @@ -847,8 +846,8 @@ msgid "Please connect your wallet to one of our supported networks." msgstr "Please connect your wallet to one of our supported networks." #: apps/cowswap-frontend/src/modules/tokensList/containers/TokenSearchResults/index.tsx -msgid "<0>Read our guide on how to add custom tokens." -msgstr "<0>Read our guide on how to add custom tokens." +#~ msgid "<0>Read our guide on how to add custom tokens." +#~ msgstr "<0>Read our guide on how to add custom tokens." #: apps/cowswap-frontend/src/modules/hooksStore/containers/TenderlySimulate/index.tsx msgid "Retry" @@ -1220,8 +1219,8 @@ msgid "Partner fee can not be more than {PARTNER_FEE_MAX_BPS} BPS!" msgstr "Partner fee can not be more than {PARTNER_FEE_MAX_BPS} BPS!" #: apps/cowswap-frontend/src/modules/tokensList/pure/TokensContent/index.tsx -msgid "Manage Token Lists" -msgstr "Manage Token Lists" +#~ msgid "Manage Token Lists" +#~ msgstr "Manage Token Lists" #: apps/cowswap-frontend/src/legacy/components/Tokens/TokensTable.tsx msgid "No results found" @@ -3156,7 +3155,6 @@ msgstr "CoW Swap's robust solver competition protects your slippage from being e msgid "Aave Debt Swap Flashloan" msgstr "Aave Debt Swap Flashloan" -#: apps/cowswap-frontend/src/modules/tokensList/pure/LpTokenLists/index.tsx #: apps/cowswap-frontend/src/modules/yield/pure/TargetPoolPreviewInfo.tsx msgid "Details" msgstr "Details" @@ -3812,6 +3810,10 @@ msgstr "User rejected approval transaction" msgid "Swap on" msgstr "Swap on" +#: apps/cowswap-frontend/src/modules/tokensList/pure/TokenSearchContent/index.tsx +msgid "Can't find your token on the list? <0>Read our guide on how to add custom tokens." +msgstr "Can't find your token on the list? <0>Read our guide on how to add custom tokens." + #: apps/cowswap-frontend/src/modules/account/containers/Transaction/ActivityDetails.tsx #: apps/cowswap-frontend/src/modules/orderProgressBar/pure/TransactionSubmittedContent/index.tsx msgid "Transaction" @@ -4322,7 +4324,6 @@ msgstr "Decrease Value" #: apps/cowswap-frontend/src/legacy/components/Tokens/TokensTable.tsx #: apps/cowswap-frontend/src/modules/ethFlow/pure/WrappingPreview/WrapCard.tsx #: apps/cowswap-frontend/src/modules/tokensList/pure/LpTokenLists/index.tsx -#: apps/cowswap-frontend/src/modules/tokensList/pure/LpTokenLists/index.tsx msgid "Balance" msgstr "Balance" @@ -4385,8 +4386,8 @@ msgid "funds" msgstr "funds" #: apps/cowswap-frontend/src/modules/tokensList/pure/LpTokenLists/index.tsx -msgid "Pool details" -msgstr "Pool details" +#~ msgid "Pool details" +#~ msgstr "Pool details" #: apps/cowswap-frontend/src/modules/tradeSlippage/containers/HighSuggestedSlippageWarning/index.tsx msgid "Slippage adjusted to {slippageBpsPercentage}% to ensure quick execution" @@ -5894,8 +5895,8 @@ msgid "You sold <0/>" msgstr "You sold <0/>" #: apps/cowswap-frontend/src/modules/tokensList/pure/ChainsSelector/index.tsx -msgid "Less" -msgstr "Less" +#~ msgid "Less" +#~ msgstr "Less" #: libs/hook-dapp-lib/src/hookDappsRegistry.ts msgid "Claim your LlamaPay vesting contract funds" diff --git a/apps/cowswap-frontend/src/theme/consts.tsx b/apps/cowswap-frontend/src/theme/consts.tsx index 5230490c10b..8a008740d70 100644 --- a/apps/cowswap-frontend/src/theme/consts.tsx +++ b/apps/cowswap-frontend/src/theme/consts.tsx @@ -25,6 +25,7 @@ export const WIDGET_MAX_WIDTH = { limit: '1350px', content: '680px', tokenSelect: '590px', + tokenSelectSidebar: '700px', } export const TextWrapper = styled(Text)<{ color: keyof Colors; override?: boolean }>` diff --git a/libs/tokens/src/pure/TokenLogo/index.tsx b/libs/tokens/src/pure/TokenLogo/index.tsx index 7f49b5e345e..6d2c67f048e 100644 --- a/libs/tokens/src/pure/TokenLogo/index.tsx +++ b/libs/tokens/src/pure/TokenLogo/index.tsx @@ -1,5 +1,5 @@ import { atom, useAtom } from 'jotai' -import { useCallback, useMemo } from 'react' +import { ReactNode, useCallback, useMemo } from 'react' import { BaseChainInfo, @@ -41,11 +41,19 @@ export interface TokenLogoProps { hideNetworkBadge?: boolean } -// TODO: Break down this large function into smaller functions -// TODO: Add proper return type annotation -// TODO: Reduce function complexity by extracting logic -// eslint-disable-next-line @typescript-eslint/explicit-function-return-type, complexity, max-lines-per-function -export function TokenLogo({ +export function TokenLogo(props: TokenLogoProps): ReactNode { + const { token } = props + + if (token instanceof LpToken) { + return + } + + return +} + +type StandardTokenLogoProps = TokenLogoProps & { token?: TokenWithLogo | Currency | null } + +function StandardTokenLogo({ logoURI, token, className, @@ -53,31 +61,10 @@ export function TokenLogo({ sizeMobile, noWrap, hideNetworkBadge, -}: TokenLogoProps) { - const tokensByAddress = useTokensByAddressMap() - +}: StandardTokenLogoProps): ReactNode { const [invalidUrls, setInvalidUrls] = useAtom(invalidUrlsAtom) - const isLpToken = token instanceof LpToken - - const urls = useMemo(() => { - if (token instanceof LpToken) return - - // TODO: get rid of Currency usage and remove type casting - if (token) { - if (token instanceof NativeCurrency) { - return [cowprotocolTokenLogoUrl(NATIVE_CURRENCY_ADDRESS.toLowerCase(), token.chainId as SupportedChainId)] - } - - return getTokenLogoUrls(token as TokenWithLogo) - } - - return logoURI ? uriToHttp(logoURI) : [] - }, [logoURI, token]) - - const validUrls = useMemo(() => urls && urls.filter((url) => !invalidUrls[url]), [urls, invalidUrls]) - - const currentUrl = validUrls?.[0] + const { currentUrl, initial } = useTokenLogoUrl({ token, logoURI, invalidUrls }) const logoUrl = useNetworkLogo(token?.chainId) const showNetworkBadge = logoUrl && !hideNetworkBadge @@ -88,40 +75,7 @@ export function TokenLogo({ setInvalidUrls((state) => ({ ...state, [currentUrl]: true })) }, [currentUrl, setInvalidUrls]) - const initial = token?.symbol?.[0] || token?.name?.[0] - - if (isLpToken) { - return ( - - -
- -
-
- -
-
-
- ) - } - - const actualTokenContent = currentUrl ? ( - - {`${token?.symbol - - ) : initial ? ( - - - - ) : ( - - - - ) + const actualTokenContent = renderTokenLogoContent({ currentUrl, onError, token, initial }) if (noWrap) { return actualTokenContent @@ -137,7 +91,12 @@ export function TokenLogo({ const cutThicknessForCalc = getBorderWidth(chainLogoSizeForCalc) return ( - + <> {showNetworkBadge ? ( ) } + +type LpTokenLogoProps = Omit & { token: LpToken } + +function LpTokenLogo({ token, className, size = 36, sizeMobile }: LpTokenLogoProps): ReactNode { + const tokensByAddress = useTokensByAddressMap() + + return ( + + +
+ +
+
+ +
+
+
+ ) +} + +interface TokenLogoUrlOptions { + token?: TokenWithLogo | Currency | null + logoURI?: string + invalidUrls: Record +} + +function useTokenLogoUrl({ token, logoURI, invalidUrls }: TokenLogoUrlOptions): { + currentUrl?: string + initial?: string +} { + const urls = useMemo(() => { + if (token instanceof LpToken) { + return [] + } + + if (token instanceof NativeCurrency) { + return [cowprotocolTokenLogoUrl(NATIVE_CURRENCY_ADDRESS.toLowerCase(), token.chainId as SupportedChainId)] + } + + if (token) { + return getTokenLogoUrls(token as TokenWithLogo) + } + + return logoURI ? uriToHttp(logoURI) : [] + }, [logoURI, token]) + + const validUrls = useMemo(() => urls && urls.filter((url) => !invalidUrls[url]), [urls, invalidUrls]) + const currentUrl = validUrls?.[0] + const initial = token?.symbol?.[0] || token?.name?.[0] + + return { currentUrl, initial } +} + +interface TokenLogoContentOptions { + currentUrl?: string + onError: () => void + token?: TokenWithLogo | Currency | null + initial?: string +} + +function renderTokenLogoContent({ currentUrl, onError, token, initial }: TokenLogoContentOptions): ReactNode { + if (currentUrl) { + return ( + + {`${token?.symbol + + ) + } + + if (initial) { + return ( + + + + ) + } + + return ( + + + + ) +} diff --git a/libs/ui/src/enum.ts b/libs/ui/src/enum.ts index 7a135004366..6e7c2039cec 100644 --- a/libs/ui/src/enum.ts +++ b/libs/ui/src/enum.ts @@ -101,6 +101,41 @@ export enum UI { COLOR_GREEN = '--cow-color-green', COLOR_RED = '--cow-color-red', + // Chain-specific accent colors + COLOR_CHAIN_ETHEREUM_BG = '--cow-color-chain-ethereum-bg', + COLOR_CHAIN_ETHEREUM_BORDER = '--cow-color-chain-ethereum-border', + COLOR_CHAIN_ETHEREUM_ACCENT = '--cow-color-chain-ethereum-accent', + COLOR_CHAIN_BNB_BG = '--cow-color-chain-bnb-bg', + COLOR_CHAIN_BNB_BORDER = '--cow-color-chain-bnb-border', + COLOR_CHAIN_BNB_ACCENT = '--cow-color-chain-bnb-accent', + COLOR_CHAIN_BASE_BG = '--cow-color-chain-base-bg', + COLOR_CHAIN_BASE_BORDER = '--cow-color-chain-base-border', + COLOR_CHAIN_BASE_ACCENT = '--cow-color-chain-base-accent', + COLOR_CHAIN_ARBITRUM_BG = '--cow-color-chain-arbitrum-bg', + COLOR_CHAIN_ARBITRUM_BORDER = '--cow-color-chain-arbitrum-border', + COLOR_CHAIN_ARBITRUM_ACCENT = '--cow-color-chain-arbitrum-accent', + COLOR_CHAIN_POLYGON_BG = '--cow-color-chain-polygon-bg', + COLOR_CHAIN_POLYGON_BORDER = '--cow-color-chain-polygon-border', + COLOR_CHAIN_POLYGON_ACCENT = '--cow-color-chain-polygon-accent', + COLOR_CHAIN_AVALANCHE_BG = '--cow-color-chain-avalanche-bg', + COLOR_CHAIN_AVALANCHE_BORDER = '--cow-color-chain-avalanche-border', + COLOR_CHAIN_AVALANCHE_ACCENT = '--cow-color-chain-avalanche-accent', + COLOR_CHAIN_GNOSIS_BG = '--cow-color-chain-gnosis-bg', + COLOR_CHAIN_GNOSIS_BORDER = '--cow-color-chain-gnosis-border', + COLOR_CHAIN_GNOSIS_ACCENT = '--cow-color-chain-gnosis-accent', + COLOR_CHAIN_LENS_BG = '--cow-color-chain-lens-bg', + COLOR_CHAIN_LENS_BORDER = '--cow-color-chain-lens-border', + COLOR_CHAIN_LENS_ACCENT = '--cow-color-chain-lens-accent', + COLOR_CHAIN_SEPOLIA_BG = '--cow-color-chain-sepolia-bg', + COLOR_CHAIN_SEPOLIA_BORDER = '--cow-color-chain-sepolia-border', + COLOR_CHAIN_SEPOLIA_ACCENT = '--cow-color-chain-sepolia-accent', + COLOR_CHAIN_LINEA_BG = '--cow-color-chain-linea-bg', + COLOR_CHAIN_LINEA_BORDER = '--cow-color-chain-linea-border', + COLOR_CHAIN_LINEA_ACCENT = '--cow-color-chain-linea-accent', + COLOR_CHAIN_PLASMA_BG = '--cow-color-chain-plasma-bg', + COLOR_CHAIN_PLASMA_BORDER = '--cow-color-chain-plasma-border', + COLOR_CHAIN_PLASMA_ACCENT = '--cow-color-chain-plasma-accent', + // Neutral colors - Base grayscale palette from black (0) to white (100) COLOR_WHITE = '--cow-color-neutral-100', COLOR_NEUTRAL_100 = '--cow-color-neutral-100', diff --git a/libs/ui/src/pure/Input/index.tsx b/libs/ui/src/pure/Input/index.tsx index 4503352d715..c1a5dfb8b35 100644 --- a/libs/ui/src/pure/Input/index.tsx +++ b/libs/ui/src/pure/Input/index.tsx @@ -1,4 +1,4 @@ -import { InputHTMLAttributes } from 'react' +import { InputHTMLAttributes, ReactNode } from 'react' import { Search } from 'react-feather' import styled from 'styled-components/macro' @@ -31,15 +31,18 @@ const SearchInputEl = styled.input` border-radius: 12px; border: none; - ::placeholder { + &::placeholder { color: inherit; opacity: 0.7; + transition: color 0.1s ease-in-out; + } + + &:focus::placeholder { + color: transparent; } ` -// TODO: Add proper return type annotation -// eslint-disable-next-line @typescript-eslint/explicit-function-return-type -export function SearchInput(props: InputHTMLAttributes) { +export function SearchInput(props: InputHTMLAttributes): ReactNode { return ( diff --git a/libs/ui/src/pure/Popover/index.tsx b/libs/ui/src/pure/Popover/index.tsx index ca0078e5e15..9e45b4c42b5 100644 --- a/libs/ui/src/pure/Popover/index.tsx +++ b/libs/ui/src/pure/Popover/index.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useMemo, useState } from 'react' +import React, { useCallback, useEffect, useMemo, useState } from 'react' import { useMediaQuery, useInterval, useElementViewportTracking } from '@cowprotocol/common-hooks' @@ -24,7 +24,9 @@ const MOBILE_FULL_WIDTH_STYLES = { boxSizing: 'border-box' as const, } -function createMobileModifiers(arrowElement: HTMLDivElement | null): Array>>> { +function createMobileModifiers( + arrowElement: HTMLDivElement | null, +): Array>>> { return [ { name: 'offset', @@ -44,7 +46,9 @@ function createMobileModifiers(arrowElement: HTMLDivElement | null): Array>>> { +function createDesktopModifiers( + arrowElement: HTMLDivElement | null, +): Array>>> { return [ { name: 'offset', options: { offset: [8, 8] } }, { name: 'arrow', options: { element: arrowElement } }, @@ -52,7 +56,6 @@ function createDesktopModifiers(arrowElement: HTMLDivElement | null): Array(() => show || forceMount) + + useEffect(() => { + if ((show || forceMount) && !hasMountedPortal) { + setHasMountedPortal(true) + } + }, [show, forceMount, hasMountedPortal]) + + return forceMount || show || hasMountedPortal } export default function Popover(props: PopoverProps): React.JSX.Element { @@ -84,75 +104,133 @@ export default function Popover(props: PopoverProps): React.JSX.Element { showMobileBackdrop = false, mobileBorderRadius, zIndex = 999999, + forceMount = false, } = props const [referenceElement, setReferenceElement] = useState(null) const [popperElement, setPopperElement] = useState(null) const [arrowElement, setArrowElement] = useState(null) - const isMobile = useMediaQuery(Media.upToSmall(false)) const shouldUseFullWidth = isMobile && mobileMode === PopoverMobileMode.FullWidth - - // Use hook for viewport tracking and utility for backdrop height calculation const { rect } = useElementViewportTracking(referenceElement, shouldUseFullWidth && showMobileBackdrop) - const backdropHeight = useMemo(() => { if (!shouldUseFullWidth || !showMobileBackdrop) return '100vh' return calculateAvailableSpaceAbove(rect, 8) }, [rect, shouldUseFullWidth, showMobileBackdrop]) - const options = useMemo( (): Options => ({ placement: shouldUseFullWidth ? 'top' : placement, strategy: 'fixed', - modifiers: shouldUseFullWidth - ? createMobileModifiers(arrowElement) - : createDesktopModifiers(arrowElement), + modifiers: shouldUseFullWidth ? createMobileModifiers(arrowElement) : createDesktopModifiers(arrowElement), }), [arrowElement, placement, shouldUseFullWidth], ) - const { styles, update, attributes } = usePopper(referenceElement, popperElement, options) - const updateCallback = useCallback(() => { update?.() }, [update]) const intervalDelay = useMemo(() => (show ? 100 : null), [show]) useInterval(updateCallback, intervalDelay) - + const shouldRenderPortal = useLazyPortalMount(show, forceMount) + const popperStyle = { + ...styles.popper, + zIndex, + ...(shouldUseFullWidth && MOBILE_FULL_WIDTH_STYLES), + ...(shouldUseFullWidth && mobileBorderRadius && { borderRadius: mobileBorderRadius }), + } + const arrowPlacement = (attributes.popper?.['data-popper-placement'] as string | undefined)?.split('-')[0] ?? '' return ( <> {children} - - {isMobile && showMobileBackdrop && } - + + ) +} + +interface PopoverPortalProps { + shouldRender: boolean + show: boolean + isMobile: boolean + showMobileBackdrop: boolean + backdropHeight: string + className?: string + setPopperElement(value: HTMLDivElement | null): void + popperStyle: React.CSSProperties + popperAttributes: ReturnType['attributes']['popper'] + bgColor?: string + color?: string + borderColor?: string + content: React.ReactNode + setArrowElement(value: HTMLDivElement | null): void + arrowStyle: React.CSSProperties + arrowAttributes: ReturnType['attributes']['arrow'] + arrowPlacement: string +} + +function PopoverPortal({ + shouldRender, + show, + isMobile, + showMobileBackdrop, + backdropHeight, + className, + setPopperElement, + popperStyle, + popperAttributes, + bgColor, + color, + borderColor, + content, + setArrowElement, + arrowStyle, + arrowAttributes, + arrowPlacement, +}: PopoverPortalProps): React.ReactNode { + if (!shouldRender) { + return null + } + + return ( + + {isMobile && showMobileBackdrop && } + + {content} + - {content} - - - - + {...arrowAttributes} + /> + + ) } diff --git a/libs/ui/src/theme/ThemeColorVars.tsx b/libs/ui/src/theme/ThemeColorVars.tsx index b5b4872b065..f39c641a938 100644 --- a/libs/ui/src/theme/ThemeColorVars.tsx +++ b/libs/ui/src/theme/ThemeColorVars.tsx @@ -5,6 +5,153 @@ import { css } from 'styled-components/macro' import { UI } from '../enum' +interface ChainAccentConfig { + bgVar: UI + borderVar: UI + accentVar?: UI + lightBg: string + darkBg: string + lightBorder: string + darkBorder: string + lightColor: string + darkColor: string +} + +interface ChainAccentInput { + bgVar: UI + borderVar: UI + accentVar?: UI + color: string + lightColor?: string + darkColor?: string + lightBgAlpha?: number + darkBgAlpha?: number + lightBorderAlpha?: number + darkBorderAlpha?: number +} + +const CHAIN_LIGHT_BG_ALPHA = 0.22 +const CHAIN_DARK_BG_ALPHA = 0.32 +const CHAIN_LIGHT_BORDER_ALPHA = 0.45 +const CHAIN_DARK_BORDER_ALPHA = 0.65 + +const chainAlpha = (color: string, alpha: number): string => transparentize(color, 1 - alpha) + +function createChainAccent({ + bgVar, + borderVar, + accentVar, + color, + lightColor = color, + darkColor = color, + lightBgAlpha = CHAIN_LIGHT_BG_ALPHA, + darkBgAlpha = CHAIN_DARK_BG_ALPHA, + lightBorderAlpha = CHAIN_LIGHT_BORDER_ALPHA, + darkBorderAlpha = CHAIN_DARK_BORDER_ALPHA, +}: ChainAccentInput): ChainAccentConfig { + return { + bgVar, + borderVar, + accentVar, + lightBg: chainAlpha(lightColor, lightBgAlpha), + darkBg: chainAlpha(darkColor, darkBgAlpha), + lightBorder: chainAlpha(lightColor, lightBorderAlpha), + darkBorder: chainAlpha(darkColor, darkBorderAlpha), + lightColor, + darkColor, + } +} + +const CHAIN_ACCENT_CONFIG: ChainAccentConfig[] = [ + createChainAccent({ + bgVar: UI.COLOR_CHAIN_ETHEREUM_BG, + borderVar: UI.COLOR_CHAIN_ETHEREUM_BORDER, + accentVar: UI.COLOR_CHAIN_ETHEREUM_ACCENT, + color: '#627EEA', + }), + createChainAccent({ + bgVar: UI.COLOR_CHAIN_BNB_BG, + borderVar: UI.COLOR_CHAIN_BNB_BORDER, + accentVar: UI.COLOR_CHAIN_BNB_ACCENT, + color: '#F0B90B', + }), + createChainAccent({ + bgVar: UI.COLOR_CHAIN_BASE_BG, + borderVar: UI.COLOR_CHAIN_BASE_BORDER, + accentVar: UI.COLOR_CHAIN_BASE_ACCENT, + color: '#0052FF', + }), + createChainAccent({ + bgVar: UI.COLOR_CHAIN_ARBITRUM_BG, + borderVar: UI.COLOR_CHAIN_ARBITRUM_BORDER, + accentVar: UI.COLOR_CHAIN_ARBITRUM_ACCENT, + color: '#1B4ADD', + }), + createChainAccent({ + bgVar: UI.COLOR_CHAIN_POLYGON_BG, + borderVar: UI.COLOR_CHAIN_POLYGON_BORDER, + accentVar: UI.COLOR_CHAIN_POLYGON_ACCENT, + color: '#8247E5', + }), + createChainAccent({ + bgVar: UI.COLOR_CHAIN_AVALANCHE_BG, + borderVar: UI.COLOR_CHAIN_AVALANCHE_BORDER, + accentVar: UI.COLOR_CHAIN_AVALANCHE_ACCENT, + color: '#FF3944', + }), + createChainAccent({ + bgVar: UI.COLOR_CHAIN_GNOSIS_BG, + borderVar: UI.COLOR_CHAIN_GNOSIS_BORDER, + accentVar: UI.COLOR_CHAIN_GNOSIS_ACCENT, + color: '#07795B', + }), + createChainAccent({ + bgVar: UI.COLOR_CHAIN_LENS_BG, + borderVar: UI.COLOR_CHAIN_LENS_BORDER, + accentVar: UI.COLOR_CHAIN_LENS_ACCENT, + color: '#5A5A5A', + darkColor: '#D7D7D7', + }), + createChainAccent({ + bgVar: UI.COLOR_CHAIN_SEPOLIA_BG, + borderVar: UI.COLOR_CHAIN_SEPOLIA_BORDER, + accentVar: UI.COLOR_CHAIN_SEPOLIA_ACCENT, + color: '#C12FF2', + }), + createChainAccent({ + bgVar: UI.COLOR_CHAIN_LINEA_BG, + borderVar: UI.COLOR_CHAIN_LINEA_BORDER, + accentVar: UI.COLOR_CHAIN_LINEA_ACCENT, + color: '#61DFFF', + }), + createChainAccent({ + bgVar: UI.COLOR_CHAIN_PLASMA_BG, + borderVar: UI.COLOR_CHAIN_PLASMA_BORDER, + accentVar: UI.COLOR_CHAIN_PLASMA_ACCENT, + color: '#569F8C', + }), +] + +const CHAIN_ACCENT_VAR_DECLARATIONS = CHAIN_ACCENT_CONFIG.map(({ + bgVar, + borderVar, + accentVar, + lightBg, + darkBg, + lightBorder, + darkBorder, + lightColor, + darkColor, +}) => css` + ${bgVar}: ${({ theme }) => (theme.darkMode ? darkBg : lightBg)}; + ${borderVar}: ${({ theme }) => (theme.darkMode ? darkBorder : lightBorder)}; + ${accentVar + ? css` + ${accentVar}: ${({ theme }) => (theme.darkMode ? darkColor : lightColor)}; + ` + : ''} +`) + export const ThemeColorVars = css` :root { // V3 @@ -83,6 +230,8 @@ export const ThemeColorVars = css` ${UI.COLOR_ALERT_TEXT_DARKER}: ${({ theme }) => getContrastText(theme.alert, theme.darkMode ? darken(theme.alert, 0.55) : darken(theme.alert, 0.35))}; + ${CHAIN_ACCENT_VAR_DECLARATIONS} + ${UI.COLOR_WARNING}: ${({ theme }) => theme.warning}; ${UI.COLOR_WARNING_BG}: ${({ theme }) => transparentize(theme.warning, 0.85)}; ${UI.COLOR_WARNING_TEXT}: ${({ theme }) => From 77f44af16fb98cf734619256f20434b45bee60cd Mon Sep 17 00:00:00 2001 From: fairlighteth <31534717+fairlighteth@users.noreply.github.com> Date: Thu, 13 Nov 2025 19:22:45 +0000 Subject: [PATCH 002/100] feat(tokenselector): add useDeferredVisibility hook for optimized rendering --- .../tokensList/hooks/useDeferredVisibility.ts | 68 ++++++++++++++++ .../tokensList/pure/TokenInfo/index.tsx | 5 +- .../tokensList/pure/TokenListItem/index.tsx | 78 ++++++++++++++----- 3 files changed, 131 insertions(+), 20 deletions(-) create mode 100644 apps/cowswap-frontend/src/modules/tokensList/hooks/useDeferredVisibility.ts diff --git a/apps/cowswap-frontend/src/modules/tokensList/hooks/useDeferredVisibility.ts b/apps/cowswap-frontend/src/modules/tokensList/hooks/useDeferredVisibility.ts new file mode 100644 index 00000000000..34e330b60fe --- /dev/null +++ b/apps/cowswap-frontend/src/modules/tokensList/hooks/useDeferredVisibility.ts @@ -0,0 +1,68 @@ +import { useCallback, useEffect, useState } from 'react' + +interface DeferredVisibilityOptions { + /** + * Expands the observed viewport so we hydrate content slightly before it + * scrolls into view. + */ + rootMargin?: string + /** + * When this key changes we reset the visibility state. Helpful when the same + * virtualized row instance renders different data. + */ + resetKey?: string | number +} + +interface DeferredVisibilityResult { + ref: (element: T | null) => void + isVisible: boolean +} + +const DEFAULT_ROOT_MARGIN = '120px' + +// Lightweight helper to delay hydration of expensive UI until the row is close to the viewport. +export function useDeferredVisibility( + options: DeferredVisibilityOptions = {}, +): DeferredVisibilityResult { + const { rootMargin = DEFAULT_ROOT_MARGIN, resetKey } = options + const [element, setElement] = useState(null) + const [isVisible, setIsVisible] = useState(false) + + useEffect(() => { + if (resetKey === undefined) { + return + } + + setIsVisible(false) + }, [resetKey]) + + useEffect(() => { + if (isVisible || !element) { + return undefined + } + + if (typeof IntersectionObserver === 'undefined') { + setIsVisible(true) + return undefined + } + + const observer = new IntersectionObserver( + (entries) => { + if (entries.some((entry) => entry.isIntersecting)) { + setIsVisible(true) + } + }, + { rootMargin }, + ) + + observer.observe(element) + + return () => observer.disconnect() + }, [element, isVisible, rootMargin]) + + const ref = useCallback((node: T | null) => { + setElement(node) + }, []) + + return { ref, isVisible } +} diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/TokenInfo/index.tsx b/apps/cowswap-frontend/src/modules/tokensList/pure/TokenInfo/index.tsx index 95d0f4ad77f..8129de48de6 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/pure/TokenInfo/index.tsx +++ b/apps/cowswap-frontend/src/modules/tokensList/pure/TokenInfo/index.tsx @@ -12,10 +12,11 @@ export interface TokenInfoProps { token: TokenWithLogo className?: string tags?: ReactNode + showAddress?: boolean } export function TokenInfo(props: TokenInfoProps): ReactNode { - const { token, className, tags } = props + const { token, className, tags, showAddress = true } = props return ( @@ -23,7 +24,7 @@ export function TokenInfo(props: TokenInfoProps): ReactNode { - + {showAddress ? : null} diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/TokenListItem/index.tsx b/apps/cowswap-frontend/src/modules/tokensList/pure/TokenListItem/index.tsx index 5594741567b..9e141417f3e 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/pure/TokenListItem/index.tsx +++ b/apps/cowswap-frontend/src/modules/tokensList/pure/TokenListItem/index.tsx @@ -12,6 +12,7 @@ import { Nullish } from 'types' import * as styledEl from './styled' +import { useDeferredVisibility } from '../../hooks/useDeferredVisibility' import { TokenInfo } from '../TokenInfo' import { TokenTags } from '../TokenTags' @@ -58,6 +59,13 @@ export function TokenListItem(props: TokenListItemProps): ReactNode { className, } = props + const tokenKey = `${token.chainId}:${token.address.toLowerCase()}` + // Defer heavyweight UI (tooltips, formatted numbers) until the row is about to enter the viewport. + const { ref: visibilityRef, isVisible: hasIntersected } = useDeferredVisibility({ + resetKey: tokenKey, + rootMargin: '200px', + }) + const handleClick: MouseEventHandler = (e) => { if (isTokenSelected) { e.preventDefault() @@ -74,11 +82,15 @@ export function TokenListItem(props: TokenListItemProps): ReactNode { ) const isSupportedChain = token.chainId in SupportedChainId - - const balanceAmount = balance ? CurrencyAmount.fromRawAmount(token, balance.toHexString()) : undefined + const shouldShowBalances = isWalletConnected && isSupportedChain + // Formatting balances (BigNumber -> CurrencyAmount -> Fiat) is expensive; delay until the row is visible. + const shouldFormatBalances = shouldShowBalances && hasIntersected + const balanceAmount = + shouldFormatBalances && balance ? CurrencyAmount.fromRawAmount(token, balance.toHexString()) : undefined return ( + hasIntersected ? ( + + ) : null } /> - {isWalletConnected && ( - - {isSupportedChain ? ( - <> - {balanceAmount ? : LoadingElement} - {usdAmount ? : null} - - ) : null} - - )} + {children} ) } + +interface TokenBalanceColumnProps { + shouldShow: boolean + shouldFormat: boolean + balanceAmount?: CurrencyAmount + usdAmount?: CurrencyAmount | null +} + +function TokenBalanceColumn({ + shouldShow, + shouldFormat, + balanceAmount, + usdAmount, +}: TokenBalanceColumnProps): ReactNode { + if (!shouldShow) { + return null + } + + return ( + + {shouldFormat ? ( + <> + {balanceAmount ? : LoadingElement} + {usdAmount ? : null} + + ) : ( + LoadingElement + )} + + ) +} From 272d2c9fef1741119de8fd68cc2ead4be8173f76 Mon Sep 17 00:00:00 2001 From: fairlighteth <31534717+fairlighteth@users.noreply.github.com> Date: Thu, 13 Nov 2025 19:30:33 +0000 Subject: [PATCH 003/100] refactor(tokenselector): streamline token search results and enhance rendering logic --- .../containers/TokenSearchResults/index.tsx | 21 -- .../containers/TokenSearchResults/styled.ts | 8 +- .../tokensList/pure/ImportTokenItem/index.tsx | 13 +- .../tokensList/pure/ImportTokenItem/styled.ts | 11 +- .../pure/TokenSearchContent/index.tsx | 277 ++++++++++++++---- 5 files changed, 233 insertions(+), 97 deletions(-) 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 4ca54eced20..4bb84a16f8d 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 068ff64547d..d528acc9465 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 5330d9c2778..a238ab18d09 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. + +

+
) } From ec63bfadfbcc3fcf22073c042316d5b29f0d3435 Mon Sep 17 00:00:00 2001 From: fairlighteth <31534717+fairlighteth@users.noreply.github.com> Date: Fri, 14 Nov 2025 09:29:33 +0000 Subject: [PATCH 004/100] feat(tokenselector): implement recent tokens feature and enhance token selection context --- apps/cowswap-frontend/src/locales/en-US.po | 15 +- .../containers/SelectTokenWidget/index.tsx | 27 ++- .../containers/TokenSearchResults/index.tsx | 11 +- .../tokensList/hooks/recentTokensStorage.ts | 218 ++++++++++++++++++ .../tokensList/hooks/useRecentTokens.ts | 136 +++++++++++ .../pure/FavoriteTokensList/index.tsx | 106 +++++---- .../pure/FavoriteTokensList/styled.ts | 47 ++-- .../pure/SelectTokenModal/index.cosmos.tsx | 8 + .../pure/SelectTokenModal/index.tsx | 21 +- .../pure/SelectTokenModal/styled.ts | 25 ++ .../tokensList/pure/TokenListItem/index.tsx | 3 +- .../pure/TokenListItemContainer/index.tsx | 12 +- .../tokensList/pure/TokensContent/index.tsx | 165 +++++++++---- .../pure/TokensVirtualList/index.tsx | 128 +++++++++- .../src/modules/tokensList/types.ts | 5 +- .../src/modules/tokensList/utils/tokenKey.ts | 7 + 16 files changed, 803 insertions(+), 131 deletions(-) create mode 100644 apps/cowswap-frontend/src/modules/tokensList/hooks/recentTokensStorage.ts create mode 100644 apps/cowswap-frontend/src/modules/tokensList/hooks/useRecentTokens.ts create mode 100644 apps/cowswap-frontend/src/modules/tokensList/utils/tokenKey.ts diff --git a/apps/cowswap-frontend/src/locales/en-US.po b/apps/cowswap-frontend/src/locales/en-US.po index ea987f8f0b2..8916c8fb9a0 100644 --- a/apps/cowswap-frontend/src/locales/en-US.po +++ b/apps/cowswap-frontend/src/locales/en-US.po @@ -455,6 +455,7 @@ msgid "View details" msgstr "View details" #: apps/cowswap-frontend/src/modules/application/containers/App/menuConsts.tsx +#: apps/cowswap-frontend/src/modules/tokensList/pure/ChainsSelector/index.tsx msgid "More" msgstr "More" @@ -1219,8 +1220,8 @@ msgid "Partner fee can not be more than {PARTNER_FEE_MAX_BPS} BPS!" msgstr "Partner fee can not be more than {PARTNER_FEE_MAX_BPS} BPS!" #: apps/cowswap-frontend/src/modules/tokensList/pure/TokensContent/index.tsx -#~ msgid "Manage Token Lists" -#~ msgstr "Manage Token Lists" +msgid "Manage Token Lists" +msgstr "Manage Token Lists" #: apps/cowswap-frontend/src/legacy/components/Tokens/TokensTable.tsx msgid "No results found" @@ -3155,6 +3156,7 @@ msgstr "CoW Swap's robust solver competition protects your slippage from being e msgid "Aave Debt Swap Flashloan" msgstr "Aave Debt Swap Flashloan" +#: apps/cowswap-frontend/src/modules/tokensList/pure/LpTokenLists/index.tsx #: apps/cowswap-frontend/src/modules/yield/pure/TargetPoolPreviewInfo.tsx msgid "Details" msgstr "Details" @@ -4324,6 +4326,7 @@ msgstr "Decrease Value" #: apps/cowswap-frontend/src/legacy/components/Tokens/TokensTable.tsx #: apps/cowswap-frontend/src/modules/ethFlow/pure/WrappingPreview/WrapCard.tsx #: apps/cowswap-frontend/src/modules/tokensList/pure/LpTokenLists/index.tsx +#: apps/cowswap-frontend/src/modules/tokensList/pure/LpTokenLists/index.tsx msgid "Balance" msgstr "Balance" @@ -4386,8 +4389,8 @@ msgid "funds" msgstr "funds" #: apps/cowswap-frontend/src/modules/tokensList/pure/LpTokenLists/index.tsx -#~ msgid "Pool details" -#~ msgstr "Pool details" +msgid "Pool details" +msgstr "Pool details" #: apps/cowswap-frontend/src/modules/tradeSlippage/containers/HighSuggestedSlippageWarning/index.tsx msgid "Slippage adjusted to {slippageBpsPercentage}% to ensure quick execution" @@ -5895,8 +5898,8 @@ msgid "You sold <0/>" msgstr "You sold <0/>" #: apps/cowswap-frontend/src/modules/tokensList/pure/ChainsSelector/index.tsx -#~ msgid "Less" -#~ msgstr "Less" +msgid "Less" +msgstr "Less" #: libs/hook-dapp-lib/src/hookDappsRegistry.ts msgid "Claim your LlamaPay vesting contract funds" diff --git a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/index.tsx b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/index.tsx index a58d847acca..fa8966f62fa 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/index.tsx +++ b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/index.tsx @@ -31,6 +31,7 @@ import { useChainsToSelect } from '../../hooks/useChainsToSelect' import { useCloseTokenSelectWidget } from '../../hooks/useCloseTokenSelectWidget' import { useOnSelectChain } from '../../hooks/useOnSelectChain' import { useOnTokenListAddingError } from '../../hooks/useOnTokenListAddingError' +import { useRecentTokens } from '../../hooks/useRecentTokens' import { useSelectTokenWidgetState } from '../../hooks/useSelectTokenWidgetState' import { useTokensToSelect } from '../../hooks/useTokensToSelect' import { useUpdateSelectTokenWidgetState } from '../../hooks/useUpdateSelectTokenWidgetState' @@ -69,6 +70,7 @@ export function SelectTokenWidget({ displayLpTokenLists, standalone }: SelectTok selectedPoolAddress, field, oppositeToken, + selectedTargetChainId, } = useSelectTokenWidgetState() const { count: lpTokensWithBalancesCount } = useLpTokensWithBalances() const chainsToSelect = useChainsToSelect() @@ -82,7 +84,7 @@ export function SelectTokenWidget({ displayLpTokenLists, standalone }: SelectTok ) const updateSelectTokenWidget = useUpdateSelectTokenWidgetState() - const { account } = useWalletInfo() + const { account, chainId: walletChainId } = useWalletInfo() const cowAnalytics = useCowAnalytics() const addCustomTokenLists = useAddList((source) => { @@ -101,6 +103,17 @@ export function SelectTokenWidget({ displayLpTokenLists, standalone }: SelectTok areTokensFromBridge, isRouteAvailable, } = useTokensToSelect() + const { recentTokens, addRecentToken, clearRecentTokens } = useRecentTokens({ + allTokens, + favoriteTokens, + activeChainId: selectedTargetChainId ?? walletChainId, + }) + const handleTokenListItemClick = useCallback( + (token: TokenWithLogo) => { + addRecentToken(token) + }, + [addRecentToken], + ) const userAddedTokens = useUserAddedTokens() const allTokenLists = useAllListsList() @@ -138,7 +151,13 @@ export function SelectTokenWidget({ displayLpTokenLists, standalone }: SelectTok const importTokenAndClose = (tokens: TokenWithLogo[]): void => { importTokenCallback(tokens) - onSelectToken?.(tokens[0]) + const [tokenToSelect] = tokens + + if (tokenToSelect) { + handleTokenListItemClick(tokenToSelect) + onSelectToken?.(tokenToSelect) + } + onDismiss() } @@ -209,9 +228,11 @@ export function SelectTokenWidget({ displayLpTokenLists, standalone }: SelectTok selectedToken={selectedToken} allTokens={allTokens} favoriteTokens={standalone ? EMPTY_FAV_TOKENS : favoriteTokens} + recentTokens={standalone ? undefined : recentTokens} balancesState={balancesState} permitCompatibleTokens={permitCompatibleTokens} onSelectToken={onSelectToken} + onTokenListItemClick={handleTokenListItemClick} onInputPressEnter={onInputPressEnter} onDismiss={onDismiss} onOpenManageWidget={() => setIsManageWidgetOpen(true)} @@ -226,6 +247,8 @@ export function SelectTokenWidget({ displayLpTokenLists, standalone }: SelectTok tokenListTags={tokenListTags} areTokensFromBridge={areTokensFromBridge} isRouteAvailable={isRouteAvailable} + onClearRecentTokens={clearRecentTokens} + selectedTargetChainId={selectedTargetChainId} /> ) })()} 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 4bb84a16f8d..b43723b9f20 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/containers/TokenSearchResults/index.tsx +++ b/apps/cowswap-frontend/src/modules/tokensList/containers/TokenSearchResults/index.tsx @@ -23,7 +23,7 @@ export function TokenSearchResults({ areTokensFromBridge, allTokens, }: TokenSearchResultsProps): ReactNode { - const { onSelectToken } = selectTokenContext + const { onSelectToken, onTokenListItemClick } = selectTokenContext // Do not make search when tokens are from bridge const defaultSearchResults = useSearchToken(areTokensFromBridge ? null : searchInput) @@ -56,9 +56,14 @@ export function TokenSearchResults({ if (!searchInput || !activeListsResult) return if (activeListsResult.length === 1 || matchedTokens.length === 1) { - onSelectToken(matchedTokens[0] || activeListsResult[0]) + const tokenToSelect = matchedTokens[0] || activeListsResult[0] + + if (tokenToSelect) { + onTokenListItemClick?.(tokenToSelect) + onSelectToken(tokenToSelect) + } } - }, [searchInput, activeListsResult, matchedTokens, onSelectToken]) + }, [searchInput, activeListsResult, matchedTokens, onSelectToken, onTokenListItemClick]) useEffect(() => { updateSelectTokenWidget({ diff --git a/apps/cowswap-frontend/src/modules/tokensList/hooks/recentTokensStorage.ts b/apps/cowswap-frontend/src/modules/tokensList/hooks/recentTokensStorage.ts new file mode 100644 index 00000000000..990b944bc29 --- /dev/null +++ b/apps/cowswap-frontend/src/modules/tokensList/hooks/recentTokensStorage.ts @@ -0,0 +1,218 @@ +import { TokenWithLogo } from '@cowprotocol/common-const' + +import { getTokenUniqueKey } from '../utils/tokenKey' + +export const RECENT_TOKENS_LIMIT = 4 +export const RECENT_TOKENS_STORAGE_KEY = 'select-token-widget:recent-tokens:v1' + +export interface StoredRecentToken { + chainId: number + address: string + decimals: number + symbol?: string + name?: string + logoURI?: string + tags?: string[] +} + +export type StoredRecentTokensByChain = Record + +export function buildTokensByKey(tokens: TokenWithLogo[]): Map { + const map = new Map() + + for (const token of tokens) { + map.set(getTokenUniqueKey(token), token) + } + + return map +} + +export function buildFavoriteTokenKeys(tokens: TokenWithLogo[]): Set { + const set = new Set() + + for (const token of tokens) { + set.add(getTokenUniqueKey(token)) + } + + return set +} + +export function hydrateStoredToken(entry: StoredRecentToken, canonical?: TokenWithLogo): TokenWithLogo | null { + if (canonical) { + return canonical + } + + try { + return new TokenWithLogo( + entry.logoURI, + entry.chainId, + entry.address, + entry.decimals, + entry.symbol, + entry.name, + undefined, + entry.tags ?? [], + ) + } catch { + return null + } +} + +export function getStoredTokenKey(token: StoredRecentToken): string { + return getTokenUniqueKey(token) +} + +export function readStoredTokens(limit: number): StoredRecentTokensByChain { + if (!canUseLocalStorage()) { + return {} + } + + try { + const rawValue = window.localStorage.getItem(RECENT_TOKENS_STORAGE_KEY) + + if (!rawValue) { + return {} + } + + const parsed: unknown = JSON.parse(rawValue) + + if (Array.isArray(parsed)) { + return migrateLegacyStoredTokens(parsed, limit) + } + + if (parsed && typeof parsed === 'object') { + return sanitizeStoredTokensMap(parsed as Record, limit) + } + + return {} + } catch { + return {} + } +} + +export function persistStoredTokens(tokens: StoredRecentTokensByChain): void { + if (!canUseLocalStorage()) { + return + } + + try { + window.localStorage.setItem(RECENT_TOKENS_STORAGE_KEY, JSON.stringify(tokens)) + } catch { + // Best effort persistence + } +} + +export function buildNextStoredTokens( + prev: StoredRecentTokensByChain, + token: TokenWithLogo, + maxItems: number, +): StoredRecentTokensByChain { + const chainId = token.chainId + const normalized = toStoredToken(token) + const chainEntries = prev[chainId] ?? [] + const updatedChain = insertToken(chainEntries, normalized, maxItems) + + return { + ...prev, + [chainId]: updatedChain, + } +} + +export function persistRecentTokenSelection( + token: TokenWithLogo, + favoriteTokens: TokenWithLogo[], + maxItems = RECENT_TOKENS_LIMIT, +): void { + const favoriteKeys = buildFavoriteTokenKeys(favoriteTokens) + + if (favoriteKeys.has(getTokenUniqueKey(token))) { + return + } + + const current = readStoredTokens(maxItems) + const next = buildNextStoredTokens(current, token, maxItems) + + persistStoredTokens(next) +} + +function sanitizeStoredTokensMap(record: Record, limit: number): StoredRecentTokensByChain { + const entries: StoredRecentTokensByChain = {} + + for (const [chainKey, tokens] of Object.entries(record)) { + const chainId = Number(chainKey) + + if (Number.isNaN(chainId) || !Array.isArray(tokens)) { + continue + } + + const sanitized = tokens + .map((token) => sanitizeStoredToken(token)) + .filter((token): token is StoredRecentToken => Boolean(token)) + + if (sanitized.length) { + entries[chainId] = sanitized.slice(0, limit) + } + } + + return entries +} + +function migrateLegacyStoredTokens(entries: unknown[], limit: number): StoredRecentTokensByChain { + return entries + .map((entry) => sanitizeStoredToken(entry)) + .filter((entry): entry is StoredRecentToken => Boolean(entry)) + .reverse() + .reduce((acc, sanitized) => { + const chainId = sanitized.chainId + const chain = acc[chainId] ?? [] + + acc[chainId] = insertToken(chain, sanitized, limit) + + return acc + }, {}) +} + +function sanitizeStoredToken(token: unknown): StoredRecentToken | null { + if (!token || typeof token !== 'object') { + return null + } + + const { chainId, address, decimals, symbol, name, logoURI, tags } = token as StoredRecentToken + + if (typeof chainId !== 'number' || typeof address !== 'string' || typeof decimals !== 'number') { + return null + } + + return { + chainId, + address: address.toLowerCase(), + decimals, + symbol: typeof symbol === 'string' ? symbol : undefined, + name: typeof name === 'string' ? name : undefined, + logoURI: typeof logoURI === 'string' ? logoURI : undefined, + tags: Array.isArray(tags) ? tags.filter((tag): tag is string => typeof tag === 'string') : undefined, + } +} + +function insertToken(tokens: StoredRecentToken[], token: StoredRecentToken, limit: number): StoredRecentToken[] { + const key = getTokenUniqueKey(token) + const withoutToken = tokens.filter((entry) => getTokenUniqueKey(entry) !== key) + + return [token, ...withoutToken].slice(0, limit) +} + +function toStoredToken(token: TokenWithLogo): StoredRecentToken { + return { + chainId: token.chainId, + address: token.address.toLowerCase(), + decimals: token.decimals, + symbol: token.symbol, + name: token.name, + logoURI: token.logoURI, + tags: token.tags, + } +} + +function canUseLocalStorage(): boolean { + return typeof window !== 'undefined' && typeof window.localStorage !== 'undefined' +} diff --git a/apps/cowswap-frontend/src/modules/tokensList/hooks/useRecentTokens.ts b/apps/cowswap-frontend/src/modules/tokensList/hooks/useRecentTokens.ts new file mode 100644 index 00000000000..e1ea8a97cf0 --- /dev/null +++ b/apps/cowswap-frontend/src/modules/tokensList/hooks/useRecentTokens.ts @@ -0,0 +1,136 @@ +import { useCallback, useEffect, useMemo, useState } from 'react' + +import { TokenWithLogo } from '@cowprotocol/common-const' + +import { + RECENT_TOKENS_LIMIT, + buildFavoriteTokenKeys, + buildNextStoredTokens, + buildTokensByKey, + getStoredTokenKey, + hydrateStoredToken, + persistRecentTokenSelection as persistRecentTokenSelectionInternal, + persistStoredTokens, + readStoredTokens, + type StoredRecentTokensByChain, +} from './recentTokensStorage' + +import { getTokenUniqueKey } from '../utils/tokenKey' + +interface UseRecentTokensParams { + allTokens: TokenWithLogo[] + favoriteTokens: TokenWithLogo[] + activeChainId?: number + maxItems?: number +} + +export interface RecentTokensState { + recentTokens: TokenWithLogo[] + addRecentToken(token: TokenWithLogo): void + clearRecentTokens(): void +} + +export function useRecentTokens({ + allTokens, + favoriteTokens, + activeChainId, + maxItems = RECENT_TOKENS_LIMIT, +}: UseRecentTokensParams): RecentTokensState { + const [storedTokensByChain, setStoredTokensByChain] = useState(() => + readStoredTokens(maxItems), + ) + + useEffect(() => { + persistStoredTokens(storedTokensByChain) + }, [storedTokensByChain]) + + const tokensByKey = useMemo(() => buildTokensByKey(allTokens), [allTokens]) + const favoriteKeys = useMemo(() => buildFavoriteTokenKeys(favoriteTokens), [favoriteTokens]) + + useEffect(() => { + setStoredTokensByChain((prev) => { + const nextEntries: StoredRecentTokensByChain = {} + let didChange = false + + for (const [chainKey, tokens] of Object.entries(prev)) { + const chainId = Number(chainKey) + const filtered = tokens.filter((token) => !favoriteKeys.has(getStoredTokenKey(token))) + + if (filtered.length) { + nextEntries[chainId] = filtered + } + + didChange = didChange || filtered.length !== tokens.length + } + + return didChange ? nextEntries : prev + }) + }, [favoriteKeys]) + + const recentTokens = useMemo(() => { + const chainEntries = activeChainId ? storedTokensByChain[activeChainId] ?? [] : [] + const seenKeys = new Set() + const result: TokenWithLogo[] = [] + + for (const entry of chainEntries) { + const key = getStoredTokenKey(entry) + + if (seenKeys.has(key) || favoriteKeys.has(key)) { + continue + } + + const hydrated = hydrateStoredToken(entry, tokensByKey.get(key)) + + if (hydrated) { + result.push(hydrated) + seenKeys.add(key) + } + + if (result.length >= maxItems) { + break + } + } + + return result + }, [activeChainId, favoriteKeys, maxItems, storedTokensByChain, tokensByKey]) + + const addRecentToken = useCallback( + (token: TokenWithLogo) => { + if (favoriteKeys.has(getTokenUniqueKey(token))) { + return + } + + setStoredTokensByChain((prev) => { + const next = buildNextStoredTokens(prev, token, maxItems) + + persistStoredTokens(next) + + return next + }) + }, + [favoriteKeys, maxItems], + ) + + const clearRecentTokens = useCallback(() => { + if (!activeChainId) { + return + } + + setStoredTokensByChain((prev) => { + const chainEntries = prev[activeChainId] + + if (!chainEntries?.length) { + return prev + } + + const next: StoredRecentTokensByChain = { ...prev, [activeChainId]: [] } + persistStoredTokens(next) + + return next + }) + }, [activeChainId]) + + return { recentTokens, addRecentToken, clearRecentTokens } +} + +export { persistRecentTokenSelectionInternal as persistRecentTokenSelection } diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/FavoriteTokensList/index.tsx b/apps/cowswap-frontend/src/modules/tokensList/pure/FavoriteTokensList/index.tsx index b7c3623b896..103ceaa01b7 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/pure/FavoriteTokensList/index.tsx +++ b/apps/cowswap-frontend/src/modules/tokensList/pure/FavoriteTokensList/index.tsx @@ -1,61 +1,87 @@ import { ReactNode } from 'react' import { TokenWithLogo } from '@cowprotocol/common-const' +import { areAddressesEqual, getCurrencyAddress } from '@cowprotocol/common-utils' import { TokenLogo } from '@cowprotocol/tokens' -import { HelpTooltip, TokenSymbol } from '@cowprotocol/ui' +import { TokenSymbol } from '@cowprotocol/ui' import { Trans } from '@lingui/react/macro' import { Link } from 'react-router' import * as styledEl from './styled' +import { SelectTokenContext } from '../../types' + export interface FavoriteTokensListProps { tokens: TokenWithLogo[] + selectTokenContext: SelectTokenContext hideTooltip?: boolean - selectedToken?: string - - onSelectToken(token: TokenWithLogo): void } export function FavoriteTokensList(props: FavoriteTokensListProps): ReactNode { - const { tokens, hideTooltip, selectedToken, onSelectToken } = props + const { tokens, selectTokenContext, hideTooltip } = props + + if (!tokens.length) { + return null + } return ( -
- -

+ + + Favorite tokens -

- {!hideTooltip && ( - - Your favorite saved tokens. Edit this list in the Tokens page. - - } - /> - )} -
- - {tokens.map((token) => { - const isTokenSelected = token.address.toLowerCase() === selectedToken?.toLowerCase() - - return ( - onSelectToken(token)} - > - - - - ) - })} - -
+ + {!hideTooltip && } + + {renderFavoriteTokenItems(tokens, selectTokenContext)} + + ) +} + +function FavoriteTokensTooltip(): ReactNode { + return ( + + Your favorite saved tokens. Edit this list in the Tokens page. + + } + /> ) } + +function renderFavoriteTokenItems(tokens: TokenWithLogo[], context: SelectTokenContext): ReactNode[] { + const { selectedToken } = context + const selectedAddress = selectedToken ? getCurrencyAddress(selectedToken) : undefined + + return tokens.map((token) => { + const isSelected = + !!selectedToken && + token.chainId === selectedToken.chainId && + !!selectedAddress && + areAddressesEqual(token.address, selectedAddress) + + const handleClick = (): void => { + if (isSelected) { + return + } + context.onTokenListItemClick?.(token) + context.onSelectToken(token) + } + + return ( + + + + + ) + }) +} diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/FavoriteTokensList/styled.ts b/apps/cowswap-frontend/src/modules/tokensList/pure/FavoriteTokensList/styled.ts index ee278a509a1..ad258cd2cc2 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/pure/FavoriteTokensList/styled.ts +++ b/apps/cowswap-frontend/src/modules/tokensList/pure/FavoriteTokensList/styled.ts @@ -1,18 +1,25 @@ -import { Media, UI } from '@cowprotocol/ui' +import { HelpTooltip, Media, UI } from '@cowprotocol/ui' import styled from 'styled-components/macro' -export const Header = styled.div` +export const Section = styled.div` + padding: 0 14px 14px; + + ${Media.upToSmall()} { + padding: 8px 14px 4px; + } +` + +export const TitleRow = styled.div` display: flex; - gap: 5px; - flex-direction: row; align-items: center; +` - > h4 { - font-size: 14px; - font-weight: 500; - margin: 0; - } +export const Title = styled.h4` + font-size: 14px; + font-weight: 500; + margin: 0; + color: var(${UI.COLOR_TEXT_OPACITY_70}); ` export const List = styled.div` @@ -25,9 +32,8 @@ export const List = styled.div` width: 0; min-width: 100%; flex-wrap: nowrap; - overflow-x: scroll; + overflow-x: auto; overflow-y: hidden; - padding: 10px 0; -webkit-overflow-scrolling: touch; @@ -44,9 +50,8 @@ export const List = styled.div` } ` -export const TokensItem = styled.button` +export const TokenButton = styled.button` display: inline-flex; - flex-direction: row; align-items: center; gap: 6px; justify-content: center; @@ -58,9 +63,9 @@ export const TokensItem = styled.button` border: 1px solid var(${UI.COLOR_PAPER_DARKER}); font-weight: 500; font-size: 16px; - cursor: ${({ disabled }) => (disabled ? '' : 'pointer')}; - background: ${({ disabled }) => disabled && `var(${UI.COLOR_PAPER_DARKER})`}; - opacity: ${({ disabled }) => (disabled ? 0.6 : 1)}; + cursor: ${({ disabled }) => (disabled ? 'default' : 'pointer')}; + background: ${({ disabled }) => (disabled ? `var(${UI.COLOR_PAPER_DARKER})` : 'transparent')}; + opacity: ${({ disabled }) => (disabled ? 0.65 : 1)}; transition: border var(${UI.ANIMATION_DURATION}) ease-in-out; white-space: nowrap; @@ -72,3 +77,13 @@ export const TokensItem = styled.button` border: 1px solid ${({ disabled }) => (disabled ? `var(${UI.COLOR_PAPER_DARKER})` : `var(${UI.COLOR_PRIMARY})`)}; } ` + +export const FavoriteTooltip = styled(HelpTooltip)` + color: var(${UI.COLOR_TEXT_OPACITY_50}); + transition: color 0.2s ease-in-out; + margin-left: 6px; + + &:hover { + color: var(${UI.COLOR_TEXT}); + } +` diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/index.cosmos.tsx b/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/index.cosmos.tsx index 4a432e5c98a..00fef623738 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/index.cosmos.tsx +++ b/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/index.cosmos.tsx @@ -48,9 +48,17 @@ const defaultProps: SelectTokenModalProps = { }, selectedToken, isRouteAvailable: true, + recentTokens: favoriteTokensMock.slice(0, 2), + selectedTargetChainId: SupportedChainId.SEPOLIA, onSelectToken() { console.log('onSelectToken') }, + onTokenListItemClick(token) { + console.log('onTokenListItemClick', token.symbol) + }, + onClearRecentTokens() { + console.log('onClearRecentTokens') + }, onOpenManageWidget() { console.log('onOpenManageWidget') }, diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/index.tsx b/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/index.tsx index f1092ce7ec9..a4e1e5f5609 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/index.tsx +++ b/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/index.tsx @@ -17,7 +17,7 @@ import { SelectTokenModalContent } from './SelectTokenModalContent' import * as styledEl from './styled' import { LpTokenListsWidget } from '../../containers/LpTokenListsWidget' -import { ChainsToSelectState, SelectTokenContext } from '../../types' +import { ChainsToSelectState, SelectTokenContext, TokenSelectionHandler } from '../../types' import { ChainsSelector } from '../ChainsSelector' import { IconButton } from '../commonElements' import { TokensContent } from '../TokensContent' @@ -25,6 +25,7 @@ import { TokensContent } from '../TokensContent' export interface SelectTokenModalProps { allTokens: TokenWithLogo[] favoriteTokens: TokenWithLogo[] + recentTokens?: TokenWithLogo[] balancesState: BalancesState unsupportedTokens: UnsupportedTokensState selectedToken?: Nullish @@ -41,13 +42,16 @@ export interface SelectTokenModalProps { standalone?: boolean areTokensFromBridge: boolean isRouteAvailable: boolean | undefined + selectedTargetChainId?: number - onSelectToken(token: TokenWithLogo): void + onSelectToken: TokenSelectionHandler + onTokenListItemClick?(token: TokenWithLogo): void openPoolPage(poolAddress: string): void onInputPressEnter?(): void onOpenManageWidget(): void onDismiss(): void onSelectChain(chain: ChainInfo): void + onClearRecentTokens?(): void } function useSelectTokenContext(props: SelectTokenModalProps): SelectTokenContext { @@ -57,6 +61,7 @@ function useSelectTokenContext(props: SelectTokenModalProps): SelectTokenContext unsupportedTokens, permitCompatibleTokens, onSelectToken, + onTokenListItemClick, account, tokenListTags, } = props @@ -66,12 +71,22 @@ function useSelectTokenContext(props: SelectTokenModalProps): SelectTokenContext balancesState, selectedToken, onSelectToken, + onTokenListItemClick, unsupportedTokens, permitCompatibleTokens, tokenListTags, isWalletConnected: !!account, }), - [balancesState, selectedToken, onSelectToken, unsupportedTokens, permitCompatibleTokens, tokenListTags, account], + [ + balancesState, + selectedToken, + onSelectToken, + onTokenListItemClick, + unsupportedTokens, + permitCompatibleTokens, + tokenListTags, + account, + ], ) } diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/styled.ts b/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/styled.ts index 3016d33f0c9..9bcc0473df5 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/styled.ts +++ b/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/styled.ts @@ -80,3 +80,28 @@ export const RouteNotAvailable = styled.div` padding: 20px 0; text-align: center; ` + +export const ListTitle = styled.div` + display: flex; + justify-content: space-between; + align-items: center; + font-size: 12px; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(${UI.COLOR_TEXT_OPACITY_50}); + padding: 12px 20px 4px; +` + +export const ListTitleActionButton = styled.button` + ${blankButtonMixin}; + font-size: 12px; + font-weight: 600; + color: var(${UI.COLOR_PRIMARY}); + cursor: pointer; + transition: opacity var(${UI.ANIMATION_DURATION}) ease-in-out; + + &:hover { + opacity: 0.75; + } +` diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/TokenListItem/index.tsx b/apps/cowswap-frontend/src/modules/tokensList/pure/TokenListItem/index.tsx index 9e141417f3e..d9d4c867a42 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/pure/TokenListItem/index.tsx +++ b/apps/cowswap-frontend/src/modules/tokensList/pure/TokenListItem/index.tsx @@ -13,6 +13,7 @@ import { Nullish } from 'types' import * as styledEl from './styled' import { useDeferredVisibility } from '../../hooks/useDeferredVisibility' +import { TokenSelectionHandler } from '../../types' import { TokenInfo } from '../TokenInfo' import { TokenTags } from '../TokenTags' @@ -28,7 +29,7 @@ export interface TokenListItemProps { balance: BigNumber | undefined usdAmount?: CurrencyAmount | null - onSelectToken?(token: TokenWithLogo): void + onSelectToken?: TokenSelectionHandler isWalletConnected: boolean isUnsupported?: boolean diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/TokenListItemContainer/index.tsx b/apps/cowswap-frontend/src/modules/tokensList/pure/TokenListItemContainer/index.tsx index 4dd4ca71e46..97208d6eb0f 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/pure/TokenListItemContainer/index.tsx +++ b/apps/cowswap-frontend/src/modules/tokensList/pure/TokenListItemContainer/index.tsx @@ -1,4 +1,4 @@ -import { ReactNode } from 'react' +import { ReactNode, useCallback } from 'react' import { TokenWithLogo } from '@cowprotocol/common-const' @@ -14,6 +14,7 @@ export function TokenListItemContainer({ token, context }: TokenListItemContaine const { unsupportedTokens, onSelectToken, + onTokenListItemClick, selectedToken, tokenListTags, permitCompatibleTokens, @@ -22,6 +23,13 @@ export function TokenListItemContainer({ token, context }: TokenListItemContaine } = context const addressLowerCase = token.address.toLowerCase() + const handleSelectToken = useCallback( + (tokenToSelect: TokenWithLogo) => { + onTokenListItemClick?.(tokenToSelect) + onSelectToken(tokenToSelect) + }, + [onSelectToken, onTokenListItemClick], + ) return ( diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/TokensContent/index.tsx b/apps/cowswap-frontend/src/modules/tokensList/pure/TokensContent/index.tsx index fa6b1ccf049..cd8bb607702 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/pure/TokensContent/index.tsx +++ b/apps/cowswap-frontend/src/modules/tokensList/pure/TokensContent/index.tsx @@ -1,17 +1,14 @@ -import React, { ReactNode } from 'react' +import React, { ReactNode, useMemo } from 'react' import { TokenWithLogo } from '@cowprotocol/common-const' -import { getCurrencyAddress } from '@cowprotocol/common-utils' -import { Nullish } from '@cowprotocol/types' import { Loader } from '@cowprotocol/ui' -import { Currency } from '@uniswap/sdk-core' import { Trans } from '@lingui/react/macro' import { Edit } from 'react-feather' import { TokenSearchResults } from '../../containers/TokenSearchResults' import { SelectTokenContext } from '../../types' -import { FavoriteTokensList } from '../FavoriteTokensList' +import { getTokenUniqueKey } from '../../utils/tokenKey' import * as styledEl from '../SelectTokenModal/styled' import { TokensVirtualList } from '../TokensVirtualList' @@ -19,24 +16,22 @@ export interface TokensContentProps { displayLpTokenLists?: boolean selectTokenContext: SelectTokenContext favoriteTokens: TokenWithLogo[] - selectedToken?: Nullish + recentTokens?: TokenWithLogo[] hideFavoriteTokensTooltip?: boolean areTokensLoading: boolean allTokens: TokenWithLogo[] searchInput: string standalone?: boolean areTokensFromBridge: boolean - - onSelectToken(token: TokenWithLogo): void + selectedTargetChainId?: number onOpenManageWidget(): void + onClearRecentTokens?: () => void } export function TokensContent({ selectTokenContext, - onSelectToken, - onOpenManageWidget, - selectedToken, favoriteTokens, + recentTokens, hideFavoriteTokensTooltip, areTokensLoading, allTokens, @@ -44,44 +39,60 @@ export function TokensContent({ searchInput, standalone, areTokensFromBridge, + selectedTargetChainId, + onOpenManageWidget, + onClearRecentTokens, }: TokensContentProps): ReactNode { + const shouldShowFavoritesInline = !areTokensLoading && !searchInput && favoriteTokens.length > 0 + const shouldShowRecentsInline = !areTokensLoading && !searchInput && (recentTokens?.length ?? 0) > 0 + + const pinnedTokenKeys = useMemo(() => { + if (!shouldShowFavoritesInline && !shouldShowRecentsInline) { + return undefined + } + + const pinned = new Set() + + if (shouldShowFavoritesInline) { + favoriteTokens.forEach((token) => pinned.add(getTokenUniqueKey(token))) + } + + if (shouldShowRecentsInline && recentTokens) { + recentTokens.forEach((token) => pinned.add(getTokenUniqueKey(token))) + } + + return pinned + }, [favoriteTokens, recentTokens, shouldShowFavoritesInline, shouldShowRecentsInline]) + + const tokensWithoutPinned = useMemo(() => { + if (!pinnedTokenKeys) { + return allTokens + } + + return allTokens.filter((token) => !pinnedTokenKeys.has(getTokenUniqueKey(token))) + }, [allTokens, pinnedTokenKeys]) + + const favoriteTokensInline = shouldShowFavoritesInline ? favoriteTokens : undefined + const recentTokensInline = shouldShowRecentsInline ? recentTokens : undefined + + const tokensView = renderTokensView({ + areTokensLoading, + searchInput, + selectTokenContext, + areTokensFromBridge, + allTokens, + tokensWithoutPinned, + displayLpTokenLists, + favoriteTokens: favoriteTokensInline, + recentTokens: recentTokensInline, + hideFavoriteTokensTooltip, + selectedTargetChainId, + onClearRecentTokens, + }) + return ( <> - {!areTokensLoading && !!favoriteTokens.length && ( - <> - - - - - - )} - {areTokensLoading ? ( - - - - ) : ( - <> - {searchInput ? ( - - ) : ( - - )} - - )} + {tokensView} {!standalone && ( <> @@ -98,3 +109,65 @@ export function TokensContent({ ) } + +interface TokensViewProps { + areTokensLoading: boolean + searchInput: string + selectTokenContext: SelectTokenContext + areTokensFromBridge: boolean + allTokens: TokenWithLogo[] + tokensWithoutPinned: TokenWithLogo[] + displayLpTokenLists?: boolean + favoriteTokens?: TokenWithLogo[] + recentTokens?: TokenWithLogo[] + hideFavoriteTokensTooltip?: boolean + selectedTargetChainId?: number + onClearRecentTokens?: () => void +} + +function renderTokensView({ + areTokensLoading, + searchInput, + selectTokenContext, + areTokensFromBridge, + allTokens, + tokensWithoutPinned, + displayLpTokenLists, + favoriteTokens, + recentTokens, + hideFavoriteTokensTooltip, + selectedTargetChainId, + onClearRecentTokens, +}: TokensViewProps): ReactNode { + if (areTokensLoading) { + return ( + + + + ) + } + + if (searchInput) { + return ( + + ) + } + + return ( + + ) +} diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/TokensVirtualList/index.tsx b/apps/cowswap-frontend/src/modules/tokensList/pure/TokensVirtualList/index.tsx index f657eb27c19..a4f65bfbaaf 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/pure/TokensVirtualList/index.tsx +++ b/apps/cowswap-frontend/src/modules/tokensList/pure/TokensVirtualList/index.tsx @@ -2,6 +2,7 @@ import { ReactNode, useCallback, useMemo } from 'react' import { TokenWithLogo } from '@cowprotocol/common-const' import { useFeatureFlags } from '@cowprotocol/common-hooks' +import { getIsNativeToken } from '@cowprotocol/common-utils' import { VirtualItem } from '@tanstack/react-virtual' @@ -10,37 +11,142 @@ import { VirtualList } from 'common/pure/VirtualList' import { SelectTokenContext } from '../../types' import { tokensListSorter } from '../../utils/tokensListSorter' +import { FavoriteTokensList } from '../FavoriteTokensList' +import * as modalStyled from '../SelectTokenModal/styled' import { TokenListItemContainer } from '../TokenListItemContainer' export interface TokensVirtualListProps { allTokens: TokenWithLogo[] displayLpTokenLists?: boolean selectTokenContext: SelectTokenContext + favoriteTokens?: TokenWithLogo[] + recentTokens?: TokenWithLogo[] + hideFavoriteTokensTooltip?: boolean + scrollResetKey?: number + onClearRecentTokens?: () => void } +type TokensVirtualRow = + | { type: 'favorite-section'; tokens: TokenWithLogo[]; hideTooltip?: boolean } + | { type: 'title'; label: string; actionLabel?: string; onAction?: () => void } + | { type: 'token'; token: TokenWithLogo } + export function TokensVirtualList(props: TokensVirtualListProps): ReactNode { - const { allTokens, selectTokenContext, displayLpTokenLists } = props + const { + allTokens, + selectTokenContext, + displayLpTokenLists, + favoriteTokens, + recentTokens, + hideFavoriteTokensTooltip, + scrollResetKey, + onClearRecentTokens, + } = props const { values: balances } = selectTokenContext.balancesState const { isYieldEnabled } = useFeatureFlags() - const sortedTokens = useMemo( - () => (balances ? allTokens.sort(tokensListSorter(balances)) : allTokens), - [allTokens, balances], - ) + const sortedTokens = useMemo(() => { + if (!balances) { + return allTokens + } + + const prioritized: TokenWithLogo[] = [] + const remainder: TokenWithLogo[] = [] + + for (const token of allTokens) { + const hasBalance = Boolean(balances[token.address.toLowerCase()]) + if (hasBalance || getIsNativeToken(token)) { + prioritized.push(token) + } else { + remainder.push(token) + } + } + + const sortedPrioritized = + prioritized.length > 1 ? [...prioritized].sort(tokensListSorter(balances)) : prioritized + + return [...sortedPrioritized, ...remainder] + }, [allTokens, balances]) + + const rows = useMemo(() => { + const tokenRows = sortedTokens.map((token) => ({ type: 'token', token })) + const composedRows: TokensVirtualRow[] = [] + + if (favoriteTokens?.length) { + composedRows.push({ + type: 'favorite-section', + tokens: favoriteTokens, + hideTooltip: hideFavoriteTokensTooltip, + }) + } - const getItemView = useCallback( - (sortedTokens: TokenWithLogo[], virtualRow: VirtualItem) => { - const token = sortedTokens[virtualRow.index] + if (recentTokens?.length) { + composedRows.push({ + type: 'title', + label: 'Recent', + actionLabel: onClearRecentTokens ? 'Clear' : undefined, + onAction: onClearRecentTokens, + }) + recentTokens.forEach((token) => composedRows.push({ type: 'token', token })) + } - return - }, + if (favoriteTokens?.length || recentTokens?.length) { + composedRows.push({ type: 'title', label: 'All tokens' }) + } + + return [...composedRows, ...tokenRows] + }, [favoriteTokens, hideFavoriteTokensTooltip, onClearRecentTokens, recentTokens, sortedTokens]) + + const virtualListKey = scrollResetKey ?? 'tokens-list' + + const renderVirtualRow = useCallback( + (virtualRows: TokensVirtualRow[], virtualItem: VirtualItem) => ( + + ), [selectTokenContext], ) return ( - + {displayLpTokenLists || !isYieldEnabled ? null : } ) } + +interface TokensVirtualRowRendererProps { + row: TokensVirtualRow + selectTokenContext: SelectTokenContext +} + +function TokensVirtualRowRenderer({ row, selectTokenContext }: TokensVirtualRowRendererProps): ReactNode { + switch (row.type) { + case 'favorite-section': + return ( + + ) + case 'title': + return ( + + {row.label} + {row.actionLabel && row.onAction ? ( + + {row.actionLabel} + + ) : null} + + ) + default: + return + } +} diff --git a/apps/cowswap-frontend/src/modules/tokensList/types.ts b/apps/cowswap-frontend/src/modules/tokensList/types.ts index 5c775d8e0a7..da71e3ab963 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/types.ts +++ b/apps/cowswap-frontend/src/modules/tokensList/types.ts @@ -8,11 +8,14 @@ import { Nullish } from 'types' import { PermitCompatibleTokens } from 'modules/permit' +export type TokenSelectionHandler = (token: TokenWithLogo) => Promise | void + export interface SelectTokenContext { balancesState: BalancesState selectedToken?: Nullish - onSelectToken(token: TokenWithLogo): void + onSelectToken: TokenSelectionHandler + onTokenListItemClick?(token: TokenWithLogo): void unsupportedTokens: { [tokenAddress: string]: { dateAdded: number } } permitCompatibleTokens: PermitCompatibleTokens diff --git a/apps/cowswap-frontend/src/modules/tokensList/utils/tokenKey.ts b/apps/cowswap-frontend/src/modules/tokensList/utils/tokenKey.ts new file mode 100644 index 00000000000..8f827b02885 --- /dev/null +++ b/apps/cowswap-frontend/src/modules/tokensList/utils/tokenKey.ts @@ -0,0 +1,7 @@ +import { TokenWithLogo } from '@cowprotocol/common-const' + +type TokenIdentifier = Pick + +export function getTokenUniqueKey(token: TokenIdentifier): string { + return `${token.chainId}:${token.address.toLowerCase()}` +} From 071ced1a4194fe552fb702cec749c3df9521ed78 Mon Sep 17 00:00:00 2001 From: fairlighteth <31534717+fairlighteth@users.noreply.github.com> Date: Fri, 14 Nov 2025 10:09:01 +0000 Subject: [PATCH 005/100] feat(tokenselector): add chain selection logic and enhance token widget interactions --- .../containers/SelectTokenWidget/index.tsx | 2 +- .../hooks/useChainsToSelect.test.ts | 48 +++++++ .../tokensList/hooks/useChainsToSelect.ts | 117 +++++++++++++----- .../hooks/useCloseTokenSelectWidget.ts | 19 ++- .../tokensList/hooks/useOnSelectChain.ts | 24 +++- .../hooks/useOpenTokenSelectWidget.ts | 34 +++-- .../src/modules/tokensList/index.ts | 1 + .../pure/AddIntermediateTokenModal/index.tsx | 2 +- .../tokensList/state/selectTokenWidgetAtom.ts | 6 + .../test-utils/createChainInfoForTests.ts | 103 +++++++++++++++ .../utils/sortChainsByDisplayOrder.ts | 52 ++++++++ 11 files changed, 357 insertions(+), 51 deletions(-) create mode 100644 apps/cowswap-frontend/src/modules/tokensList/hooks/useChainsToSelect.test.ts create mode 100644 apps/cowswap-frontend/src/modules/tokensList/test-utils/createChainInfoForTests.ts create mode 100644 apps/cowswap-frontend/src/modules/tokensList/utils/sortChainsByDisplayOrder.ts diff --git a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/index.tsx b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/index.tsx index fa8966f62fa..21cc6df5bef 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/index.tsx +++ b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/index.tsx @@ -146,7 +146,7 @@ export function SelectTokenWidget({ displayLpTokenLists, standalone }: SelectTok const onDismiss = useCallback(() => { setIsManageWidgetOpen(false) - closeTokenSelectWidget() + closeTokenSelectWidget({ overrideForceLock: true }) }, [closeTokenSelectWidget]) const importTokenAndClose = (tokens: TokenWithLogo[]): void => { diff --git a/apps/cowswap-frontend/src/modules/tokensList/hooks/useChainsToSelect.test.ts b/apps/cowswap-frontend/src/modules/tokensList/hooks/useChainsToSelect.test.ts new file mode 100644 index 00000000000..5bf6a56e458 --- /dev/null +++ b/apps/cowswap-frontend/src/modules/tokensList/hooks/useChainsToSelect.test.ts @@ -0,0 +1,48 @@ +import { SupportedChainId } from '@cowprotocol/cow-sdk' + +import { createInputChainsState, createOutputChainsState } from './useChainsToSelect' + +import { createChainInfoForTests } from '../test-utils/createChainInfoForTests' + +describe('useChainsToSelect state builders', () => { + it('sorts sell-side chains using the canonical order', () => { + const supportedChains = [ + createChainInfoForTests(SupportedChainId.AVALANCHE), + createChainInfoForTests(SupportedChainId.BASE), + createChainInfoForTests(SupportedChainId.MAINNET), + ] + + const state = createInputChainsState(SupportedChainId.BASE, supportedChains) + + expect((state.chains ?? []).map((chain) => chain.id)).toEqual([ + SupportedChainId.MAINNET, + SupportedChainId.BASE, + SupportedChainId.AVALANCHE, + ]) + }) + + it('sorts bridge destination chains to match the canonical order', () => { + const bridgeChains = [ + createChainInfoForTests(SupportedChainId.AVALANCHE), + createChainInfoForTests(SupportedChainId.BASE), + createChainInfoForTests(SupportedChainId.ARBITRUM_ONE), + createChainInfoForTests(SupportedChainId.MAINNET), + ] + + const state = createOutputChainsState({ + selectedTargetChainId: SupportedChainId.POLYGON, + chainId: SupportedChainId.MAINNET, + currentChainInfo: createChainInfoForTests(SupportedChainId.MAINNET), + bridgeSupportedNetworks: bridgeChains, + areUnsupportedChainsEnabled: true, + isLoading: false, + }) + + expect((state.chains ?? []).map((chain) => chain.id)).toEqual([ + SupportedChainId.MAINNET, + SupportedChainId.BASE, + SupportedChainId.ARBITRUM_ONE, + SupportedChainId.AVALANCHE, + ]) + }) +}) diff --git a/apps/cowswap-frontend/src/modules/tokensList/hooks/useChainsToSelect.ts b/apps/cowswap-frontend/src/modules/tokensList/hooks/useChainsToSelect.ts index b781b7c1121..c42664014cd 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/hooks/useChainsToSelect.ts +++ b/apps/cowswap-frontend/src/modules/tokensList/hooks/useChainsToSelect.ts @@ -9,10 +9,13 @@ import { useBridgeSupportedNetworks } from 'entities/bridgeProvider' import { Field } from 'legacy/state/types' +import { TradeType } from 'modules/trade/types' + import { useSelectTokenWidgetState } from './useSelectTokenWidgetState' import { ChainsToSelectState } from '../types' import { mapChainInfo } from '../utils/mapChainInfo' +import { sortChainsByDisplayOrder } from '../utils/sortChainsByDisplayOrder' /** * Returns an array of chains to select in the token selector widget. @@ -22,11 +25,12 @@ import { mapChainInfo } from '../utils/mapChainInfo' */ export function useChainsToSelect(): ChainsToSelectState | undefined { const { chainId } = useWalletInfo() - const { field, selectedTargetChainId = chainId } = useSelectTokenWidgetState() + const { field, selectedTargetChainId = chainId, tradeType } = useSelectTokenWidgetState() const { data: bridgeSupportedNetworks, isLoading } = useBridgeSupportedNetworks() const { areUnsupportedChainsEnabled } = useFeatureFlags() const isBridgingEnabled = useIsBridgingEnabled() const availableChains = useAvailableChains() + const isAdvancedTradeType = tradeType === TradeType.LIMIT_ORDER || tradeType === TradeType.ADVANCED_ORDERS const supportedChains = useMemo(() => { return availableChains.reduce((acc, id) => { @@ -41,42 +45,33 @@ export function useChainsToSelect(): ChainsToSelectState | undefined { }, [availableChains]) return useMemo(() => { - if (!field || !isBridgingEnabled) return undefined + if (!field || !chainId) return undefined - const currentChainInfo = mapChainInfo(chainId, CHAIN_INFO[chainId]) - const isSourceChainSupportedByBridge = Boolean( - bridgeSupportedNetworks?.find((bridgeChain) => bridgeChain.id === chainId), - ) + const chainInfo = CHAIN_INFO[chainId] + if (!chainInfo) return undefined - // For the sell token selector we only display supported chains - if (field === Field.INPUT) { - return { - defaultChainId: selectedTargetChainId, - chains: supportedChains, - isLoading: false, - } - } + const currentChainInfo = mapChainInfo(chainId, chainInfo) + // Limit/TWAP buys must stay on the wallet chain, so skip bridge wiring entirely. + const shouldForceSingleChain = isAdvancedTradeType && field === Field.OUTPUT - /** - * When the source chain is not supported by bridge provider - * We act as non-bridge mode - */ - if (!isSourceChainSupportedByBridge) { - return { - defaultChainId: selectedTargetChainId, - chains: [], - isLoading: false, - } + if (!isBridgingEnabled && !shouldForceSingleChain) return undefined + + if (shouldForceSingleChain) { + return createSingleChainState(chainId, currentChainInfo) } - const destinationChains = filterDestinationChains(bridgeSupportedNetworks, areUnsupportedChainsEnabled) + if (field === Field.INPUT) { + return createInputChainsState(selectedTargetChainId, supportedChains) + } - return { - defaultChainId: selectedTargetChainId, - // Add the source network to the list if it's not supported by bridge provider - chains: [...(isSourceChainSupportedByBridge ? [] : [currentChainInfo]), ...(destinationChains || [])], + return createOutputChainsState({ + selectedTargetChainId, + chainId, + currentChainInfo, + bridgeSupportedNetworks, + areUnsupportedChainsEnabled, isLoading, - } + }) }, [ field, selectedTargetChainId, @@ -86,6 +81,7 @@ export function useChainsToSelect(): ChainsToSelectState | undefined { isBridgingEnabled, areUnsupportedChainsEnabled, supportedChains, + isAdvancedTradeType, ]) } @@ -101,3 +97,64 @@ function filterDestinationChains( return bridgeSupportedNetworks?.filter((chain) => chain.id in SupportedChainId) } } + +// Represents the “non-bridge” UX (bridging disabled or advanced-trade guardrail) where only the current chain is available. +function createSingleChainState( + defaultChainId: SupportedChainId | number, + chain: ChainInfo, + isLoading = false, +): ChainsToSelectState { + return { + defaultChainId, + chains: [chain], + isLoading, + } +} + +// Sell-side selector intentionally limits chains to the wallet-supported list; bridge destinations never appear here. +export function createInputChainsState( + selectedTargetChainId: SupportedChainId | number, + supportedChains: ChainInfo[], +): ChainsToSelectState { + return { + defaultChainId: selectedTargetChainId, + chains: sortChainsByDisplayOrder(supportedChains), + isLoading: false, + } +} + +interface CreateOutputChainsOptions { + selectedTargetChainId: SupportedChainId | number + chainId: SupportedChainId + currentChainInfo: ChainInfo + bridgeSupportedNetworks: ChainInfo[] | undefined + areUnsupportedChainsEnabled: boolean | undefined + isLoading: boolean +} + +export function createOutputChainsState({ + selectedTargetChainId, + chainId, + currentChainInfo, + bridgeSupportedNetworks, + areUnsupportedChainsEnabled, + isLoading, +}: CreateOutputChainsOptions): ChainsToSelectState { + const destinationChains = filterDestinationChains(bridgeSupportedNetworks, areUnsupportedChainsEnabled) ?? [] + const orderedDestinationChains = sortChainsByDisplayOrder(destinationChains) + const isSourceChainSupportedByBridge = Boolean( + bridgeSupportedNetworks?.some((bridgeChain) => bridgeChain.id === chainId), + ) + + if (!isSourceChainSupportedByBridge) { + // Source chain is unsupported by the bridge provider; fall back to non-bridge behavior. + return createSingleChainState(selectedTargetChainId, currentChainInfo) + } + + return { + defaultChainId: selectedTargetChainId, + // Bridge supports this chain, so expose the provider-supplied destinations. + chains: orderedDestinationChains, + isLoading, + } +} diff --git a/apps/cowswap-frontend/src/modules/tokensList/hooks/useCloseTokenSelectWidget.ts b/apps/cowswap-frontend/src/modules/tokensList/hooks/useCloseTokenSelectWidget.ts index 6434545dfac..9f52134ab7e 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/hooks/useCloseTokenSelectWidget.ts +++ b/apps/cowswap-frontend/src/modules/tokensList/hooks/useCloseTokenSelectWidget.ts @@ -1,15 +1,22 @@ import { useCallback } from 'react' +import { useSelectTokenWidgetState } from './useSelectTokenWidgetState' import { useUpdateSelectTokenWidgetState } from './useUpdateSelectTokenWidgetState' import { DEFAULT_SELECT_TOKEN_WIDGET_STATE } from '../state/selectTokenWidgetAtom' -// TODO: Add proper return type annotation -// eslint-disable-next-line @typescript-eslint/explicit-function-return-type -export function useCloseTokenSelectWidget() { +type CloseTokenSelectWidget = (options?: { overrideForceLock?: boolean }) => void + +export function useCloseTokenSelectWidget(): CloseTokenSelectWidget { const updateSelectTokenWidget = useUpdateSelectTokenWidgetState() + const widgetState = useSelectTokenWidgetState() + + return useCallback( + (options?: { overrideForceLock?: boolean }) => { + if (widgetState.forceOpen && !options?.overrideForceLock) return - return useCallback(() => { - updateSelectTokenWidget(DEFAULT_SELECT_TOKEN_WIDGET_STATE) - }, [updateSelectTokenWidget]) + updateSelectTokenWidget(DEFAULT_SELECT_TOKEN_WIDGET_STATE) + }, + [updateSelectTokenWidget, widgetState.forceOpen], + ) } diff --git a/apps/cowswap-frontend/src/modules/tokensList/hooks/useOnSelectChain.ts b/apps/cowswap-frontend/src/modules/tokensList/hooks/useOnSelectChain.ts index eaf2b8997f2..f29896d1469 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/hooks/useOnSelectChain.ts +++ b/apps/cowswap-frontend/src/modules/tokensList/hooks/useOnSelectChain.ts @@ -2,17 +2,31 @@ import { useCallback } from 'react' import { ChainInfo } from '@cowprotocol/cow-sdk' +import { Field } from 'legacy/state/types' + +import { TradeType } from 'modules/trade/types' + +import { useSelectTokenWidgetState } from './useSelectTokenWidgetState' import { useUpdateSelectTokenWidgetState } from './useUpdateSelectTokenWidgetState' -// TODO: Add proper return type annotation -// eslint-disable-next-line @typescript-eslint/explicit-function-return-type -export function useOnSelectChain() { +type OnSelectChainHandler = (chain: ChainInfo) => void + +export function useOnSelectChain(): OnSelectChainHandler { const updateSelectTokenWidget = useUpdateSelectTokenWidgetState() + const widgetState = useSelectTokenWidgetState() + const shouldForceOpen = + widgetState.field === Field.INPUT && + (widgetState.tradeType === TradeType.LIMIT_ORDER || widgetState.tradeType === TradeType.ADVANCED_ORDERS) + // Limit/TWAP sells keep the widget pinned while the user flips chains; forceOpen keeps that behavior intact. return useCallback( (chain: ChainInfo) => { - updateSelectTokenWidget({ selectedTargetChainId: chain.id }) + updateSelectTokenWidget({ + selectedTargetChainId: chain.id, + open: true, + forceOpen: shouldForceOpen, + }) }, - [updateSelectTokenWidget], + [updateSelectTokenWidget, shouldForceOpen], ) } diff --git a/apps/cowswap-frontend/src/modules/tokensList/hooks/useOpenTokenSelectWidget.ts b/apps/cowswap-frontend/src/modules/tokensList/hooks/useOpenTokenSelectWidget.ts index 2f1a24c8abe..1b7de718630 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/hooks/useOpenTokenSelectWidget.ts +++ b/apps/cowswap-frontend/src/modules/tokensList/hooks/useOpenTokenSelectWidget.ts @@ -8,6 +8,10 @@ import { Nullish } from 'types' import { Field } from 'legacy/state/types' +import { useTradeTypeInfo } from 'modules/trade/hooks/useTradeTypeInfo' +import { useTradeTypeInfoFromUrl } from 'modules/trade/hooks/useTradeTypeInfoFromUrl' +import { TradeType } from 'modules/trade/types' + import { useCloseTokenSelectWidget } from './useCloseTokenSelectWidget' import { useUpdateSelectTokenWidgetState } from './useUpdateSelectTokenWidgetState' @@ -20,28 +24,42 @@ export function useOpenTokenSelectWidget(): ( const updateSelectTokenWidget = useUpdateSelectTokenWidgetState() const closeTokenSelectWidget = useCloseTokenSelectWidget() const isBridgingEnabled = useIsBridgingEnabled() + const tradeTypeInfoFromState = useTradeTypeInfo() + const tradeTypeInfoFromUrl = useTradeTypeInfoFromUrl() + const tradeTypeInfo = tradeTypeInfoFromState ?? tradeTypeInfoFromUrl + const tradeType = tradeTypeInfo?.tradeType + // Advanced trades lock the target chain so price guarantees stay valid while the widget is open. + const shouldLockTargetChain = tradeType === TradeType.LIMIT_ORDER || tradeType === TradeType.ADVANCED_ORDERS return useCallback( (selectedToken, field, oppositeToken, onSelectToken) => { const isOutputField = field === Field.OUTPUT - const selectedTargetChainId = - isOutputField && selectedToken && isBridgingEnabled ? selectedToken.chainId : undefined + const nextSelectedTargetChainId = + isOutputField && selectedToken && isBridgingEnabled && !shouldLockTargetChain + ? selectedToken.chainId + : undefined updateSelectTokenWidget({ selectedToken, field, oppositeToken, open: true, - selectedTargetChainId, + forceOpen: false, + selectedTargetChainId: nextSelectedTargetChainId, + tradeType, onSelectToken: (currency) => { - // Close the token selector regardless of network switching. - // UX: When a user picks a token (even from another network), - // the selector should close as per issue #6251 expected behavior. - closeTokenSelectWidget() + // Keep selector UX consistent with #6251: always close after a selection, even if a chain switch follows. + closeTokenSelectWidget({ overrideForceLock: true }) onSelectToken(currency) }, }) }, - [closeTokenSelectWidget, updateSelectTokenWidget, isBridgingEnabled], + [ + closeTokenSelectWidget, + updateSelectTokenWidget, + isBridgingEnabled, + shouldLockTargetChain, + tradeType, + ], ) } diff --git a/apps/cowswap-frontend/src/modules/tokensList/index.ts b/apps/cowswap-frontend/src/modules/tokensList/index.ts index c38c9b46b97..648d15da924 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/index.ts +++ b/apps/cowswap-frontend/src/modules/tokensList/index.ts @@ -11,3 +11,4 @@ export { useUpdateSelectTokenWidgetState } from './hooks/useUpdateSelectTokenWid export { useOnTokenListAddingError } from './hooks/useOnTokenListAddingError' export { useTokenListAddingError } from './hooks/useTokenListAddingError' export { useSourceChainId } from './hooks/useSourceChainId' +export { useChainsToSelect } from './hooks/useChainsToSelect' diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/AddIntermediateTokenModal/index.tsx b/apps/cowswap-frontend/src/modules/tokensList/pure/AddIntermediateTokenModal/index.tsx index dea219daaed..afaf243779d 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/pure/AddIntermediateTokenModal/index.tsx +++ b/apps/cowswap-frontend/src/modules/tokensList/pure/AddIntermediateTokenModal/index.tsx @@ -36,7 +36,7 @@ export function AddIntermediateTokenModal({ onDismiss, onBack, onImport }: AddIn importTokenCallback([tokenToImport]) onImport(tokenToImport) // when we import the token from here, we don't need to import it again in the SelectTokenWidget - closeTokenSelectWidget() + closeTokenSelectWidget({ overrideForceLock: true }) } }, [onImport, importTokenCallback, closeTokenSelectWidget, tokenToImport]) diff --git a/apps/cowswap-frontend/src/modules/tokensList/state/selectTokenWidgetAtom.ts b/apps/cowswap-frontend/src/modules/tokensList/state/selectTokenWidgetAtom.ts index d8cb8eadc39..fda562f5a37 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/state/selectTokenWidgetAtom.ts +++ b/apps/cowswap-frontend/src/modules/tokensList/state/selectTokenWidgetAtom.ts @@ -10,6 +10,8 @@ import { Nullish } from 'types' import { Field } from 'legacy/state/types' +import { TradeType } from 'modules/trade/types' + interface SelectTokenWidgetState { open: boolean field?: Field @@ -21,6 +23,8 @@ interface SelectTokenWidgetState { onSelectToken?: (currency: Currency) => void onInputPressEnter?: Command selectedTargetChainId?: number + tradeType?: TradeType + forceOpen?: boolean } export const DEFAULT_SELECT_TOKEN_WIDGET_STATE: SelectTokenWidgetState = { @@ -32,6 +36,8 @@ export const DEFAULT_SELECT_TOKEN_WIDGET_STATE: SelectTokenWidgetState = { listToImport: undefined, selectedPoolAddress: undefined, selectedTargetChainId: undefined, + tradeType: undefined, + forceOpen: false, } export const { atom: selectTokenWidgetAtom, updateAtom: updateSelectTokenWidgetAtom } = atomWithPartialUpdate( diff --git a/apps/cowswap-frontend/src/modules/tokensList/test-utils/createChainInfoForTests.ts b/apps/cowswap-frontend/src/modules/tokensList/test-utils/createChainInfoForTests.ts new file mode 100644 index 00000000000..05c88af1966 --- /dev/null +++ b/apps/cowswap-frontend/src/modules/tokensList/test-utils/createChainInfoForTests.ts @@ -0,0 +1,103 @@ +import { ALL_SUPPORTED_CHAINS_MAP, ChainInfo, SupportedChainId } from '@cowprotocol/cow-sdk' + +export function createChainInfoForTests(baseChainId: SupportedChainId, overrides?: Partial): ChainInfo { + const base = ALL_SUPPORTED_CHAINS_MAP[baseChainId] + + if (!base) { + throw new Error(`Missing base chain definition for ${baseChainId}`) + } + + return buildChainInfo(base, overrides) +} + +function buildChainInfo(base: ChainInfo, overrides: Partial | undefined): ChainInfo { + const chainId = resolveChainId(base, overrides) + + return { + ...base, + ...overrides, + id: chainId, + contracts: resolveContracts(base, overrides), + bridges: resolveBridges(base, overrides), + rpcUrls: resolveRpcUrls(base, overrides), + logo: resolveLogo(base, overrides), + docs: resolveDocs(base, overrides), + website: resolveWebsite(base, overrides), + blockExplorer: resolveBlockExplorer(base, overrides), + nativeCurrency: resolveNativeCurrency(base, overrides, chainId), + } +} + +function resolveChainId(base: ChainInfo, overrides: Partial | undefined): ChainInfo['id'] { + return overrides?.id ?? base.id +} + +function resolveContracts(base: ChainInfo, overrides: Partial | undefined): ChainInfo['contracts'] { + const merged = overrides?.contracts + + return merged ? { ...base.contracts, ...merged } : { ...base.contracts } +} + +function resolveBridges(base: ChainInfo, overrides: Partial | undefined): ChainInfo['bridges'] { + const bridges = overrides?.bridges ?? base.bridges + + return bridges?.map(cloneBridge) +} + +function resolveRpcUrls(base: ChainInfo, overrides: Partial | undefined): ChainInfo['rpcUrls'] { + return cloneRpcUrls(overrides?.rpcUrls ?? base.rpcUrls) +} + +function resolveLogo(base: ChainInfo, overrides: Partial | undefined): ChainInfo['logo'] { + return cloneThemedImage(overrides?.logo ?? base.logo) +} + +function resolveDocs(base: ChainInfo, overrides: Partial | undefined): ChainInfo['docs'] { + return cloneWebUrl(overrides?.docs ?? base.docs) +} + +function resolveWebsite(base: ChainInfo, overrides: Partial | undefined): ChainInfo['website'] { + return cloneWebUrl(overrides?.website ?? base.website) +} + +function resolveBlockExplorer(base: ChainInfo, overrides: Partial | undefined): ChainInfo['blockExplorer'] { + return cloneWebUrl(overrides?.blockExplorer ?? base.blockExplorer) +} + +function resolveNativeCurrency( + base: ChainInfo, + overrides: Partial | undefined, + chainId: ChainInfo['id'], +): ChainInfo['nativeCurrency'] { + return { + ...base.nativeCurrency, + ...(overrides?.nativeCurrency ?? {}), + chainId, + } +} + +function cloneBridge(bridge: NonNullable[number]): NonNullable[number] { + return { ...bridge } +} + +function cloneRpcUrls(rpcUrls: ChainInfo['rpcUrls']): ChainInfo['rpcUrls'] { + return Object.entries(rpcUrls).reduce( + (acc, [key, value]) => { + acc[key] = { + http: [...value.http], + ...(value.webSocket ? { webSocket: [...value.webSocket] } : {}), + } + + return acc + }, + {} as ChainInfo['rpcUrls'], + ) +} + +function cloneThemedImage(image: ChainInfo['logo']): ChainInfo['logo'] { + return { ...image } +} + +function cloneWebUrl(webUrl: T): T { + return { ...webUrl } +} diff --git a/apps/cowswap-frontend/src/modules/tokensList/utils/sortChainsByDisplayOrder.ts b/apps/cowswap-frontend/src/modules/tokensList/utils/sortChainsByDisplayOrder.ts new file mode 100644 index 00000000000..59d30cd945d --- /dev/null +++ b/apps/cowswap-frontend/src/modules/tokensList/utils/sortChainsByDisplayOrder.ts @@ -0,0 +1,52 @@ +import { SORTED_CHAIN_IDS } from '@cowprotocol/common-const' +import { ChainInfo, SupportedChainId } from '@cowprotocol/cow-sdk' + +const CHAIN_ORDER = SORTED_CHAIN_IDS.reduce>((acc, chainId, index) => { + acc[chainId] = index + return acc +}, {} as Record) + +interface SortOptions { + pinChainId?: ChainInfo['id'] +} + +/** + * Sorts a list of chains so it matches the canonical network selector order. + * Optionally promotes the provided `pinChainId` to the first position. + */ +export function sortChainsByDisplayOrder(chains: ChainInfo[], options?: SortOptions): ChainInfo[] { + if (chains.length <= 1) { + return chains.slice() + } + + const weightedChains = chains.map((chain, index) => ({ + chain, + weight: CHAIN_ORDER[chain.id as SupportedChainId] ?? Number.MAX_SAFE_INTEGER, + index, + })) + + weightedChains.sort((a, b) => { + if (a.weight === b.weight) { + return a.index - b.index + } + + return a.weight - b.weight + }) + + const orderedChains = weightedChains.map((entry) => entry.chain) + + if (!options?.pinChainId) { + return orderedChains + } + + const pinIndex = orderedChains.findIndex((chain) => chain.id === options.pinChainId) + + if (pinIndex <= 0) { + return orderedChains + } + + const [pinnedChain] = orderedChains.splice(pinIndex, 1) + orderedChains.unshift(pinnedChain) + + return orderedChains +} From 41e42819da8a7983cacad7dc936b042c7ef100f4 Mon Sep 17 00:00:00 2001 From: fairlighteth <31534717+fairlighteth@users.noreply.github.com> Date: Fri, 14 Nov 2025 13:16:15 +0000 Subject: [PATCH 006/100] feat(tokenselector): enhance TradeWidget with token selection and responsive layout adjustments --- .../TradeWidget/TradeWidgetModals.tsx | 37 +++++++------------ .../trade/containers/TradeWidget/index.tsx | 37 ++++++++++++------- .../trade/containers/TradeWidget/styled.tsx | 9 ++++- 3 files changed, 43 insertions(+), 40 deletions(-) diff --git a/apps/cowswap-frontend/src/modules/trade/containers/TradeWidget/TradeWidgetModals.tsx b/apps/cowswap-frontend/src/modules/trade/containers/TradeWidget/TradeWidgetModals.tsx index d1a1a34f8af..eb8ddf1b92b 100644 --- a/apps/cowswap-frontend/src/modules/trade/containers/TradeWidget/TradeWidgetModals.tsx +++ b/apps/cowswap-frontend/src/modules/trade/containers/TradeWidget/TradeWidgetModals.tsx @@ -13,13 +13,8 @@ import { useSetUserApproveAmountModalState, } from 'modules/erc20Approve' import { useTradeApproveState } from 'modules/erc20Approve/state/useTradeApproveState' -import { - ImportTokenModal, - SelectTokenWidget, - useSelectTokenWidgetState, - useTokenListAddingError, - useUpdateSelectTokenWidgetState, -} from 'modules/tokensList' +import { ImportTokenModal, useSelectTokenWidgetState, useTokenListAddingError } from 'modules/tokensList' +import { useCloseTokenSelectWidget } from 'modules/tokensList/hooks/useCloseTokenSelectWidget' import { useZeroApproveModalState, ZeroApprovalModal } from 'modules/zeroApproval' import { TransactionErrorContent } from 'common/pure/TransactionErrorContent' @@ -34,22 +29,17 @@ import { WrapNativeModal } from '../WrapNativeModal' interface TradeWidgetModalsProps { confirmModal: ReactNode | undefined genericModal: ReactNode | undefined - selectTokenWidget: ReactNode | undefined } // todo refactor it -// eslint-disable-next-line complexity,max-lines-per-function -export function TradeWidgetModals({ - confirmModal, - genericModal, - selectTokenWidget = , -}: TradeWidgetModalsProps): ReactNode { +// eslint-disable-next-line max-lines-per-function +export function TradeWidgetModals({ confirmModal, genericModal }: TradeWidgetModalsProps): ReactNode { const { chainId, account } = useWalletInfo() const { state: rawState } = useTradeState() const importTokenCallback = useAddUserToken() const { isOpen: isTradeReviewOpen, error: confirmError, pendingTrade } = useTradeConfirmState() - const { open: isTokenSelectOpen, field } = useSelectTokenWidgetState() + const { field } = useSelectTokenWidgetState() const [{ isOpen: isWrapNativeOpen }, setWrapNativeScreenState] = useWrapNativeScreenState() const { approveInProgress, @@ -67,16 +57,16 @@ export function TradeWidgetModals({ } = useAutoImportTokensState(rawState?.inputCurrencyId, rawState?.outputCurrencyId) const { onDismiss: closeTradeConfirm } = useTradeConfirmActions() - const updateSelectTokenWidgetState = useUpdateSelectTokenWidgetState() + const closeTokenSelectWidget = useCloseTokenSelectWidget() const resetApproveModalState = useResetApproveProgressModalState() const updateApproveAmountState = useSetUserApproveAmountModalState() const resetAllScreens = useCallback( - (closeTokenSelectWidget = true, shouldCloseAutoImportModal = true) => { + (shouldCloseTokenSelectWidget = true, shouldCloseAutoImportModal = true) => { closeTradeConfirm() closeZeroApprovalModal() if (shouldCloseAutoImportModal) closeAutoImportModal() - if (closeTokenSelectWidget) updateSelectTokenWidgetState({ open: false }) + if (shouldCloseTokenSelectWidget) closeTokenSelectWidget() setWrapNativeScreenState({ isOpen: false }) resetApproveModalState() setTokenListAddingError(null) @@ -86,7 +76,7 @@ export function TradeWidgetModals({ closeTradeConfirm, closeZeroApprovalModal, closeAutoImportModal, - updateSelectTokenWidgetState, + closeTokenSelectWidget, setWrapNativeScreenState, resetApproveModalState, updateApproveAmountState, @@ -96,7 +86,10 @@ export function TradeWidgetModals({ const isOutputTokenSelector = field === Field.OUTPUT const isOutputTokenSelectorRef = useRef(isOutputTokenSelector) - isOutputTokenSelectorRef.current = isOutputTokenSelector + + useEffect(() => { + isOutputTokenSelectorRef.current = isOutputTokenSelector + }, [isOutputTokenSelector]) const error = tokenListAddingError || approveError || confirmError @@ -127,10 +120,6 @@ export function TradeWidgetModals({ return } - if (isTokenSelectOpen) { - return selectTokenWidget - } - if (isAutoImportModalOpen) { return } diff --git a/apps/cowswap-frontend/src/modules/trade/containers/TradeWidget/index.tsx b/apps/cowswap-frontend/src/modules/trade/containers/TradeWidget/index.tsx index a2fa5606d57..804ecf787b3 100644 --- a/apps/cowswap-frontend/src/modules/trade/containers/TradeWidget/index.tsx +++ b/apps/cowswap-frontend/src/modules/trade/containers/TradeWidget/index.tsx @@ -1,6 +1,6 @@ import { JSX, useEffect } from 'react' -import { useSelectTokenWidgetState } from 'modules/tokensList' +import { SelectTokenWidget, useChainsToSelect, useSelectTokenWidgetState } from 'modules/tokensList' import { useSetShouldUseAutoSlippage } from 'modules/tradeSlippage' import * as styledEl from './styled' @@ -17,8 +17,13 @@ export function TradeWidget(props: TradeWidgetProps): JSX.Element { disableSuggestedSlippageApi = false, enableSmartSlippage, } = params - const modals = TradeWidgetModals({ confirmModal, genericModal, selectTokenWidget: slots.selectTokenWidget }) + const modals = TradeWidgetModals({ confirmModal, genericModal }) const { open: isTokenSelectOpen } = useSelectTokenWidgetState() + const chainsToSelect = useChainsToSelect() + const isTokenSelectWide = + isTokenSelectOpen && !!chainsToSelect && (chainsToSelect.isLoading || (chainsToSelect.chains?.length ?? 0) > 0) + + const selectTokenWidgetNode = slots.selectTokenWidget ?? const setShouldUseAutoSlippage = useSetShouldUseAutoSlippage() @@ -27,17 +32,21 @@ export function TradeWidget(props: TradeWidgetProps): JSX.Element { }, [enableSmartSlippage, setShouldUseAutoSlippage]) return ( - - - {slots.updaters} - - - {modals || } - + <> + + + {slots.updaters} + + + {modals || } + + + {selectTokenWidgetNode} + ) } diff --git a/apps/cowswap-frontend/src/modules/trade/containers/TradeWidget/styled.tsx b/apps/cowswap-frontend/src/modules/trade/containers/TradeWidget/styled.tsx index 2e93ac29d82..7cb13d23761 100644 --- a/apps/cowswap-frontend/src/modules/trade/containers/TradeWidget/styled.tsx +++ b/apps/cowswap-frontend/src/modules/trade/containers/TradeWidget/styled.tsx @@ -3,9 +3,14 @@ import { UI } from '@cowprotocol/ui' import styled from 'styled-components/macro' import { WIDGET_MAX_WIDTH } from 'theme' -export const Container = styled.div<{ isTokenSelectOpen?: boolean }>` +export const Container = styled.div<{ isTokenSelectOpen?: boolean; isTokenSelectWide?: boolean }>` width: 100%; - max-width: ${({ isTokenSelectOpen }) => (isTokenSelectOpen ? WIDGET_MAX_WIDTH.tokenSelect : WIDGET_MAX_WIDTH.swap)}; + max-width: ${({ isTokenSelectOpen, isTokenSelectWide }) => + isTokenSelectOpen + ? isTokenSelectWide + ? WIDGET_MAX_WIDTH.tokenSelectSidebar + : WIDGET_MAX_WIDTH.tokenSelect + : WIDGET_MAX_WIDTH.swap}; margin: 0 auto; position: relative; ` From 7063b6a8de9c8786a9f8681dc9582feda2c98567 Mon Sep 17 00:00:00 2001 From: fairlighteth <31534717+fairlighteth@users.noreply.github.com> Date: Fri, 14 Nov 2025 16:38:50 +0000 Subject: [PATCH 007/100] feat(tokenselector): implement SelectTokenModal with enhanced token selection --- .../pure/SelectTokenModal/helpers.tsx | 142 ++++++++ .../pure/SelectTokenModal/index.cosmos.tsx | 25 +- .../pure/SelectTokenModal/index.tsx | 312 ++++++++++-------- .../pure/SelectTokenModal/styled.ts | 206 +++++++++--- .../tokensList/pure/SelectTokenModal/types.ts | 46 +++ 5 files changed, 548 insertions(+), 183 deletions(-) create mode 100644 apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/helpers.tsx create mode 100644 apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/types.ts diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/helpers.tsx b/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/helpers.tsx new file mode 100644 index 00000000000..829b24179b2 --- /dev/null +++ b/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/helpers.tsx @@ -0,0 +1,142 @@ +import { ReactNode, useMemo, useState } from 'react' + +import { BackButton } from '@cowprotocol/ui' + +import { SettingsIcon } from 'modules/trade/pure/Settings' + +import * as styledEl from './styled' + +import { SelectTokenContext } from '../../types' +import { TokensContent } from '../TokensContent' + +import type { SelectTokenModalProps } from './types' + +export function useSelectTokenContext(props: SelectTokenModalProps): SelectTokenContext { + const { + selectedToken, + balancesState, + unsupportedTokens, + permitCompatibleTokens, + onSelectToken, + onTokenListItemClick, + account, + tokenListTags, + } = props + + return useMemo( + () => ({ + balancesState, + selectedToken, + onSelectToken, + onTokenListItemClick, + unsupportedTokens, + permitCompatibleTokens, + tokenListTags, + isWalletConnected: !!account, + }), + [ + balancesState, + selectedToken, + onSelectToken, + onTokenListItemClick, + unsupportedTokens, + permitCompatibleTokens, + tokenListTags, + account, + ], + ) +} + +export function useTokenSearchInput(defaultInputValue = ''): [string, (value: string) => void, string] { + const [inputValue, setInputValue] = useState(defaultInputValue) + + return [inputValue, setInputValue, inputValue.trim()] +} + +interface TokensContentSectionProps + extends Pick< + SelectTokenModalProps, + | 'displayLpTokenLists' + | 'favoriteTokens' + | 'recentTokens' + | 'areTokensLoading' + | 'allTokens' + | 'areTokensFromBridge' + | 'hideFavoriteTokensTooltip' + | 'selectedTargetChainId' + | 'onClearRecentTokens' + | 'onOpenManageWidget' + | 'standalone' + > { + searchInput: string + selectTokenContext: SelectTokenContext +} + +export function TokensContentSection({ + displayLpTokenLists, + favoriteTokens, + recentTokens, + areTokensLoading, + allTokens, + searchInput, + areTokensFromBridge, + hideFavoriteTokensTooltip, + selectedTargetChainId, + selectTokenContext, + onClearRecentTokens, + onOpenManageWidget, + standalone, +}: TokensContentSectionProps): ReactNode { + return ( + + ) +} + +interface TitleBarActionsProps { + showManageButton: boolean + onDismiss(): void + onOpenManageWidget(): void + title: string +} + +export function TitleBarActions({ + showManageButton, + onDismiss, + onOpenManageWidget, + title, +}: TitleBarActionsProps): ReactNode { + return ( + + + + {title} + + {showManageButton && ( + + + + + + )} + + ) +} diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/index.cosmos.tsx b/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/index.cosmos.tsx index 00fef623738..b7335114950 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/index.cosmos.tsx +++ b/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/index.cosmos.tsx @@ -1,4 +1,5 @@ import { BalancesState } from '@cowprotocol/balances-and-allowances' +import { CHAIN_INFO } from '@cowprotocol/common-const' import { getRandomInt } from '@cowprotocol/common-utils' import { SupportedChainId, ChainInfo } from '@cowprotocol/cow-sdk' import { BigNumber } from '@ethersproject/bignumber' @@ -6,6 +7,7 @@ import { BigNumber } from '@ethersproject/bignumber' import styled from 'styled-components/macro' import { allTokensMock, favoriteTokensMock } from '../../mocks' +import { mapChainInfo } from '../../utils/mapChainInfo' import { SelectTokenModal, SelectTokenModalProps } from './index' @@ -13,7 +15,7 @@ const Wrapper = styled.div` max-height: 90vh; margin: 20px auto; display: flex; - width: 450px; + width: 520px; ` const unsupportedTokens = {} @@ -26,6 +28,20 @@ const balances = allTokensMock.reduce((acc, token) => { return acc }, {}) +const chainsMock: ChainInfo[] = [ + SupportedChainId.MAINNET, + SupportedChainId.BASE, + SupportedChainId.ARBITRUM_ONE, +].reduce((acc, id) => { + const info = CHAIN_INFO[id] + + if (info) { + acc.push(mapChainInfo(id, info)) + } + + return acc +}, []) + const defaultProps: SelectTokenModalProps = { tokenListTags: {}, account: undefined, @@ -35,7 +51,11 @@ const defaultProps: SelectTokenModalProps = { favoriteTokens: favoriteTokensMock, areTokensLoading: false, areTokensFromBridge: false, - chainsToSelect: undefined, + chainsToSelect: { + chains: chainsMock, + isLoading: false, + defaultChainId: SupportedChainId.MAINNET, + }, onSelectChain(chain: ChainInfo) { console.log('onSelectChain', chain) }, @@ -48,6 +68,7 @@ const defaultProps: SelectTokenModalProps = { }, selectedToken, isRouteAvailable: true, + modalTitle: 'Swap from', recentTokens: favoriteTokensMock.slice(0, 2), selectedTargetChainId: SupportedChainId.SEPOLIA, onSelectToken() { diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/index.tsx b/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/index.tsx index a4e1e5f5609..18b7cd849b5 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/index.tsx +++ b/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/index.tsx @@ -1,94 +1,21 @@ -import React, { ReactNode, useMemo, useState } from 'react' +import { ReactNode } from 'react' -import { BalancesState } from '@cowprotocol/balances-and-allowances' -import { TokenWithLogo } from '@cowprotocol/common-const' -import { ChainInfo } from '@cowprotocol/cow-sdk' -import { TokenListCategory, TokenListTags, UnsupportedTokensState } from '@cowprotocol/tokens' import { SearchInput } from '@cowprotocol/ui' -import { Currency } from '@uniswap/sdk-core' import { t } from '@lingui/core/macro' -import { X } from 'react-feather' -import { Nullish } from 'types' - -import { PermitCompatibleTokens } from 'modules/permit' +import { TokensContentSection, TitleBarActions, useSelectTokenContext, useTokenSearchInput } from './helpers' import { SelectTokenModalContent } from './SelectTokenModalContent' import * as styledEl from './styled' + import { LpTokenListsWidget } from '../../containers/LpTokenListsWidget' -import { ChainsToSelectState, SelectTokenContext, TokenSelectionHandler } from '../../types' import { ChainsSelector } from '../ChainsSelector' -import { IconButton } from '../commonElements' -import { TokensContent } from '../TokensContent' - -export interface SelectTokenModalProps { - allTokens: TokenWithLogo[] - favoriteTokens: TokenWithLogo[] - recentTokens?: TokenWithLogo[] - balancesState: BalancesState - unsupportedTokens: UnsupportedTokensState - selectedToken?: Nullish - permitCompatibleTokens: PermitCompatibleTokens - hideFavoriteTokensTooltip?: boolean - displayLpTokenLists?: boolean - disableErc20?: boolean - account: string | undefined - chainsToSelect: ChainsToSelectState | undefined - tokenListCategoryState: [T, (category: T) => void] - defaultInputValue?: string - areTokensLoading: boolean - tokenListTags: TokenListTags - standalone?: boolean - areTokensFromBridge: boolean - isRouteAvailable: boolean | undefined - selectedTargetChainId?: number - onSelectToken: TokenSelectionHandler - onTokenListItemClick?(token: TokenWithLogo): void - openPoolPage(poolAddress: string): void - onInputPressEnter?(): void - onOpenManageWidget(): void - onDismiss(): void - onSelectChain(chain: ChainInfo): void - onClearRecentTokens?(): void -} +import type { SelectTokenModalProps } from './types' +import type { TokenSelectionHandler } from '../../types' -function useSelectTokenContext(props: SelectTokenModalProps): SelectTokenContext { - const { - selectedToken, - balancesState, - unsupportedTokens, - permitCompatibleTokens, - onSelectToken, - onTokenListItemClick, - account, - tokenListTags, - } = props - - return useMemo( - () => ({ - balancesState, - selectedToken, - onSelectToken, - onTokenListItemClick, - unsupportedTokens, - permitCompatibleTokens, - tokenListTags, - isWalletConnected: !!account, - }), - [ - balancesState, - selectedToken, - onSelectToken, - onTokenListItemClick, - unsupportedTokens, - permitCompatibleTokens, - tokenListTags, - account, - ], - ) -} +export type { SelectTokenModalProps } export function SelectTokenModal(props: SelectTokenModalProps): ReactNode { const { @@ -105,64 +32,191 @@ export function SelectTokenModal(props: SelectTokenModalProps): ReactNode { onSelectChain, areTokensFromBridge, isRouteAvailable, + standalone, + onOpenManageWidget, + favoriteTokens, + recentTokens, + onClearRecentTokens, + areTokensLoading, + allTokens, + hideFavoriteTokensTooltip, + selectedTargetChainId, + modalTitle, + hasChainPanel = false, + isFullScreenMobile, } = props - const [inputValue, setInputValue] = useState(defaultInputValue) + const [inputValue, setInputValue, trimmedInputValue] = useTokenSearchInput(defaultInputValue) const selectTokenContext = useSelectTokenContext(props) + const resolvedModalTitle = modalTitle ?? t`Select token` + const legacyChainsState = + !hasChainPanel && chainsToSelect && (chainsToSelect.chains?.length ?? 0) > 0 ? chainsToSelect : undefined - const trimmedInputValue = inputValue.trim() + return ( + + + + + + ) +} - const allListsContent = ( - +interface TokenColumnContentProps { + displayLpTokenLists?: boolean + account: string | undefined + inputValue: string + onSelectToken: TokenSelectionHandler + openPoolPage(poolAddress: string): void + disableErc20?: boolean + tokenListCategoryState: SelectTokenModalProps['tokenListCategoryState'] + isRouteAvailable: boolean | undefined + chainsToSelect?: SelectTokenModalProps['chainsToSelect'] + onSelectChain: SelectTokenModalProps['onSelectChain'] + children: ReactNode +} + +function TokenColumnContent({ + displayLpTokenLists, + account, + inputValue, + onSelectToken, + openPoolPage, + disableErc20, + tokenListCategoryState, + isRouteAvailable, + chainsToSelect, + onSelectChain, + children, +}: TokenColumnContentProps): ReactNode { + if (displayLpTokenLists) { + return ( + + {children} + + ) + } + + return ( + <> + {renderLegacyChainSelector(chainsToSelect, onSelectChain)} + {children} + ) +} + +function renderLegacyChainSelector( + chainsToSelect: SelectTokenModalProps['chainsToSelect'], + onSelectChain: SelectTokenModalProps['onSelectChain'], +): ReactNode { + if (!chainsToSelect?.chains?.length) { + return null + } return ( - - - e.key === 'Enter' && onInputPressEnter?.()} - onChange={(e) => setInputValue(e.target.value)} - placeholder={t`Search name or paste address...`} - /> - - - - - {displayLpTokenLists ? ( - - {allListsContent} - - ) : ( - <> - {!!chainsToSelect?.chains?.length && ( - <> - - - - - )} - {allListsContent} - - )} + + + + ) +} + +interface SelectTokenModalShellProps { + children: ReactNode + hasChainPanel: boolean + isFullScreenMobile?: boolean + title: string + showManageButton: boolean + onDismiss(): void + onOpenManageWidget: () => void + searchValue: string + onSearchChange(value: string): void + onSearchEnter?: () => void +} + +function SelectTokenModalShell({ + children, + hasChainPanel, + isFullScreenMobile, + title, + showManageButton, + onDismiss, + onOpenManageWidget, + searchValue, + onSearchChange, + onSearchEnter, +}: SelectTokenModalShellProps): ReactNode { + return ( + + + + + { + if (event.key === 'Enter') { + onSearchEnter?.() + } + }} + onChange={(event) => onSearchChange(event.target.value)} + placeholder={t`Search name or paste address...`} + /> + + + + {children} + ) } diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/styled.ts b/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/styled.ts index 9bcc0473df5..b5860b1c0a0 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/styled.ts +++ b/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/styled.ts @@ -1,67 +1,171 @@ -import { UI } from '@cowprotocol/ui' +import { Media, UI } from '@cowprotocol/ui' import styled from 'styled-components/macro' import { blankButtonMixin } from '../commonElements' -export const Wrapper = styled.div` +export const Wrapper = styled.div<{ $hasChainPanel?: boolean; $isFullScreen?: boolean }>` display: flex; flex-direction: column; background: var(${UI.COLOR_PAPER}); - border-radius: 20px; + border-radius: ${({ $isFullScreen }) => ($isFullScreen ? '0' : '20px')}; width: 100%; + overflow: hidden; + border-top-right-radius: ${({ $hasChainPanel, $isFullScreen }) => + $isFullScreen ? '0' : $hasChainPanel ? '0' : '20px'}; + border-bottom-right-radius: ${({ $hasChainPanel, $isFullScreen }) => + $isFullScreen ? '0' : $hasChainPanel ? '0' : '20px'}; + + ${Media.upToMedium()} { + border-radius: ${({ $isFullScreen }) => ($isFullScreen ? '0' : '20px')}; + } ` -export const Row = styled.div` - margin: 0 20px 20px; +export const TitleBar = styled.div` + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 14px; + gap: 12px; + + ${Media.upToSmall()} { + padding: 14px 14px 8px; + } ` -export const ChainsSelectorWrapper = styled.div` - border-bottom: 1px solid var(${UI.COLOR_BORDER}); - padding: 2px 16px 10px 20px; - margin-bottom: 20px; +export const TitleGroup = styled.div` + display: flex; + align-items: center; + gap: 8px; ` -export const Separator = styled.div` - width: 100%; - border-bottom: 1px solid var(${UI.COLOR_BORDER}); +export const ModalTitle = styled.h3` + font-size: 20px; + font-weight: 600; + margin: 0; + + ${Media.upToSmall()} { + font-size: 18px; + } ` -export const Header = styled.div` +export const TitleActions = styled.div` display: flex; - flex-direction: row; - padding: 10px 16px; - margin-bottom: 8px; align-items: center; - border-bottom: 1px solid var(${UI.COLOR_BORDER}); - - > h3 { - font-size: 16px; - font-weight: 500; - margin: 0; - } + gap: 8px; ` -export const ActionButton = styled.button` +export const TitleActionButton = styled.button` ${blankButtonMixin}; display: flex; - width: 100%; align-items: center; - flex-direction: row; justify-content: center; - gap: 10px; + padding: 2px; + border-radius: 8px; cursor: pointer; - padding: 20px 0; - margin: 0; - font-size: 16px; - font-weight: 500; color: inherit; - opacity: 0.6; - transition: opacity var(${UI.ANIMATION_DURATION}) ease-in-out; + transition: background var(${UI.ANIMATION_DURATION}) ease-in-out; &:hover { - opacity: 1; + background: var(${UI.COLOR_PAPER_DARKER}); + } +` + +export const SearchRow = styled.div` + padding: 0 14px 14px; + display: flex; + align-items: center; +` + +export const SearchInputWrapper = styled.div` + --input-height: 46px; + width: 100%; + + > div { + width: 100%; + background: var(${UI.COLOR_PAPER_DARKER}); + border-radius: var(--input-height); + height: var(--input-height); + display: flex; + align-items: center; + padding: 0 14px; + font-size: 15px; + } + + input { + background: transparent; + height: 100%; + } +` + +export const Body = styled.div` + display: flex; + flex: 1; + min-height: 0; + + ${Media.upToMedium()} { + flex-direction: column; + } +` + +export const TokenColumn = styled.div` + flex: 1; + min-width: 0; + min-height: 0; + display: flex; + flex-direction: column; + padding: 0; +` + +export const LegacyChainsWrapper = styled.div` + border-bottom: 1px solid var(${UI.COLOR_BORDER}); + padding: 2px 10px 10px 14px; + margin: 0 14px 16px; + + ${Media.upToSmall()} { + margin: 0 10px 16px; + padding: 2px 4px 10px 8px; + } +` + +export const Row = styled.div` + padding: 0 24px; + margin-bottom: 16px; + + ${Media.upToSmall()} { + padding: 0 16px; + } +` + +export const Separator = styled.div` + width: 100%; + border-bottom: 1px solid var(${UI.COLOR_BORDER}); + margin: 0 0 16px; +` + +export const ListTitle = styled.div` + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + font-size: 14px; + font-weight: 500; + color: var(${UI.COLOR_TEXT_OPACITY_70}); + padding: 8px 16px 4px; +` + +export const ListTitleActionButton = styled.button` + ${blankButtonMixin}; + font-size: 13px; + font-weight: 600; + color: var(${UI.COLOR_TEXT}); + padding: 2px 6px; + border-radius: 6px; + transition: color var(${UI.ANIMATION_DURATION}) ease-in-out; + + &:hover { + color: var(${UI.COLOR_TEXT_OPACITY_70}); } ` @@ -69,7 +173,7 @@ export const TokensLoader = styled.div` width: 100%; height: 100%; overflow: auto; - padding: 20px 0; + padding: 40px 0; text-align: center; ` @@ -77,31 +181,29 @@ export const RouteNotAvailable = styled.div` width: 100%; height: 100%; overflow: auto; - padding: 20px 0; + padding: 40px 0; text-align: center; ` -export const ListTitle = styled.div` +export const ActionButton = styled.button` + ${blankButtonMixin}; + display: flex; - justify-content: space-between; + width: 100%; align-items: center; - font-size: 12px; - font-weight: 500; - text-transform: uppercase; - letter-spacing: 0.04em; - color: var(${UI.COLOR_TEXT_OPACITY_50}); - padding: 12px 20px 4px; -` - -export const ListTitleActionButton = styled.button` - ${blankButtonMixin}; - font-size: 12px; - font-weight: 600; - color: var(${UI.COLOR_PRIMARY}); + flex-direction: row; + justify-content: center; + gap: 10px; cursor: pointer; + padding: 20px 0; + margin: 0; + font-size: 16px; + font-weight: 500; + color: inherit; + opacity: 0.6; transition: opacity var(${UI.ANIMATION_DURATION}) ease-in-out; &:hover { - opacity: 0.75; + opacity: 1; } ` diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/types.ts b/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/types.ts new file mode 100644 index 00000000000..649cfcfe1b8 --- /dev/null +++ b/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/types.ts @@ -0,0 +1,46 @@ +import { BalancesState } from '@cowprotocol/balances-and-allowances' +import { TokenWithLogo } from '@cowprotocol/common-const' +import { ChainInfo } from '@cowprotocol/cow-sdk' +import { TokenListCategory, TokenListTags, UnsupportedTokensState } from '@cowprotocol/tokens' +import { Currency } from '@uniswap/sdk-core' + +import { Nullish } from 'types' + +import { PermitCompatibleTokens } from 'modules/permit' + +import { ChainsToSelectState, TokenSelectionHandler } from '../../types' + +export interface SelectTokenModalProps { + allTokens: TokenWithLogo[] + favoriteTokens: TokenWithLogo[] + recentTokens?: TokenWithLogo[] + balancesState: BalancesState + unsupportedTokens: UnsupportedTokensState + selectedToken?: Nullish + permitCompatibleTokens: PermitCompatibleTokens + hideFavoriteTokensTooltip?: boolean + displayLpTokenLists?: boolean + disableErc20?: boolean + account: string | undefined + chainsToSelect?: ChainsToSelectState + tokenListCategoryState: [T, (category: T) => void] + defaultInputValue?: string + areTokensLoading: boolean + tokenListTags: TokenListTags + standalone?: boolean + areTokensFromBridge: boolean + isRouteAvailable: boolean | undefined + selectedTargetChainId?: number + modalTitle?: string + hasChainPanel?: boolean + isFullScreenMobile?: boolean + + onSelectToken: TokenSelectionHandler + onTokenListItemClick?(token: TokenWithLogo): void + onClearRecentTokens?(): void + openPoolPage(poolAddress: string): void + onInputPressEnter?(): void + onOpenManageWidget(): void + onDismiss(): void + onSelectChain(chain: ChainInfo): void +} From a52a5dda66c7d89bab5f2a2cf49dce0c4baaa876 Mon Sep 17 00:00:00 2001 From: fairlighteth <31534717+fairlighteth@users.noreply.github.com> Date: Fri, 14 Nov 2025 17:31:48 +0000 Subject: [PATCH 008/100] feat(tokenselector): enhance SelectTokenModal with chain selection --- apps/cowswap-frontend/src/locales/en-US.po | 26 +- .../containers/SelectTokenWidget/index.tsx | 9 + .../tokensList/pure/ChainPanel/index.tsx | 119 +++++++++ .../tokensList/pure/ChainPanel/styled.tsx | 115 ++++++++ .../pure/ChainsSelector/index.cosmos.tsx | 23 +- .../tokensList/pure/ChainsSelector/index.tsx | 248 ++++++++++++------ .../tokensList/pure/ChainsSelector/styled.tsx | 202 +++++++------- .../pure/SelectTokenModal/index.cosmos.tsx | 9 +- .../pure/SelectTokenModal/index.tsx | 70 ++++- .../pure/SelectTokenModal/styled.ts | 2 + .../tokensList/pure/SelectTokenModal/types.ts | 1 + 11 files changed, 621 insertions(+), 203 deletions(-) create mode 100644 apps/cowswap-frontend/src/modules/tokensList/pure/ChainPanel/index.tsx create mode 100644 apps/cowswap-frontend/src/modules/tokensList/pure/ChainPanel/styled.tsx diff --git a/apps/cowswap-frontend/src/locales/en-US.po b/apps/cowswap-frontend/src/locales/en-US.po index 8916c8fb9a0..ab5722a3b5b 100644 --- a/apps/cowswap-frontend/src/locales/en-US.po +++ b/apps/cowswap-frontend/src/locales/en-US.po @@ -270,6 +270,10 @@ msgstr "replaced" msgid "Bridge via" msgstr "Bridge via" +#: apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/index.tsx +msgid "Select token" +msgstr "Select token" + #: apps/cowswap-frontend/src/modules/trade/pure/LimitOrdersPromoBanner/index.tsx msgid "Trade your way - personalize the interface and customize your limit orders" msgstr "Trade your way - personalize the interface and customize your limit orders" @@ -455,7 +459,6 @@ msgid "View details" msgstr "View details" #: apps/cowswap-frontend/src/modules/application/containers/App/menuConsts.tsx -#: apps/cowswap-frontend/src/modules/tokensList/pure/ChainsSelector/index.tsx msgid "More" msgstr "More" @@ -2208,6 +2211,11 @@ msgstr "Dismiss hiring message" msgid "dialog content" msgstr "dialog content" +#: apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/index.tsx +#: apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/index.tsx +msgid "Cross chain swap" +msgstr "Cross chain swap" + #: apps/cowswap-frontend/src/modules/accountProxy/pure/FAQContent/index.tsx msgid "Since {accountProxyLabelString} is not an upgradeable smart-contract, it can be versioned and there are" msgstr "Since {accountProxyLabelString} is not an upgradeable smart-contract, it can be versioned and there are" @@ -3660,6 +3668,10 @@ msgstr "Create LlamaPay Vesting" msgid "Transaction expiration" msgstr "Transaction expiration" +#: apps/cowswap-frontend/src/modules/tokensList/pure/ChainPanel/index.tsx +msgid "Search network" +msgstr "Search network" + #: apps/cowswap-frontend/src/legacy/components/ErrorBoundary/ChunkLoadError.tsx msgid "CowSwap no connection" msgstr "CowSwap no connection" @@ -4725,6 +4737,10 @@ msgstr "Execution price" msgid "No tokens found" msgstr "No tokens found" +#: apps/cowswap-frontend/src/modules/tokensList/pure/ChainPanel/index.tsx +msgid "No networks available for this trade." +msgstr "No networks available for this trade." + #: apps/cowswap-frontend/src/modules/tokensList/pure/TokenTags/index.tsx msgid "Unsupported" msgstr "Unsupported" @@ -4765,6 +4781,10 @@ msgstr "<0/><1/>Consider waiting for lower network costs.<2/><3/>You may still m msgid "Swapping on" msgstr "Swapping on" +#: apps/cowswap-frontend/src/modules/tokensList/pure/ChainPanel/index.tsx +msgid "No networks match \"{chainQuery}\"." +msgstr "No networks match \"{chainQuery}\"." + #: apps/cowswap-frontend/src/modules/ordersTable/pure/ReceiptModal/OrderTypeField.tsx msgid "(Fill or Kill)" msgstr "(Fill or Kill)" @@ -5898,8 +5918,8 @@ msgid "You sold <0/>" msgstr "You sold <0/>" #: apps/cowswap-frontend/src/modules/tokensList/pure/ChainsSelector/index.tsx -msgid "Less" -msgstr "Less" +#~ msgid "Less" +#~ msgstr "Less" #: libs/hook-dapp-lib/src/hookDappsRegistry.ts msgid "Claim your LlamaPay vesting contract funds" diff --git a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/index.tsx b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/index.tsx index 21cc6df5bef..4fdb05af604 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/index.tsx +++ b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/index.tsx @@ -2,6 +2,7 @@ import { ReactNode, useCallback, useState } from 'react' import { useCowAnalytics } from '@cowprotocol/analytics' import { TokenWithLogo } from '@cowprotocol/common-const' +import { useIsBridgingEnabled } from '@cowprotocol/common-hooks' import { isInjectedWidget } from '@cowprotocol/common-utils' import { ListState, @@ -15,6 +16,7 @@ import { } from '@cowprotocol/tokens' import { useWalletInfo } from '@cowprotocol/wallet' +import { t } from '@lingui/core/macro' import styled from 'styled-components/macro' import { Field } from 'legacy/state/types' @@ -23,6 +25,7 @@ import { useTokensBalancesCombined } from 'modules/combinedBalances' import { usePermitCompatibleTokens } from 'modules/permit' import { useLpTokensWithBalances } from 'modules/yield/shared' + import { CowSwapAnalyticsCategory } from 'common/analytics/types' import { getDefaultTokenListCategories } from './getDefaultTokenListCategories' @@ -74,6 +77,7 @@ export function SelectTokenWidget({ displayLpTokenLists, standalone }: SelectTok } = useSelectTokenWidgetState() const { count: lpTokensWithBalancesCount } = useLpTokensWithBalances() const chainsToSelect = useChainsToSelect() + const isBridgingEnabled = useIsBridgingEnabled() const onSelectChain = useOnSelectChain() const [isManageWidgetOpen, setIsManageWidgetOpen] = useState(false) @@ -125,6 +129,9 @@ export function SelectTokenWidget({ displayLpTokenLists, standalone }: SelectTok const isInjectedWidgetMode = isInjectedWidget() + const showChainPanel = isBridgingEnabled && Boolean(chainsToSelect?.chains?.length) + const chainsPanelTitle = t`Cross chain swap` + const closeTokenSelectWidget = useCloseTokenSelectWidget() const openPoolPage = useCallback( @@ -242,6 +249,8 @@ export function SelectTokenWidget({ displayLpTokenLists, standalone }: SelectTok disableErc20={disableErc20} account={account} chainsToSelect={chainsToSelect} + hasChainPanel={showChainPanel} + chainsPanelTitle={chainsPanelTitle} onSelectChain={onSelectChain} areTokensLoading={areTokensLoading} tokenListTags={tokenListTags} diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/ChainPanel/index.tsx b/apps/cowswap-frontend/src/modules/tokensList/pure/ChainPanel/index.tsx new file mode 100644 index 00000000000..bc4ff079993 --- /dev/null +++ b/apps/cowswap-frontend/src/modules/tokensList/pure/ChainPanel/index.tsx @@ -0,0 +1,119 @@ +import { ReactNode, useMemo, useState } from 'react' + +import { ChainInfo } from '@cowprotocol/cow-sdk' +import { BackButton } from '@cowprotocol/ui' + +import { t } from '@lingui/core/macro' + +import * as styledEl from './styled' + +import { ChainsToSelectState } from '../../types' +import { ChainsSelector } from '../ChainsSelector' + +const EMPTY_CHAINS: ChainInfo[] = [] + +export interface ChainPanelProps { + title: string + chainsState: ChainsToSelectState | undefined + onSelectChain(chain: ChainInfo): void + variant?: 'default' | 'fullscreen' + onClose?(): void +} + +export function ChainPanel({ + title, + chainsState, + onSelectChain, + variant = 'default', + onClose, +}: ChainPanelProps): ReactNode { + const [chainQuery, setChainQuery] = useState('') + const chains = chainsState?.chains ?? EMPTY_CHAINS + const isLoading = chainsState?.isLoading ?? false + const normalizedChainQuery = chainQuery.trim().toLowerCase() + + const filteredChains = useMemo( + () => filterChainsByQuery(chains, normalizedChainQuery), + [chains, normalizedChainQuery], + ) + + const { showSearchEmptyState, showUnavailableState } = getEmptyStateFlags({ + filteredChainsLength: filteredChains.length, + isLoading, + normalizedChainQuery, + totalChains: chains.length, + }) + + return ( + + + + setChainQuery(event.target.value)} + placeholder={t`Search network`} + /> + + + + {showUnavailableState && {t`No networks available for this trade.`}} + {showSearchEmptyState && {t`No networks match "${chainQuery}".`}} + + + ) +} + +interface ChainPanelHeaderProps { + title: string + variant: 'default' | 'fullscreen' + onClose?: () => void +} + +function ChainPanelHeader({ title, variant, onClose }: ChainPanelHeaderProps): ReactNode { + const isFullscreen = variant === 'fullscreen' + + return ( + + {isFullscreen && onClose ? : null} + {title} + {isFullscreen && onClose ? : null} + + ) +} + +function filterChainsByQuery(chains: ChainInfo[], normalizedChainQuery: string): ChainInfo[] { + if (!chains.length || !normalizedChainQuery) { + return chains + } + + return chains.filter((chain) => { + const labelMatch = chain.label.toLowerCase().includes(normalizedChainQuery) + const idMatch = String(chain.id).includes(normalizedChainQuery) + + return labelMatch || idMatch + }) +} + +function getEmptyStateFlags({ + filteredChainsLength, + isLoading, + normalizedChainQuery, + totalChains, +}: { + filteredChainsLength: number + isLoading: boolean + normalizedChainQuery: string + totalChains: number +}): { showSearchEmptyState: boolean; showUnavailableState: boolean } { + const hasQuery = Boolean(normalizedChainQuery) + + return { + showUnavailableState: !isLoading && totalChains === 0 && !hasQuery, + showSearchEmptyState: !isLoading && filteredChainsLength === 0 && hasQuery, + } +} diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/ChainPanel/styled.tsx b/apps/cowswap-frontend/src/modules/tokensList/pure/ChainPanel/styled.tsx new file mode 100644 index 00000000000..6cbbb54eb75 --- /dev/null +++ b/apps/cowswap-frontend/src/modules/tokensList/pure/ChainPanel/styled.tsx @@ -0,0 +1,115 @@ +import { Media, SearchInput as UISearchInput, UI } from '@cowprotocol/ui' + +import styled from 'styled-components/macro' + +export const Panel = styled.div<{ $variant: 'default' | 'fullscreen' }>` + width: ${({ $variant }) => ($variant === 'fullscreen' ? '100%' : '240px')}; + min-width: ${({ $variant }) => ($variant === 'fullscreen' ? '0' : '220px')}; + max-width: ${({ $variant }) => ($variant === 'fullscreen' ? '100%' : '280px')}; + height: ${({ $variant }) => ($variant === 'fullscreen' ? '100%' : 'auto')}; + flex-shrink: 0; + background: var(${UI.COLOR_PAPER_DARKER}); + border-left: ${({ $variant }) => ($variant === 'fullscreen' ? 'none' : `1px solid var(${UI.COLOR_BORDER})`)}; + padding: ${({ $variant }) => ($variant === 'fullscreen' ? '20px 16px' : '16px 10px')}; + display: flex; + flex-direction: column; + gap: 12px; + min-height: 0; + border-top-right-radius: ${({ $variant }) => ($variant === 'fullscreen' ? '0' : '20px')}; + border-bottom-right-radius: ${({ $variant }) => ($variant === 'fullscreen' ? '0' : '20px')}; + + ${Media.upToMedium()} { + width: 100%; + min-width: 0; + border-left: none; + border-top: 1px solid var(${UI.COLOR_BORDER}); + border-radius: ${({ $variant }) => ($variant === 'fullscreen' ? '0' : '0 0 20px 20px')}; + } + + ${Media.upToSmall()} { + padding: ${({ $variant }) => ($variant === 'fullscreen' ? '14px' : '16px')}; + background: var(${UI.COLOR_PAPER}); + } +` + +export const PanelHeader = styled.div<{ $isFullscreen?: boolean }>` + display: flex; + align-items: center; + justify-content: ${({ $isFullscreen }) => ($isFullscreen ? 'space-between' : 'space-between')}; + gap: 12px; + padding: ${({ $isFullscreen }) => ($isFullscreen ? '4px 0' : '0')}; +` + +export const PanelTitle = styled.h4<{ $isFullscreen?: boolean }>` + font-size: ${({ $isFullscreen }) => ($isFullscreen ? '18px' : '14px')}; + font-weight: ${({ $isFullscreen }) => ($isFullscreen ? 600 : 500)}; + margin: 0; + flex: 1; + text-align: ${({ $isFullscreen }) => ($isFullscreen ? 'left' : 'center')}; + color: ${({ $isFullscreen }) => ($isFullscreen ? `var(${UI.COLOR_TEXT})` : `var(${UI.COLOR_TEXT_OPACITY_70})`)}; +` + +export const PanelSearchInputWrapper = styled.div` + --min-height: 36px; + min-height: var(--min-height); + border: 1px solid var(${UI.COLOR_PAPER_DARKEST}); + background: transparent; + border-radius: var(--min-height); + padding: 0 10px; + color: var(${UI.COLOR_TEXT}); + + ${Media.upToSmall()} { + --min-height: 46px; + border: none; + padding: 0; + background: transparent; + color: inherit; + + > div { + width: 100%; + background: var(${UI.COLOR_PAPER_DARKER}); + border-radius: var(--min-height); + height: var(--min-height); + display: flex; + align-items: center; + padding: 0 14px; + font-size: 15px; + color: inherit; + } + + input { + background: transparent; + height: 100%; + } + } +` + +export const PanelSearchInput = styled(UISearchInput)` + width: 100%; + color: inherit; + border: none; + background: transparent; + font-size: 14px; + font-weight: 400; + + ${Media.upToSmall()} { + font-size: 16px; + } +` + +export const PanelList = styled.div` + flex: 1; + overflow-y: auto; + padding-right: 8px; + margin-right: -8px; + box-sizing: content-box; + ${({ theme }) => theme.colorScrollbar}; + scrollbar-gutter: stable; +` + +export const EmptyState = styled.div` + text-align: center; + font-size: 14px; + color: var(${UI.COLOR_TEXT_OPACITY_70}); + padding: 32px 8px; +` diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/ChainsSelector/index.cosmos.tsx b/apps/cowswap-frontend/src/modules/tokensList/pure/ChainsSelector/index.cosmos.tsx index 0d84c815ad6..18e22153db9 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/pure/ChainsSelector/index.cosmos.tsx +++ b/apps/cowswap-frontend/src/modules/tokensList/pure/ChainsSelector/index.cosmos.tsx @@ -1,5 +1,5 @@ import { CHAIN_INFO } from '@cowprotocol/common-const' -import { SupportedChainId, ChainInfo } from '@cowprotocol/cow-sdk' +import { ChainInfo, SupportedChainId } from '@cowprotocol/cow-sdk' import styled from 'styled-components/macro' @@ -7,17 +7,15 @@ import { mapChainInfo } from '../../utils/mapChainInfo' import { ChainsSelector } from './index' -const chains: ChainInfo[] = [ - ...Object.keys(CHAIN_INFO).map((chainId) => { - const supportedChainId = +chainId as SupportedChainId - const info = CHAIN_INFO[supportedChainId] +const chains: ChainInfo[] = Object.keys(CHAIN_INFO).map((chainId) => { + const supportedChainId = Number(chainId) as SupportedChainId + const info = CHAIN_INFO[supportedChainId] - return mapChainInfo(supportedChainId, info) - }), -] + return mapChainInfo(supportedChainId, info) +}) const Wrapper = styled.div` - width: 450px; + width: 320px; ` const Fixtures = { @@ -26,10 +24,15 @@ const Fixtures = { console.log('Chain selected: ', chainId)} + onSelectChain={(chain) => console.log('Chain selected: ', chain.label)} />
), + loading: () => ( + + undefined} /> + + ), } export default Fixtures diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/ChainsSelector/index.tsx b/apps/cowswap-frontend/src/modules/tokensList/pure/ChainsSelector/index.tsx index c41203af129..7a12265d19f 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/pure/ChainsSelector/index.tsx +++ b/apps/cowswap-frontend/src/modules/tokensList/pure/ChainsSelector/index.tsx @@ -1,112 +1,188 @@ import { ReactNode } from 'react' -import { useMediaQuery, useTheme } from '@cowprotocol/common-hooks' -import { ChainInfo } from '@cowprotocol/cow-sdk' -import { HoverTooltip, Media } from '@cowprotocol/ui' +import OrderCheckIcon from '@cowprotocol/assets/cow-swap/order-check.svg' +import { useTheme } from '@cowprotocol/common-hooks' +import { ChainInfo, SupportedChainId } from '@cowprotocol/cow-sdk' +import { UI } from '@cowprotocol/ui' -import { Trans } from '@lingui/react/macro' -import { Menu, MenuButton, MenuItem } from '@reach/menu-button' -import { Check, ChevronDown, ChevronUp } from 'react-feather' +import SVG from 'react-inlinesvg' import * as styledEl from './styled' -// Number of skeleton shimmers to show during loading state +import type { ChainAccentVars } from './styled' + const LOADING_ITEMS_COUNT = 10 +const LOADING_SKELETON_INDICES = Array.from({ length: LOADING_ITEMS_COUNT }, (_, index) => index) -const LoadingShimmerElements = ( - - {Array.from({ length: LOADING_ITEMS_COUNT }, (_, index) => ( - - ))} - -) +const CHAIN_ACCENT_VAR_MAP: Record = { + [SupportedChainId.MAINNET]: { + backgroundVar: UI.COLOR_CHAIN_ETHEREUM_BG, + borderVar: UI.COLOR_CHAIN_ETHEREUM_BORDER, + accentColorVar: UI.COLOR_CHAIN_ETHEREUM_ACCENT, + }, + [SupportedChainId.BNB]: { + backgroundVar: UI.COLOR_CHAIN_BNB_BG, + borderVar: UI.COLOR_CHAIN_BNB_BORDER, + accentColorVar: UI.COLOR_CHAIN_BNB_ACCENT, + }, + [SupportedChainId.BASE]: { + backgroundVar: UI.COLOR_CHAIN_BASE_BG, + borderVar: UI.COLOR_CHAIN_BASE_BORDER, + accentColorVar: UI.COLOR_CHAIN_BASE_ACCENT, + }, + [SupportedChainId.ARBITRUM_ONE]: { + backgroundVar: UI.COLOR_CHAIN_ARBITRUM_BG, + borderVar: UI.COLOR_CHAIN_ARBITRUM_BORDER, + accentColorVar: UI.COLOR_CHAIN_ARBITRUM_ACCENT, + }, + [SupportedChainId.POLYGON]: { + backgroundVar: UI.COLOR_CHAIN_POLYGON_BG, + borderVar: UI.COLOR_CHAIN_POLYGON_BORDER, + accentColorVar: UI.COLOR_CHAIN_POLYGON_ACCENT, + }, + [SupportedChainId.AVALANCHE]: { + backgroundVar: UI.COLOR_CHAIN_AVALANCHE_BG, + borderVar: UI.COLOR_CHAIN_AVALANCHE_BORDER, + accentColorVar: UI.COLOR_CHAIN_AVALANCHE_ACCENT, + }, + [SupportedChainId.GNOSIS_CHAIN]: { + backgroundVar: UI.COLOR_CHAIN_GNOSIS_BG, + borderVar: UI.COLOR_CHAIN_GNOSIS_BORDER, + accentColorVar: UI.COLOR_CHAIN_GNOSIS_ACCENT, + }, + [SupportedChainId.LENS]: { + backgroundVar: UI.COLOR_CHAIN_LENS_BG, + borderVar: UI.COLOR_CHAIN_LENS_BORDER, + accentColorVar: UI.COLOR_CHAIN_LENS_ACCENT, + }, + [SupportedChainId.SEPOLIA]: { + backgroundVar: UI.COLOR_CHAIN_SEPOLIA_BG, + borderVar: UI.COLOR_CHAIN_SEPOLIA_BORDER, + accentColorVar: UI.COLOR_CHAIN_SEPOLIA_ACCENT, + }, + [SupportedChainId.LINEA]: { + backgroundVar: UI.COLOR_CHAIN_LINEA_BG, + borderVar: UI.COLOR_CHAIN_LINEA_BORDER, + accentColorVar: UI.COLOR_CHAIN_LINEA_ACCENT, + }, + [SupportedChainId.PLASMA]: { + backgroundVar: UI.COLOR_CHAIN_PLASMA_BG, + borderVar: UI.COLOR_CHAIN_PLASMA_BORDER, + accentColorVar: UI.COLOR_CHAIN_PLASMA_ACCENT, + }, +} export interface ChainsSelectorProps { chains: ChainInfo[] onSelectChain: (chainId: ChainInfo) => void defaultChainId?: ChainInfo['id'] - visibleNetworkIcons?: number // Number of network icons to display before showing "More" dropdown isLoading: boolean } -export function ChainsSelector({ +export function ChainsSelector({ chains, onSelectChain, defaultChainId, isLoading }: ChainsSelectorProps): ReactNode { + const { darkMode } = useTheme() + + if (isLoading) { + return + } + + return ( + + ) +} + +function ChainsLoadingList(): ReactNode { + const skeletonRows = renderChainSkeletonRows() + + return {skeletonRows} +} + +function renderChainSkeletonRows(): ReactNode[] { + const elements: ReactNode[] = [] + + for (const index of LOADING_SKELETON_INDICES) { + elements.push( + + + + , + ) + } + + return elements +} + +interface ChainsListProps { + chains: ChainInfo[] + defaultChainId?: ChainInfo['id'] + onSelectChain(chain: ChainInfo): void + isDarkMode: boolean +} + +function ChainsList({ chains, defaultChainId, onSelectChain, isDarkMode }: ChainsListProps): ReactNode { + const chainButtons = renderChainButtons({ chains, defaultChainId, onSelectChain, isDarkMode }) + + return {chainButtons} +} + +interface ChainButtonsRenderProps extends ChainsListProps {} + +function renderChainButtons({ chains, - onSelectChain, defaultChainId, - isLoading, - visibleNetworkIcons = LOADING_ITEMS_COUNT, -}: ChainsSelectorProps): ReactNode { - const isMobile = useMediaQuery(Media.upToSmall(false)) - - const theme = useTheme() + onSelectChain, + isDarkMode, +}: ChainButtonsRenderProps): ReactNode[] { + const elements: ReactNode[] = [] - if (isLoading) { - return LoadingShimmerElements + for (const chain of chains) { + elements.push( + , + ) } - const shouldDisplayMore = !isMobile && chains.length > visibleNetworkIcons - const visibleChains = isMobile ? chains : chains.slice(0, visibleNetworkIcons) - // Find the selected chain that isn't visible in the main row (so we can display it in the dropdown) - const selectedMenuChain = !isMobile && chains.find((i) => i.id === defaultChainId && !visibleChains.includes(i)) + return elements +} + +export function getChainAccent(chainId: ChainInfo['id']): ChainAccentVars | undefined { + return CHAIN_ACCENT_VAR_MAP[chainId as SupportedChainId] +} + +interface ChainButtonProps { + chain: ChainInfo + isActive: boolean + isDarkMode: boolean + onSelectChain(chain: ChainInfo): void +} + +function ChainButton({ chain, isActive, isDarkMode, onSelectChain }: ChainButtonProps): ReactNode { + const logoSrc = isDarkMode ? chain.logo.dark : chain.logo.light + const accent = getChainAccent(chain.id) return ( - - {visibleChains.map((chain) => ( - - onSelectChain(chain)} iconOnly> - {chain.label} - - - ))} - {shouldDisplayMore && ( - - {({ isOpen }) => ( - <> - - {selectedMenuChain ? ( - {selectedMenuChain.label} - ) : isOpen ? ( - - Less - - ) : ( - - More - - )} - {isOpen ? : } - - - {chains.map((chain) => ( - onSelectChain(chain)} - active$={defaultChainId === chain.id} - iconSize={21} - tabIndex={0} - borderless - > - {chain.label} - {chain.label} - {chain.id === defaultChainId && } - - ))} - - - )} - + onSelectChain(chain)} + active$={isActive} + accent$={accent} + aria-pressed={isActive} + > + + + {chain.label} + + {chain.label} + + {isActive && ( + )} - + ) } diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/ChainsSelector/styled.tsx b/apps/cowswap-frontend/src/modules/tokensList/pure/ChainsSelector/styled.tsx index 7b8260b2e89..9391d6976f3 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/pure/ChainsSelector/styled.tsx +++ b/apps/cowswap-frontend/src/modules/tokensList/pure/ChainsSelector/styled.tsx @@ -1,118 +1,136 @@ import { UI } from '@cowprotocol/ui' -import { Media } from '@cowprotocol/ui' -import { MenuList } from '@reach/menu-button' import styled from 'styled-components/macro' -export const Wrapper = styled.div` - display: flex; - flex-flow: row; - gap: 8px; - width: 100%; +import { blankButtonMixin } from '../commonElements' - ${Media.upToSmall()} { - overflow-x: auto; - overflow-y: hidden; - -webkit-overflow-scrolling: touch; - scrollbar-width: none; /* Firefox */ - -ms-overflow-style: none; /* IE and Edge */ +export interface ChainAccentVars { + backgroundVar: UI + borderVar: UI + accentColorVar?: UI +} - &::-webkit-scrollbar { - display: none; - } - } +const fallbackBackground = `var(${UI.COLOR_PRIMARY_OPACITY_10})` +const fallbackBorder = `var(${UI.COLOR_PRIMARY_OPACITY_80})` +const fallbackHoverBorder = `var(${UI.COLOR_PRIMARY_OPACITY_70})` + +const getBackground = (accent$?: ChainAccentVars, fallback = fallbackBackground): string => + accent$ ? `var(${accent$.backgroundVar})` : fallback + +const getBorder = (accent$?: ChainAccentVars, fallback = fallbackBorder): string => + accent$ ? `var(${accent$.borderVar})` : fallback + +const getAccentColor = (accent$?: ChainAccentVars): string | undefined => + accent$?.accentColorVar ? `var(${accent$.accentColorVar})` : undefined + +export const List = styled.div` + display: flex; + flex-direction: column; + gap: 4px; + width: 100%; ` -export const ChainItem = styled.button<{ - active$?: boolean - iconOnly?: boolean - iconSize?: number - borderless?: boolean - isLoading?: boolean -}>` - --itemSize: 38px; - width: ${({ iconOnly }) => (iconOnly ? 'var(--itemSize)' : 'auto')}; - height: var(--itemSize); +export const ChainButton = styled.button<{ active$?: boolean; accent$?: ChainAccentVars }>` + --min-height: 46px; + ${blankButtonMixin}; + + width: 100%; display: flex; align-items: center; - justify-content: ${({ iconOnly }) => (iconOnly ? 'center' : 'flex-start')}; - gap: 4px; - font-weight: 500; - font-size: 13px; - border-radius: 14px; - padding: 6px; - border: ${({ active$, borderless }) => - borderless ? 'none' : `1px solid var(${active$ ? UI.COLOR_PRIMARY_OPACITY_70 : UI.COLOR_TEXT_OPACITY_10})`}; - cursor: ${({ isLoading }) => (isLoading ? 'default' : 'pointer')}; - line-height: 1; - outline: none; - margin: 0; - vertical-align: top; - background: ${({ active$ }) => (active$ ? `var(${UI.COLOR_PAPER_DARKER})` : 'transparent')}; - color: var(${UI.COLOR_TEXT_OPACITY_70}); - box-shadow: ${({ active$ }) => - active$ - ? `0px -1px 0px 0px var(${UI.COLOR_TEXT_OPACITY_10}) inset, - 0px 0px 0px 1px var(${UI.COLOR_PRIMARY_OPACITY_10}) inset, - 0px 1px 3px 0px var(${UI.COLOR_TEXT_OPACITY_10})` - : '0'}; + justify-content: space-between; + gap: 16px; + padding: 8px 12px; + min-height: var(--min-height); + border-radius: var(--min-height); + border: 1px solid ${({ active$, accent$ }) => (active$ ? getBorder(accent$) : 'transparent')}; + background: ${({ active$, accent$ }) => (active$ ? getBackground(accent$) : 'transparent')}; + box-shadow: ${({ active$, accent$ }) => (active$ ? `0 0 0 1px ${getBackground(accent$)} inset` : 'none')}; + cursor: pointer; transition: - color 0.2s ease-in-out, - background 0.2s ease-in-out, - box-shadow 0.2s ease-in-out; - overflow: ${({ isLoading }) => (isLoading ? 'hidden' : 'visible')}; - position: relative; + border 0.2s ease, + background 0.2s ease, + box-shadow 0.2s ease; &:hover { - border-color: ${({ isLoading }) => - isLoading ? `var(${UI.COLOR_TEXT_OPACITY_10})` : `var(${UI.COLOR_TEXT_OPACITY_25})`}; - background: ${({ isLoading }) => (isLoading ? 'transparent' : `var(${UI.COLOR_PAPER_DARKER})`)}; - color: ${({ isLoading }) => (isLoading ? `var(${UI.COLOR_TEXT_OPACITY_70})` : `var(${UI.COLOR_TEXT})`)}; + border-color: ${({ accent$ }) => getBorder(accent$, fallbackHoverBorder)}; + background: ${({ accent$ }) => getBackground(accent$)}; } - > img { - width: ${({ iconOnly, iconSize = 24 }) => (iconOnly ? '100%' : `${iconSize}px`)}; - height: ${({ iconOnly, iconSize = 24 }) => (iconOnly ? '100%' : `${iconSize}px`)}; - border-radius: 100%; + &:focus-visible { + outline: none; + border-color: ${({ accent$ }) => getBorder(accent$, fallbackHoverBorder)}; } +` - > span { - padding: 0 4px; - } +export const ChainInfo = styled.div` + display: flex; + align-items: center; + gap: 12px; +` + +export const ChainLogo = styled.div` + --size: 28px; + width: var(--size); + height: var(--size); + border-radius: var(--size); + overflow: hidden; + background: var(${UI.COLOR_PAPER}); + display: flex; + align-items: center; + justify-content: center; - &:before { - content: ''; - width: var(--itemSize); - height: var(--itemSize); - display: ${({ isLoading }) => (isLoading ? 'block' : 'none')}; - transform: translateX(-100%); - position: absolute; - left: 0; - top: 0; - ${({ theme, isLoading }) => isLoading && theme.shimmer}; + > img { + width: 100%; + height: 100%; + object-fit: cover; } ` -export const MenuWrapper = styled.div` - position: relative; +export const ChainText = styled.span` + font-weight: 500; + font-size: 15px; + color: var(${UI.COLOR_TEXT}); ` -export const MenuListStyled = styled(MenuList)` +export const ActiveIcon = styled.span<{ accent$?: ChainAccentVars; color$?: string }>` + width: 20px; + height: 20px; display: flex; - justify-content: flex-start; - align-items: stretch; - flex-direction: column; - gap: 4px; - position: absolute; - right: 0; - top: 40px; - z-index: 12; - border-radius: 12px; - padding: 10px; - background: var(${UI.COLOR_PAPER}); - box-shadow: var(${UI.BOX_SHADOW}); + align-items: center; + justify-content: center; + color: ${({ color$, accent$ }) => getAccentColor(accent$) ?? color$ ?? getBorder(accent$, `var(${UI.COLOR_PRIMARY})`)}; + + > svg { + width: 16px; + height: 16px; + display: block; + } + + > svg > path { + fill: currentColor; + } +` + +export const LoadingRow = styled.div` + width: 100%; + display: flex; + align-items: center; + gap: 16px; + padding: 10px 14px; + border-radius: 18px; border: 1px solid var(${UI.COLOR_TEXT_OPACITY_10}); - outline: none; - overflow: hidden; - min-width: 200px; +` + +export const LoadingCircle = styled.div` + width: 36px; + height: 36px; + border-radius: 50%; + ${({ theme }) => theme.shimmer}; +` + +export const LoadingBar = styled.div` + flex: 1; + height: 14px; + border-radius: 8px; + ${({ theme }) => theme.shimmer}; ` diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/index.cosmos.tsx b/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/index.cosmos.tsx index b7335114950..67199062de7 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/index.cosmos.tsx +++ b/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/index.cosmos.tsx @@ -15,7 +15,7 @@ const Wrapper = styled.div` max-height: 90vh; margin: 20px auto; display: flex; - width: 520px; + width: 900px; ` const unsupportedTokens = {} @@ -56,6 +56,8 @@ const defaultProps: SelectTokenModalProps = { isLoading: false, defaultChainId: SupportedChainId.MAINNET, }, + hasChainPanel: true, + chainsPanelTitle: 'Cross chain swap', onSelectChain(chain: ChainInfo) { console.log('onSelectChain', chain) }, @@ -97,6 +99,11 @@ const Fixtures = { ), + noChainPanel: () => ( + + + + ), importByAddress: () => ( diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/index.tsx b/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/index.tsx index 18b7cd849b5..dbe1e45f8a0 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/index.tsx +++ b/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/index.tsx @@ -8,8 +8,8 @@ import { TokensContentSection, TitleBarActions, useSelectTokenContext, useTokenS import { SelectTokenModalContent } from './SelectTokenModalContent' import * as styledEl from './styled' - import { LpTokenListsWidget } from '../../containers/LpTokenListsWidget' +import { ChainPanel } from '../ChainPanel' import { ChainsSelector } from '../ChainsSelector' import type { SelectTokenModalProps } from './types' @@ -19,7 +19,6 @@ export type { SelectTokenModalProps } export function SelectTokenModal(props: SelectTokenModalProps): ReactNode { const { - defaultInputValue = '', onSelectToken, onDismiss, onInputPressEnter, @@ -28,7 +27,6 @@ export function SelectTokenModal(props: SelectTokenModalProps): ReactNode { openPoolPage, tokenListCategoryState, disableErc20, - chainsToSelect, onSelectChain, areTokensFromBridge, isRouteAvailable, @@ -41,20 +39,23 @@ export function SelectTokenModal(props: SelectTokenModalProps): ReactNode { allTokens, hideFavoriteTokensTooltip, selectedTargetChainId, - modalTitle, - hasChainPanel = false, isFullScreenMobile, } = props - const [inputValue, setInputValue, trimmedInputValue] = useTokenSearchInput(defaultInputValue) - const selectTokenContext = useSelectTokenContext(props) - const resolvedModalTitle = modalTitle ?? t`Select token` - const legacyChainsState = - !hasChainPanel && chainsToSelect && (chainsToSelect.chains?.length ?? 0) > 0 ? chainsToSelect : undefined + const { + inputValue, + setInputValue, + trimmedInputValue, + selectTokenContext, + showChainPanel, + legacyChainsState, + chainPanel, + resolvedModalTitle, + } = useSelectTokenModalLayout(props) return ( void + trimmedInputValue: string + selectTokenContext: ReturnType + showChainPanel: boolean + legacyChainsState: SelectTokenModalProps['chainsToSelect'] + chainPanel: ReactNode + resolvedModalTitle: string +} { + const { + defaultInputValue = '', + chainsToSelect, + onSelectChain, + modalTitle, + hasChainPanel = false, + chainsPanelTitle, + } = props + + const [inputValue, setInputValue, trimmedInputValue] = useTokenSearchInput(defaultInputValue) + const selectTokenContext = useSelectTokenContext(props) + const resolvedModalTitle = modalTitle ?? t`Select token` + const showChainPanel = hasChainPanel && Boolean(chainsToSelect?.chains?.length) + const legacyChainsState = + !showChainPanel && chainsToSelect && (chainsToSelect.chains?.length ?? 0) > 0 ? chainsToSelect : undefined + const resolvedChainPanelTitle = chainsPanelTitle ?? t`Cross chain swap` + const chainPanel = + showChainPanel && chainsToSelect ? ( + + ) : null + + return { + inputValue, + setInputValue, + trimmedInputValue, + selectTokenContext, + showChainPanel, + legacyChainsState, + chainPanel, + resolvedModalTitle, + } +} + interface SelectTokenModalShellProps { children: ReactNode hasChainPanel: boolean @@ -177,6 +222,7 @@ interface SelectTokenModalShellProps { searchValue: string onSearchChange(value: string): void onSearchEnter?: () => void + sideContent?: ReactNode } function SelectTokenModalShell({ @@ -190,6 +236,7 @@ function SelectTokenModalShell({ searchValue, onSearchChange, onSearchEnter, + sideContent, }: SelectTokenModalShellProps): ReactNode { return ( @@ -216,6 +263,7 @@ function SelectTokenModalShell({ {children} + {sideContent} ) diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/styled.ts b/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/styled.ts index b5860b1c0a0..98b614e413a 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/styled.ts +++ b/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/styled.ts @@ -103,6 +103,8 @@ export const Body = styled.div` display: flex; flex: 1; min-height: 0; + gap: 16px; + align-items: stretch; ${Media.upToMedium()} { flex-direction: column; diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/types.ts b/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/types.ts index 649cfcfe1b8..afc04da70c7 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/types.ts +++ b/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/types.ts @@ -33,6 +33,7 @@ export interface SelectTokenModalProps { selectedTargetChainId?: number modalTitle?: string hasChainPanel?: boolean + chainsPanelTitle?: string isFullScreenMobile?: boolean onSelectToken: TokenSelectionHandler From b849a44f3ed66dd05c8a955d410f67a5fd9e127e Mon Sep 17 00:00:00 2001 From: fairlighteth <31534717+fairlighteth@users.noreply.github.com> Date: Mon, 17 Nov 2025 12:21:24 +0000 Subject: [PATCH 009/100] refactor: extract SelectTokenWidget controller --- .../SelectTokenWidget/controller.ts | 85 +++++ .../controllerDependencies.ts | 84 +++++ .../SelectTokenWidget/controllerModalProps.ts | 214 +++++++++++ .../SelectTokenWidget/controllerProps.ts | 248 +++++++++++++ .../SelectTokenWidget/controllerState.ts | 300 ++++++++++++++++ .../SelectTokenWidget/controllerViewState.ts | 136 +++++++ .../containers/SelectTokenWidget/index.tsx | 331 +++++------------- .../containers/SelectTokenWidget/styled.ts | 22 ++ 8 files changed, 1181 insertions(+), 239 deletions(-) create mode 100644 apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controller.ts create mode 100644 apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerDependencies.ts create mode 100644 apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerModalProps.ts create mode 100644 apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerProps.ts create mode 100644 apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerState.ts create mode 100644 apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerViewState.ts create mode 100644 apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/styled.ts diff --git a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controller.ts b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controller.ts new file mode 100644 index 00000000000..e064930038a --- /dev/null +++ b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controller.ts @@ -0,0 +1,85 @@ +import { useIsBridgingEnabled } from '@cowprotocol/common-hooks' +import { useWalletInfo } from '@cowprotocol/wallet' + +import { Field } from 'legacy/state/types' + +import { useLpTokensWithBalances } from 'modules/yield/shared' + +import { SelectTokenWidgetViewProps } from './controllerProps' +import { + useManageWidgetVisibility, + useTokenAdminActions, + useTokenDataSources, + useWidgetMetadata, +} from './controllerState' +import { useSelectTokenWidgetViewState } from './controllerViewState' + +import { useChainsToSelect } from '../../hooks/useChainsToSelect' +import { useCloseTokenSelectWidget } from '../../hooks/useCloseTokenSelectWidget' +import { useOnSelectChain } from '../../hooks/useOnSelectChain' +import { useOnTokenListAddingError } from '../../hooks/useOnTokenListAddingError' +import { useSelectTokenWidgetState } from '../../hooks/useSelectTokenWidgetState' +import { useUpdateSelectTokenWidgetState } from '../../hooks/useUpdateSelectTokenWidgetState' + +export interface SelectTokenWidgetProps { + displayLpTokenLists?: boolean + standalone?: boolean +} + +export interface SelectTokenWidgetController { + shouldRender: boolean + hasChainPanel: boolean + viewProps: SelectTokenWidgetViewProps +} + +export function useSelectTokenWidgetController({ + displayLpTokenLists, + standalone, +}: SelectTokenWidgetProps): SelectTokenWidgetController { + const widgetState = useSelectTokenWidgetState() + const { count: lpTokensWithBalancesCount } = useLpTokensWithBalances() + const resolvedField = widgetState.field ?? Field.INPUT + const chainsToSelect = useChainsToSelect() + const onSelectChain = useOnSelectChain() + const isBridgeFeatureEnabled = useIsBridgingEnabled() + const manageWidget = useManageWidgetVisibility() + const updateSelectTokenWidget = useUpdateSelectTokenWidgetState() + const { account, chainId: walletChainId } = useWalletInfo() + const closeTokenSelectWidget = useCloseTokenSelectWidget() + const tokenData = useTokenDataSources() + const onTokenListAddingError = useOnTokenListAddingError() + const tokenAdminActions = useTokenAdminActions() + const widgetMetadata = useWidgetMetadata( + resolvedField, + widgetState.tradeType, + displayLpTokenLists, + widgetState.oppositeToken, + lpTokensWithBalancesCount, + ) + + const { isChainPanelEnabled, viewProps } = useSelectTokenWidgetViewState({ + displayLpTokenLists, + standalone, + widgetState, + chainsToSelect, + onSelectChain, + manageWidget, + updateSelectTokenWidget, + account, + closeTokenSelectWidget, + tokenData, + onTokenListAddingError, + tokenAdminActions, + widgetMetadata, + walletChainId, + isBridgeFeatureEnabled, + }) + + return { + shouldRender: Boolean(widgetState.onSelectToken && (widgetState.open || widgetState.forceOpen)), + hasChainPanel: isChainPanelEnabled, + viewProps, + } +} + +export type { SelectTokenWidgetViewProps } from './controllerProps' diff --git a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerDependencies.ts b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerDependencies.ts new file mode 100644 index 00000000000..76925a3e776 --- /dev/null +++ b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerDependencies.ts @@ -0,0 +1,84 @@ +import { + useDismissHandler, + useImportFlowCallbacks, + useManageWidgetVisibility, + usePoolPageHandlers, + useRecentTokenSection, + useTokenAdminActions, + useTokenDataSources, + useTokenSelectionHandler, +} from './controllerState' + +import { useCloseTokenSelectWidget } from '../../hooks/useCloseTokenSelectWidget' +import { useOnTokenListAddingError } from '../../hooks/useOnTokenListAddingError' +import { useSelectTokenWidgetState } from '../../hooks/useSelectTokenWidgetState' +import { useUpdateSelectTokenWidgetState } from '../../hooks/useUpdateSelectTokenWidgetState' + +export interface WidgetViewDependenciesResult { + isManageWidgetOpen: boolean + openManageWidget: ReturnType['openManageWidget'] + closeManageWidget: ReturnType['closeManageWidget'] + onDismiss(): void + openPoolPage: ReturnType['openPoolPage'] + closePoolPage: ReturnType['closePoolPage'] + recentTokens: ReturnType['recentTokens'] + handleTokenListItemClick: ReturnType['handleTokenListItemClick'] + clearRecentTokens: ReturnType['clearRecentTokens'] + handleSelectToken: ReturnType + importFlows: ReturnType +} + +interface WidgetViewDependenciesArgs { + manageWidget: ReturnType + closeTokenSelectWidget: ReturnType + updateSelectTokenWidget: ReturnType + tokenData: ReturnType + tokenAdminActions: ReturnType + onTokenListAddingError: ReturnType + widgetState: ReturnType + activeChainId: number | undefined +} + +export function useWidgetViewDependencies({ + manageWidget, + closeTokenSelectWidget, + updateSelectTokenWidget, + tokenData, + tokenAdminActions, + onTokenListAddingError, + widgetState, + activeChainId, +}: WidgetViewDependenciesArgs): WidgetViewDependenciesResult { + const { isManageWidgetOpen, openManageWidget, closeManageWidget } = manageWidget + const onDismiss = useDismissHandler(closeManageWidget, closeTokenSelectWidget) + const { openPoolPage, closePoolPage } = usePoolPageHandlers(updateSelectTokenWidget) + const { recentTokens, handleTokenListItemClick, clearRecentTokens } = useRecentTokenSection( + tokenData.allTokens, + tokenData.favoriteTokens, + activeChainId, + ) + const handleSelectToken = useTokenSelectionHandler(widgetState.onSelectToken, widgetState) + const importFlows = useImportFlowCallbacks( + tokenAdminActions.importTokenCallback, + handleSelectToken, + onDismiss, + tokenAdminActions.addCustomTokenLists, + onTokenListAddingError, + updateSelectTokenWidget, + tokenData.favoriteTokens, + ) + + return { + isManageWidgetOpen, + openManageWidget, + closeManageWidget, + onDismiss, + openPoolPage, + closePoolPage, + recentTokens, + handleTokenListItemClick, + clearRecentTokens, + handleSelectToken, + importFlows, + } +} diff --git a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerModalProps.ts b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerModalProps.ts new file mode 100644 index 00000000000..2ed07f83c63 --- /dev/null +++ b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerModalProps.ts @@ -0,0 +1,214 @@ +import { TokenWithLogo } from '@cowprotocol/common-const' + +import { buildSelectTokenModalPropsInput, buildSelectTokenWidgetViewProps, useSelectTokenModalPropsMemo } from './controllerProps' +import { + useManageWidgetVisibility, + usePoolPageHandlers, + useRecentTokenSection, + useTokenDataSources, + useTokenSelectionHandler, + useWidgetMetadata, +} from './controllerState' + +import { useChainsToSelect } from '../../hooks/useChainsToSelect' +import { useOnSelectChain } from '../../hooks/useOnSelectChain' +import { useSelectTokenWidgetState } from '../../hooks/useSelectTokenWidgetState' + +import type { WidgetViewDependenciesResult } from './controllerDependencies' +import type { SelectTokenModalProps } from '../../pure/SelectTokenModal' + +const EMPTY_FAV_TOKENS: TokenWithLogo[] = [] + +interface WidgetModalPropsArgs { + account: string | undefined + chainsToSelect: ReturnType + displayLpTokenLists?: boolean + widgetDeps: WidgetViewDependenciesResult + hasChainPanel: boolean + onSelectChain: ReturnType + recentTokens: ReturnType['recentTokens'] + standalone?: boolean + tokenData: ReturnType + widgetMetadata: ReturnType + widgetState: ReturnType + isInjectedWidgetMode: boolean +} + +export function useWidgetModalProps({ + account, + chainsToSelect, + displayLpTokenLists, + widgetDeps, + hasChainPanel, + onSelectChain, + recentTokens, + standalone, + tokenData, + widgetMetadata, + widgetState, + isInjectedWidgetMode, +}: WidgetModalPropsArgs): SelectTokenModalProps { + const favoriteTokens = standalone ? EMPTY_FAV_TOKENS : tokenData.favoriteTokens + + return useSelectTokenModalPropsMemo( + createSelectTokenModalProps({ + account, + chainsPanelTitle: widgetMetadata.chainsPanelTitle, + chainsState: chainsToSelect, + disableErc20: widgetMetadata.disableErc20, + displayLpTokenLists, + favoriteTokens, + handleSelectToken: widgetDeps.handleSelectToken, + hasChainPanel, + isInjectedWidgetMode, + modalTitle: widgetMetadata.modalTitle, + onDismiss: widgetDeps.onDismiss, + onSelectChain, + onTokenListItemClick: widgetDeps.handleTokenListItemClick, + onClearRecentTokens: widgetDeps.clearRecentTokens, + onOpenManageWidget: widgetDeps.openManageWidget, + openPoolPage: widgetDeps.openPoolPage, + recentTokens, + standalone, + tokenData, + tokenListCategoryState: widgetMetadata.tokenListCategoryState, + widgetState, + }), + ) +} + +interface BuildViewPropsArgs { + allTokenLists: ReturnType['allTokenLists'] + chainsPanelTitle: string + chainsToSelect: ReturnType + closeManageWidget: ReturnType['closeManageWidget'] + closePoolPage: ReturnType['closePoolPage'] + importFlows: WidgetViewDependenciesResult['importFlows'] + isChainPanelEnabled: boolean + onDismiss: () => void + onSelectChain: ReturnType + selectTokenModalProps: ReturnType + selectedPoolAddress: ReturnType['selectedPoolAddress'] + standalone: boolean | undefined + tokenToImport: ReturnType['tokenToImport'] + listToImport: ReturnType['listToImport'] + isManageWidgetOpen: ReturnType['isManageWidgetOpen'] + userAddedTokens: ReturnType['userAddedTokens'] + handleSelectToken: ReturnType +} + +type BuildViewPropsInput = Parameters[0] + +export function getSelectTokenWidgetViewPropsArgs(args: BuildViewPropsArgs): BuildViewPropsInput { + const { + standalone, + tokenToImport, + listToImport, + isManageWidgetOpen, + selectedPoolAddress, + isChainPanelEnabled, + chainsPanelTitle, + chainsToSelect, + onSelectChain, + onDismiss, + importFlows, + allTokenLists, + userAddedTokens, + closeManageWidget, + closePoolPage, + selectTokenModalProps, + handleSelectToken, + } = args + + return { + standalone, + tokenToImport, + listToImport, + isManageWidgetOpen, + selectedPoolAddress, + isChainPanelEnabled, + chainsPanelTitle, + chainsToSelect, + onSelectChain, + onDismiss, + onBackFromImport: importFlows.resetTokenImport, + onImportTokens: importFlows.importTokenAndClose, + onImportList: importFlows.importListAndBack, + allTokenLists, + userAddedTokens, + onCloseManageWidget: closeManageWidget, + onClosePoolPage: closePoolPage, + selectTokenModalProps, + onSelectToken: handleSelectToken, + } +} + +function createSelectTokenModalProps({ + account, + chainsPanelTitle, + chainsState, + disableErc20, + displayLpTokenLists, + favoriteTokens, + handleSelectToken, + hasChainPanel, + isInjectedWidgetMode, + modalTitle, + onDismiss, + onSelectChain, + onTokenListItemClick, + onClearRecentTokens, + onOpenManageWidget, + openPoolPage, + recentTokens, + standalone, + tokenData, + tokenListCategoryState, + widgetState, +}: { + account: string | undefined + chainsPanelTitle: string + chainsState: ReturnType + disableErc20: boolean + displayLpTokenLists: boolean | undefined + favoriteTokens: TokenWithLogo[] + handleSelectToken: ReturnType + hasChainPanel: boolean + isInjectedWidgetMode: boolean + modalTitle: string + onDismiss: () => void + onSelectChain: ReturnType + onTokenListItemClick: ReturnType['handleTokenListItemClick'] + onClearRecentTokens: ReturnType['clearRecentTokens'] + onOpenManageWidget: ReturnType['openManageWidget'] + openPoolPage: ReturnType['openPoolPage'] + recentTokens: ReturnType['recentTokens'] + standalone: boolean | undefined + tokenData: ReturnType + tokenListCategoryState: ReturnType['tokenListCategoryState'] + widgetState: ReturnType +}): SelectTokenModalProps { + return buildSelectTokenModalPropsInput({ + standalone, + displayLpTokenLists, + tokenData, + widgetState, + favoriteTokens, + recentTokens, + handleSelectToken, + onTokenListItemClick, + onClearRecentTokens, + onDismiss, + onOpenManageWidget, + openPoolPage, + tokenListCategoryState, + disableErc20, + account, + hasChainPanel, + chainsState, + chainsPanelTitle, + onSelectChain, + isInjectedWidgetMode, + modalTitle, + }) +} diff --git a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerProps.ts b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerProps.ts new file mode 100644 index 00000000000..2b3d77c7fab --- /dev/null +++ b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerProps.ts @@ -0,0 +1,248 @@ +import { useMemo } from 'react' + +import { TokenWithLogo } from '@cowprotocol/common-const' +import { ChainInfo } from '@cowprotocol/cow-sdk' +import { ListState } from '@cowprotocol/tokens' + +import { ChainsToSelectState, TokenSelectionHandler } from '../../types' + +import type { TokenDataSources, TokenListCategoryState } from './controllerState' +import type { useSelectTokenWidgetState } from '../../hooks/useSelectTokenWidgetState' +import type { SelectTokenModalProps } from '../../pure/SelectTokenModal' + +type WidgetState = ReturnType +export interface SelectTokenWidgetViewProps { + standalone?: boolean + tokenToImport?: TokenWithLogo + listToImport?: ListState + isManageWidgetOpen: boolean + selectedPoolAddress?: string + isChainPanelEnabled: boolean + chainsPanelTitle: string + chainsToSelect: ChainsToSelectState | undefined + onSelectChain(chain: ChainInfo): void + onDismiss(): void + onBackFromImport(): void + onImportTokens(tokens: TokenWithLogo[]): void + onImportList(list: ListState): void + allTokenLists: ListState[] + userAddedTokens: TokenWithLogo[] + onCloseManageWidget(): void + onClosePoolPage(): void + selectTokenModalProps: SelectTokenModalProps + onSelectToken: TokenSelectionHandler +} + +interface BuildViewPropsArgs { + standalone?: boolean + tokenToImport?: TokenWithLogo + listToImport?: ListState + isManageWidgetOpen: boolean + selectedPoolAddress?: string + isChainPanelEnabled: boolean + chainsPanelTitle: string + chainsToSelect: ChainsToSelectState | undefined + onSelectChain(chain: ChainInfo): void + onDismiss(): void + onBackFromImport(): void + onImportTokens(tokens: TokenWithLogo[]): void + onImportList(list: ListState): void + allTokenLists: ListState[] + userAddedTokens: TokenWithLogo[] + onCloseManageWidget(): void + onClosePoolPage(): void + selectTokenModalProps: SelectTokenModalProps + onSelectToken: TokenSelectionHandler +} + +interface BuildModalPropsArgs { + standalone?: boolean + displayLpTokenLists?: boolean + tokenData: TokenDataSources + widgetState: WidgetState + favoriteTokens: TokenWithLogo[] + recentTokens: TokenWithLogo[] + handleSelectToken: TokenSelectionHandler + onTokenListItemClick(token: TokenWithLogo): void + onClearRecentTokens(): void + onDismiss(): void + onOpenManageWidget(): void + openPoolPage(poolAddress: string): void + tokenListCategoryState: TokenListCategoryState + disableErc20: boolean + account: string | undefined + hasChainPanel: boolean + chainsState?: ChainsToSelectState + onSelectChain?(chain: ChainInfo): void + isInjectedWidgetMode: boolean + chainsPanelTitle: string + modalTitle: string +} + +export function buildSelectTokenWidgetViewProps({ + standalone, + tokenToImport, + listToImport, + isManageWidgetOpen, + selectedPoolAddress, + isChainPanelEnabled, + chainsPanelTitle, + chainsToSelect, + onSelectChain, + onDismiss, + onBackFromImport, + onImportTokens, + onImportList, + allTokenLists, + userAddedTokens, + onCloseManageWidget, + onClosePoolPage, + selectTokenModalProps, + onSelectToken, +}: BuildViewPropsArgs): SelectTokenWidgetViewProps { + return { + standalone, + tokenToImport, + listToImport, + isManageWidgetOpen, + selectedPoolAddress, + isChainPanelEnabled, + chainsPanelTitle, + chainsToSelect, + onSelectChain, + onDismiss, + onBackFromImport, + onImportTokens, + onImportList, + allTokenLists, + userAddedTokens, + onCloseManageWidget, + onClosePoolPage, + selectTokenModalProps, + onSelectToken, + } +} + +export function buildSelectTokenModalPropsInput({ + standalone, + displayLpTokenLists, + tokenData, + widgetState, + favoriteTokens, + recentTokens, + handleSelectToken, + onTokenListItemClick, + onClearRecentTokens, + onDismiss, + onOpenManageWidget, + openPoolPage, + tokenListCategoryState, + disableErc20, + account, + hasChainPanel, + chainsState, + onSelectChain, + isInjectedWidgetMode, + chainsPanelTitle, + modalTitle, +}: BuildModalPropsArgs): SelectTokenModalProps { + return { + standalone, + displayLpTokenLists, + unsupportedTokens: tokenData.unsupportedTokens, + selectedToken: widgetState.selectedToken, + allTokens: tokenData.allTokens, + favoriteTokens, + recentTokens, + balancesState: tokenData.balancesState, + permitCompatibleTokens: tokenData.permitCompatibleTokens, + onSelectToken: handleSelectToken, + onTokenListItemClick, + onInputPressEnter: widgetState.onInputPressEnter, + onDismiss, + onOpenManageWidget, + openPoolPage, + tokenListCategoryState, + disableErc20, + account, + areTokensLoading: tokenData.areTokensLoading, + tokenListTags: tokenData.tokenListTags, + areTokensFromBridge: tokenData.areTokensFromBridge, + isRouteAvailable: tokenData.isRouteAvailable, + modalTitle, + hasChainPanel, + chainsToSelect: chainsState, + chainsPanelTitle, + hideFavoriteTokensTooltip: isInjectedWidgetMode, + selectedTargetChainId: widgetState.selectedTargetChainId, + onSelectChain, + onClearRecentTokens, + } +} + +export function useSelectTokenModalPropsMemo(props: SelectTokenModalProps): SelectTokenModalProps { + return useMemo( + () => ({ + standalone: props.standalone, + displayLpTokenLists: props.displayLpTokenLists, + unsupportedTokens: props.unsupportedTokens, + selectedToken: props.selectedToken, + allTokens: props.allTokens, + favoriteTokens: props.favoriteTokens, + recentTokens: props.recentTokens, + balancesState: props.balancesState, + permitCompatibleTokens: props.permitCompatibleTokens, + onSelectToken: props.onSelectToken, + onTokenListItemClick: props.onTokenListItemClick, + onInputPressEnter: props.onInputPressEnter, + onDismiss: props.onDismiss, + onOpenManageWidget: props.onOpenManageWidget, + openPoolPage: props.openPoolPage, + tokenListCategoryState: props.tokenListCategoryState, + disableErc20: props.disableErc20, + account: props.account, + areTokensLoading: props.areTokensLoading, + tokenListTags: props.tokenListTags, + areTokensFromBridge: props.areTokensFromBridge, + isRouteAvailable: props.isRouteAvailable, + modalTitle: props.modalTitle, + hasChainPanel: props.hasChainPanel, + hideFavoriteTokensTooltip: props.hideFavoriteTokensTooltip, + chainsPanelTitle: props.chainsPanelTitle, + selectedTargetChainId: props.selectedTargetChainId, + onSelectChain: props.onSelectChain, + onClearRecentTokens: props.onClearRecentTokens, + }), + [ + props.standalone, + props.displayLpTokenLists, + props.unsupportedTokens, + props.selectedToken, + props.allTokens, + props.favoriteTokens, + props.recentTokens, + props.balancesState, + props.permitCompatibleTokens, + props.onSelectToken, + props.onTokenListItemClick, + props.onInputPressEnter, + props.onDismiss, + props.onOpenManageWidget, + props.openPoolPage, + props.tokenListCategoryState, + props.disableErc20, + props.account, + props.areTokensLoading, + props.tokenListTags, + props.areTokensFromBridge, + props.isRouteAvailable, + props.modalTitle, + props.hasChainPanel, + props.chainsPanelTitle, + props.hideFavoriteTokensTooltip, + props.selectedTargetChainId, + props.onSelectChain, + props.onClearRecentTokens, + ], + ) +} diff --git a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerState.ts b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerState.ts new file mode 100644 index 00000000000..0a4c44ac65a --- /dev/null +++ b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerState.ts @@ -0,0 +1,300 @@ +import { Dispatch, SetStateAction, useCallback, useState } from 'react' + +import { useCowAnalytics } from '@cowprotocol/analytics' +import { TokenWithLogo } from '@cowprotocol/common-const' +import { SupportedChainId } from '@cowprotocol/cow-sdk' +import { + ListState, + TokenListCategory, + useAddList, + useAddUserToken, + useAllListsList, + useTokenListsTags, + useUnsupportedTokens, + useUserAddedTokens, +} from '@cowprotocol/tokens' +import { useWalletInfo } from '@cowprotocol/wallet' + +import { Field } from 'legacy/state/types' + +import { useTokensBalancesCombined } from 'modules/combinedBalances' +import { usePermitCompatibleTokens } from 'modules/permit' +import { TradeType } from 'modules/trade/types' + +import { CowSwapAnalyticsCategory } from 'common/analytics/types' +import { useOnSelectNetwork } from 'common/hooks/useOnSelectNetwork' + +import { getDefaultTokenListCategories } from './getDefaultTokenListCategories' + +import { persistRecentTokenSelection, useRecentTokens } from '../../hooks/useRecentTokens' +import { useSelectTokenWidgetState } from '../../hooks/useSelectTokenWidgetState' +import { useTokensToSelect } from '../../hooks/useTokensToSelect' +import { ChainsToSelectState, TokenSelectionHandler } from '../../types' + +import type { useUpdateSelectTokenWidgetState } from '../../hooks/useUpdateSelectTokenWidgetState' + +type UpdateSelectTokenWidgetFn = ReturnType + +export type TokenListCategoryState = [TokenListCategory[] | null, Dispatch>] + +interface ManageWidgetVisibility { + isManageWidgetOpen: boolean + openManageWidget(): void + closeManageWidget(): void +} + +interface TokenAdminActions { + addCustomTokenLists(list: ListState): void + importTokenCallback(tokens: TokenWithLogo[]): void +} + +export interface TokenDataSources { + allTokens: TokenWithLogo[] + favoriteTokens: TokenWithLogo[] + areTokensLoading: boolean + areTokensFromBridge: boolean + isRouteAvailable: boolean | undefined + userAddedTokens: TokenWithLogo[] + allTokenLists: ListState[] + balancesState: ReturnType + unsupportedTokens: ReturnType + permitCompatibleTokens: ReturnType + tokenListTags: ReturnType +} + +interface WidgetMetadata { + disableErc20: boolean + tokenListCategoryState: TokenListCategoryState + modalTitle: string + chainsPanelTitle: string +} + +interface PoolPageHandlers { + openPoolPage(poolAddress: string): void + closePoolPage(): void +} + +interface ImportFlowCallbacks { + importTokenAndClose(tokens: TokenWithLogo[]): void + importListAndBack(list: ListState): void + resetTokenImport(): void +} + +interface RecentTokenSection { + recentTokens: TokenWithLogo[] + handleTokenListItemClick(token: TokenWithLogo): void + clearRecentTokens(): void +} + +export function useManageWidgetVisibility(): ManageWidgetVisibility { + const [isManageWidgetOpen, setIsManageWidgetOpen] = useState(false) + + const openManageWidget = useCallback(() => setIsManageWidgetOpen(true), []) + const closeManageWidget = useCallback(() => setIsManageWidgetOpen(false), []) + + return { isManageWidgetOpen, openManageWidget, closeManageWidget } +} + +export function useTokenAdminActions(): TokenAdminActions { + const cowAnalytics = useCowAnalytics() + + const addCustomTokenLists = useAddList((source) => { + cowAnalytics.sendEvent({ + category: CowSwapAnalyticsCategory.LIST, + action: 'Add List Success', + label: source, + }) + }) + const importTokenCallback = useAddUserToken() + + return { addCustomTokenLists, importTokenCallback } +} + +export function useTokenDataSources(): TokenDataSources { + const tokensState = useTokensToSelect() + const userAddedTokens = useUserAddedTokens() + const allTokenLists = useAllListsList() + const balancesState = useTokensBalancesCombined() + const unsupportedTokens = useUnsupportedTokens() + const permitCompatibleTokens = usePermitCompatibleTokens() + const tokenListTags = useTokenListsTags() + + return { + allTokens: tokensState.tokens, + favoriteTokens: tokensState.favoriteTokens, + areTokensLoading: tokensState.isLoading, + areTokensFromBridge: tokensState.areTokensFromBridge, + isRouteAvailable: tokensState.isRouteAvailable, + userAddedTokens, + allTokenLists, + balancesState, + unsupportedTokens, + permitCompatibleTokens, + tokenListTags, + } +} + +export function useWidgetMetadata( + field: Field, + tradeType: TradeType | undefined, + displayLpTokenLists: boolean | undefined, + oppositeToken: Parameters[1], + lpTokensWithBalancesCount: number, +): WidgetMetadata { + const disableErc20 = field === Field.OUTPUT && !!displayLpTokenLists + const tokenListCategoryState: TokenListCategoryState = useState( + getDefaultTokenListCategories(field, oppositeToken, lpTokensWithBalancesCount), + ) + const modalTitle = resolveModalTitle(field, tradeType) + const chainsPanelTitle = + field === Field.INPUT ? 'From network' : field === Field.OUTPUT ? 'To network' : 'Select network' + + return { disableErc20, tokenListCategoryState, modalTitle, chainsPanelTitle } +} + +function resolveModalTitle(field: Field, tradeType: TradeType | undefined): string { + const isSwapTrade = !tradeType || tradeType === TradeType.SWAP + + if (field === Field.INPUT) { + return isSwapTrade ? 'Swap from' : 'Sell token' + } + + if (field === Field.OUTPUT) { + return isSwapTrade ? 'Swap to' : 'Buy token' + } + + return 'Select token' +} + +export function useDismissHandler( + closeManageWidget: () => void, + closeTokenSelectWidget: (options?: { overrideForceLock?: boolean }) => void, +): () => void { + return useCallback(() => { + closeManageWidget() + closeTokenSelectWidget({ overrideForceLock: true }) + }, [closeManageWidget, closeTokenSelectWidget]) +} + +export function usePoolPageHandlers(updateSelectTokenWidget: UpdateSelectTokenWidgetFn): PoolPageHandlers { + const openPoolPage = useCallback( + (selectedPoolAddress: string) => { + updateSelectTokenWidget({ selectedPoolAddress }) + }, + [updateSelectTokenWidget], + ) + + const closePoolPage = useCallback(() => { + updateSelectTokenWidget({ selectedPoolAddress: undefined }) + }, [updateSelectTokenWidget]) + + return { openPoolPage, closePoolPage } +} + +export function useImportFlowCallbacks( + importTokenCallback: ReturnType, + onSelectToken: TokenSelectionHandler | undefined, + onDismiss: () => void, + addCustomTokenLists: (list: ListState) => void, + onTokenListAddingError: (error: Error) => void, + updateSelectTokenWidget: UpdateSelectTokenWidgetFn, + favoriteTokens: TokenWithLogo[], +): ImportFlowCallbacks { + const importTokenAndClose = useCallback( + (tokens: TokenWithLogo[]) => { + importTokenCallback(tokens) + const [selectedToken] = tokens + + if (selectedToken) { + persistRecentTokenSelection(selectedToken, favoriteTokens) + onSelectToken?.(selectedToken) + } + + onDismiss() + }, + [importTokenCallback, onSelectToken, onDismiss, favoriteTokens], + ) + + const importListAndBack = useCallback( + (list: ListState) => { + try { + addCustomTokenLists(list) + } catch (error) { + onDismiss() + onTokenListAddingError(error as Error) + } + updateSelectTokenWidget({ listToImport: undefined }) + }, + [addCustomTokenLists, onDismiss, onTokenListAddingError, updateSelectTokenWidget], + ) + + const resetTokenImport = useCallback(() => { + updateSelectTokenWidget({ tokenToImport: undefined }) + }, [updateSelectTokenWidget]) + + return { importTokenAndClose, importListAndBack, resetTokenImport } +} + +export function useRecentTokenSection( + allTokens: TokenWithLogo[], + favoriteTokens: TokenWithLogo[], + activeChainId?: number, +): RecentTokenSection { + const { recentTokens, addRecentToken, clearRecentTokens } = useRecentTokens({ + allTokens, + favoriteTokens, + activeChainId, + }) + + const handleTokenListItemClick = useCallback( + (token: TokenWithLogo) => { + addRecentToken(token) + }, + [addRecentToken], + ) + + return { recentTokens, handleTokenListItemClick, clearRecentTokens } +} + +export function useTokenSelectionHandler( + onSelectToken: TokenSelectionHandler | undefined, + widgetState: ReturnType, +): TokenSelectionHandler { + const { chainId: walletChainId } = useWalletInfo() + const onSelectNetwork = useOnSelectNetwork() + + return useCallback( + async (token: TokenWithLogo) => { + const targetChainId = widgetState.selectedTargetChainId + // SELL-side limit/TWAP orders must run on the picked network, + // so nudge the wallet onto that chain before finalizing selection. + const shouldSwitchWalletNetwork = + widgetState.field === Field.INPUT && + (widgetState.tradeType === TradeType.LIMIT_ORDER || widgetState.tradeType === TradeType.ADVANCED_ORDERS) && + typeof targetChainId === 'number' && + targetChainId !== walletChainId + + if (shouldSwitchWalletNetwork && targetChainId in SupportedChainId) { + try { + await onSelectNetwork(targetChainId as SupportedChainId, true) + } catch (error) { + console.error('Failed to switch network after token selection', error) + } + } + + onSelectToken?.(token) + }, + [ + onSelectToken, + widgetState.field, + widgetState.tradeType, + widgetState.selectedTargetChainId, + walletChainId, + onSelectNetwork, + ], + ) +} + +export function hasAvailableChains(chainsToSelect: ChainsToSelectState | undefined): boolean { + return Boolean(chainsToSelect) +} diff --git a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerViewState.ts b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerViewState.ts new file mode 100644 index 00000000000..7c1e1a1d3a7 --- /dev/null +++ b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerViewState.ts @@ -0,0 +1,136 @@ +import { isInjectedWidget } from '@cowprotocol/common-utils' + +import { TradeType } from 'modules/trade/types' + +import { useWidgetViewDependencies } from './controllerDependencies' +import { getSelectTokenWidgetViewPropsArgs, useWidgetModalProps } from './controllerModalProps' +import { SelectTokenWidgetViewProps, buildSelectTokenWidgetViewProps } from './controllerProps' +import { + hasAvailableChains, + useManageWidgetVisibility, + useTokenAdminActions, + useTokenDataSources, + useWidgetMetadata, +} from './controllerState' + +import { useChainsToSelect } from '../../hooks/useChainsToSelect' +import { useCloseTokenSelectWidget } from '../../hooks/useCloseTokenSelectWidget' +import { useOnSelectChain } from '../../hooks/useOnSelectChain' +import { useOnTokenListAddingError } from '../../hooks/useOnTokenListAddingError' +import { useSelectTokenWidgetState } from '../../hooks/useSelectTokenWidgetState' +import { useUpdateSelectTokenWidgetState } from '../../hooks/useUpdateSelectTokenWidgetState' + +export interface SelectTokenWidgetViewStateArgs { + displayLpTokenLists?: boolean + standalone?: boolean + widgetState: ReturnType + chainsToSelect: ReturnType + onSelectChain: ReturnType + manageWidget: ReturnType + updateSelectTokenWidget: ReturnType + account: string | undefined + closeTokenSelectWidget: ReturnType + tokenData: ReturnType + onTokenListAddingError: ReturnType + tokenAdminActions: ReturnType + widgetMetadata: ReturnType + walletChainId?: number + isBridgeFeatureEnabled: boolean +} + +interface ViewStateResult { + isChainPanelEnabled: boolean + viewProps: SelectTokenWidgetViewProps +} + +// TODO: Re-enable once Yield should support cross-network selection in the modal. +const ENABLE_YIELD_CHAIN_PANEL = false + +export function useSelectTokenWidgetViewState(args: SelectTokenWidgetViewStateArgs): ViewStateResult { + const { + displayLpTokenLists, + standalone, + widgetState, + chainsToSelect, + onSelectChain, + manageWidget, + updateSelectTokenWidget, + account, + closeTokenSelectWidget, + tokenData, + onTokenListAddingError, + tokenAdminActions, + widgetMetadata, + walletChainId, + isBridgeFeatureEnabled, + } = args + + const activeChainId = resolveActiveChainId(widgetState, walletChainId) + const widgetDeps = useWidgetViewDependencies({ + manageWidget, + closeTokenSelectWidget, + updateSelectTokenWidget, + tokenData, + tokenAdminActions, + onTokenListAddingError, + widgetState, + activeChainId, + }) + const shouldDisableChainPanelForYield = widgetState.tradeType === TradeType.YIELD && !ENABLE_YIELD_CHAIN_PANEL + const isChainPanelEnabled = + isBridgeFeatureEnabled && hasAvailableChains(chainsToSelect) && !shouldDisableChainPanelForYield + const selectTokenModalProps = useWidgetModalProps({ + account, + chainsToSelect, + displayLpTokenLists, + widgetDeps, + hasChainPanel: isChainPanelEnabled, + onSelectChain, + recentTokens: widgetDeps.recentTokens, + standalone, + tokenData, + widgetMetadata, + widgetState, + isInjectedWidgetMode: isInjectedWidget(), + }) + + const viewProps = buildSelectTokenWidgetViewProps( + getSelectTokenWidgetViewPropsArgs({ + allTokenLists: tokenData.allTokenLists, + chainsPanelTitle: widgetMetadata.chainsPanelTitle, + chainsToSelect, + closeManageWidget: widgetDeps.closeManageWidget, + closePoolPage: widgetDeps.closePoolPage, + importFlows: widgetDeps.importFlows, + isChainPanelEnabled, + onDismiss: widgetDeps.onDismiss, + onSelectChain, + selectTokenModalProps, + selectedPoolAddress: widgetState.selectedPoolAddress, + standalone, + tokenToImport: widgetState.tokenToImport, + listToImport: widgetState.listToImport, + isManageWidgetOpen: widgetDeps.isManageWidgetOpen, + userAddedTokens: tokenData.userAddedTokens, + handleSelectToken: widgetDeps.handleSelectToken, + }), + ) + + return { isChainPanelEnabled, viewProps } +} + +function resolveActiveChainId( + widgetState: ReturnType, + walletChainId?: number, +): number | undefined { + return ( + widgetState.selectedTargetChainId ?? + walletChainId ?? + extractChainId(widgetState.oppositeToken) ?? + extractChainId(widgetState.selectedToken) + ) +} + +function extractChainId(token: { chainId?: number } | undefined | null): number | undefined { + return typeof token?.chainId === 'number' ? token.chainId : undefined +} diff --git a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/index.tsx b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/index.tsx index 4fdb05af604..7c080c01dad 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/index.tsx +++ b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/index.tsx @@ -1,266 +1,119 @@ -import { ReactNode, useCallback, useState } from 'react' +import { ReactNode } from 'react' -import { useCowAnalytics } from '@cowprotocol/analytics' -import { TokenWithLogo } from '@cowprotocol/common-const' -import { useIsBridgingEnabled } from '@cowprotocol/common-hooks' -import { isInjectedWidget } from '@cowprotocol/common-utils' import { - ListState, - TokenListCategory, - useAddList, - useAddUserToken, - useAllListsList, - useTokenListsTags, - useUnsupportedTokens, - useUserAddedTokens, -} from '@cowprotocol/tokens' -import { useWalletInfo } from '@cowprotocol/wallet' + useSelectTokenWidgetController, + type SelectTokenWidgetProps, + type SelectTokenWidgetViewProps, +} from './controller' +import { InnerWrapper, ModalContainer, Wrapper } from './styled' -import { t } from '@lingui/core/macro' -import styled from 'styled-components/macro' - -import { Field } from 'legacy/state/types' - -import { useTokensBalancesCombined } from 'modules/combinedBalances' -import { usePermitCompatibleTokens } from 'modules/permit' -import { useLpTokensWithBalances } from 'modules/yield/shared' - - -import { CowSwapAnalyticsCategory } from 'common/analytics/types' - -import { getDefaultTokenListCategories } from './getDefaultTokenListCategories' - -import { useChainsToSelect } from '../../hooks/useChainsToSelect' -import { useCloseTokenSelectWidget } from '../../hooks/useCloseTokenSelectWidget' -import { useOnSelectChain } from '../../hooks/useOnSelectChain' -import { useOnTokenListAddingError } from '../../hooks/useOnTokenListAddingError' -import { useRecentTokens } from '../../hooks/useRecentTokens' -import { useSelectTokenWidgetState } from '../../hooks/useSelectTokenWidgetState' -import { useTokensToSelect } from '../../hooks/useTokensToSelect' -import { useUpdateSelectTokenWidgetState } from '../../hooks/useUpdateSelectTokenWidgetState' +import { ChainPanel } from '../../pure/ChainPanel' import { ImportListModal } from '../../pure/ImportListModal' import { ImportTokenModal } from '../../pure/ImportTokenModal' import { SelectTokenModal } from '../../pure/SelectTokenModal' import { LpTokenPage } from '../LpTokenPage' import { ManageListsAndTokens } from '../ManageListsAndTokens' -const Wrapper = styled.div` - width: 100%; +export function SelectTokenWidget(props: SelectTokenWidgetProps): ReactNode { + const { shouldRender, hasChainPanel, viewProps } = useSelectTokenWidgetController(props) - > div { - height: calc(100vh - 200px); - min-height: 600px; + if (!shouldRender) { + return null } -` - -const EMPTY_FAV_TOKENS: TokenWithLogo[] = [] - -interface SelectTokenWidgetProps { - displayLpTokenLists?: boolean - standalone?: boolean -} - -// TODO: Break down this large function into smaller functions -// eslint-disable-next-line max-lines-per-function -export function SelectTokenWidget({ displayLpTokenLists, standalone }: SelectTokenWidgetProps): ReactNode { - const { - open, - onSelectToken, - tokenToImport, - listToImport, - selectedToken, - onInputPressEnter, - selectedPoolAddress, - field, - oppositeToken, - selectedTargetChainId, - } = useSelectTokenWidgetState() - const { count: lpTokensWithBalancesCount } = useLpTokensWithBalances() - const chainsToSelect = useChainsToSelect() - const isBridgingEnabled = useIsBridgingEnabled() - const onSelectChain = useOnSelectChain() - - const [isManageWidgetOpen, setIsManageWidgetOpen] = useState(false) - const disableErc20 = field === Field.OUTPUT && !!displayLpTokenLists - - const tokenListCategoryState = useState( - getDefaultTokenListCategories(field, oppositeToken, lpTokensWithBalancesCount), - ) - const updateSelectTokenWidget = useUpdateSelectTokenWidgetState() - const { account, chainId: walletChainId } = useWalletInfo() - - const cowAnalytics = useCowAnalytics() - const addCustomTokenLists = useAddList((source) => { - cowAnalytics.sendEvent({ - category: CowSwapAnalyticsCategory.LIST, - action: 'Add List Success', - label: source, - }) - }) - const importTokenCallback = useAddUserToken() - - const { - tokens: allTokens, - isLoading: areTokensLoading, - favoriteTokens, - areTokensFromBridge, - isRouteAvailable, - } = useTokensToSelect() - const { recentTokens, addRecentToken, clearRecentTokens } = useRecentTokens({ - allTokens, - favoriteTokens, - activeChainId: selectedTargetChainId ?? walletChainId, - }) - const handleTokenListItemClick = useCallback( - (token: TokenWithLogo) => { - addRecentToken(token) - }, - [addRecentToken], + return ( + + + + + ) +} - const userAddedTokens = useUserAddedTokens() - const allTokenLists = useAllListsList() - const balancesState = useTokensBalancesCombined() - const unsupportedTokens = useUnsupportedTokens() - const permitCompatibleTokens = usePermitCompatibleTokens() - const tokenListTags = useTokenListsTags() - const onTokenListAddingError = useOnTokenListAddingError() +function SelectTokenWidgetView( + props: SelectTokenWidgetViewProps & { + isChainPanelVisible: boolean + }, +): ReactNode { + const { isChainPanelVisible, isChainPanelEnabled, chainsPanelTitle, chainsToSelect, onSelectChain, selectTokenModalProps } = + props - const isInjectedWidgetMode = isInjectedWidget() + const blockingView = getBlockingView(props) - const showChainPanel = isBridgingEnabled && Boolean(chainsToSelect?.chains?.length) - const chainsPanelTitle = t`Cross chain swap` + if (blockingView) { + return blockingView + } - const closeTokenSelectWidget = useCloseTokenSelectWidget() + const showDesktopChainPanel = isChainPanelVisible && isChainPanelEnabled && chainsToSelect - const openPoolPage = useCallback( - (selectedPoolAddress: string) => { - updateSelectTokenWidget({ selectedPoolAddress }) - }, - [updateSelectTokenWidget], + return ( + <> + + + + {showDesktopChainPanel && ( + + )} + ) +} - const closePoolPage = useCallback(() => { - updateSelectTokenWidget({ selectedPoolAddress: undefined }) - }, [updateSelectTokenWidget]) - - const resetTokenImport = useCallback(() => { - updateSelectTokenWidget({ - tokenToImport: undefined, - }) - }, [updateSelectTokenWidget]) - - const onDismiss = useCallback(() => { - setIsManageWidgetOpen(false) - closeTokenSelectWidget({ overrideForceLock: true }) - }, [closeTokenSelectWidget]) - - const importTokenAndClose = (tokens: TokenWithLogo[]): void => { - importTokenCallback(tokens) - const [tokenToSelect] = tokens - - if (tokenToSelect) { - handleTokenListItemClick(tokenToSelect) - onSelectToken?.(tokenToSelect) - } - - onDismiss() +function getBlockingView(props: SelectTokenWidgetViewProps): ReactNode | null { + const { + standalone, + tokenToImport, + listToImport, + isManageWidgetOpen, + selectedPoolAddress, + allTokenLists, + userAddedTokens, + onDismiss, + onBackFromImport, + onImportTokens, + onImportList, + onCloseManageWidget, + onClosePoolPage, + onSelectToken, + } = props + + if (tokenToImport && !standalone) { + return ( + + ) } - const importListAndBack = (list: ListState): void => { - try { - addCustomTokenLists(list) - } catch (error) { - onDismiss() - onTokenListAddingError(error) - } - updateSelectTokenWidget({ listToImport: undefined }) + if (listToImport && !standalone) { + return ( + + ) } - if (!onSelectToken || !open) return null - - return ( - - {(() => { - if (tokenToImport && !standalone) { - return ( - - ) - } - - if (listToImport && !standalone) { - return ( - - ) - } - - if (isManageWidgetOpen && !standalone) { - return ( - setIsManageWidgetOpen(false)} - /> - ) - } + if (isManageWidgetOpen && !standalone) { + return ( + + ) + } - if (selectedPoolAddress) { - return ( - - ) - } + if (selectedPoolAddress) { + return ( + + ) + } - return ( - setIsManageWidgetOpen(true)} - hideFavoriteTokensTooltip={isInjectedWidgetMode} - openPoolPage={openPoolPage} - tokenListCategoryState={tokenListCategoryState} - disableErc20={disableErc20} - account={account} - chainsToSelect={chainsToSelect} - hasChainPanel={showChainPanel} - chainsPanelTitle={chainsPanelTitle} - onSelectChain={onSelectChain} - areTokensLoading={areTokensLoading} - tokenListTags={tokenListTags} - areTokensFromBridge={areTokensFromBridge} - isRouteAvailable={isRouteAvailable} - onClearRecentTokens={clearRecentTokens} - selectedTargetChainId={selectedTargetChainId} - /> - ) - })()} - - ) + return null } diff --git a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/styled.ts b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/styled.ts new file mode 100644 index 00000000000..5af04231e12 --- /dev/null +++ b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/styled.ts @@ -0,0 +1,22 @@ +import styled from 'styled-components/macro' + +export const Wrapper = styled.div` + width: 100%; + height: 100%; +` + +export const InnerWrapper = styled.div<{ $hasSidebar: boolean }>` + min-height: min(600px, 100%); + width: 100%; + margin: 0 auto; + display: flex; + align-items: stretch; + flex-direction: ${({ $hasSidebar }) => ($hasSidebar ? 'row' : 'column')}; +` + +export const ModalContainer = styled.div` + flex: 1; + min-width: 0; + display: flex; + height: 100%; +` From 259c318cfd0719092bb1a3ae2d859b09a2bd000f Mon Sep 17 00:00:00 2001 From: fairlighteth <31534717+fairlighteth@users.noreply.github.com> Date: Mon, 17 Nov 2025 12:24:29 +0000 Subject: [PATCH 010/100] docs: describe recent token storage schema --- .../src/modules/tokensList/hooks/recentTokensStorage.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/cowswap-frontend/src/modules/tokensList/hooks/recentTokensStorage.ts b/apps/cowswap-frontend/src/modules/tokensList/hooks/recentTokensStorage.ts index 990b944bc29..25c13dbb210 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/hooks/recentTokensStorage.ts +++ b/apps/cowswap-frontend/src/modules/tokensList/hooks/recentTokensStorage.ts @@ -3,6 +3,8 @@ import { TokenWithLogo } from '@cowprotocol/common-const' import { getTokenUniqueKey } from '../utils/tokenKey' export const RECENT_TOKENS_LIMIT = 4 +// Storage schema: { [chainId: number]: StoredRecentToken[] } serialized under this key. +// `migrateLegacyStoredTokens` upgrades the legacy array payload into the map format. export const RECENT_TOKENS_STORAGE_KEY = 'select-token-widget:recent-tokens:v1' export interface StoredRecentToken { From d64ef3c87ff2a7d913a3eedcc9c2ec93e7478ea8 Mon Sep 17 00:00:00 2001 From: fairlighteth <31534717+fairlighteth@users.noreply.github.com> Date: Mon, 17 Nov 2025 14:18:19 +0000 Subject: [PATCH 011/100] fix: make SelectTokenModal onSelectChain optional --- .../src/modules/tokensList/pure/SelectTokenModal/types.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/types.ts b/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/types.ts index afc04da70c7..41dfe6ef284 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/types.ts +++ b/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/types.ts @@ -43,5 +43,5 @@ export interface SelectTokenModalProps { onInputPressEnter?(): void onOpenManageWidget(): void onDismiss(): void - onSelectChain(chain: ChainInfo): void + onSelectChain?(chain: ChainInfo): void } From a60856cde5bbcd072de98c2c200f496d5c88473f Mon Sep 17 00:00:00 2001 From: fairlighteth <31534717+fairlighteth@users.noreply.github.com> Date: Mon, 17 Nov 2025 14:32:29 +0000 Subject: [PATCH 012/100] fix: guard optional chain selector handlers --- .../pure/SelectTokenModal/index.tsx | 52 +++++++++++++------ 1 file changed, 35 insertions(+), 17 deletions(-) diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/index.tsx b/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/index.tsx index dbe1e45f8a0..b898ea7285d 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/index.tsx +++ b/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/index.tsx @@ -152,7 +152,7 @@ function renderLegacyChainSelector( chainsToSelect: SelectTokenModalProps['chainsToSelect'], onSelectChain: SelectTokenModalProps['onSelectChain'], ): ReactNode { - if (!chainsToSelect?.chains?.length) { + if (!chainsToSelect?.chains?.length || !onSelectChain) { return null } @@ -168,6 +168,33 @@ function renderLegacyChainSelector( ) } +interface ChainPanelLayout { + showChainPanel: boolean + legacyChainsState: SelectTokenModalProps['chainsToSelect'] + chainPanel: ReactNode + resolvedTitle: string +} + +function getChainPanelLayout({ + hasChainPanel, + chainsToSelect, + onSelectChain, + chainsPanelTitle, +}: Pick): ChainPanelLayout { + const resolvedTitle = chainsPanelTitle ?? t`Cross chain swap` + if (!chainsToSelect?.chains?.length || !onSelectChain) { + return { showChainPanel: false, legacyChainsState: undefined, chainPanel: null, resolvedTitle } + } + + const showChainPanel = Boolean(hasChainPanel) + const legacyChainsState = showChainPanel ? undefined : chainsToSelect + const chainPanel = showChainPanel ? ( + + ) : null + + return { showChainPanel, legacyChainsState, chainPanel, resolvedTitle } +} + function useSelectTokenModalLayout(props: SelectTokenModalProps): { inputValue: string setInputValue: (value: string) => void @@ -178,26 +205,17 @@ function useSelectTokenModalLayout(props: SelectTokenModalProps): { chainPanel: ReactNode resolvedModalTitle: string } { - const { - defaultInputValue = '', - chainsToSelect, - onSelectChain, - modalTitle, - hasChainPanel = false, - chainsPanelTitle, - } = props + const { defaultInputValue = '', chainsToSelect, onSelectChain, modalTitle, hasChainPanel = false, chainsPanelTitle } = props const [inputValue, setInputValue, trimmedInputValue] = useTokenSearchInput(defaultInputValue) const selectTokenContext = useSelectTokenContext(props) const resolvedModalTitle = modalTitle ?? t`Select token` - const showChainPanel = hasChainPanel && Boolean(chainsToSelect?.chains?.length) - const legacyChainsState = - !showChainPanel && chainsToSelect && (chainsToSelect.chains?.length ?? 0) > 0 ? chainsToSelect : undefined - const resolvedChainPanelTitle = chainsPanelTitle ?? t`Cross chain swap` - const chainPanel = - showChainPanel && chainsToSelect ? ( - - ) : null + const { showChainPanel, legacyChainsState, chainPanel } = getChainPanelLayout({ + hasChainPanel, + chainsToSelect, + onSelectChain, + chainsPanelTitle, + }) return { inputValue, From dd238e83feec6d79991e456889519484f783eeaf Mon Sep 17 00:00:00 2001 From: fairlighteth <31534717+fairlighteth@users.noreply.github.com> Date: Mon, 17 Nov 2025 11:02:11 +0000 Subject: [PATCH 013/100] feat(tokenselector): implement SelectTokenWidget with enhanced chain selection --- .../MobileChainPanelPortal.tsx | 45 ++++ .../SelectTokenWidget/controllerProps.ts | 32 +-- .../containers/SelectTokenWidget/index.tsx | 114 +++++++++- .../containers/SelectTokenWidget/styled.ts | 78 ++++++- .../tokensList/pure/ChainPanel/styled.tsx | 15 +- .../tokensList/pure/ChainsSelector/styled.tsx | 8 +- .../SelectTokenModal/MobileChainSelector.tsx | 108 ++++++++++ .../pure/SelectTokenModal/helpers.tsx | 8 +- .../pure/SelectTokenModal/index.cosmos.tsx | 67 +++--- .../pure/SelectTokenModal/index.tsx | 198 +++++++----------- .../mobileChainSelector.styled.ts | 147 +++++++++++++ .../pure/SelectTokenModal/styled.ts | 36 ---- .../tokensList/pure/SelectTokenModal/types.ts | 8 +- .../tokensList/pure/TokensContent/index.tsx | 123 +++-------- 14 files changed, 658 insertions(+), 329 deletions(-) create mode 100644 apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/MobileChainPanelPortal.tsx create mode 100644 apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/MobileChainSelector.tsx create mode 100644 apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/mobileChainSelector.styled.ts diff --git a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/MobileChainPanelPortal.tsx b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/MobileChainPanelPortal.tsx new file mode 100644 index 00000000000..c85b9f888ed --- /dev/null +++ b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/MobileChainPanelPortal.tsx @@ -0,0 +1,45 @@ +import { MouseEvent, ReactNode } from 'react' + +import { createPortal } from 'react-dom' + +import { MobileChainPanelCard, MobileChainPanelOverlay } from './styled' + +import { ChainPanel } from '../../pure/ChainPanel' + +import type { SelectTokenWidgetViewProps } from './controllerProps' + +interface MobileChainPanelPortalProps { + chainsPanelTitle: string + chainsToSelect: SelectTokenWidgetViewProps['chainsToSelect'] + onSelectChain: SelectTokenWidgetViewProps['onSelectChain'] + onClose(): void +} + +export function MobileChainPanelPortal({ + chainsPanelTitle, + chainsToSelect, + onSelectChain, + onClose, +}: MobileChainPanelPortalProps): ReactNode { + if (typeof document === 'undefined') { + return null + } + + return createPortal( + + ) => event.stopPropagation()}> + { + onSelectChain(chain) + onClose() + }} + variant="fullscreen" + onClose={onClose} + /> + + , + document.body, + ) +} diff --git a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerProps.ts b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerProps.ts index 2b3d77c7fab..4223a467d43 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerProps.ts +++ b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerProps.ts @@ -73,9 +73,9 @@ interface BuildModalPropsArgs { account: string | undefined hasChainPanel: boolean chainsState?: ChainsToSelectState + chainsPanelTitle: string onSelectChain?(chain: ChainInfo): void isInjectedWidgetMode: boolean - chainsPanelTitle: string modalTitle: string } @@ -136,15 +136,15 @@ export function buildSelectTokenModalPropsInput({ onDismiss, onOpenManageWidget, openPoolPage, - tokenListCategoryState, - disableErc20, - account, - hasChainPanel, - chainsState, - onSelectChain, - isInjectedWidgetMode, - chainsPanelTitle, - modalTitle, + tokenListCategoryState, + disableErc20, + account, + hasChainPanel, + chainsState, + chainsPanelTitle, + onSelectChain, + isInjectedWidgetMode, + modalTitle, }: BuildModalPropsArgs): SelectTokenModalProps { return { standalone, @@ -171,10 +171,10 @@ export function buildSelectTokenModalPropsInput({ isRouteAvailable: tokenData.isRouteAvailable, modalTitle, hasChainPanel, - chainsToSelect: chainsState, - chainsPanelTitle, + mobileChainsLabel: chainsPanelTitle, hideFavoriteTokensTooltip: isInjectedWidgetMode, selectedTargetChainId: widgetState.selectedTargetChainId, + mobileChainsState: chainsState, onSelectChain, onClearRecentTokens, } @@ -208,9 +208,11 @@ export function useSelectTokenModalPropsMemo(props: SelectTokenModalProps): Sele modalTitle: props.modalTitle, hasChainPanel: props.hasChainPanel, hideFavoriteTokensTooltip: props.hideFavoriteTokensTooltip, - chainsPanelTitle: props.chainsPanelTitle, selectedTargetChainId: props.selectedTargetChainId, + mobileChainsState: props.mobileChainsState, + mobileChainsLabel: props.mobileChainsLabel, onSelectChain: props.onSelectChain, + onOpenMobileChainPanel: props.onOpenMobileChainPanel, onClearRecentTokens: props.onClearRecentTokens, }), [ @@ -238,10 +240,12 @@ export function useSelectTokenModalPropsMemo(props: SelectTokenModalProps): Sele props.isRouteAvailable, props.modalTitle, props.hasChainPanel, - props.chainsPanelTitle, props.hideFavoriteTokensTooltip, props.selectedTargetChainId, + props.mobileChainsState, + props.mobileChainsLabel, props.onSelectChain, + props.onOpenMobileChainPanel, props.onClearRecentTokens, ], ) diff --git a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/index.tsx b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/index.tsx index 7c080c01dad..ff512fb1947 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/index.tsx +++ b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/index.tsx @@ -1,11 +1,18 @@ -import { ReactNode } from 'react' +import { MouseEvent, ReactNode, useEffect, useState } from 'react' + +import { useMediaQuery } from '@cowprotocol/common-hooks' +import { addBodyClass, removeBodyClass } from '@cowprotocol/common-utils' +import { Media } from '@cowprotocol/ui' + +import { createPortal } from 'react-dom' import { useSelectTokenWidgetController, type SelectTokenWidgetProps, type SelectTokenWidgetViewProps, } from './controller' -import { InnerWrapper, ModalContainer, Wrapper } from './styled' +import { MobileChainPanelPortal } from './MobileChainPanelPortal' +import { InnerWrapper, ModalContainer, WidgetCard, WidgetOverlay, Wrapper } from './styled' import { ChainPanel } from '../../pure/ChainPanel' import { ImportListModal } from '../../pure/ImportListModal' @@ -16,27 +23,90 @@ import { ManageListsAndTokens } from '../ManageListsAndTokens' export function SelectTokenWidget(props: SelectTokenWidgetProps): ReactNode { const { shouldRender, hasChainPanel, viewProps } = useSelectTokenWidgetController(props) + const isCompactLayout = useMediaQuery(Media.upToMedium(false)) + const [isMobileChainPanelOpen, setIsMobileChainPanelOpen] = useState(false) + const isChainPanelVisible = hasChainPanel && !isCompactLayout + + useEffect(() => { + if (!shouldRender) { + return + } + + if (isChainPanelVisible) { + setIsMobileChainPanelOpen(false) + } + }, [isChainPanelVisible, shouldRender]) + + useEffect(() => { + if (!shouldRender) { + removeBodyClass('noScroll') + return undefined + } + + addBodyClass('noScroll') + return () => removeBodyClass('noScroll') + }, [shouldRender]) if (!shouldRender) { return null } - return ( + const widgetContent = ( - - + + ) + + const handleOverlayClick = (event: MouseEvent): void => { + if (event.target !== event.currentTarget) { + return + } + + viewProps.onDismiss() + } + + const overlay = ( + + + {widgetContent} + + + ) + + if (typeof document === 'undefined') { + return overlay + } + + return createPortal(overlay, document.body) } function SelectTokenWidgetView( props: SelectTokenWidgetViewProps & { isChainPanelVisible: boolean + isCompactLayout: boolean + isMobileChainPanelOpen: boolean + setIsMobileChainPanelOpen(value: boolean): void }, ): ReactNode { - const { isChainPanelVisible, isChainPanelEnabled, chainsPanelTitle, chainsToSelect, onSelectChain, selectTokenModalProps } = - props + const { + isChainPanelVisible, + isCompactLayout, + isMobileChainPanelOpen, + setIsMobileChainPanelOpen, + isChainPanelEnabled, + chainsPanelTitle, + chainsToSelect, + onSelectChain, + selectTokenModalProps, + } = props const blockingView = getBlockingView(props) @@ -44,21 +114,47 @@ function SelectTokenWidgetView( return blockingView } + const closeMobileChainPanel = (): void => setIsMobileChainPanelOpen(false) + const mobileChainsState = isChainPanelEnabled && !isChainPanelVisible ? chainsToSelect : undefined + const handleOpenMobileChainPanel = mobileChainsState ? () => setIsMobileChainPanelOpen(true) : undefined const showDesktopChainPanel = isChainPanelVisible && isChainPanelEnabled && chainsToSelect + const showMobileChainPanel = !isChainPanelVisible && isChainPanelEnabled && chainsToSelect && isMobileChainPanelOpen return ( <> - + {showDesktopChainPanel && ( )} + {showMobileChainPanel && ( + + )} ) } -function getBlockingView(props: SelectTokenWidgetViewProps): ReactNode | null { +function getBlockingView( + props: SelectTokenWidgetViewProps & { + isChainPanelVisible: boolean + isCompactLayout: boolean + isMobileChainPanelOpen: boolean + setIsMobileChainPanelOpen(value: boolean): void + }, +): ReactNode | null { const { standalone, tokenToImport, diff --git a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/styled.ts b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/styled.ts index 5af04231e12..04eddcd7d8d 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/styled.ts +++ b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/styled.ts @@ -1,17 +1,43 @@ -import styled from 'styled-components/macro' +import { Media } from '@cowprotocol/ui' + +import styled, { css } from 'styled-components/macro' +import { WIDGET_MAX_WIDTH } from 'theme' export const Wrapper = styled.div` width: 100%; height: 100%; ` -export const InnerWrapper = styled.div<{ $hasSidebar: boolean }>` - min-height: min(600px, 100%); +export const InnerWrapper = styled.div<{ $hasSidebar: boolean; $isMobileOverlay?: boolean }>` + height: 100%; + min-height: ${({ $isMobileOverlay }) => ($isMobileOverlay ? '0' : 'min(600px, 100%)')}; width: 100%; margin: 0 auto; display: flex; align-items: stretch; - flex-direction: ${({ $hasSidebar }) => ($hasSidebar ? 'row' : 'column')}; + + ${({ $hasSidebar }) => + $hasSidebar && + css` + /* Stack modal + sidebar vertically on narrow screens so neither pane collapses */ + ${Media.upToMedium()} { + flex-direction: column; + height: auto; + min-height: 0; + } + + ${Media.upToSmall()} { + min-height: 0; + } + `} + + ${({ $isMobileOverlay }) => + $isMobileOverlay && + css` + flex-direction: column; + height: 100%; + min-height: 0; + `} ` export const ModalContainer = styled.div` @@ -20,3 +46,47 @@ export const ModalContainer = styled.div` display: flex; height: 100%; ` + +export const MobileChainPanelOverlay = styled.div` + position: fixed; + inset: 0; + z-index: 1200; + background: rgba(0, 0, 0, 0.6); + display: flex; + align-items: stretch; + justify-content: center; +` + +export const MobileChainPanelCard = styled.div` + flex: 1; + max-width: 100%; + height: 100%; +` + +export const WidgetOverlay = styled.div` + position: fixed; + inset: 0; + z-index: 1000; + background: rgba(0, 0, 0, 0.4); + display: flex; + align-items: center; + justify-content: center; + padding: 16px; + box-sizing: border-box; + + ${Media.upToMedium()} { + padding: 0; + } +` + +export const WidgetCard = styled.div<{ $isCompactLayout: boolean; $hasChainPanel: boolean }>` + width: 100%; + max-width: ${({ $isCompactLayout, $hasChainPanel }) => + $isCompactLayout ? '100%' : $hasChainPanel ? WIDGET_MAX_WIDTH.tokenSelectSidebar : WIDGET_MAX_WIDTH.tokenSelect}; + height: ${({ $isCompactLayout }) => ($isCompactLayout ? '100%' : '90vh')}; + max-height: 100%; + display: flex; + align-items: stretch; + justify-content: center; + box-sizing: border-box; +` diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/ChainPanel/styled.tsx b/apps/cowswap-frontend/src/modules/tokensList/pure/ChainPanel/styled.tsx index 6cbbb54eb75..c52a99f92c5 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/pure/ChainPanel/styled.tsx +++ b/apps/cowswap-frontend/src/modules/tokensList/pure/ChainPanel/styled.tsx @@ -2,10 +2,10 @@ import { Media, SearchInput as UISearchInput, UI } from '@cowprotocol/ui' import styled from 'styled-components/macro' +import { IconButton } from '../commonElements' + export const Panel = styled.div<{ $variant: 'default' | 'fullscreen' }>` - width: ${({ $variant }) => ($variant === 'fullscreen' ? '100%' : '240px')}; - min-width: ${({ $variant }) => ($variant === 'fullscreen' ? '0' : '220px')}; - max-width: ${({ $variant }) => ($variant === 'fullscreen' ? '100%' : '280px')}; + width: ${({ $variant }) => ($variant === 'fullscreen' ? '100%' : '200px')}; height: ${({ $variant }) => ($variant === 'fullscreen' ? '100%' : 'auto')}; flex-shrink: 0; background: var(${UI.COLOR_PAPER_DARKER}); @@ -20,7 +20,6 @@ export const Panel = styled.div<{ $variant: 'default' | 'fullscreen' }>` ${Media.upToMedium()} { width: 100%; - min-width: 0; border-left: none; border-top: 1px solid var(${UI.COLOR_BORDER}); border-radius: ${({ $variant }) => ($variant === 'fullscreen' ? '0' : '0 0 20px 20px')}; @@ -49,6 +48,14 @@ export const PanelTitle = styled.h4<{ $isFullscreen?: boolean }>` color: ${({ $isFullscreen }) => ($isFullscreen ? `var(${UI.COLOR_TEXT})` : `var(${UI.COLOR_TEXT_OPACITY_70})`)}; ` +export const PanelCloseButton = styled(IconButton)` + flex-shrink: 0; + border-radius: 50%; + width: 32px; + height: 32px; + background: var(${UI.COLOR_PAPER}); +` + export const PanelSearchInputWrapper = styled.div` --min-height: 36px; min-height: var(--min-height); diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/ChainsSelector/styled.tsx b/apps/cowswap-frontend/src/modules/tokensList/pure/ChainsSelector/styled.tsx index 9391d6976f3..bab99e3ead3 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/pure/ChainsSelector/styled.tsx +++ b/apps/cowswap-frontend/src/modules/tokensList/pure/ChainsSelector/styled.tsx @@ -72,9 +72,8 @@ export const ChainLogo = styled.div` --size: 28px; width: var(--size); height: var(--size); - border-radius: var(--size); overflow: hidden; - background: var(${UI.COLOR_PAPER}); + background: transparent; display: flex; align-items: center; justify-content: center; @@ -82,7 +81,7 @@ export const ChainLogo = styled.div` > img { width: 100%; height: 100%; - object-fit: cover; + object-fit: contain; } ` @@ -98,7 +97,8 @@ export const ActiveIcon = styled.span<{ accent$?: ChainAccentVars; color$?: stri display: flex; align-items: center; justify-content: center; - color: ${({ color$, accent$ }) => getAccentColor(accent$) ?? color$ ?? getBorder(accent$, `var(${UI.COLOR_PRIMARY})`)}; + color: ${({ color$, accent$ }) => + getAccentColor(accent$) ?? color$ ?? getBorder(accent$, `var(${UI.COLOR_PRIMARY})`)}; > svg { width: 16px; diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/MobileChainSelector.tsx b/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/MobileChainSelector.tsx new file mode 100644 index 00000000000..bae7db6e408 --- /dev/null +++ b/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/MobileChainSelector.tsx @@ -0,0 +1,108 @@ +import { ReactNode, useEffect, useMemo, useRef } from 'react' + +import { useTheme } from '@cowprotocol/common-hooks' +import { ChainInfo } from '@cowprotocol/cow-sdk' + +import { ChevronDown } from 'react-feather' + +import * as styledEl from './mobileChainSelector.styled' + +import { ChainsToSelectState } from '../../types' +import { sortChainsByDisplayOrder } from '../../utils/sortChainsByDisplayOrder' +import { getChainAccent } from '../ChainsSelector' + +interface MobileChainSelectorProps { + chainsState: ChainsToSelectState + label?: string + onSelectChain(chain: ChainInfo): void + onOpenPanel(): void +} + +export function MobileChainSelector({ + chainsState, + label, + onSelectChain, + onOpenPanel, +}: MobileChainSelectorProps): ReactNode { + const scrollRef = useRef(null) + const orderedChains = useMemo( + () => + sortChainsByDisplayOrder(chainsState.chains ?? [], { + pinChainId: chainsState.defaultChainId, + }), + [chainsState.chains, chainsState.defaultChainId], + ) + + const totalChains = chainsState.chains?.length ?? 0 + const canRenderChains = orderedChains.length > 0 + const activeChainLabel = orderedChains.find((chain) => chain.id === chainsState.defaultChainId)?.label + + useEffect(() => { + if (!scrollRef.current) { + return + } + + scrollRef.current.scrollTo({ left: 0, behavior: 'auto' }) + }, [chainsState.defaultChainId]) + + return ( + + {label ? ( + + {label} + {activeChainLabel ? ( + + {activeChainLabel} + + ) : null} + + ) : null} + + {canRenderChains ? ( + + {orderedChains.map((chain) => ( + + ))} + + ) : null} + {totalChains > 0 ? ( + + + View all ({totalChains}) + + + + ) : null} + + + ) +} + +interface ChainChipProps { + chain: ChainInfo + isActive: boolean + onSelectChain(chain: ChainInfo): void +} + +function ChainChip({ chain, isActive, onSelectChain }: ChainChipProps): ReactNode { + const { darkMode } = useTheme() + const accent = getChainAccent(chain.id) + const logoSrc = darkMode ? chain.logo.dark : chain.logo.light + + return ( + onSelectChain(chain)} + $active={isActive} + $accent={accent} + aria-pressed={isActive} + > + {chain.label} + + ) +} diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/helpers.tsx b/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/helpers.tsx index 829b24179b2..8793810e452 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/helpers.tsx +++ b/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/helpers.tsx @@ -65,8 +65,6 @@ interface TokensContentSectionProps | 'hideFavoriteTokensTooltip' | 'selectedTargetChainId' | 'onClearRecentTokens' - | 'onOpenManageWidget' - | 'standalone' > { searchInput: string selectTokenContext: SelectTokenContext @@ -84,8 +82,6 @@ export function TokensContentSection({ selectedTargetChainId, selectTokenContext, onClearRecentTokens, - onOpenManageWidget, - standalone, }: TokensContentSectionProps): ReactNode { return ( ) } @@ -128,7 +122,7 @@ export function TitleBarActions({ {showManageButton && ( ((acc, id) => { const info = CHAIN_INFO[id] @@ -42,25 +50,19 @@ const chainsMock: ChainInfo[] = [ return acc }, []) -const defaultProps: SelectTokenModalProps = { +const favoriteTokenAddresses = new Set(favoriteTokensMock.map((token) => token.address.toLowerCase())) +const recentTokensMock = allTokensMock.filter((token) => !favoriteTokenAddresses.has(token.address.toLowerCase())).slice(0, 3) + +const defaultModalProps: SelectTokenModalProps = { tokenListTags: {}, account: undefined, permitCompatibleTokens: {}, unsupportedTokens, allTokens: allTokensMock, favoriteTokens: favoriteTokensMock, + recentTokens: recentTokensMock, areTokensLoading: false, areTokensFromBridge: false, - chainsToSelect: { - chains: chainsMock, - isLoading: false, - defaultChainId: SupportedChainId.MAINNET, - }, - hasChainPanel: true, - chainsPanelTitle: 'Cross chain swap', - onSelectChain(chain: ChainInfo) { - console.log('onSelectChain', chain) - }, tokenListCategoryState: [null, () => void 0], balancesState: { values: balances, @@ -71,17 +73,12 @@ const defaultProps: SelectTokenModalProps = { selectedToken, isRouteAvailable: true, modalTitle: 'Swap from', - recentTokens: favoriteTokensMock.slice(0, 2), - selectedTargetChainId: SupportedChainId.SEPOLIA, onSelectToken() { console.log('onSelectToken') }, onTokenListItemClick(token) { console.log('onTokenListItemClick', token.symbol) }, - onClearRecentTokens() { - console.log('onClearRecentTokens') - }, onOpenManageWidget() { console.log('onOpenManageWidget') }, @@ -93,35 +90,57 @@ const defaultProps: SelectTokenModalProps = { }, } +const defaultChainPanelProps = { + title: 'Cross chain swap', + chainsState: { + defaultChainId: SupportedChainId.MAINNET, + chains: chainsMock, + isLoading: false, + }, + onSelectChain(chain: ChainInfo) { + console.log('onSelectChain', chain) + }, +} + const Fixtures = { default: () => ( - + + + + ), + loadingSidebar: () => ( + + + ), - noChainPanel: () => ( + noSidebar: () => ( - + ), importByAddress: () => ( - + ), NoTokenFound: () => ( - + ), searchFromInactiveLists: () => ( - + ), searchFromExternalSources: () => ( - + ), } diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/index.tsx b/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/index.tsx index b898ea7285d..ceb58448b39 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/index.tsx +++ b/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/index.tsx @@ -1,24 +1,23 @@ -import { ReactNode } from 'react' +import { ComponentProps, ReactNode } from 'react' import { SearchInput } from '@cowprotocol/ui' import { t } from '@lingui/core/macro' import { TokensContentSection, TitleBarActions, useSelectTokenContext, useTokenSearchInput } from './helpers' +import { MobileChainSelector } from './MobileChainSelector' import { SelectTokenModalContent } from './SelectTokenModalContent' import * as styledEl from './styled' import { LpTokenListsWidget } from '../../containers/LpTokenListsWidget' -import { ChainPanel } from '../ChainPanel' -import { ChainsSelector } from '../ChainsSelector' import type { SelectTokenModalProps } from './types' import type { TokenSelectionHandler } from '../../types' - export type { SelectTokenModalProps } export function SelectTokenModal(props: SelectTokenModalProps): ReactNode { const { + defaultInputValue = '', onSelectToken, onDismiss, onInputPressEnter, @@ -27,9 +26,9 @@ export function SelectTokenModal(props: SelectTokenModalProps): ReactNode { openPoolPage, tokenListCategoryState, disableErc20, - onSelectChain, - areTokensFromBridge, isRouteAvailable, + modalTitle, + hasChainPanel = false, standalone, onOpenManageWidget, favoriteTokens, @@ -37,25 +36,30 @@ export function SelectTokenModal(props: SelectTokenModalProps): ReactNode { onClearRecentTokens, areTokensLoading, allTokens, + areTokensFromBridge, hideFavoriteTokensTooltip, selectedTargetChainId, + mobileChainsState, + mobileChainsLabel, + onSelectChain, + onOpenMobileChainPanel, isFullScreenMobile, } = props - const { - inputValue, - setInputValue, - trimmedInputValue, - selectTokenContext, - showChainPanel, - legacyChainsState, - chainPanel, - resolvedModalTitle, - } = useSelectTokenModalLayout(props) + const [inputValue, setInputValue, trimmedInputValue] = useTokenSearchInput(defaultInputValue) + const selectTokenContext = useSelectTokenContext(props) + const resolvedModalTitle = modalTitle ?? 'Select token' + const mobileChainSelector = getMobileChainSelectorConfig({ + hasChainPanel, + mobileChainsState, + mobileChainsLabel, + onSelectChain, + onOpenMobileChainPanel, + }) return ( - {renderLegacyChainSelector(chainsToSelect, onSelectChain)} - {children} - - ) -} - -function renderLegacyChainSelector( - chainsToSelect: SelectTokenModalProps['chainsToSelect'], - onSelectChain: SelectTokenModalProps['onSelectChain'], -): ReactNode { - if (!chainsToSelect?.chains?.length || !onSelectChain) { - return null - } - - return ( - - - - ) -} - -interface ChainPanelLayout { - showChainPanel: boolean - legacyChainsState: SelectTokenModalProps['chainsToSelect'] - chainPanel: ReactNode - resolvedTitle: string -} - -function getChainPanelLayout({ - hasChainPanel, - chainsToSelect, - onSelectChain, - chainsPanelTitle, -}: Pick): ChainPanelLayout { - const resolvedTitle = chainsPanelTitle ?? t`Cross chain swap` - if (!chainsToSelect?.chains?.length || !onSelectChain) { - return { showChainPanel: false, legacyChainsState: undefined, chainPanel: null, resolvedTitle } - } - - const showChainPanel = Boolean(hasChainPanel) - const legacyChainsState = showChainPanel ? undefined : chainsToSelect - const chainPanel = showChainPanel ? ( - - ) : null - - return { showChainPanel, legacyChainsState, chainPanel, resolvedTitle } -} - -function useSelectTokenModalLayout(props: SelectTokenModalProps): { - inputValue: string - setInputValue: (value: string) => void - trimmedInputValue: string - selectTokenContext: ReturnType - showChainPanel: boolean - legacyChainsState: SelectTokenModalProps['chainsToSelect'] - chainPanel: ReactNode - resolvedModalTitle: string -} { - const { defaultInputValue = '', chainsToSelect, onSelectChain, modalTitle, hasChainPanel = false, chainsPanelTitle } = props - - const [inputValue, setInputValue, trimmedInputValue] = useTokenSearchInput(defaultInputValue) - const selectTokenContext = useSelectTokenContext(props) - const resolvedModalTitle = modalTitle ?? t`Select token` - const { showChainPanel, legacyChainsState, chainPanel } = getChainPanelLayout({ - hasChainPanel, - chainsToSelect, - onSelectChain, - chainsPanelTitle, - }) - - return { - inputValue, - setInputValue, - trimmedInputValue, - selectTokenContext, - showChainPanel, - legacyChainsState, - chainPanel, - resolvedModalTitle, - } + return {children} } interface SelectTokenModalShellProps { @@ -240,7 +152,7 @@ interface SelectTokenModalShellProps { searchValue: string onSearchChange(value: string): void onSearchEnter?: () => void - sideContent?: ReactNode + mobileChainSelector?: ComponentProps } function SelectTokenModalShell({ @@ -254,7 +166,7 @@ function SelectTokenModalShell({ searchValue, onSearchChange, onSearchEnter, - sideContent, + mobileChainSelector, }: SelectTokenModalShellProps): ReactNode { return ( @@ -279,10 +191,42 @@ function SelectTokenModalShell({ /> + {mobileChainSelector ? : null} {children} - {sideContent} ) } + +function getMobileChainSelectorConfig({ + hasChainPanel, + mobileChainsState, + mobileChainsLabel, + onSelectChain, + onOpenMobileChainPanel, +}: { + hasChainPanel: boolean + mobileChainsState: SelectTokenModalProps['mobileChainsState'] + mobileChainsLabel: SelectTokenModalProps['mobileChainsLabel'] + onSelectChain: SelectTokenModalProps['onSelectChain'] + onOpenMobileChainPanel: SelectTokenModalProps['onOpenMobileChainPanel'] +}): ComponentProps | undefined { + const canRender = + !hasChainPanel && + mobileChainsState && + onSelectChain && + onOpenMobileChainPanel && + (mobileChainsState.chains?.length ?? 0) > 0 + + if (!canRender) { + return undefined + } + + return { + chainsState: mobileChainsState, + label: mobileChainsLabel, + onSelectChain, + onOpenPanel: onOpenMobileChainPanel, + } +} diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/mobileChainSelector.styled.ts b/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/mobileChainSelector.styled.ts new file mode 100644 index 00000000000..2f3fd129525 --- /dev/null +++ b/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/mobileChainSelector.styled.ts @@ -0,0 +1,147 @@ +import { UI } from '@cowprotocol/ui' + +import styled from 'styled-components/macro' + +import { ListTitle } from './styled' + +import type { ChainAccentVars } from '../ChainsSelector/styled' + +const fallbackBackground = `var(${UI.COLOR_PRIMARY_OPACITY_10})` +const fallbackBorder = `var(${UI.COLOR_PRIMARY_OPACITY_50})` + +const getBackground = (accent?: ChainAccentVars): string => + accent ? `var(${accent.backgroundVar})` : fallbackBackground +const getBorder = (accent?: ChainAccentVars): string => (accent ? `var(${accent.borderVar})` : fallbackBorder) + +export const MobileSelectorRow = styled.div` + padding: 0 14px 12px; + display: flex; + flex-direction: column; + gap: 8px; +` + +export const MobileSelectorLabel = styled(ListTitle)` + padding: 4px 0; + justify-content: flex-start; + gap: 6px; + flex-wrap: wrap; +` + +export const ActiveChainLabel = styled.span` + color: var(${UI.COLOR_TEXT}); + font-weight: 600; + font-size: 14px; +` + +export const ScrollContainer = styled.div` + --cta-width: min(45vw, 130px); + --fade-width: clamp(14px, 6vw, 32px); + --cta-gap: 2px; + --cta-offset: calc(var(--cta-width) + var(--cta-gap)); + position: relative; + min-height: 44px; + overflow: hidden; + padding-right: var(--cta-offset); +` + +export const ScrollArea = styled.div` + display: flex; + align-items: center; + gap: 6px; + overflow-x: auto; + overflow-y: hidden; + padding-right: var(--fade-width); + -webkit-overflow-scrolling: touch; + scrollbar-width: none; + -ms-overflow-style: none; + scroll-snap-type: x proximity; + + &::-webkit-scrollbar { + display: none; + } + + mask-image: linear-gradient( + 90deg, + #000 0%, + #000 calc(100% - var(--cta-offset) - var(--fade-width)), + rgba(0, 0, 0, 0) 100% + ); + -webkit-mask-image: linear-gradient( + 90deg, + #000 0%, + #000 calc(100% - var(--cta-offset) - var(--fade-width)), + rgba(0, 0, 0, 0) 100% + ); +` + +export const FixedAllNetworks = styled.div` + pointer-events: none; + position: absolute; + top: 0; + bottom: 0; + right: 0; + width: var(--cta-width); + display: flex; + align-items: center; + justify-content: flex-end; + + > button { + pointer-events: auto; + width: 100%; + position: relative; + z-index: 1; + } +` + +export const ChainChipButton = styled.button<{ $active?: boolean; $accent?: ChainAccentVars }>` + --size: 44px; + width: var(--size); + height: var(--size); + border-radius: 10px; + border: 2px solid ${({ $active, $accent }) => ($active ? getBorder($accent) : 'transparent')}; + background: ${({ $active, $accent }) => ($active ? getBackground($accent) : 'transparent')}; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: + border 0.2s ease, + background 0.2s ease; + flex-shrink: 0; + scroll-snap-align: start; + + > img { + --size: 100%; + width: var(--size); + height: var(--size); + object-fit: contain; + } +` + +export const MoreChipButton = styled.button` + --size: 44px; + height: var(--size); + padding: 0 12px; + border-radius: var(--size); + border: 1px solid var(${UI.COLOR_PAPER_DARKEST}); + background: var(${UI.COLOR_PAPER}); + color: var(${UI.COLOR_TEXT}); + font-weight: 600; + font-size: 13px; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 4px; + cursor: pointer; + white-space: nowrap; + flex-shrink: 0; + + svg { + --size: 18px; + stroke: var(${UI.COLOR_TEXT_OPACITY_50}); + width: var(--size); + height: var(--size); + min-width: var(--size); + min-height: var(--size); + } +` diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/styled.ts b/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/styled.ts index 98b614e413a..4dae09d3ba6 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/styled.ts +++ b/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/styled.ts @@ -103,8 +103,6 @@ export const Body = styled.div` display: flex; flex: 1; min-height: 0; - gap: 16px; - align-items: stretch; ${Media.upToMedium()} { flex-direction: column; @@ -120,17 +118,6 @@ export const TokenColumn = styled.div` padding: 0; ` -export const LegacyChainsWrapper = styled.div` - border-bottom: 1px solid var(${UI.COLOR_BORDER}); - padding: 2px 10px 10px 14px; - margin: 0 14px 16px; - - ${Media.upToSmall()} { - margin: 0 10px 16px; - padding: 2px 4px 10px 8px; - } -` - export const Row = styled.div` padding: 0 24px; margin-bottom: 16px; @@ -186,26 +173,3 @@ export const RouteNotAvailable = styled.div` padding: 40px 0; text-align: center; ` - -export const ActionButton = styled.button` - ${blankButtonMixin}; - - display: flex; - width: 100%; - align-items: center; - flex-direction: row; - justify-content: center; - gap: 10px; - cursor: pointer; - padding: 20px 0; - margin: 0; - font-size: 16px; - font-weight: 500; - color: inherit; - opacity: 0.6; - transition: opacity var(${UI.ANIMATION_DURATION}) ease-in-out; - - &:hover { - opacity: 1; - } -` diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/types.ts b/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/types.ts index 41dfe6ef284..e29be36aca4 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/types.ts +++ b/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/types.ts @@ -22,7 +22,6 @@ export interface SelectTokenModalProps { displayLpTokenLists?: boolean disableErc20?: boolean account: string | undefined - chainsToSelect?: ChainsToSelectState tokenListCategoryState: [T, (category: T) => void] defaultInputValue?: string areTokensLoading: boolean @@ -30,10 +29,13 @@ export interface SelectTokenModalProps { standalone?: boolean areTokensFromBridge: boolean isRouteAvailable: boolean | undefined - selectedTargetChainId?: number modalTitle?: string hasChainPanel?: boolean - chainsPanelTitle?: string + selectedTargetChainId?: number + mobileChainsState?: ChainsToSelectState + mobileChainsLabel?: string + onSelectChain?(chain: ChainInfo): void + onOpenMobileChainPanel?(): void isFullScreenMobile?: boolean onSelectToken: TokenSelectionHandler diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/TokensContent/index.tsx b/apps/cowswap-frontend/src/modules/tokensList/pure/TokensContent/index.tsx index cd8bb607702..2bad71148a4 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/pure/TokensContent/index.tsx +++ b/apps/cowswap-frontend/src/modules/tokensList/pure/TokensContent/index.tsx @@ -3,9 +3,6 @@ import React, { ReactNode, useMemo } from 'react' import { TokenWithLogo } from '@cowprotocol/common-const' import { Loader } from '@cowprotocol/ui' -import { Trans } from '@lingui/react/macro' -import { Edit } from 'react-feather' - import { TokenSearchResults } from '../../containers/TokenSearchResults' import { SelectTokenContext } from '../../types' import { getTokenUniqueKey } from '../../utils/tokenKey' @@ -17,14 +14,12 @@ export interface TokensContentProps { selectTokenContext: SelectTokenContext favoriteTokens: TokenWithLogo[] recentTokens?: TokenWithLogo[] - hideFavoriteTokensTooltip?: boolean areTokensLoading: boolean allTokens: TokenWithLogo[] searchInput: string - standalone?: boolean areTokensFromBridge: boolean + hideFavoriteTokensTooltip?: boolean selectedTargetChainId?: number - onOpenManageWidget(): void onClearRecentTokens?: () => void } @@ -32,15 +27,13 @@ export function TokensContent({ selectTokenContext, favoriteTokens, recentTokens, - hideFavoriteTokensTooltip, areTokensLoading, allTokens, displayLpTokenLists, searchInput, - standalone, areTokensFromBridge, + hideFavoriteTokensTooltip, selectedTargetChainId, - onOpenManageWidget, onClearRecentTokens, }: TokensContentProps): ReactNode { const shouldShowFavoritesInline = !areTokensLoading && !searchInput && favoriteTokens.length > 0 @@ -75,99 +68,35 @@ export function TokensContent({ const favoriteTokensInline = shouldShowFavoritesInline ? favoriteTokens : undefined const recentTokensInline = shouldShowRecentsInline ? recentTokens : undefined - const tokensView = renderTokensView({ - areTokensLoading, - searchInput, - selectTokenContext, - areTokensFromBridge, - allTokens, - tokensWithoutPinned, - displayLpTokenLists, - favoriteTokens: favoriteTokensInline, - recentTokens: recentTokensInline, - hideFavoriteTokensTooltip, - selectedTargetChainId, - onClearRecentTokens, - }) - return ( <> - {tokensView} - {!standalone && ( + {areTokensLoading ? ( + + + + ) : ( <> - -
- - {' '} - - Manage Token Lists - - -
+ {searchInput ? ( + + ) : ( + + )} )} ) } - -interface TokensViewProps { - areTokensLoading: boolean - searchInput: string - selectTokenContext: SelectTokenContext - areTokensFromBridge: boolean - allTokens: TokenWithLogo[] - tokensWithoutPinned: TokenWithLogo[] - displayLpTokenLists?: boolean - favoriteTokens?: TokenWithLogo[] - recentTokens?: TokenWithLogo[] - hideFavoriteTokensTooltip?: boolean - selectedTargetChainId?: number - onClearRecentTokens?: () => void -} - -function renderTokensView({ - areTokensLoading, - searchInput, - selectTokenContext, - areTokensFromBridge, - allTokens, - tokensWithoutPinned, - displayLpTokenLists, - favoriteTokens, - recentTokens, - hideFavoriteTokensTooltip, - selectedTargetChainId, - onClearRecentTokens, -}: TokensViewProps): ReactNode { - if (areTokensLoading) { - return ( - - - - ) - } - - if (searchInput) { - return ( - - ) - } - - return ( - - ) -} From 7739ad4f97e9dffa63da7741f11ee7d70abd2e9d Mon Sep 17 00:00:00 2001 From: fairlighteth <31534717+fairlighteth@users.noreply.github.com> Date: Tue, 18 Nov 2025 08:30:55 +0000 Subject: [PATCH 014/100] fix: update fallback logic for unsupported source chains in chain selection --- .../tokensList/hooks/useChainsToSelect.test.ts | 17 +++++++++++++++++ .../tokensList/hooks/useChainsToSelect.ts | 2 +- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/apps/cowswap-frontend/src/modules/tokensList/hooks/useChainsToSelect.test.ts b/apps/cowswap-frontend/src/modules/tokensList/hooks/useChainsToSelect.test.ts index 5bf6a56e458..d6547eab754 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/hooks/useChainsToSelect.test.ts +++ b/apps/cowswap-frontend/src/modules/tokensList/hooks/useChainsToSelect.test.ts @@ -45,4 +45,21 @@ describe('useChainsToSelect state builders', () => { SupportedChainId.AVALANCHE, ]) }) + + it('falls back to wallet chain when bridge does not support the source chain', () => { + const state = createOutputChainsState({ + selectedTargetChainId: SupportedChainId.BASE, + chainId: SupportedChainId.SEPOLIA, + currentChainInfo: createChainInfoForTests(SupportedChainId.SEPOLIA), + bridgeSupportedNetworks: [ + createChainInfoForTests(SupportedChainId.MAINNET), + createChainInfoForTests(SupportedChainId.ARBITRUM_ONE), + ], + areUnsupportedChainsEnabled: true, + isLoading: false, + }) + + expect(state.defaultChainId).toBe(SupportedChainId.SEPOLIA) + expect(state.chains?.map((chain) => chain.id)).toEqual([SupportedChainId.SEPOLIA]) + }) }) diff --git a/apps/cowswap-frontend/src/modules/tokensList/hooks/useChainsToSelect.ts b/apps/cowswap-frontend/src/modules/tokensList/hooks/useChainsToSelect.ts index c42664014cd..f59b315a8d4 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/hooks/useChainsToSelect.ts +++ b/apps/cowswap-frontend/src/modules/tokensList/hooks/useChainsToSelect.ts @@ -148,7 +148,7 @@ export function createOutputChainsState({ if (!isSourceChainSupportedByBridge) { // Source chain is unsupported by the bridge provider; fall back to non-bridge behavior. - return createSingleChainState(selectedTargetChainId, currentChainInfo) + return createSingleChainState(chainId, currentChainInfo) } return { From 43247d6c922add8eaae7ec199d5f741585d48fd5 Mon Sep 17 00:00:00 2001 From: fairlighteth <31534717+fairlighteth@users.noreply.github.com> Date: Tue, 18 Nov 2025 10:45:47 +0000 Subject: [PATCH 015/100] refactor: remove onSelectChain from SelectTokenModalProps interface --- .../src/modules/tokensList/pure/SelectTokenModal/types.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/types.ts b/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/types.ts index e29be36aca4..5f4c388b31c 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/types.ts +++ b/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/types.ts @@ -45,5 +45,4 @@ export interface SelectTokenModalProps { onInputPressEnter?(): void onOpenManageWidget(): void onDismiss(): void - onSelectChain?(chain: ChainInfo): void } From 03f600243b46e283a2772ea47c1068ff1cdfacf0 Mon Sep 17 00:00:00 2001 From: fairlighteth <31534717+fairlighteth@users.noreply.github.com> Date: Tue, 18 Nov 2025 12:49:02 +0000 Subject: [PATCH 016/100] refactor: improve tooltip handling in TokenSourceTitle component --- .../tokensList/pure/TokenSearchContent/index.tsx | 2 +- .../tokensList/pure/TokenSourceTitle/index.tsx | 11 +++++++---- 2 files changed, 8 insertions(+), 5 deletions(-) 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 a238ab18d09..b70d5756636 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/pure/TokenSearchContent/index.tsx +++ b/apps/cowswap-frontend/src/modules/tokensList/pure/TokenSearchContent/index.tsx @@ -233,7 +233,7 @@ function TokenSearchRowRenderer({ row, selectTokenContext, importToken }: TokenS case 'token': return case 'section-title': { - const tooltip = row.tooltip ?? '' + const tooltip = row.tooltip?.trim() || undefined return ( {row.text} diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/TokenSourceTitle/index.tsx b/apps/cowswap-frontend/src/modules/tokensList/pure/TokenSourceTitle/index.tsx index bf12a0f6bc7..51e8fbcd1ae 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/pure/TokenSourceTitle/index.tsx +++ b/apps/cowswap-frontend/src/modules/tokensList/pure/TokenSourceTitle/index.tsx @@ -25,13 +25,14 @@ const Title = styled.h4` export interface TokenSourceTitleProps { children: string - tooltip: string + tooltip?: string } // TODO: Add proper return type annotation // eslint-disable-next-line @typescript-eslint/explicit-function-return-type export function TokenSourceTitle(props: TokenSourceTitleProps) { const { children, tooltip } = props + const tooltipText = tooltip?.trim() return ( @@ -39,9 +40,11 @@ export function TokenSourceTitle(props: TokenSourceTitleProps) { {children} -
- -
+ {tooltipText ? ( +
+ +
+ ) : null}
) } From 7e85dc119b903827a116b89758fa34502f3fd5b0 Mon Sep 17 00:00:00 2001 From: fairlighteth <31534717+fairlighteth@users.noreply.github.com> Date: Wed, 19 Nov 2025 16:18:20 +0000 Subject: [PATCH 017/100] refactor: token pinning logic to ensure recent tokens are displayed correctly --- .../modules/tokensList/pure/TokensContent/index.tsx | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/TokensContent/index.tsx b/apps/cowswap-frontend/src/modules/tokensList/pure/TokensContent/index.tsx index 2bad71148a4..02199a83aa6 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/pure/TokensContent/index.tsx +++ b/apps/cowswap-frontend/src/modules/tokensList/pure/TokensContent/index.tsx @@ -40,22 +40,21 @@ export function TokensContent({ const shouldShowRecentsInline = !areTokensLoading && !searchInput && (recentTokens?.length ?? 0) > 0 const pinnedTokenKeys = useMemo(() => { - if (!shouldShowFavoritesInline && !shouldShowRecentsInline) { + // Only hide "Recent" tokens from the main list. + // Favorite tokens should still appear in "All tokens" so they participate + // in balance-based sorting and show their balances. + if (!shouldShowRecentsInline) { return undefined } const pinned = new Set() - if (shouldShowFavoritesInline) { - favoriteTokens.forEach((token) => pinned.add(getTokenUniqueKey(token))) - } - if (shouldShowRecentsInline && recentTokens) { recentTokens.forEach((token) => pinned.add(getTokenUniqueKey(token))) } return pinned - }, [favoriteTokens, recentTokens, shouldShowFavoritesInline, shouldShowRecentsInline]) + }, [recentTokens, shouldShowRecentsInline]) const tokensWithoutPinned = useMemo(() => { if (!pinnedTokenKeys) { From 5e58eaf53aec9d141b83f148ca3a7313c6a2c2c1 Mon Sep 17 00:00:00 2001 From: fairlighteth <31534717+fairlighteth@users.noreply.github.com> Date: Wed, 19 Nov 2025 16:25:10 +0000 Subject: [PATCH 018/100] refactor: remove x close button in manage token list modal --- .../containers/ManageListsAndTokens/index.tsx | 11 +++-------- .../tokensList/containers/SelectTokenWidget/index.tsx | 1 - 2 files changed, 3 insertions(+), 9 deletions(-) diff --git a/apps/cowswap-frontend/src/modules/tokensList/containers/ManageListsAndTokens/index.tsx b/apps/cowswap-frontend/src/modules/tokensList/containers/ManageListsAndTokens/index.tsx index 46aff689b97..833b0ba034a 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/containers/ManageListsAndTokens/index.tsx +++ b/apps/cowswap-frontend/src/modules/tokensList/containers/ManageListsAndTokens/index.tsx @@ -17,7 +17,7 @@ export interface ManageListsAndTokensProps { lists: ListState[] customTokens: TokenWithLogo[] onBack(): void - onDismiss(): void + onDismiss?(): void } const tokensInputPlaceholder = '0x0000' @@ -50,20 +50,15 @@ export function ManageListsAndTokens(props: ManageListsAndTokensProps): ReactNod const tokenSearchResponse = useSearchToken(isTokenAddressValid ? tokenInput : null) const listSearchResponse = useSearchList(isListUrlValid ? listInput : null) - // TODO: Add proper return type annotation - // eslint-disable-next-line @typescript-eslint/explicit-function-return-type - const setListsTab = () => { + const setListsTab = (): void => { setCurrentTab('lists') setInputValue('') } - // TODO: Add proper return type annotation - // eslint-disable-next-line @typescript-eslint/explicit-function-return-type - const setTokensTab = () => { + const setTokensTab = (): void => { setCurrentTab('tokens') setInputValue('') } - return ( diff --git a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/index.tsx b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/index.tsx index ff512fb1947..40d3a5c89e9 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/index.tsx +++ b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/index.tsx @@ -194,7 +194,6 @@ function getBlockingView( ) From 93ec9eded9f6fa2558166ef5a2301e1b78d12f6e Mon Sep 17 00:00:00 2001 From: fairlighteth <31534717+fairlighteth@users.noreply.github.com> Date: Thu, 20 Nov 2025 14:16:03 +0000 Subject: [PATCH 019/100] refactor: standardize token key generation across hooks and components --- .../modules/accountProxy/hooks/useRefundAmounts.ts | 3 ++- .../hooks/useTokenBalanceAndUsdValue.ts | 3 ++- .../modules/accountProxy/hooks/useTokensToRefund.ts | 3 ++- .../modules/tokensList/pure/TokenListItem/index.tsx | 3 ++- .../src/modules/tokensList/utils/tokenKey.ts | 3 ++- apps/explorer/src/hooks/useTokenList.ts | 5 +++-- libs/common-utils/src/areAddressesEqual.ts | 7 +++++-- libs/common-utils/src/tokens.ts | 13 +++++++++++++ 8 files changed, 31 insertions(+), 9 deletions(-) diff --git a/apps/cowswap-frontend/src/modules/accountProxy/hooks/useRefundAmounts.ts b/apps/cowswap-frontend/src/modules/accountProxy/hooks/useRefundAmounts.ts index 98ebb04b94c..fc8f4e819f0 100644 --- a/apps/cowswap-frontend/src/modules/accountProxy/hooks/useRefundAmounts.ts +++ b/apps/cowswap-frontend/src/modules/accountProxy/hooks/useRefundAmounts.ts @@ -1,5 +1,6 @@ import { useMemo } from 'react' +import { getTokenId } from '@cowprotocol/common-utils' import { CurrencyAmount } from '@uniswap/sdk-core' import { getUsdPriceStateKey, useUsdPrices } from 'modules/usdAmount' @@ -25,7 +26,7 @@ export function useRefundAmounts(): TokenUsdAmounts | null { return tokensToRefund.reduce((acc, { token, balance }) => { const usdPrice = usdPrices[getUsdPriceStateKey(token)] - const tokenKey = token.address.toLowerCase() + const tokenKey = getTokenId(token) acc[tokenKey] = { token, diff --git a/apps/cowswap-frontend/src/modules/accountProxy/hooks/useTokenBalanceAndUsdValue.ts b/apps/cowswap-frontend/src/modules/accountProxy/hooks/useTokenBalanceAndUsdValue.ts index d6a425c41a2..efb26014813 100644 --- a/apps/cowswap-frontend/src/modules/accountProxy/hooks/useTokenBalanceAndUsdValue.ts +++ b/apps/cowswap-frontend/src/modules/accountProxy/hooks/useTokenBalanceAndUsdValue.ts @@ -2,6 +2,7 @@ import { useMemo } from 'react' import { useTokensBalances } from '@cowprotocol/balances-and-allowances' import { TokenWithLogo } from '@cowprotocol/common-const' +import { getTokenAddressKey } from '@cowprotocol/common-utils' import { useTokensByAddressMap } from '@cowprotocol/tokens' import { CurrencyAmount, Token } from '@uniswap/sdk-core' @@ -16,7 +17,7 @@ export function useTokenBalanceAndUsdValue(tokenAddress: string | undefined): To const tokensByAddress = useTokensByAddressMap() const { values: balances } = useTokensBalances() - const tokenKey = tokenAddress?.toLowerCase() || undefined + const tokenKey = tokenAddress ? getTokenAddressKey(tokenAddress) : undefined const token = !!tokenKey && tokensByAddress[tokenKey] const balanceRaw = !!tokenKey && balances[tokenKey] diff --git a/apps/cowswap-frontend/src/modules/accountProxy/hooks/useTokensToRefund.ts b/apps/cowswap-frontend/src/modules/accountProxy/hooks/useTokensToRefund.ts index acd702340f4..598848a5a31 100644 --- a/apps/cowswap-frontend/src/modules/accountProxy/hooks/useTokensToRefund.ts +++ b/apps/cowswap-frontend/src/modules/accountProxy/hooks/useTokensToRefund.ts @@ -2,6 +2,7 @@ import { useMemo } from 'react' import { useTokensBalances } from '@cowprotocol/balances-and-allowances' import { TokenWithLogo } from '@cowprotocol/common-const' +import { getTokenAddressKey } from '@cowprotocol/common-utils' import { useTokensByAddressMap } from '@cowprotocol/tokens' import { BigNumber } from '@ethersproject/bignumber' @@ -17,7 +18,7 @@ export function useTokensToRefund(): TokenToRefund[] | undefined { return useMemo(() => { return Object.keys(balances.values) .reduce((acc, tokenAddress) => { - const token = tokensByAddress[tokenAddress.toLowerCase()] + const token = tokensByAddress[getTokenAddressKey(tokenAddress)] const balance = balances.values[tokenAddress] if (token && balance?.gt(0)) { diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/TokenListItem/index.tsx b/apps/cowswap-frontend/src/modules/tokensList/pure/TokenListItem/index.tsx index d9d4c867a42..3c6d27dda1c 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/pure/TokenListItem/index.tsx +++ b/apps/cowswap-frontend/src/modules/tokensList/pure/TokenListItem/index.tsx @@ -14,6 +14,7 @@ import * as styledEl from './styled' import { useDeferredVisibility } from '../../hooks/useDeferredVisibility' import { TokenSelectionHandler } from '../../types' +import { getTokenUniqueKey } from '../../utils/tokenKey' import { TokenInfo } from '../TokenInfo' import { TokenTags } from '../TokenTags' @@ -60,7 +61,7 @@ export function TokenListItem(props: TokenListItemProps): ReactNode { className, } = props - const tokenKey = `${token.chainId}:${token.address.toLowerCase()}` + const tokenKey = getTokenUniqueKey(token) // Defer heavyweight UI (tooltips, formatted numbers) until the row is about to enter the viewport. const { ref: visibilityRef, isVisible: hasIntersected } = useDeferredVisibility({ resetKey: tokenKey, diff --git a/apps/cowswap-frontend/src/modules/tokensList/utils/tokenKey.ts b/apps/cowswap-frontend/src/modules/tokensList/utils/tokenKey.ts index 8f827b02885..5abaeeaeea3 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/utils/tokenKey.ts +++ b/apps/cowswap-frontend/src/modules/tokensList/utils/tokenKey.ts @@ -1,7 +1,8 @@ import { TokenWithLogo } from '@cowprotocol/common-const' +import { getTokenId } from '@cowprotocol/common-utils' type TokenIdentifier = Pick export function getTokenUniqueKey(token: TokenIdentifier): string { - return `${token.chainId}:${token.address.toLowerCase()}` + return getTokenId(token) } diff --git a/apps/explorer/src/hooks/useTokenList.ts b/apps/explorer/src/hooks/useTokenList.ts index 8eb0a45f1f3..2ab85ac0685 100644 --- a/apps/explorer/src/hooks/useTokenList.ts +++ b/apps/explorer/src/hooks/useTokenList.ts @@ -1,6 +1,7 @@ import { useMemo } from 'react' import { SWR_NO_REFRESH_OPTIONS } from '@cowprotocol/common-const' +import { getTokenAddressKey } from '@cowprotocol/common-utils' import { ALL_SUPPORTED_CHAIN_IDS, mapSupportedNetworks, SupportedChainId } from '@cowprotocol/cow-sdk' import type { TokenInfo, TokenList } from '@uniswap/token-lists' @@ -65,7 +66,7 @@ export function useTokenList(chainId: SupportedChainId | undefined): { data: Tok const nativeToken = NATIVE_TOKEN_PER_NETWORK[chainId] - data[NATIVE_TOKEN_ADDRESS.toLowerCase()] = { + data[getTokenAddressKey(NATIVE_TOKEN_ADDRESS)] = { ...nativeToken, name: nativeToken.name || '', symbol: nativeToken.symbol || '', @@ -93,7 +94,7 @@ function fetcher(tokenListUrl: string): Promise { tokens.reduce((acc, token) => { // Pick only supported chains if (SUPPORTED_CHAIN_IDS_SET.has(token.chainId)) { - acc[token.chainId][token.address.toLowerCase()] = token + acc[token.chainId][getTokenAddressKey(token.address)] = token } return acc }, INITIAL_TOKEN_LIST_PER_NETWORK), diff --git a/libs/common-utils/src/areAddressesEqual.ts b/libs/common-utils/src/areAddressesEqual.ts index 41765bee726..5cd80b3e9b5 100644 --- a/libs/common-utils/src/areAddressesEqual.ts +++ b/libs/common-utils/src/areAddressesEqual.ts @@ -1,7 +1,10 @@ import { Nullish } from '@cowprotocol/types' +import { getTokenAddressKey } from './tokens' + export function areAddressesEqual(a: Nullish, b: Nullish): boolean { - if ((a && !b) || (!a && b)) return false + if (!a && !b) return true + if (!a || !b) return false - return a?.toLowerCase() === b?.toLowerCase() + return getTokenAddressKey(a) === getTokenAddressKey(b) } diff --git a/libs/common-utils/src/tokens.ts b/libs/common-utils/src/tokens.ts index a3a5287030d..53a69fb205f 100644 --- a/libs/common-utils/src/tokens.ts +++ b/libs/common-utils/src/tokens.ts @@ -14,3 +14,16 @@ export function isNativeAddress(tokenAddress: string, chainId: ChainId): boolean return native && doesTokenMatchSymbolOrAddress(native, tokenAddressLower) } + +export function getTokenAddressKey(address: string): string { + return address.toLowerCase() +} + +export interface TokenIdentifier { + address: string + chainId: number +} + +export function getTokenId(token: TokenIdentifier): string { + return `${token.chainId}:${getTokenAddressKey(token.address)}` +} From edaaed4fea9653b49ff690a46b4dd9e6922783bb Mon Sep 17 00:00:00 2001 From: fairlighteth <31534717+fairlighteth@users.noreply.github.com> Date: Thu, 20 Nov 2025 15:53:56 +0000 Subject: [PATCH 020/100] refactor: integrate useCloseTokenSelectWidget for improved widget closure handling --- .../containers/SelectTokenWidget/index.tsx | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/index.tsx b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/index.tsx index 40d3a5c89e9..39f2d04c837 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/index.tsx +++ b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/index.tsx @@ -1,4 +1,4 @@ -import { MouseEvent, ReactNode, useEffect, useState } from 'react' +import { MouseEvent, ReactNode, useEffect, useRef, useState } from 'react' import { useMediaQuery } from '@cowprotocol/common-hooks' import { addBodyClass, removeBodyClass } from '@cowprotocol/common-utils' @@ -14,6 +14,7 @@ import { import { MobileChainPanelPortal } from './MobileChainPanelPortal' import { InnerWrapper, ModalContainer, WidgetCard, WidgetOverlay, Wrapper } from './styled' +import { useCloseTokenSelectWidget } from '../../hooks/useCloseTokenSelectWidget' import { ChainPanel } from '../../pure/ChainPanel' import { ImportListModal } from '../../pure/ImportListModal' import { ImportTokenModal } from '../../pure/ImportTokenModal' @@ -26,6 +27,20 @@ export function SelectTokenWidget(props: SelectTokenWidgetProps): ReactNode { const isCompactLayout = useMediaQuery(Media.upToMedium(false)) const [isMobileChainPanelOpen, setIsMobileChainPanelOpen] = useState(false) const isChainPanelVisible = hasChainPanel && !isCompactLayout + const closeTokenSelectWidget = useCloseTokenSelectWidget() + + const closeTokenSelectWidgetRef = + useRef>(closeTokenSelectWidget) + + useEffect(() => { + closeTokenSelectWidgetRef.current = closeTokenSelectWidget + }, [closeTokenSelectWidget]) + + useEffect(() => { + return () => { + closeTokenSelectWidgetRef.current?.({ overrideForceLock: true }) + } + }, []) useEffect(() => { if (!shouldRender) { From f21738b5829ffcbfef6f74b2e14e2c508ecb0b28 Mon Sep 17 00:00:00 2001 From: fairlighteth <31534717+fairlighteth@users.noreply.github.com> Date: Tue, 25 Nov 2025 13:35:03 +0000 Subject: [PATCH 021/100] refactor: optimize token import logic and fix balance retrieval in TokenListItemContainer --- .../containers/SelectTokenWidget/index.tsx | 21 +++++++++++-------- .../pure/TokenListItemContainer/index.tsx | 2 +- 2 files changed, 13 insertions(+), 10 deletions(-) diff --git a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/index.tsx b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/index.tsx index fa8966f62fa..cfbba946687 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/index.tsx +++ b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/index.tsx @@ -149,17 +149,20 @@ export function SelectTokenWidget({ displayLpTokenLists, standalone }: SelectTok closeTokenSelectWidget() }, [closeTokenSelectWidget]) - const importTokenAndClose = (tokens: TokenWithLogo[]): void => { - importTokenCallback(tokens) - const [tokenToSelect] = tokens + const importTokenAndClose = useCallback( + (tokens: TokenWithLogo[]): void => { + importTokenCallback(tokens) + const [tokenToSelect] = tokens - if (tokenToSelect) { - handleTokenListItemClick(tokenToSelect) - onSelectToken?.(tokenToSelect) - } + if (tokenToSelect) { + handleTokenListItemClick(tokenToSelect) + onSelectToken?.(tokenToSelect) + } - onDismiss() - } + onDismiss() + }, + [handleTokenListItemClick, importTokenCallback, onDismiss, onSelectToken], + ) const importListAndBack = (list: ListState): void => { try { diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/TokenListItemContainer/index.tsx b/apps/cowswap-frontend/src/modules/tokensList/pure/TokenListItemContainer/index.tsx index 97208d6eb0f..b7d8c72506f 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/pure/TokenListItemContainer/index.tsx +++ b/apps/cowswap-frontend/src/modules/tokensList/pure/TokenListItemContainer/index.tsx @@ -37,7 +37,7 @@ export function TokenListItemContainer({ token, context }: TokenListItemContaine isPermitCompatible={permitCompatibleTokens[addressLowerCase]} selectedToken={selectedToken} token={token} - balance={balances ? balances[token.address.toLowerCase()] : undefined} + balance={balances ? balances[addressLowerCase] : undefined} onSelectToken={handleSelectToken} isWalletConnected={isWalletConnected} tokenListTags={tokenListTags} From 38939a49a1b5edd13d30eb077cd449c0ea718fa7 Mon Sep 17 00:00:00 2001 From: fairlighteth <31534717+fairlighteth@users.noreply.github.com> Date: Wed, 26 Nov 2025 09:53:18 +0000 Subject: [PATCH 022/100] refactor: enhance TradeWidgetModals to utilize usePrevious --- .../TradeWidget/TradeWidgetModals.tsx | 24 +++++++++++++------ 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/apps/cowswap-frontend/src/modules/trade/containers/TradeWidget/TradeWidgetModals.tsx b/apps/cowswap-frontend/src/modules/trade/containers/TradeWidget/TradeWidgetModals.tsx index eb8ddf1b92b..1a348f30311 100644 --- a/apps/cowswap-frontend/src/modules/trade/containers/TradeWidget/TradeWidgetModals.tsx +++ b/apps/cowswap-frontend/src/modules/trade/containers/TradeWidget/TradeWidgetModals.tsx @@ -1,5 +1,6 @@ import { ReactNode, useCallback, useEffect, useRef } from 'react' +import { usePrevious } from '@cowprotocol/common-hooks' import { useAddUserToken } from '@cowprotocol/tokens' import { useWalletInfo } from '@cowprotocol/wallet' @@ -85,11 +86,9 @@ export function TradeWidgetModals({ confirmModal, genericModal }: TradeWidgetMod ) const isOutputTokenSelector = field === Field.OUTPUT - const isOutputTokenSelectorRef = useRef(isOutputTokenSelector) - - useEffect(() => { - isOutputTokenSelectorRef.current = isOutputTokenSelector - }, [isOutputTokenSelector]) + const previousIsOutputTokenSelector = usePrevious(isOutputTokenSelector) + const previousChainId = usePrevious(chainId) + const isInitialRenderRef = useRef(true) const error = tokenListAddingError || approveError || confirmError @@ -105,8 +104,19 @@ export function TradeWidgetModals({ confirmModal, genericModal }: TradeWidgetMod * Because network might be changed from the widget inside */ useEffect(() => { - resetAllScreens(isOutputTokenSelectorRef.current) - }, [chainId, resetAllScreens]) + const chainChanged = previousChainId !== chainId + + if (!chainChanged && !isInitialRenderRef.current) { + return + } + + isInitialRenderRef.current = false + + const shouldCloseTokenSelectWidget = + chainChanged ? isOutputTokenSelector : previousIsOutputTokenSelector ?? isOutputTokenSelector + + resetAllScreens(shouldCloseTokenSelectWidget) + }, [chainId, isOutputTokenSelector, previousChainId, previousIsOutputTokenSelector, resetAllScreens]) if (genericModal) { return genericModal From cd49f8e3b6a8806a136518172aaa1c08dca01bcd Mon Sep 17 00:00:00 2001 From: fairlighteth <31534717+fairlighteth@users.noreply.github.com> Date: Wed, 26 Nov 2025 09:55:24 +0000 Subject: [PATCH 023/100] refactor: simplify max-width logic in TradeWidget container --- .../trade/containers/TradeWidget/styled.tsx | 21 ++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/apps/cowswap-frontend/src/modules/trade/containers/TradeWidget/styled.tsx b/apps/cowswap-frontend/src/modules/trade/containers/TradeWidget/styled.tsx index 7cb13d23761..0c02d8d7ae8 100644 --- a/apps/cowswap-frontend/src/modules/trade/containers/TradeWidget/styled.tsx +++ b/apps/cowswap-frontend/src/modules/trade/containers/TradeWidget/styled.tsx @@ -3,14 +3,21 @@ import { UI } from '@cowprotocol/ui' import styled from 'styled-components/macro' import { WIDGET_MAX_WIDTH } from 'theme' -export const Container = styled.div<{ isTokenSelectOpen?: boolean; isTokenSelectWide?: boolean }>` +type ContainerSizeProps = { isTokenSelectOpen?: boolean; isTokenSelectWide?: boolean } + +const getContainerMaxWidth = ({ isTokenSelectOpen, isTokenSelectWide }: ContainerSizeProps): string => { + if (!isTokenSelectOpen) { + return WIDGET_MAX_WIDTH.swap + } + + const openedWidth = isTokenSelectWide ? WIDGET_MAX_WIDTH.tokenSelectSidebar : WIDGET_MAX_WIDTH.tokenSelect + + return openedWidth +} + +export const Container = styled.div` width: 100%; - max-width: ${({ isTokenSelectOpen, isTokenSelectWide }) => - isTokenSelectOpen - ? isTokenSelectWide - ? WIDGET_MAX_WIDTH.tokenSelectSidebar - : WIDGET_MAX_WIDTH.tokenSelect - : WIDGET_MAX_WIDTH.swap}; + max-width: ${getContainerMaxWidth}; margin: 0 auto; position: relative; ` From b3f48f1525aeb39959a7f8f634f290e8dc9b4c1e Mon Sep 17 00:00:00 2001 From: fairlighteth <31534717+fairlighteth@users.noreply.github.com> Date: Thu, 27 Nov 2025 10:13:13 +0000 Subject: [PATCH 024/100] refactor: extract TokenColumnContent component and clean up SelectTokenModal --- .../SelectTokenModal/TokenColumnContent.tsx | 83 +++++++++++++++++++ .../pure/SelectTokenModal/index.tsx | 77 +---------------- 2 files changed, 84 insertions(+), 76 deletions(-) create mode 100644 apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/TokenColumnContent.tsx diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/TokenColumnContent.tsx b/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/TokenColumnContent.tsx new file mode 100644 index 00000000000..0d2c51b9de6 --- /dev/null +++ b/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/TokenColumnContent.tsx @@ -0,0 +1,83 @@ +import { ReactNode } from 'react' + +import { ChainInfo } from '@cowprotocol/cow-sdk' +import { TokenListCategory } from '@cowprotocol/tokens' + +import { SelectTokenModalContent } from './SelectTokenModalContent' +import * as styledEl from './styled' + +import { LpTokenListsWidget } from '../../containers/LpTokenListsWidget' +import { ChainsToSelectState, TokenSelectionHandler } from '../../types' +import { ChainsSelector } from '../ChainsSelector' + +type TokenListCategoryState = [TokenListCategory[] | null, (category: TokenListCategory[] | null) => void] + +export interface TokenColumnContentProps { + displayLpTokenLists?: boolean + account: string | undefined + inputValue: string + onSelectToken: TokenSelectionHandler + openPoolPage(poolAddress: string): void + disableErc20?: boolean + tokenListCategoryState: TokenListCategoryState + isRouteAvailable: boolean | undefined + chainsToSelect?: ChainsToSelectState + onSelectChain: (chain: ChainInfo) => void + children: ReactNode +} + +export function TokenColumnContent({ + displayLpTokenLists, + account, + inputValue, + onSelectToken, + openPoolPage, + disableErc20, + tokenListCategoryState, + isRouteAvailable, + chainsToSelect, + onSelectChain, + children, +}: TokenColumnContentProps): ReactNode { + if (displayLpTokenLists) { + return ( + + {children} + + ) + } + + return ( + <> + {renderLegacyChainSelector(chainsToSelect, onSelectChain)} + {children} + + ) +} + +function renderLegacyChainSelector( + chainsToSelect: ChainsToSelectState | undefined, + onSelectChain: (chain: ChainInfo) => void, +): ReactNode { + if (!chainsToSelect?.chains?.length) { + return null + } + + return ( + + + + ) +} diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/index.tsx b/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/index.tsx index 18b7cd849b5..2c40b037392 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/index.tsx +++ b/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/index.tsx @@ -5,15 +5,10 @@ import { SearchInput } from '@cowprotocol/ui' import { t } from '@lingui/core/macro' import { TokensContentSection, TitleBarActions, useSelectTokenContext, useTokenSearchInput } from './helpers' -import { SelectTokenModalContent } from './SelectTokenModalContent' import * as styledEl from './styled' - - -import { LpTokenListsWidget } from '../../containers/LpTokenListsWidget' -import { ChainsSelector } from '../ChainsSelector' +import { TokenColumnContent } from './TokenColumnContent' import type { SelectTokenModalProps } from './types' -import type { TokenSelectionHandler } from '../../types' export type { SelectTokenModalProps } @@ -96,76 +91,6 @@ export function SelectTokenModal(props: SelectTokenModalProps): ReactNode { ) } -interface TokenColumnContentProps { - displayLpTokenLists?: boolean - account: string | undefined - inputValue: string - onSelectToken: TokenSelectionHandler - openPoolPage(poolAddress: string): void - disableErc20?: boolean - tokenListCategoryState: SelectTokenModalProps['tokenListCategoryState'] - isRouteAvailable: boolean | undefined - chainsToSelect?: SelectTokenModalProps['chainsToSelect'] - onSelectChain: SelectTokenModalProps['onSelectChain'] - children: ReactNode -} - -function TokenColumnContent({ - displayLpTokenLists, - account, - inputValue, - onSelectToken, - openPoolPage, - disableErc20, - tokenListCategoryState, - isRouteAvailable, - chainsToSelect, - onSelectChain, - children, -}: TokenColumnContentProps): ReactNode { - if (displayLpTokenLists) { - return ( - - {children} - - ) - } - - return ( - <> - {renderLegacyChainSelector(chainsToSelect, onSelectChain)} - {children} - - ) -} - -function renderLegacyChainSelector( - chainsToSelect: SelectTokenModalProps['chainsToSelect'], - onSelectChain: SelectTokenModalProps['onSelectChain'], -): ReactNode { - if (!chainsToSelect?.chains?.length) { - return null - } - - return ( - - - - ) -} - interface SelectTokenModalShellProps { children: ReactNode hasChainPanel: boolean From 8288b94dc5c35ff195e0e5fd2cb9c43561a5765a Mon Sep 17 00:00:00 2001 From: fairlighteth <31534717+fairlighteth@users.noreply.github.com> Date: Thu, 27 Nov 2025 10:21:39 +0000 Subject: [PATCH 025/100] refactor: rename SelectTokenModalProps to TokenListContentProps --- .../tokensList/pure/SelectTokenModal/types.ts | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/types.ts b/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/types.ts index 649cfcfe1b8..70f4c4b7399 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/types.ts +++ b/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/types.ts @@ -10,7 +10,7 @@ import { PermitCompatibleTokens } from 'modules/permit' import { ChainsToSelectState, TokenSelectionHandler } from '../../types' -export interface SelectTokenModalProps { +export interface TokenListContentProps { allTokens: TokenWithLogo[] favoriteTokens: TokenWithLogo[] recentTokens?: TokenWithLogo[] @@ -22,7 +22,6 @@ export interface SelectTokenModalProps { displayLpTokenLists?: boolean disableErc20?: boolean account: string | undefined - chainsToSelect?: ChainsToSelectState tokenListCategoryState: [T, (category: T) => void] defaultInputValue?: string areTokensLoading: boolean @@ -30,11 +29,18 @@ export interface SelectTokenModalProps { standalone?: boolean areTokensFromBridge: boolean isRouteAvailable: boolean | undefined - selectedTargetChainId?: number modalTitle?: string hasChainPanel?: boolean isFullScreenMobile?: boolean + selectedTargetChainId?: number +} + +export interface ChainSelectionProps { + chainsToSelect?: ChainsToSelectState + onSelectChain(chain: ChainInfo): void +} +export interface ModalCallbackProps { onSelectToken: TokenSelectionHandler onTokenListItemClick?(token: TokenWithLogo): void onClearRecentTokens?(): void @@ -42,5 +48,8 @@ export interface SelectTokenModalProps { onInputPressEnter?(): void onOpenManageWidget(): void onDismiss(): void - onSelectChain(chain: ChainInfo): void } + +export type SelectTokenModalProps = TokenListContentProps & + ChainSelectionProps & + ModalCallbackProps From 8c81595163195bc0f46a3b3e822f4bf374d57582 Mon Sep 17 00:00:00 2001 From: fairlighteth <31534717+fairlighteth@users.noreply.github.com> Date: Thu, 27 Nov 2025 10:26:06 +0000 Subject: [PATCH 026/100] refactor: add TODO for prop count reduction in TokenListContentProps --- .../src/modules/tokensList/pure/SelectTokenModal/types.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/types.ts b/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/types.ts index 70f4c4b7399..725d69d9ee3 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/types.ts +++ b/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/types.ts @@ -10,6 +10,7 @@ import { PermitCompatibleTokens } from 'modules/permit' import { ChainsToSelectState, TokenSelectionHandler } from '../../types' +// TODO: Refactor to reduce prop count export interface TokenListContentProps { allTokens: TokenWithLogo[] favoriteTokens: TokenWithLogo[] From 0bbdc81788d7d126d610645cfb20899aa75a9c2a Mon Sep 17 00:00:00 2001 From: fairlighteth <31534717+fairlighteth@users.noreply.github.com> Date: Thu, 27 Nov 2025 16:03:04 +0000 Subject: [PATCH 027/100] refactor: update useSourceChainId to include field check for chain selection --- .../tokensList/hooks/useSourceChainId.test.ts | 95 +++++++++++++++++++ .../tokensList/hooks/useSourceChainId.ts | 15 ++- 2 files changed, 107 insertions(+), 3 deletions(-) create mode 100644 apps/cowswap-frontend/src/modules/tokensList/hooks/useSourceChainId.test.ts diff --git a/apps/cowswap-frontend/src/modules/tokensList/hooks/useSourceChainId.test.ts b/apps/cowswap-frontend/src/modules/tokensList/hooks/useSourceChainId.test.ts new file mode 100644 index 00000000000..04a2a1521fc --- /dev/null +++ b/apps/cowswap-frontend/src/modules/tokensList/hooks/useSourceChainId.test.ts @@ -0,0 +1,95 @@ +import { SupportedChainId } from '@cowprotocol/cow-sdk' +import { useWalletInfo, WalletInfo } from '@cowprotocol/wallet' + +import { renderHook } from '@testing-library/react' + +import { Field } from 'legacy/state/types' + +import { useSelectTokenWidgetState } from './useSelectTokenWidgetState' +import { useSourceChainId } from './useSourceChainId' + +import { DEFAULT_SELECT_TOKEN_WIDGET_STATE } from '../state/selectTokenWidgetAtom' + +jest.mock('@cowprotocol/wallet', () => ({ + ...jest.requireActual('@cowprotocol/wallet'), + useWalletInfo: jest.fn(), +})) + +jest.mock('./useSelectTokenWidgetState', () => ({ + useSelectTokenWidgetState: jest.fn(), +})) + +const mockUseWalletInfo = useWalletInfo as jest.MockedFunction +const mockUseSelectTokenWidgetState = useSelectTokenWidgetState as jest.MockedFunction + +type WidgetState = ReturnType +const createWidgetState = (override: Partial): WidgetState => { + return { + ...DEFAULT_SELECT_TOKEN_WIDGET_STATE, + ...override, + } as WidgetState +} + +describe('useSourceChainId', () => { + beforeEach(() => { + jest.clearAllMocks() + mockUseWalletInfo.mockReturnValue({ chainId: SupportedChainId.MAINNET } as WalletInfo) + mockUseSelectTokenWidgetState.mockReturnValue(createWidgetState({ open: false })) + }) + + it('returns wallet chain when selector is closed', () => { + mockUseSelectTokenWidgetState.mockReturnValue( + createWidgetState({ + open: false, + field: Field.OUTPUT, + selectedTargetChainId: SupportedChainId.GNOSIS_CHAIN, + }), + ) + + const { result } = renderHook(() => useSourceChainId()) + + expect(result.current).toEqual({ chainId: SupportedChainId.MAINNET, source: 'wallet' }) + }) + + it('keeps wallet chain for output selection even while open', () => { + mockUseSelectTokenWidgetState.mockReturnValue( + createWidgetState({ + open: true, + field: Field.OUTPUT, + selectedTargetChainId: SupportedChainId.GNOSIS_CHAIN, + }), + ) + + const { result } = renderHook(() => useSourceChainId()) + + expect(result.current).toEqual({ chainId: SupportedChainId.MAINNET, source: 'wallet' }) + }) + + it('uses selector chain for input selection when open on a supported chain', () => { + mockUseSelectTokenWidgetState.mockReturnValue( + createWidgetState({ + open: true, + field: Field.INPUT, + selectedTargetChainId: SupportedChainId.GNOSIS_CHAIN, + }), + ) + + const { result } = renderHook(() => useSourceChainId()) + + expect(result.current).toEqual({ chainId: SupportedChainId.GNOSIS_CHAIN, source: 'selector' }) + }) + + it('ignores unsupported chains and falls back to wallet', () => { + mockUseSelectTokenWidgetState.mockReturnValue( + createWidgetState({ + open: true, + field: Field.INPUT, + selectedTargetChainId: 999, + }), + ) + + const { result } = renderHook(() => useSourceChainId()) + + expect(result.current).toEqual({ chainId: SupportedChainId.MAINNET, source: 'wallet' }) + }) +}) diff --git a/apps/cowswap-frontend/src/modules/tokensList/hooks/useSourceChainId.ts b/apps/cowswap-frontend/src/modules/tokensList/hooks/useSourceChainId.ts index 6a3e4ad475d..61f1f41556e 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/hooks/useSourceChainId.ts +++ b/apps/cowswap-frontend/src/modules/tokensList/hooks/useSourceChainId.ts @@ -3,15 +3,24 @@ import { useMemo } from 'react' import { SupportedChainId } from '@cowprotocol/cow-sdk' import { useWalletInfo } from '@cowprotocol/wallet' +import { Field } from 'legacy/state/types' + import { useSelectTokenWidgetState } from './useSelectTokenWidgetState' export function useSourceChainId(): { chainId: number; source: 'wallet' | 'selector' } { const { chainId } = useWalletInfo() - const { selectedTargetChainId = chainId, open } = useSelectTokenWidgetState() + const { selectedTargetChainId = chainId, open, field } = useSelectTokenWidgetState() return useMemo(() => { // Source chainId should always be a value from SupportedChainId - if (!open || !(selectedTargetChainId in SupportedChainId) || selectedTargetChainId === chainId) { + const isSelectingSellChain = field === Field.INPUT + + if ( + !open || + !isSelectingSellChain || + !(selectedTargetChainId in SupportedChainId) || + selectedTargetChainId === chainId + ) { return { chainId, source: 'wallet', @@ -22,5 +31,5 @@ export function useSourceChainId(): { chainId: number; source: 'wallet' | 'selec chainId: selectedTargetChainId, source: 'selector', } - }, [open, chainId, selectedTargetChainId]) + }, [open, field, chainId, selectedTargetChainId]) } From 4f46d1f704049b93dc8daa56f08538c5afee00b3 Mon Sep 17 00:00:00 2001 From: fairlighteth <31534717+fairlighteth@users.noreply.github.com> Date: Fri, 28 Nov 2025 16:05:07 +0000 Subject: [PATCH 028/100] fix: improve error logging for network switch failure in token selection --- .../containers/SelectTokenWidget/controllerState.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerState.ts b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerState.ts index 0a4c44ac65a..2207aee2946 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerState.ts +++ b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerState.ts @@ -3,6 +3,7 @@ import { Dispatch, SetStateAction, useCallback, useState } from 'react' import { useCowAnalytics } from '@cowprotocol/analytics' import { TokenWithLogo } from '@cowprotocol/common-const' import { SupportedChainId } from '@cowprotocol/cow-sdk' +import { log } from '@cowprotocol/sdk-common' import { ListState, TokenListCategory, @@ -278,7 +279,8 @@ export function useTokenSelectionHandler( try { await onSelectNetwork(targetChainId as SupportedChainId, true) } catch (error) { - console.error('Failed to switch network after token selection', error) + const message = error instanceof Error ? error.message : String(error) + log(`Failed to switch network after token selection: ${message}`) } } From 9f68f8a36c5e824a1f4709b136eb7c9c9ed11fe1 Mon Sep 17 00:00:00 2001 From: fairlighteth <31534717+fairlighteth@users.noreply.github.com> Date: Fri, 28 Nov 2025 16:12:08 +0000 Subject: [PATCH 029/100] refactor: streamline onSelectChain handling in SelectTokenWidget --- .../SelectTokenWidget/controllerProps.ts | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerProps.ts b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerProps.ts index 2b3d77c7fab..f6c0e621790 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerProps.ts +++ b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerProps.ts @@ -139,13 +139,15 @@ export function buildSelectTokenModalPropsInput({ tokenListCategoryState, disableErc20, account, - hasChainPanel, - chainsState, - onSelectChain, - isInjectedWidgetMode, - chainsPanelTitle, - modalTitle, + hasChainPanel, + chainsState, + onSelectChain, + isInjectedWidgetMode, + chainsPanelTitle, + modalTitle, }: BuildModalPropsArgs): SelectTokenModalProps { + const selectChainHandler: (chain: ChainInfo) => void = onSelectChain ?? (() => undefined) + return { standalone, displayLpTokenLists, @@ -175,7 +177,7 @@ export function buildSelectTokenModalPropsInput({ chainsPanelTitle, hideFavoriteTokensTooltip: isInjectedWidgetMode, selectedTargetChainId: widgetState.selectedTargetChainId, - onSelectChain, + onSelectChain: selectChainHandler, onClearRecentTokens, } } From ec0d7c2fa84304998a0c324a6fca84b4af3cbc52 Mon Sep 17 00:00:00 2001 From: fairlighteth <31534717+fairlighteth@users.noreply.github.com> Date: Fri, 28 Nov 2025 17:11:39 +0000 Subject: [PATCH 030/100] refactor: make onSelectChain and onOpenMobileChainPanel optional in SelectTokenModal --- .../src/modules/tokensList/pure/SelectTokenModal/index.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/index.tsx b/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/index.tsx index f9ee127034c..8e2bb51b7f1 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/index.tsx +++ b/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/index.tsx @@ -231,8 +231,8 @@ function getMobileChainSelectorConfig({ showChainPanel: boolean mobileChainsState: SelectTokenModalProps['mobileChainsState'] mobileChainsLabel: SelectTokenModalProps['mobileChainsLabel'] - onSelectChain: SelectTokenModalProps['onSelectChain'] - onOpenMobileChainPanel: SelectTokenModalProps['onOpenMobileChainPanel'] + onSelectChain?: SelectTokenModalProps['onSelectChain'] + onOpenMobileChainPanel?: SelectTokenModalProps['onOpenMobileChainPanel'] }): ComponentProps | undefined { const canRender = !showChainPanel && From 1baae327bd84a4a8ab25a00831141066ad968692 Mon Sep 17 00:00:00 2001 From: fairlighteth <31534717+fairlighteth@users.noreply.github.com> Date: Fri, 28 Nov 2025 17:17:46 +0000 Subject: [PATCH 031/100] refactor: simplify JSX structure in SelectTokenModal and TokenColumnContent --- .../SelectTokenModal/TokenColumnContent.tsx | 15 ++++++--------- .../tokensList/pure/SelectTokenModal/index.tsx | 18 ++++++++---------- 2 files changed, 14 insertions(+), 19 deletions(-) diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/TokenColumnContent.tsx b/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/TokenColumnContent.tsx index 0d2c51b9de6..7b9e32cdcc8 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/TokenColumnContent.tsx +++ b/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/TokenColumnContent.tsx @@ -4,7 +4,6 @@ import { ChainInfo } from '@cowprotocol/cow-sdk' import { TokenListCategory } from '@cowprotocol/tokens' import { SelectTokenModalContent } from './SelectTokenModalContent' -import * as styledEl from './styled' import { LpTokenListsWidget } from '../../containers/LpTokenListsWidget' import { ChainsToSelectState, TokenSelectionHandler } from '../../types' @@ -71,13 +70,11 @@ function renderLegacyChainSelector( } return ( - - - + ) } diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/index.tsx b/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/index.tsx index 8e2bb51b7f1..c92149d7b1d 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/index.tsx +++ b/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/index.tsx @@ -99,16 +99,14 @@ export function SelectTokenModal(props: SelectTokenModalProps): ReactNode { > From b72b0e05205e3a5955a189c5f0c5283cc059fbbb Mon Sep 17 00:00:00 2001 From: fairlighteth <31534717+fairlighteth@users.noreply.github.com> Date: Fri, 28 Nov 2025 17:20:59 +0000 Subject: [PATCH 032/100] feat: add onSelectChain prop to defaultModalProps in SelectTokenModal --- .../modules/tokensList/pure/SelectTokenModal/index.cosmos.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/index.cosmos.tsx b/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/index.cosmos.tsx index 08b158b7532..7a715d72407 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/index.cosmos.tsx +++ b/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/index.cosmos.tsx @@ -73,6 +73,7 @@ const defaultModalProps: SelectTokenModalProps = { selectedToken, isRouteAvailable: true, modalTitle: 'Swap from', + onSelectChain: () => undefined, onSelectToken() { console.log('onSelectToken') }, From 2cf36f009559c12304041e135feafe5b076e35ce Mon Sep 17 00:00:00 2001 From: fairlighteth <31534717+fairlighteth@users.noreply.github.com> Date: Fri, 28 Nov 2025 17:40:46 +0000 Subject: [PATCH 033/100] refactor: update chainsToSelect handling in SelectTokenWidget and SelectTokenModal --- .../tokensList/containers/SelectTokenWidget/index.tsx | 2 ++ .../src/modules/tokensList/pure/SelectTokenModal/index.tsx | 5 +++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/index.tsx b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/index.tsx index 48330411b13..7a805cae316 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/index.tsx +++ b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/index.tsx @@ -119,6 +119,7 @@ function SelectTokenWidgetView( const handleOpenMobileChainPanel = mobileChainsState ? () => setIsMobileChainPanelOpen(true) : undefined const showDesktopChainPanel = isChainPanelVisible && isChainPanelEnabled && chainsToSelect const showMobileChainPanel = !isChainPanelVisible && isChainPanelEnabled && chainsToSelect && isMobileChainPanelOpen + const modalChainsToSelect = isChainPanelVisible ? undefined : chainsToSelect return ( <> @@ -126,6 +127,7 @@ function SelectTokenWidgetView( 0 ? chainsToSelect : undefined const resolvedChainPanelTitle = chainsPanelTitle ?? t`Cross chain swap` From b47f361b2e527f17c41f9375e6f29961b632fea9 Mon Sep 17 00:00:00 2001 From: fairlighteth <31534717+fairlighteth@users.noreply.github.com> Date: Wed, 3 Dec 2025 13:34:47 +0000 Subject: [PATCH 034/100] refactor: streamline chain accent color management --- libs/ui/src/enum.ts | 35 ---- libs/ui/src/theme/ThemeColorVars.tsx | 251 ++++++++++++++++----------- 2 files changed, 146 insertions(+), 140 deletions(-) diff --git a/libs/ui/src/enum.ts b/libs/ui/src/enum.ts index 6e7c2039cec..7a135004366 100644 --- a/libs/ui/src/enum.ts +++ b/libs/ui/src/enum.ts @@ -101,41 +101,6 @@ export enum UI { COLOR_GREEN = '--cow-color-green', COLOR_RED = '--cow-color-red', - // Chain-specific accent colors - COLOR_CHAIN_ETHEREUM_BG = '--cow-color-chain-ethereum-bg', - COLOR_CHAIN_ETHEREUM_BORDER = '--cow-color-chain-ethereum-border', - COLOR_CHAIN_ETHEREUM_ACCENT = '--cow-color-chain-ethereum-accent', - COLOR_CHAIN_BNB_BG = '--cow-color-chain-bnb-bg', - COLOR_CHAIN_BNB_BORDER = '--cow-color-chain-bnb-border', - COLOR_CHAIN_BNB_ACCENT = '--cow-color-chain-bnb-accent', - COLOR_CHAIN_BASE_BG = '--cow-color-chain-base-bg', - COLOR_CHAIN_BASE_BORDER = '--cow-color-chain-base-border', - COLOR_CHAIN_BASE_ACCENT = '--cow-color-chain-base-accent', - COLOR_CHAIN_ARBITRUM_BG = '--cow-color-chain-arbitrum-bg', - COLOR_CHAIN_ARBITRUM_BORDER = '--cow-color-chain-arbitrum-border', - COLOR_CHAIN_ARBITRUM_ACCENT = '--cow-color-chain-arbitrum-accent', - COLOR_CHAIN_POLYGON_BG = '--cow-color-chain-polygon-bg', - COLOR_CHAIN_POLYGON_BORDER = '--cow-color-chain-polygon-border', - COLOR_CHAIN_POLYGON_ACCENT = '--cow-color-chain-polygon-accent', - COLOR_CHAIN_AVALANCHE_BG = '--cow-color-chain-avalanche-bg', - COLOR_CHAIN_AVALANCHE_BORDER = '--cow-color-chain-avalanche-border', - COLOR_CHAIN_AVALANCHE_ACCENT = '--cow-color-chain-avalanche-accent', - COLOR_CHAIN_GNOSIS_BG = '--cow-color-chain-gnosis-bg', - COLOR_CHAIN_GNOSIS_BORDER = '--cow-color-chain-gnosis-border', - COLOR_CHAIN_GNOSIS_ACCENT = '--cow-color-chain-gnosis-accent', - COLOR_CHAIN_LENS_BG = '--cow-color-chain-lens-bg', - COLOR_CHAIN_LENS_BORDER = '--cow-color-chain-lens-border', - COLOR_CHAIN_LENS_ACCENT = '--cow-color-chain-lens-accent', - COLOR_CHAIN_SEPOLIA_BG = '--cow-color-chain-sepolia-bg', - COLOR_CHAIN_SEPOLIA_BORDER = '--cow-color-chain-sepolia-border', - COLOR_CHAIN_SEPOLIA_ACCENT = '--cow-color-chain-sepolia-accent', - COLOR_CHAIN_LINEA_BG = '--cow-color-chain-linea-bg', - COLOR_CHAIN_LINEA_BORDER = '--cow-color-chain-linea-border', - COLOR_CHAIN_LINEA_ACCENT = '--cow-color-chain-linea-accent', - COLOR_CHAIN_PLASMA_BG = '--cow-color-chain-plasma-bg', - COLOR_CHAIN_PLASMA_BORDER = '--cow-color-chain-plasma-border', - COLOR_CHAIN_PLASMA_ACCENT = '--cow-color-chain-plasma-accent', - // Neutral colors - Base grayscale palette from black (0) to white (100) COLOR_WHITE = '--cow-color-neutral-100', COLOR_NEUTRAL_100 = '--cow-color-neutral-100', diff --git a/libs/ui/src/theme/ThemeColorVars.tsx b/libs/ui/src/theme/ThemeColorVars.tsx index f39c641a938..7fdd986b244 100644 --- a/libs/ui/src/theme/ThemeColorVars.tsx +++ b/libs/ui/src/theme/ThemeColorVars.tsx @@ -1,3 +1,5 @@ +import { CHAIN_INFO } from '@cowprotocol/common-const' +import { SupportedChainId } from '@cowprotocol/cow-sdk' import { getContrastText } from '@cowprotocol/ui-utils' import { darken, lighten, transparentize } from 'color2k' @@ -5,10 +7,37 @@ import { css } from 'styled-components/macro' import { UI } from '../enum' -interface ChainAccentConfig { - bgVar: UI - borderVar: UI - accentVar?: UI +/** + * Gets the chain name for CSS variables from CHAIN_INFO. + * Only 2 chains have different names in CHAIN_INFO vs CSS variables, so we handle those explicitly. + * For all other chains, CHAIN_INFO.name matches the CSS variable name format. + */ +const getChainName = (chainId: SupportedChainId): string => { + const chainInfoName = CHAIN_INFO[chainId].name + + // Only 2 exceptions: arbitrum_one → arbitrum, gnosis_chain → gnosis + // All other chains use CHAIN_INFO.name directly + if (chainInfoName === 'arbitrum_one') return 'arbitrum' + if (chainInfoName === 'gnosis_chain') return 'gnosis' + + return chainInfoName +} + +/** + * Generates CSS variable names for chain-specific colors. + * This allows adding new chains without modifying the UI enum. + */ +const getChainColorVars = (chainName: string): { bgVar: string; borderVar: string; accentVar: string } => ({ + bgVar: `--cow-color-chain-${chainName}-bg`, + borderVar: `--cow-color-chain-${chainName}-border`, + accentVar: `--cow-color-chain-${chainName}-accent`, +}) + +export interface ChainAccentConfig { + chainId: SupportedChainId + bgVar: string + borderVar: string + accentVar?: string lightBg: string darkBg: string lightBorder: string @@ -18,10 +47,8 @@ interface ChainAccentConfig { } interface ChainAccentInput { - bgVar: UI - borderVar: UI - accentVar?: UI - color: string + chainId: SupportedChainId + color?: string // Optional: defaults to CHAIN_INFO[chainId].color lightColor?: string darkColor?: string lightBgAlpha?: number @@ -38,119 +65,133 @@ const CHAIN_DARK_BORDER_ALPHA = 0.65 const chainAlpha = (color: string, alpha: number): string => transparentize(color, 1 - alpha) function createChainAccent({ - bgVar, - borderVar, - accentVar, + chainId, color, - lightColor = color, - darkColor = color, + lightColor, + darkColor, lightBgAlpha = CHAIN_LIGHT_BG_ALPHA, darkBgAlpha = CHAIN_DARK_BG_ALPHA, lightBorderAlpha = CHAIN_LIGHT_BORDER_ALPHA, darkBorderAlpha = CHAIN_DARK_BORDER_ALPHA, }: ChainAccentInput): ChainAccentConfig { + // Use CHAIN_INFO.color as the single source of truth, allow override if needed + const baseColor = color ?? CHAIN_INFO[chainId].color + const finalLightColor = lightColor ?? baseColor + const finalDarkColor = darkColor ?? baseColor + + const chainName = getChainName(chainId) + const { bgVar, borderVar, accentVar } = getChainColorVars(chainName) + return { + chainId, bgVar, borderVar, accentVar, - lightBg: chainAlpha(lightColor, lightBgAlpha), - darkBg: chainAlpha(darkColor, darkBgAlpha), - lightBorder: chainAlpha(lightColor, lightBorderAlpha), - darkBorder: chainAlpha(darkColor, darkBorderAlpha), - lightColor, - darkColor, + lightBg: chainAlpha(finalLightColor, lightBgAlpha), + darkBg: chainAlpha(finalDarkColor, darkBgAlpha), + lightBorder: chainAlpha(finalLightColor, lightBorderAlpha), + darkBorder: chainAlpha(finalDarkColor, darkBorderAlpha), + lightColor: finalLightColor, + darkColor: finalDarkColor, } } -const CHAIN_ACCENT_CONFIG: ChainAccentConfig[] = [ - createChainAccent({ - bgVar: UI.COLOR_CHAIN_ETHEREUM_BG, - borderVar: UI.COLOR_CHAIN_ETHEREUM_BORDER, - accentVar: UI.COLOR_CHAIN_ETHEREUM_ACCENT, +/** + * Chain accent color configuration. + * + * NEW NETWORKS WORK OUT OF THE BOX: + * - When a new network is added to CHAIN_INFO (from @cowprotocol/cow-sdk), it automatically gets accent colors + * - Colors are derived from CHAIN_INFO[chainId].color (single source of truth) + * - Chain names and CSS variables are generated automatically from CHAIN_INFO + * + * CUSTOMIZATION: + * - Exclude chains from accent colors by adding them to CHAIN_ACCENT_EXCLUSIONS + * - Override colors in CHAIN_ACCENT_OVERRIDES if CHAIN_INFO color differs from design (e.g., MAINNET, LENS) + */ + +// Chains to exclude from accent color configuration (e.g., testnets that don't need accent colors) +const CHAIN_ACCENT_EXCLUSIONS: Set = new Set([ + // Add chain IDs here if they shouldn't have accent colors + // Example: SupportedChainId.SOME_TESTNET, +]) + +// Color overrides for chains where CHAIN_INFO color differs from original design +const CHAIN_ACCENT_OVERRIDES: Partial>> = { + [SupportedChainId.MAINNET]: { + // Override: Original color #627EEA differs from SDK's #62688F color: '#627EEA', - }), - createChainAccent({ - bgVar: UI.COLOR_CHAIN_BNB_BG, - borderVar: UI.COLOR_CHAIN_BNB_BORDER, - accentVar: UI.COLOR_CHAIN_BNB_ACCENT, - color: '#F0B90B', - }), - createChainAccent({ - bgVar: UI.COLOR_CHAIN_BASE_BG, - borderVar: UI.COLOR_CHAIN_BASE_BORDER, - accentVar: UI.COLOR_CHAIN_BASE_ACCENT, - color: '#0052FF', - }), - createChainAccent({ - bgVar: UI.COLOR_CHAIN_ARBITRUM_BG, - borderVar: UI.COLOR_CHAIN_ARBITRUM_BORDER, - accentVar: UI.COLOR_CHAIN_ARBITRUM_ACCENT, - color: '#1B4ADD', - }), - createChainAccent({ - bgVar: UI.COLOR_CHAIN_POLYGON_BG, - borderVar: UI.COLOR_CHAIN_POLYGON_BORDER, - accentVar: UI.COLOR_CHAIN_POLYGON_ACCENT, - color: '#8247E5', - }), - createChainAccent({ - bgVar: UI.COLOR_CHAIN_AVALANCHE_BG, - borderVar: UI.COLOR_CHAIN_AVALANCHE_BORDER, - accentVar: UI.COLOR_CHAIN_AVALANCHE_ACCENT, - color: '#FF3944', - }), - createChainAccent({ - bgVar: UI.COLOR_CHAIN_GNOSIS_BG, - borderVar: UI.COLOR_CHAIN_GNOSIS_BORDER, - accentVar: UI.COLOR_CHAIN_GNOSIS_ACCENT, - color: '#07795B', - }), - createChainAccent({ - bgVar: UI.COLOR_CHAIN_LENS_BG, - borderVar: UI.COLOR_CHAIN_LENS_BORDER, - accentVar: UI.COLOR_CHAIN_LENS_ACCENT, + }, + [SupportedChainId.LENS]: { + // Override: Original color #5A5A5A differs from SDK's #FFFFFF color: '#5A5A5A', darkColor: '#D7D7D7', - }), - createChainAccent({ - bgVar: UI.COLOR_CHAIN_SEPOLIA_BG, - borderVar: UI.COLOR_CHAIN_SEPOLIA_BORDER, - accentVar: UI.COLOR_CHAIN_SEPOLIA_ACCENT, - color: '#C12FF2', - }), - createChainAccent({ - bgVar: UI.COLOR_CHAIN_LINEA_BG, - borderVar: UI.COLOR_CHAIN_LINEA_BORDER, - accentVar: UI.COLOR_CHAIN_LINEA_ACCENT, - color: '#61DFFF', - }), - createChainAccent({ - bgVar: UI.COLOR_CHAIN_PLASMA_BG, - borderVar: UI.COLOR_CHAIN_PLASMA_BORDER, - accentVar: UI.COLOR_CHAIN_PLASMA_ACCENT, - color: '#569F8C', - }), -] - -const CHAIN_ACCENT_VAR_DECLARATIONS = CHAIN_ACCENT_CONFIG.map(({ - bgVar, - borderVar, - accentVar, - lightBg, - darkBg, - lightBorder, - darkBorder, - lightColor, - darkColor, -}) => css` - ${bgVar}: ${({ theme }) => (theme.darkMode ? darkBg : lightBg)}; - ${borderVar}: ${({ theme }) => (theme.darkMode ? darkBorder : lightBorder)}; - ${accentVar - ? css` - ${accentVar}: ${({ theme }) => (theme.darkMode ? darkColor : lightColor)}; - ` - : ''} -`) + }, +} + +// Automatically generate accent colors for all chains in CHAIN_INFO, excluding those in CHAIN_ACCENT_EXCLUSIONS +const CHAIN_ACCENT_CONFIG_ARRAY: ChainAccentConfig[] = Object.keys(CHAIN_INFO) + .map((key) => Number(key) as SupportedChainId) + .filter((chainId) => { + // Type guard: ensure chainId exists in CHAIN_INFO and is not excluded + return CHAIN_INFO[chainId] && !CHAIN_ACCENT_EXCLUSIONS.has(chainId) + }) + .map((chainId) => + createChainAccent({ + chainId, + ...CHAIN_ACCENT_OVERRIDES[chainId], + }), + ) + +/** + * Map of chain accent colors keyed by SupportedChainId for programmatic access. + * This allows components to access theme-aware chain colors without using CSS variables. + * + * @example + * ```tsx + * import { CHAIN_ACCENT_CONFIG } from '@cowprotocol/ui' + * + * const colors = CHAIN_ACCENT_CONFIG[SupportedChainId.MAINNET] + * // colors.bgVar, colors.borderVar, colors.lightBg, colors.darkBg, etc. + * ``` + */ +export const CHAIN_ACCENT_CONFIG: Record = + CHAIN_ACCENT_CONFIG_ARRAY.reduce( + (acc, config) => { + acc[config.chainId] = config + return acc + }, + {} as Record, + ) + +/** + * Helper function to get chain accent colors for a given chainId. + * Returns undefined if the chain doesn't have accent colors configured. + * + * @example + * ```tsx + * import { getChainAccentColors } from '@cowprotocol/ui' + * + * const colors = getChainAccentColors(SupportedChainId.MAINNET) + * if (colors) { + * // Use colors.lightBg, colors.darkBg, etc. + * } + * ``` + */ +export function getChainAccentColors(chainId: SupportedChainId): ChainAccentConfig | undefined { + return CHAIN_ACCENT_CONFIG[chainId] +} + +const CHAIN_ACCENT_VAR_DECLARATIONS = CHAIN_ACCENT_CONFIG_ARRAY.map( + ({ bgVar, borderVar, accentVar, lightBg, darkBg, lightBorder, darkBorder, lightColor, darkColor }) => css` + ${bgVar}: ${({ theme }) => (theme.darkMode ? darkBg : lightBg)}; + ${borderVar}: ${({ theme }) => (theme.darkMode ? darkBorder : lightBorder)}; + ${accentVar + ? css` + ${accentVar}: ${({ theme }) => (theme.darkMode ? darkColor : lightColor)}; + ` + : ''} + `, +) export const ThemeColorVars = css` :root { From 956724a11451e5ee36c16000aef132b689db0334 Mon Sep 17 00:00:00 2001 From: fairlighteth <31534717+fairlighteth@users.noreply.github.com> Date: Wed, 3 Dec 2025 13:42:50 +0000 Subject: [PATCH 035/100] test: add unit tests for chain accent color management --- libs/ui/src/theme/ThemeColorVars.test.ts | 188 +++++++++++++++++++++++ 1 file changed, 188 insertions(+) create mode 100644 libs/ui/src/theme/ThemeColorVars.test.ts diff --git a/libs/ui/src/theme/ThemeColorVars.test.ts b/libs/ui/src/theme/ThemeColorVars.test.ts new file mode 100644 index 00000000000..63496201ce3 --- /dev/null +++ b/libs/ui/src/theme/ThemeColorVars.test.ts @@ -0,0 +1,188 @@ +import { CHAIN_INFO } from '@cowprotocol/common-const' +import { SupportedChainId } from '@cowprotocol/cow-sdk' + +import { CHAIN_ACCENT_CONFIG, getChainAccentColors } from './ThemeColorVars' + +describe('Chain Accent Colors', () => { + describe('getChainAccentColors', () => { + it('should return accent colors for MAINNET', () => { + const colors = getChainAccentColors(SupportedChainId.MAINNET) + + expect(colors).toBeDefined() + expect(colors?.chainId).toBe(SupportedChainId.MAINNET) + expect(colors?.bgVar).toBe('--cow-color-chain-ethereum-bg') + expect(colors?.borderVar).toBe('--cow-color-chain-ethereum-border') + expect(colors?.accentVar).toBe('--cow-color-chain-ethereum-accent') + expect(colors?.lightColor).toBe('#627EEA') // Override applied + expect(colors?.darkColor).toBe('#627EEA') + }) + + it('should return accent colors for LENS with darkColor override', () => { + const colors = getChainAccentColors(SupportedChainId.LENS) + + expect(colors).toBeDefined() + expect(colors?.chainId).toBe(SupportedChainId.LENS) + expect(colors?.lightColor).toBe('#5A5A5A') // Override applied + expect(colors?.darkColor).toBe('#D7D7D7') // Dark color override applied + }) + + it('should return accent colors for BNB using CHAIN_INFO color', () => { + const colors = getChainAccentColors(SupportedChainId.BNB) + + expect(colors).toBeDefined() + expect(colors?.chainId).toBe(SupportedChainId.BNB) + expect(colors?.lightColor).toBe(CHAIN_INFO[SupportedChainId.BNB].color) + expect(colors?.darkColor).toBe(CHAIN_INFO[SupportedChainId.BNB].color) + }) + + it('should return undefined for non-existent chain', () => { + // Use a very large number that's unlikely to be a real chain ID + const colors = getChainAccentColors(999999 as SupportedChainId) + + expect(colors).toBeUndefined() + }) + }) + + describe('CHAIN_ACCENT_CONFIG', () => { + it('should include all chains from CHAIN_INFO', () => { + const chainIdsInChainInfo = Object.keys(CHAIN_INFO).map((key) => Number(key) as SupportedChainId) + + // All chains from CHAIN_INFO should be in the config (unless excluded) + chainIdsInChainInfo.forEach((chainId) => { + const config = CHAIN_ACCENT_CONFIG[chainId] + // Config should exist unless it's explicitly excluded + // Since CHAIN_ACCENT_EXCLUSIONS is empty, all should exist + expect(config).toBeDefined() + }) + }) + + it('should have correct CSS variable format for all chains', () => { + Object.values(CHAIN_ACCENT_CONFIG).forEach((config) => { + if (!config) return + + expect(config.bgVar).toMatch(/^--cow-color-chain-[a-z_]+-bg$/) + expect(config.borderVar).toMatch(/^--cow-color-chain-[a-z_]+-border$/) + expect(config.accentVar).toMatch(/^--cow-color-chain-[a-z_]+-accent$/) + }) + }) + + it('should normalize arbitrum_one to arbitrum in CSS variables', () => { + const colors = getChainAccentColors(SupportedChainId.ARBITRUM_ONE) + + expect(colors).toBeDefined() + expect(colors?.bgVar).toBe('--cow-color-chain-arbitrum-bg') + expect(colors?.borderVar).toBe('--cow-color-chain-arbitrum-border') + expect(colors?.accentVar).toBe('--cow-color-chain-arbitrum-accent') + // Verify it's NOT using the CHAIN_INFO name directly + expect(colors?.bgVar).not.toContain('arbitrum_one') + }) + + it('should normalize gnosis_chain to gnosis in CSS variables', () => { + const colors = getChainAccentColors(SupportedChainId.GNOSIS_CHAIN) + + expect(colors).toBeDefined() + expect(colors?.bgVar).toBe('--cow-color-chain-gnosis-bg') + expect(colors?.borderVar).toBe('--cow-color-chain-gnosis-border') + expect(colors?.accentVar).toBe('--cow-color-chain-gnosis-accent') + // Verify it's NOT using the CHAIN_INFO name directly + expect(colors?.bgVar).not.toContain('gnosis_chain') + }) + + it('should use CHAIN_INFO.name directly for other chains', () => { + const colors = getChainAccentColors(SupportedChainId.POLYGON) + + expect(colors).toBeDefined() + expect(colors?.bgVar).toBe('--cow-color-chain-polygon-bg') + // Polygon name in CHAIN_INFO is 'polygon', so it should match exactly + expect(CHAIN_INFO[SupportedChainId.POLYGON].name).toBe('polygon') + }) + }) + + describe('Color overrides', () => { + it('should apply MAINNET color override', () => { + const colors = getChainAccentColors(SupportedChainId.MAINNET) + const sdkColor = CHAIN_INFO[SupportedChainId.MAINNET].color + + expect(colors?.lightColor).toBe('#627EEA') + expect(colors?.lightColor).not.toBe(sdkColor) // Should differ from SDK + expect(sdkColor).toBe('#62688F') // Verify SDK color is different + }) + + it('should apply LENS color and darkColor overrides', () => { + const colors = getChainAccentColors(SupportedChainId.LENS) + const sdkColor = CHAIN_INFO[SupportedChainId.LENS].color + + expect(colors?.lightColor).toBe('#5A5A5A') + expect(colors?.darkColor).toBe('#D7D7D7') + expect(colors?.lightColor).not.toBe(sdkColor) // Should differ from SDK + expect(sdkColor).toBe('#FFFFFF') // Verify SDK color is different + }) + + it('should use CHAIN_INFO color for chains without overrides', () => { + const colors = getChainAccentColors(SupportedChainId.BASE) + const sdkColor = CHAIN_INFO[SupportedChainId.BASE].color + + expect(colors?.lightColor).toBe(sdkColor) + expect(colors?.darkColor).toBe(sdkColor) + }) + }) + + describe('ChainAccentConfig structure', () => { + it('should have all required properties', () => { + const colors = getChainAccentColors(SupportedChainId.MAINNET) + + expect(colors).toHaveProperty('chainId') + expect(colors).toHaveProperty('bgVar') + expect(colors).toHaveProperty('borderVar') + expect(colors).toHaveProperty('accentVar') + expect(colors).toHaveProperty('lightBg') + expect(colors).toHaveProperty('darkBg') + expect(colors).toHaveProperty('lightBorder') + expect(colors).toHaveProperty('darkBorder') + expect(colors).toHaveProperty('lightColor') + expect(colors).toHaveProperty('darkColor') + }) + + it('should have calculated alpha colors', () => { + const colors = getChainAccentColors(SupportedChainId.MAINNET) + + // lightBg should be transparentized version of lightColor + expect(colors?.lightBg).toBeDefined() + expect(colors?.lightBg).not.toBe(colors?.lightColor) + expect(colors?.lightBg).toContain('rgba') // transparentize returns rgba + + // darkBg should be transparentized version of darkColor + expect(colors?.darkBg).toBeDefined() + expect(colors?.darkBg).not.toBe(colors?.darkColor) + expect(colors?.darkBg).toContain('rgba') + + // Borders should also be transparentized + expect(colors?.lightBorder).toContain('rgba') + expect(colors?.darkBorder).toContain('rgba') + }) + }) + + describe('Automatic chain inclusion', () => { + it('should automatically include new chains from CHAIN_INFO', () => { + // This test verifies that the system automatically picks up all chains + // If a new chain is added to CHAIN_INFO, it should appear in CHAIN_ACCENT_CONFIG + const allChainIds = Object.keys(CHAIN_INFO).map((key) => Number(key) as SupportedChainId) + + allChainIds.forEach((chainId) => { + const config = CHAIN_ACCENT_CONFIG[chainId] + // Currently no exclusions, so all should be present + expect(config).toBeDefined() + expect(config?.chainId).toBe(chainId) + }) + }) + + it('should have consistent chainId in config', () => { + Object.entries(CHAIN_ACCENT_CONFIG).forEach(([key, config]) => { + if (!config) return + + const chainId = Number(key) as SupportedChainId + expect(config.chainId).toBe(chainId) + }) + }) + }) +}) From eb337a1388fff8f9577ba40e2519943d7e3e3627 Mon Sep 17 00:00:00 2001 From: fairlighteth <31534717+fairlighteth@users.noreply.github.com> Date: Wed, 3 Dec 2025 13:47:48 +0000 Subject: [PATCH 036/100] refactor: optimize VirtualList rendering by replacing loop with map function --- .../src/common/pure/VirtualList/index.tsx | 49 +++++++++---------- 1 file changed, 23 insertions(+), 26 deletions(-) diff --git a/apps/cowswap-frontend/src/common/pure/VirtualList/index.tsx b/apps/cowswap-frontend/src/common/pure/VirtualList/index.tsx index 2134dc41297..2e6e2d614ef 100644 --- a/apps/cowswap-frontend/src/common/pure/VirtualList/index.tsx +++ b/apps/cowswap-frontend/src/common/pure/VirtualList/index.tsx @@ -49,29 +49,27 @@ interface VirtualListRowsProps { measureElement(element: Element | null): void } -function renderVirtualListRows({ +function VirtualListRows({ virtualItems, loading, items, getItemView, measureElement, -}: VirtualListRowsProps): ReactNode[] { - const elements: ReactNode[] = [] - - for (const item of virtualItems) { - elements.push( - , - ) - } - - return elements +}: VirtualListRowsProps): ReactNode { + return ( + <> + {virtualItems.map((item) => ( + + ))} + + ) } interface VirtualListProps { @@ -138,20 +136,19 @@ export function VirtualList({ }, [scrollResetKey, virtualizer]) const virtualItems = virtualizer.getVirtualItems() - const virtualRows = renderVirtualListRows({ - virtualItems, - loading, - items, - getItemView, - measureElement: virtualizer.measureElement, - }) return ( {children} - {virtualRows} + From ead0b9eff679b786d522753c234a57af05cc4f38 Mon Sep 17 00:00:00 2001 From: fairlighteth <31534717+fairlighteth@users.noreply.github.com> Date: Wed, 3 Dec 2025 14:42:12 +0000 Subject: [PATCH 037/100] refactor: update scrollResetKey type in VirtualList component to number --- apps/cowswap-frontend/src/common/pure/VirtualList/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/cowswap-frontend/src/common/pure/VirtualList/index.tsx b/apps/cowswap-frontend/src/common/pure/VirtualList/index.tsx index 2e6e2d614ef..41640698b7f 100644 --- a/apps/cowswap-frontend/src/common/pure/VirtualList/index.tsx +++ b/apps/cowswap-frontend/src/common/pure/VirtualList/index.tsx @@ -81,7 +81,7 @@ interface VirtualListProps { loading?: boolean estimateSize?: () => number children?: ReactNode - scrollResetKey?: string | number | boolean + scrollResetKey?: number } export function VirtualList({ From bba11f98f4da36ef8f90082740d9809bc92f82cc Mon Sep 17 00:00:00 2001 From: fairlighteth <31534717+fairlighteth@users.noreply.github.com> Date: Wed, 3 Dec 2025 14:51:53 +0000 Subject: [PATCH 038/100] refactor: implement fallback color handling for chain accent colors --- libs/ui/src/theme/ThemeColorVars.test.ts | 40 +++++++++++++--------- libs/ui/src/theme/ThemeColorVars.tsx | 43 +++++++++++------------- 2 files changed, 43 insertions(+), 40 deletions(-) diff --git a/libs/ui/src/theme/ThemeColorVars.test.ts b/libs/ui/src/theme/ThemeColorVars.test.ts index 63496201ce3..ed66df89697 100644 --- a/libs/ui/src/theme/ThemeColorVars.test.ts +++ b/libs/ui/src/theme/ThemeColorVars.test.ts @@ -3,6 +3,8 @@ import { SupportedChainId } from '@cowprotocol/cow-sdk' import { CHAIN_ACCENT_CONFIG, getChainAccentColors } from './ThemeColorVars' +import { Color } from '../colors' + describe('Chain Accent Colors', () => { describe('getChainAccentColors', () => { it('should return accent colors for MAINNET', () => { @@ -34,32 +36,21 @@ describe('Chain Accent Colors', () => { expect(colors?.lightColor).toBe(CHAIN_INFO[SupportedChainId.BNB].color) expect(colors?.darkColor).toBe(CHAIN_INFO[SupportedChainId.BNB].color) }) - - it('should return undefined for non-existent chain', () => { - // Use a very large number that's unlikely to be a real chain ID - const colors = getChainAccentColors(999999 as SupportedChainId) - - expect(colors).toBeUndefined() - }) }) describe('CHAIN_ACCENT_CONFIG', () => { it('should include all chains from CHAIN_INFO', () => { const chainIdsInChainInfo = Object.keys(CHAIN_INFO).map((key) => Number(key) as SupportedChainId) - // All chains from CHAIN_INFO should be in the config (unless excluded) + // All chains from CHAIN_INFO should be in the config chainIdsInChainInfo.forEach((chainId) => { const config = CHAIN_ACCENT_CONFIG[chainId] - // Config should exist unless it's explicitly excluded - // Since CHAIN_ACCENT_EXCLUSIONS is empty, all should exist expect(config).toBeDefined() }) }) it('should have correct CSS variable format for all chains', () => { Object.values(CHAIN_ACCENT_CONFIG).forEach((config) => { - if (!config) return - expect(config.bgVar).toMatch(/^--cow-color-chain-[a-z_]+-bg$/) expect(config.borderVar).toMatch(/^--cow-color-chain-[a-z_]+-border$/) expect(config.accentVar).toMatch(/^--cow-color-chain-[a-z_]+-accent$/) @@ -170,19 +161,36 @@ describe('Chain Accent Colors', () => { allChainIds.forEach((chainId) => { const config = CHAIN_ACCENT_CONFIG[chainId] - // Currently no exclusions, so all should be present expect(config).toBeDefined() - expect(config?.chainId).toBe(chainId) + expect(config.chainId).toBe(chainId) }) }) it('should have consistent chainId in config', () => { Object.entries(CHAIN_ACCENT_CONFIG).forEach(([key, config]) => { - if (!config) return - const chainId = Number(key) as SupportedChainId expect(config.chainId).toBe(chainId) }) }) }) + + describe('Fallback color handling', () => { + it('should use Color.neutral50 as fallback when chain color is missing', () => { + // Verify the fallback color constant exists and has the correct value + // This is defensive - in practice all chains in CHAIN_INFO should have colors + const fallbackColor = Color.neutral50 + expect(fallbackColor).toBe('#827474') + + // Verify all chains have valid colors (ensuring fallback logic doesn't break anything) + Object.values(CHAIN_ACCENT_CONFIG).forEach((config) => { + expect(config.lightColor).toBeDefined() + expect(config.darkColor).toBeDefined() + expect(typeof config.lightColor).toBe('string') + expect(typeof config.darkColor).toBe('string') + // Colors should be valid hex or rgba format + expect(config.lightColor).toMatch(/^#[\da-fA-F]{6}$|^rgba?\(/) + expect(config.darkColor).toMatch(/^#[\da-fA-F]{6}$|^rgba?\(/) + }) + }) + }) }) diff --git a/libs/ui/src/theme/ThemeColorVars.tsx b/libs/ui/src/theme/ThemeColorVars.tsx index 7fdd986b244..4c3f04d379f 100644 --- a/libs/ui/src/theme/ThemeColorVars.tsx +++ b/libs/ui/src/theme/ThemeColorVars.tsx @@ -5,6 +5,7 @@ import { getContrastText } from '@cowprotocol/ui-utils' import { darken, lighten, transparentize } from 'color2k' import { css } from 'styled-components/macro' +import { Color } from '../colors' import { UI } from '../enum' /** @@ -62,6 +63,9 @@ const CHAIN_DARK_BG_ALPHA = 0.32 const CHAIN_LIGHT_BORDER_ALPHA = 0.45 const CHAIN_DARK_BORDER_ALPHA = 0.65 +// Fallback color if chain color is missing (uses neutral50 as approximation of UI.COLOR_TEXT) +const FALLBACK_CHAIN_COLOR = Color.neutral50 + const chainAlpha = (color: string, alpha: number): string => transparentize(color, 1 - alpha) function createChainAccent({ @@ -75,7 +79,8 @@ function createChainAccent({ darkBorderAlpha = CHAIN_DARK_BORDER_ALPHA, }: ChainAccentInput): ChainAccentConfig { // Use CHAIN_INFO.color as the single source of truth, allow override if needed - const baseColor = color ?? CHAIN_INFO[chainId].color + // Fallback to neutral gray if color is missing + const baseColor = color ?? CHAIN_INFO[chainId]?.color ?? FALLBACK_CHAIN_COLOR const finalLightColor = lightColor ?? baseColor const finalDarkColor = darkColor ?? baseColor @@ -105,16 +110,9 @@ function createChainAccent({ * - Chain names and CSS variables are generated automatically from CHAIN_INFO * * CUSTOMIZATION: - * - Exclude chains from accent colors by adding them to CHAIN_ACCENT_EXCLUSIONS * - Override colors in CHAIN_ACCENT_OVERRIDES if CHAIN_INFO color differs from design (e.g., MAINNET, LENS) */ -// Chains to exclude from accent color configuration (e.g., testnets that don't need accent colors) -const CHAIN_ACCENT_EXCLUSIONS: Set = new Set([ - // Add chain IDs here if they shouldn't have accent colors - // Example: SupportedChainId.SOME_TESTNET, -]) - // Color overrides for chains where CHAIN_INFO color differs from original design const CHAIN_ACCENT_OVERRIDES: Partial>> = { [SupportedChainId.MAINNET]: { @@ -128,12 +126,12 @@ const CHAIN_ACCENT_OVERRIDES: Partial Number(key) as SupportedChainId) .filter((chainId) => { - // Type guard: ensure chainId exists in CHAIN_INFO and is not excluded - return CHAIN_INFO[chainId] && !CHAIN_ACCENT_EXCLUSIONS.has(chainId) + // Type guard: ensure chainId exists in CHAIN_INFO + return CHAIN_INFO[chainId] }) .map((chainId) => createChainAccent({ @@ -154,30 +152,27 @@ const CHAIN_ACCENT_CONFIG_ARRAY: ChainAccentConfig[] = Object.keys(CHAIN_INFO) * // colors.bgVar, colors.borderVar, colors.lightBg, colors.darkBg, etc. * ``` */ -export const CHAIN_ACCENT_CONFIG: Record = - CHAIN_ACCENT_CONFIG_ARRAY.reduce( - (acc, config) => { - acc[config.chainId] = config - return acc - }, - {} as Record, - ) +export const CHAIN_ACCENT_CONFIG: Record = CHAIN_ACCENT_CONFIG_ARRAY.reduce( + (acc, config) => { + acc[config.chainId] = config + return acc + }, + {} as Record, +) /** * Helper function to get chain accent colors for a given chainId. - * Returns undefined if the chain doesn't have accent colors configured. + * All chains have accent colors configured. * * @example * ```tsx * import { getChainAccentColors } from '@cowprotocol/ui' * * const colors = getChainAccentColors(SupportedChainId.MAINNET) - * if (colors) { - * // Use colors.lightBg, colors.darkBg, etc. - * } + * // Use colors.lightBg, colors.darkBg, etc. * ``` */ -export function getChainAccentColors(chainId: SupportedChainId): ChainAccentConfig | undefined { +export function getChainAccentColors(chainId: SupportedChainId): ChainAccentConfig { return CHAIN_ACCENT_CONFIG[chainId] } From 9ed94c9921a38f069f926bfc06beb975f8534593 Mon Sep 17 00:00:00 2001 From: fairlighteth <31534717+fairlighteth@users.noreply.github.com> Date: Wed, 3 Dec 2025 15:03:04 +0000 Subject: [PATCH 039/100] refactor: enhance chain accent color configuration with a helper function --- libs/ui/src/theme/ThemeColorVars.tsx | 49 +++++++++++++++++----------- 1 file changed, 30 insertions(+), 19 deletions(-) diff --git a/libs/ui/src/theme/ThemeColorVars.tsx b/libs/ui/src/theme/ThemeColorVars.tsx index 4c3f04d379f..6142a429706 100644 --- a/libs/ui/src/theme/ThemeColorVars.tsx +++ b/libs/ui/src/theme/ThemeColorVars.tsx @@ -1,4 +1,4 @@ -import { CHAIN_INFO } from '@cowprotocol/common-const' +import { BaseChainInfo, CHAIN_INFO } from '@cowprotocol/common-const' import { SupportedChainId } from '@cowprotocol/cow-sdk' import { getContrastText } from '@cowprotocol/ui-utils' @@ -126,24 +126,38 @@ const CHAIN_ACCENT_OVERRIDES: Partial Number(key) as SupportedChainId) - .filter((chainId) => { - // Type guard: ensure chainId exists in CHAIN_INFO - return CHAIN_INFO[chainId] - }) - .map((chainId) => - createChainAccent({ +/** + * Helper function to create a Record with all SupportedChainId keys. + * Since CHAIN_INFO is Record, TypeScript ensures + * all keys are present when iterating over CHAIN_INFO entries. + * + * Note: TypeScript cannot statically verify completeness of dynamically constructed Records, + * but since we iterate over all CHAIN_INFO entries (which is a complete Record), all keys + * are guaranteed to be present at runtime. + */ +function createChainAccentConfig(): Record { + const config = {} as Record + + // Iterate over all CHAIN_INFO entries - since CHAIN_INFO is Record, + // all SupportedChainId keys are guaranteed to be present + for (const [key, _chainInfo] of Object.entries(CHAIN_INFO) as [string, BaseChainInfo][]) { + const chainId = Number(key) as SupportedChainId + config[chainId] = createChainAccent({ chainId, ...CHAIN_ACCENT_OVERRIDES[chainId], - }), - ) + }) + } + + return config satisfies Record +} /** * Map of chain accent colors keyed by SupportedChainId for programmatic access. * This allows components to access theme-aware chain colors without using CSS variables. * + * TypeScript verifies completeness: since CHAIN_INFO is Record, + * iterating over all entries ensures all SupportedChainId keys are present. + * * @example * ```tsx * import { CHAIN_ACCENT_CONFIG } from '@cowprotocol/ui' @@ -152,13 +166,10 @@ const CHAIN_ACCENT_CONFIG_ARRAY: ChainAccentConfig[] = Object.keys(CHAIN_INFO) * // colors.bgVar, colors.borderVar, colors.lightBg, colors.darkBg, etc. * ``` */ -export const CHAIN_ACCENT_CONFIG: Record = CHAIN_ACCENT_CONFIG_ARRAY.reduce( - (acc, config) => { - acc[config.chainId] = config - return acc - }, - {} as Record, -) +export const CHAIN_ACCENT_CONFIG: Record = createChainAccentConfig() + +// Array version for CSS variable generation +const CHAIN_ACCENT_CONFIG_ARRAY: ChainAccentConfig[] = Object.values(CHAIN_ACCENT_CONFIG) /** * Helper function to get chain accent colors for a given chainId. From 1815ecfd7ac0a46a55ed4b7814b4e67f88016f38 Mon Sep 17 00:00:00 2001 From: fairlighteth <31534717+fairlighteth@users.noreply.github.com> Date: Wed, 3 Dec 2025 15:10:37 +0000 Subject: [PATCH 040/100] fix: undo po file --- apps/cowswap-frontend/src/locales/en-US.po | 34 +++++++++------------- 1 file changed, 13 insertions(+), 21 deletions(-) diff --git a/apps/cowswap-frontend/src/locales/en-US.po b/apps/cowswap-frontend/src/locales/en-US.po index 88e9cbb5847..25486dac0e4 100644 --- a/apps/cowswap-frontend/src/locales/en-US.po +++ b/apps/cowswap-frontend/src/locales/en-US.po @@ -464,6 +464,7 @@ msgid "View details" msgstr "View details" #: apps/cowswap-frontend/src/modules/application/containers/App/menuConsts.tsx +#: apps/cowswap-frontend/src/modules/tokensList/pure/ChainsSelector/index.tsx msgid "More" msgstr "More" @@ -835,8 +836,8 @@ msgid "Copied" msgstr "Copied" #: apps/cowswap-frontend/src/modules/tokensList/containers/TokenSearchResults/index.tsx -#~ msgid "Can't find your token on the list?" -#~ msgstr "Can't find your token on the list?" +msgid "Can't find your token on the list?" +msgstr "Can't find your token on the list?" #: apps/cowswap-frontend/src/modules/trade/pure/ReceiveAmountTitle/index.tsx msgid "icon" @@ -855,8 +856,8 @@ msgid "Please connect your wallet to one of our supported networks." msgstr "Please connect your wallet to one of our supported networks." #: apps/cowswap-frontend/src/modules/tokensList/containers/TokenSearchResults/index.tsx -#~ msgid "<0>Read our guide on how to add custom tokens." -#~ msgstr "<0>Read our guide on how to add custom tokens." +msgid "<0>Read our guide on how to add custom tokens." +msgstr "<0>Read our guide on how to add custom tokens." #: apps/cowswap-frontend/src/modules/hooksStore/containers/TenderlySimulate/index.tsx msgid "Retry" @@ -1232,8 +1233,8 @@ msgid "Partner fee can not be more than {PARTNER_FEE_MAX_BPS} BPS!" msgstr "Partner fee can not be more than {PARTNER_FEE_MAX_BPS} BPS!" #: apps/cowswap-frontend/src/modules/tokensList/pure/TokensContent/index.tsx -#~ msgid "Manage Token Lists" -#~ msgstr "Manage Token Lists" +msgid "Manage Token Lists" +msgstr "Manage Token Lists" #: apps/cowswap-frontend/src/legacy/components/Tokens/TokensTable.tsx msgid "No results found" @@ -3213,6 +3214,7 @@ msgstr "CoW Swap's robust solver competition protects your slippage from being e msgid "Aave Debt Swap Flashloan" msgstr "Aave Debt Swap Flashloan" +#: apps/cowswap-frontend/src/modules/tokensList/pure/LpTokenLists/index.tsx #: apps/cowswap-frontend/src/modules/yield/pure/TargetPoolPreviewInfo.tsx msgid "Details" msgstr "Details" @@ -3888,10 +3890,6 @@ msgstr "User rejected approval transaction" msgid "Swap on" msgstr "Swap on" -#: apps/cowswap-frontend/src/modules/tokensList/pure/TokenSearchContent/index.tsx -msgid "Can't find your token on the list? <0>Read our guide on how to add custom tokens." -msgstr "Can't find your token on the list? <0>Read our guide on how to add custom tokens." - #: apps/cowswap-frontend/src/modules/trade/pure/ProtocolFeeRow/index.tsx #~ msgid "The fee is {protocolFeeBps} BPS ({protocolFeeAsPercent}%), applied only if the trade is executed.<0/><1/>Solver rewards are taken from this fee amount." #~ msgstr "The fee is {protocolFeeBps} BPS ({protocolFeeAsPercent}%), applied only if the trade is executed.<0/><1/>Solver rewards are taken from this fee amount." @@ -4406,6 +4404,7 @@ msgstr "Decrease Value" #: apps/cowswap-frontend/src/legacy/components/Tokens/TokensTable.tsx #: apps/cowswap-frontend/src/modules/ethFlow/pure/WrappingPreview/WrapCard.tsx #: apps/cowswap-frontend/src/modules/tokensList/pure/LpTokenLists/index.tsx +#: apps/cowswap-frontend/src/modules/tokensList/pure/LpTokenLists/index.tsx msgid "Balance" msgstr "Balance" @@ -4468,8 +4467,8 @@ msgid "funds" msgstr "funds" #: apps/cowswap-frontend/src/modules/tokensList/pure/LpTokenLists/index.tsx -#~ msgid "Pool details" -#~ msgstr "Pool details" +msgid "Pool details" +msgstr "Pool details" #: apps/cowswap-frontend/src/modules/tradeSlippage/containers/HighSuggestedSlippageWarning/index.tsx msgid "Slippage adjusted to {slippageBpsPercentage}% to ensure quick execution" @@ -5989,8 +5988,8 @@ msgid "You sold <0/>" msgstr "You sold <0/>" #: apps/cowswap-frontend/src/modules/tokensList/pure/ChainsSelector/index.tsx -#~ msgid "Less" -#~ msgstr "Less" +msgid "Less" +msgstr "Less" #: libs/hook-dapp-lib/src/hookDappsRegistry.ts msgid "Claim your LlamaPay vesting contract funds" @@ -6286,10 +6285,3 @@ msgstr "Learn more" #: apps/cowswap-frontend/src/modules/tradeWidgetAddons/containers/HighFeeWarning/highFeeWarningHelpers.ts msgid "Swap and bridge costs are at least {formattedFeePercentage}% of the swap amount" msgstr "Swap and bridge costs are at least {formattedFeePercentage}% of the swap amount" - -# Notifications / jobs aria labels -msgid "Trade alert settings" -msgstr "Trade alert settings" - -msgid "View jobs (opens in a new tab)" -msgstr "View jobs (opens in a new tab)" From 956c4408574e995a3981e79b32e631e55dacd54a Mon Sep 17 00:00:00 2001 From: fairlighteth <31534717+fairlighteth@users.noreply.github.com> Date: Wed, 3 Dec 2025 15:12:25 +0000 Subject: [PATCH 041/100] fix: revert changes po file --- apps/cowswap-frontend/src/locales/en-US.po | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/apps/cowswap-frontend/src/locales/en-US.po b/apps/cowswap-frontend/src/locales/en-US.po index 25486dac0e4..8c753759980 100644 --- a/apps/cowswap-frontend/src/locales/en-US.po +++ b/apps/cowswap-frontend/src/locales/en-US.po @@ -6285,3 +6285,10 @@ msgstr "Learn more" #: apps/cowswap-frontend/src/modules/tradeWidgetAddons/containers/HighFeeWarning/highFeeWarningHelpers.ts msgid "Swap and bridge costs are at least {formattedFeePercentage}% of the swap amount" msgstr "Swap and bridge costs are at least {formattedFeePercentage}% of the swap amount" + +# Notifications / jobs aria labels +msgid "Trade alert settings" +msgstr "Trade alert settings" + +msgid "View jobs (opens in a new tab)" +msgstr "View jobs (opens in a new tab)" From 4e2b3360fba1f2bc1805b21008456565f8d3275a Mon Sep 17 00:00:00 2001 From: fairlighteth <31534717+fairlighteth@users.noreply.github.com> Date: Wed, 3 Dec 2025 15:17:36 +0000 Subject: [PATCH 042/100] refactor: enforce accentVar as a required property in ChainAccentConfig and update related tests --- libs/ui/src/theme/ThemeColorVars.test.ts | 3 +++ libs/ui/src/theme/ThemeColorVars.tsx | 8 ++------ 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/libs/ui/src/theme/ThemeColorVars.test.ts b/libs/ui/src/theme/ThemeColorVars.test.ts index ed66df89697..e9548e69655 100644 --- a/libs/ui/src/theme/ThemeColorVars.test.ts +++ b/libs/ui/src/theme/ThemeColorVars.test.ts @@ -53,6 +53,9 @@ describe('Chain Accent Colors', () => { Object.values(CHAIN_ACCENT_CONFIG).forEach((config) => { expect(config.bgVar).toMatch(/^--cow-color-chain-[a-z_]+-bg$/) expect(config.borderVar).toMatch(/^--cow-color-chain-[a-z_]+-border$/) + expect(config.accentVar).toBeDefined() + expect(config.accentVar).not.toBeUndefined() + expect(typeof config.accentVar).toBe('string') expect(config.accentVar).toMatch(/^--cow-color-chain-[a-z_]+-accent$/) }) }) diff --git a/libs/ui/src/theme/ThemeColorVars.tsx b/libs/ui/src/theme/ThemeColorVars.tsx index 6142a429706..393c7ed13fb 100644 --- a/libs/ui/src/theme/ThemeColorVars.tsx +++ b/libs/ui/src/theme/ThemeColorVars.tsx @@ -38,7 +38,7 @@ export interface ChainAccentConfig { chainId: SupportedChainId bgVar: string borderVar: string - accentVar?: string + accentVar: string lightBg: string darkBg: string lightBorder: string @@ -191,11 +191,7 @@ const CHAIN_ACCENT_VAR_DECLARATIONS = CHAIN_ACCENT_CONFIG_ARRAY.map( ({ bgVar, borderVar, accentVar, lightBg, darkBg, lightBorder, darkBorder, lightColor, darkColor }) => css` ${bgVar}: ${({ theme }) => (theme.darkMode ? darkBg : lightBg)}; ${borderVar}: ${({ theme }) => (theme.darkMode ? darkBorder : lightBorder)}; - ${accentVar - ? css` - ${accentVar}: ${({ theme }) => (theme.darkMode ? darkColor : lightColor)}; - ` - : ''} + ${accentVar}: ${({ theme }) => (theme.darkMode ? darkColor : lightColor)}; `, ) From 2825b637e1e5ff9a8561eb06f117d2e98bea73b0 Mon Sep 17 00:00:00 2001 From: fairlighteth <31534717+fairlighteth@users.noreply.github.com> Date: Wed, 3 Dec 2025 15:20:59 +0000 Subject: [PATCH 043/100] test: add validation for chainId consistency in ChainAccentColors --- libs/ui/src/theme/ThemeColorVars.test.ts | 18 ++++++++++++++++++ libs/ui/src/theme/ThemeColorVars.tsx | 5 ++++- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/libs/ui/src/theme/ThemeColorVars.test.ts b/libs/ui/src/theme/ThemeColorVars.test.ts index e9548e69655..67c876a8b3e 100644 --- a/libs/ui/src/theme/ThemeColorVars.test.ts +++ b/libs/ui/src/theme/ThemeColorVars.test.ts @@ -119,6 +119,24 @@ describe('Chain Accent Colors', () => { expect(colors?.lightColor).toBe(sdkColor) expect(colors?.darkColor).toBe(sdkColor) }) + + it('should always use Record key as chainId source of truth', () => { + // This test verifies that chainId always comes from the Record key, + // not from override objects. The type system (ChainAccentOverride) prevents + // chainId from being set in overrides, but this test documents the runtime behavior. + const mainnetColors = getChainAccentColors(SupportedChainId.MAINNET) + const lensColors = getChainAccentColors(SupportedChainId.LENS) + + // Even though MAINNET and LENS have overrides, chainId should match the Record key + expect(mainnetColors.chainId).toBe(SupportedChainId.MAINNET) + expect(lensColors.chainId).toBe(SupportedChainId.LENS) + + // Verify this holds for all chains with overrides + Object.entries(CHAIN_ACCENT_CONFIG).forEach(([key, config]) => { + const chainId = Number(key) as SupportedChainId + expect(config.chainId).toBe(chainId) + }) + }) }) describe('ChainAccentConfig structure', () => { diff --git a/libs/ui/src/theme/ThemeColorVars.tsx b/libs/ui/src/theme/ThemeColorVars.tsx index 393c7ed13fb..40ea7d179e5 100644 --- a/libs/ui/src/theme/ThemeColorVars.tsx +++ b/libs/ui/src/theme/ThemeColorVars.tsx @@ -58,6 +58,9 @@ interface ChainAccentInput { darkBorderAlpha?: number } +// Override type excludes chainId since the Record key is the single source of truth +type ChainAccentOverride = Partial> + const CHAIN_LIGHT_BG_ALPHA = 0.22 const CHAIN_DARK_BG_ALPHA = 0.32 const CHAIN_LIGHT_BORDER_ALPHA = 0.45 @@ -114,7 +117,7 @@ function createChainAccent({ */ // Color overrides for chains where CHAIN_INFO color differs from original design -const CHAIN_ACCENT_OVERRIDES: Partial>> = { +const CHAIN_ACCENT_OVERRIDES: Partial> = { [SupportedChainId.MAINNET]: { // Override: Original color #627EEA differs from SDK's #62688F color: '#627EEA', From aa7fa8f9f901079ce02ecd835223ec538d003063 Mon Sep 17 00:00:00 2001 From: fairlighteth <31534717+fairlighteth@users.noreply.github.com> Date: Wed, 3 Dec 2025 15:33:37 +0000 Subject: [PATCH 044/100] refactor: decompose TokenSeachContent component --- .../src/common/pure/VirtualList/index.tsx | 2 +- .../pure/TokenSearchContent/GuideBanner.tsx | 29 +++ .../TokenSearchRowRenderer.tsx | 43 ++++ .../pure/TokenSearchContent/helpers.ts | 27 +++ .../pure/TokenSearchContent/index.tsx | 212 +----------------- .../pure/TokenSearchContent/types.ts | 52 +++++ .../pure/TokenSearchContent/useSearchRows.ts | 65 ++++++ 7 files changed, 228 insertions(+), 202 deletions(-) create mode 100644 apps/cowswap-frontend/src/modules/tokensList/pure/TokenSearchContent/GuideBanner.tsx create mode 100644 apps/cowswap-frontend/src/modules/tokensList/pure/TokenSearchContent/TokenSearchRowRenderer.tsx create mode 100644 apps/cowswap-frontend/src/modules/tokensList/pure/TokenSearchContent/helpers.ts create mode 100644 apps/cowswap-frontend/src/modules/tokensList/pure/TokenSearchContent/types.ts create mode 100644 apps/cowswap-frontend/src/modules/tokensList/pure/TokenSearchContent/useSearchRows.ts diff --git a/apps/cowswap-frontend/src/common/pure/VirtualList/index.tsx b/apps/cowswap-frontend/src/common/pure/VirtualList/index.tsx index 41640698b7f..96bee5c71a5 100644 --- a/apps/cowswap-frontend/src/common/pure/VirtualList/index.tsx +++ b/apps/cowswap-frontend/src/common/pure/VirtualList/index.tsx @@ -81,7 +81,7 @@ interface VirtualListProps { loading?: boolean estimateSize?: () => number children?: ReactNode - scrollResetKey?: number + scrollResetKey?: number | string } export function VirtualList({ diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/TokenSearchContent/GuideBanner.tsx b/apps/cowswap-frontend/src/modules/tokensList/pure/TokenSearchContent/GuideBanner.tsx new file mode 100644 index 00000000000..5765907cd04 --- /dev/null +++ b/apps/cowswap-frontend/src/modules/tokensList/pure/TokenSearchContent/GuideBanner.tsx @@ -0,0 +1,29 @@ +import { ReactNode } from 'react' + +import { + BannerOrientation, + ExternalLink, + InlineBanner, + LINK_GUIDE_ADD_CUSTOM_TOKEN, + StatusColorVariant, +} from '@cowprotocol/ui' + +import { Trans } from '@lingui/react/macro' + +export function GuideBanner(): ReactNode { + 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/TokenSearchContent/TokenSearchRowRenderer.tsx b/apps/cowswap-frontend/src/modules/tokensList/pure/TokenSearchContent/TokenSearchRowRenderer.tsx new file mode 100644 index 00000000000..a0faa2bd110 --- /dev/null +++ b/apps/cowswap-frontend/src/modules/tokensList/pure/TokenSearchContent/TokenSearchRowRenderer.tsx @@ -0,0 +1,43 @@ +import { ReactNode } from 'react' + +import { GuideBanner } from './GuideBanner' +import { TokenSearchRowRendererProps } from './types' + +import * as styledEl from '../../containers/TokenSearchResults/styled' +import { ImportTokenItem } from '../ImportTokenItem' +import { TokenListItemContainer } from '../TokenListItemContainer' +import { TokenSourceTitle } from '../TokenSourceTitle' + +export function TokenSearchRowRenderer({ + row, + selectTokenContext, + importToken, +}: TokenSearchRowRendererProps): ReactNode { + switch (row.type) { + case 'banner': + return + case 'token': + return + case 'section-title': { + const tooltip = row.tooltip?.trim() || undefined + return ( + + {row.text} + + ) + } + case 'import-token': + return ( + + ) + default: + return null + } +} diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/TokenSearchContent/helpers.ts b/apps/cowswap-frontend/src/modules/tokensList/pure/TokenSearchContent/helpers.ts new file mode 100644 index 00000000000..d82d466f17e --- /dev/null +++ b/apps/cowswap-frontend/src/modules/tokensList/pure/TokenSearchContent/helpers.ts @@ -0,0 +1,27 @@ +import { AppendImportSectionParams, TokenSearchRow } from './types' + +export 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, + }) + }) +} 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 b70d5756636..9f5a0437d6e 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/pure/TokenSearchContent/index.tsx +++ b/apps/cowswap-frontend/src/modules/tokensList/pure/TokenSearchContent/index.tsx @@ -2,36 +2,18 @@ import { ReactNode, useCallback, useMemo } from 'react' import { TokenWithLogo } from '@cowprotocol/common-const' import { doesTokenMatchSymbolOrAddress } from '@cowprotocol/common-utils' -import { TokenSearchResponse } from '@cowprotocol/tokens' -import { - BannerOrientation, - ExternalLink, - InlineBanner, - LINK_GUIDE_ADD_CUSTOM_TOKEN, - Loader, - StatusColorVariant, -} from '@cowprotocol/ui' +import { Loader } 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' -import { ImportTokenItem } from '../ImportTokenItem' -import { TokenListItemContainer } from '../TokenListItemContainer' -import { TokenSourceTitle } from '../TokenSourceTitle' - -const SEARCH_RESULTS_LIMIT = 100 +import { TokenSearchRowRenderer } from './TokenSearchRowRenderer' +import { TokenSearchContentProps, TokenSearchRow } from './types' +import { useSearchRows } from './useSearchRows' -interface TokenSearchContentProps { - searchInput: string - searchResults: TokenSearchResponse - selectTokenContext: SelectTokenContext - importToken: (tokenToImport: TokenWithLogo) => void -} +import * as styledEl from '../../containers/TokenSearchResults/styled' export function TokenSearchContent({ searchInput, @@ -43,10 +25,10 @@ export function TokenSearchContent({ const searchCount = [ activeListsResult.length, - inactiveListsResult.length, - blockchainResult.length, - externalApiResult.length, - ].reduce((acc, cur) => acc + (cur ?? 0), 0) + inactiveListsResult?.length ?? 0, + blockchainResult?.length ?? 0, + externalApiResult?.length ?? 0, + ].reduce((acc, cur) => acc + cur, 0) const isTokenNotFound = isLoading ? false : searchCount === 0 @@ -74,7 +56,7 @@ export function TokenSearchContent({ externalApiResult, }) - const renderRow = useCallback( + const getItemView = useCallback( (items: TokenSearchRow[], virtualItem: VirtualItem) => ( ) - 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?.trim() || undefined - 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. - -

-
- ) + return } diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/TokenSearchContent/types.ts b/apps/cowswap-frontend/src/modules/tokensList/pure/TokenSearchContent/types.ts new file mode 100644 index 00000000000..b6d1809135a --- /dev/null +++ b/apps/cowswap-frontend/src/modules/tokensList/pure/TokenSearchContent/types.ts @@ -0,0 +1,52 @@ +import { TokenWithLogo } from '@cowprotocol/common-const' +import { TokenSearchResponse } from '@cowprotocol/tokens' + +import { SelectTokenContext } from '../../types' + +export interface TokenSearchContentProps { + searchInput: string + searchResults: TokenSearchResponse + selectTokenContext: SelectTokenContext + importToken: (tokenToImport: TokenWithLogo) => void +} + +export type TokenImportSection = 'blockchain' | 'inactive' | 'external' + +export 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 + } + +export interface UseSearchRowsParams { + isLoading: boolean + matchedTokens: TokenWithLogo[] + activeList: TokenWithLogo[] + blockchainResult?: TokenWithLogo[] + inactiveListsResult?: TokenWithLogo[] + externalApiResult?: TokenWithLogo[] +} + +export interface AppendImportSectionParams { + tokens?: TokenWithLogo[] + section: TokenImportSection + limit: number + sectionTitle?: string + tooltip?: string + shadowed?: boolean + wrapperId?: string +} + +export interface TokenSearchRowRendererProps { + row: TokenSearchRow + selectTokenContext: SelectTokenContext + importToken(token: TokenWithLogo): void +} diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/TokenSearchContent/useSearchRows.ts b/apps/cowswap-frontend/src/modules/tokensList/pure/TokenSearchContent/useSearchRows.ts new file mode 100644 index 00000000000..3ed9a6ed9f6 --- /dev/null +++ b/apps/cowswap-frontend/src/modules/tokensList/pure/TokenSearchContent/useSearchRows.ts @@ -0,0 +1,65 @@ +import { useMemo } from 'react' + +import { t } from '@lingui/core/macro' + +import { appendImportSection } from './helpers' +import { TokenSearchRow, UseSearchRowsParams } from './types' + +const SEARCH_RESULTS_LIMIT = 100 + +export 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]) +} From e0daf213537b4f81b4742d178289a99874a22276 Mon Sep 17 00:00:00 2001 From: fairlighteth <31534717+fairlighteth@users.noreply.github.com> Date: Wed, 3 Dec 2025 15:42:46 +0000 Subject: [PATCH 045/100] test: add comprehensive tests for useSearchRows hook in TokenSearchContent --- .../TokenSearchContent/useSearchRows.test.ts | 778 ++++++++++++++++++ 1 file changed, 778 insertions(+) create mode 100644 apps/cowswap-frontend/src/modules/tokensList/pure/TokenSearchContent/useSearchRows.test.ts diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/TokenSearchContent/useSearchRows.test.ts b/apps/cowswap-frontend/src/modules/tokensList/pure/TokenSearchContent/useSearchRows.test.ts new file mode 100644 index 00000000000..a394cbc2c5b --- /dev/null +++ b/apps/cowswap-frontend/src/modules/tokensList/pure/TokenSearchContent/useSearchRows.test.ts @@ -0,0 +1,778 @@ +import { TokenWithLogo } from '@cowprotocol/common-const' +import { SupportedChainId } from '@cowprotocol/cow-sdk' + +import { renderHook } from '@testing-library/react' + +import { useSearchRows } from './useSearchRows' + +// Helper to create test tokens with valid Ethereum addresses +function createToken( + symbol: string, + address: string, + name: string, + chainId: SupportedChainId = SupportedChainId.SEPOLIA, +): TokenWithLogo { + // Ensure address is valid (42 chars: 0x + 40 hex chars) + // If address is too short, pad it with zeros + let validAddress = address + if (address.length < 42) { + const hexPart = address.startsWith('0x') ? address.slice(2) : address + validAddress = `0x${hexPart.padStart(40, '0')}` + } + return new TokenWithLogo(undefined, chainId, validAddress, 18, symbol, name) +} + +// Helper to create multiple tokens +function createTokens(count: number, prefix = 'TOKEN'): TokenWithLogo[] { + return Array.from({ length: count }, (_, i) => { + // Generate valid 40-char hex address by padding the index + const hexIndex = i.toString(16).padStart(40, '0') + return createToken(`${prefix}${i}`, `0x${hexIndex}`, `${prefix} ${i}`) + }) +} + +// eslint-disable-next-line max-lines-per-function +describe('useSearchRows', () => { + describe('Loading State', () => { + it('should return empty array when isLoading is true', () => { + const { result } = renderHook(() => + useSearchRows({ + isLoading: true, + matchedTokens: [createToken('TOKEN', '0x1', 'Test Token')], + activeList: [createToken('TOKEN2', '0x2', 'Test Token 2')], + blockchainResult: [createToken('TOKEN3', '0x3', 'Test Token 3')], + inactiveListsResult: [createToken('TOKEN4', '0x4', 'Test Token 4')], + externalApiResult: [createToken('TOKEN5', '0x5', 'Test Token 5')], + }), + ) + + expect(result.current).toEqual([]) + }) + + it('should ignore all token arrays when loading', () => { + const matchedTokens = [createToken('MATCHED', '0x1', 'Matched Token')] + const activeList = [createToken('ACTIVE', '0x2', 'Active Token')] + + const { result: loadingResult } = renderHook(() => + useSearchRows({ + isLoading: true, + matchedTokens, + activeList, + blockchainResult: [createToken('BLOCKCHAIN', '0x3', 'Blockchain Token')], + inactiveListsResult: [createToken('INACTIVE', '0x4', 'Inactive Token')], + externalApiResult: [createToken('EXTERNAL', '0x5', 'External Token')], + }), + ) + + expect(loadingResult.current).toEqual([]) + + const { result: notLoadingResult } = renderHook(() => + useSearchRows({ + isLoading: false, + matchedTokens, + activeList, + }), + ) + + expect(notLoadingResult.current.length).toBeGreaterThan(0) + }) + }) + + describe('Banner Row', () => { + it('should always include banner row as first entry when not loading', () => { + const { result } = renderHook(() => + useSearchRows({ + isLoading: false, + matchedTokens: [], + activeList: [], + }), + ) + + expect(result.current.length).toBeGreaterThan(0) + expect(result.current[0]).toEqual({ type: 'banner' }) + }) + + it('should have correct banner row type', () => { + const { result } = renderHook(() => + useSearchRows({ + isLoading: false, + matchedTokens: [], + activeList: [], + }), + ) + + const bannerRow = result.current[0] + expect(bannerRow).toEqual({ type: 'banner' }) + }) + }) + + describe('Matched Tokens', () => { + it('should add matched tokens with correct structure', () => { + const token1 = createToken('TOKEN1', '0x1', 'Token 1') + const token2 = createToken('TOKEN2', '0x2', 'Token 2') + + const { result } = renderHook(() => + useSearchRows({ + isLoading: false, + matchedTokens: [token1, token2], + activeList: [], + }), + ) + + expect(result.current[0]).toEqual({ type: 'banner' }) + expect(result.current[1]).toEqual({ type: 'token', token: token1 }) + expect(result.current[2]).toEqual({ type: 'token', token: token2 }) + }) + + it('should preserve order of matched tokens', () => { + const token1 = createToken('TOKEN1', '0x1', 'Token 1') + const token2 = createToken('TOKEN2', '0x2', 'Token 2') + const token3 = createToken('TOKEN3', '0x3', 'Token 3') + + const { result } = renderHook(() => + useSearchRows({ + isLoading: false, + matchedTokens: [token1, token2, token3], + activeList: [], + }), + ) + + const tokenRows = result.current.filter((row) => row.type === 'token') as Array<{ + type: 'token' + token: TokenWithLogo + }> + expect(tokenRows[0].token).toBe(token1) + expect(tokenRows[1].token).toBe(token2) + expect(tokenRows[2].token).toBe(token3) + }) + + it('should handle empty matched tokens array', () => { + const { result } = renderHook(() => + useSearchRows({ + isLoading: false, + matchedTokens: [], + activeList: [], + }), + ) + + const tokenRows = result.current.filter((row) => row.type === 'token') + expect(tokenRows.length).toBe(0) + expect(result.current[0]).toEqual({ type: 'banner' }) + }) + }) + + describe('Active List Tokens', () => { + it('should add active list tokens after matched tokens', () => { + const matchedToken = createToken('MATCHED', '0x1', 'Matched Token') + const activeToken = createToken('ACTIVE', '0x2', 'Active Token') + + const { result } = renderHook(() => + useSearchRows({ + isLoading: false, + matchedTokens: [matchedToken], + activeList: [activeToken], + }), + ) + + expect(result.current[0]).toEqual({ type: 'banner' }) + expect(result.current[1]).toEqual({ type: 'token', token: matchedToken }) + expect(result.current[2]).toEqual({ type: 'token', token: activeToken }) + }) + + it('should have correct structure for active list tokens', () => { + const activeToken = createToken('ACTIVE', '0x1', 'Active Token') + + const { result } = renderHook(() => + useSearchRows({ + isLoading: false, + matchedTokens: [], + activeList: [activeToken], + }), + ) + + const tokenRow = result.current.find((row) => row.type === 'token') + expect(tokenRow).toEqual({ type: 'token', token: activeToken }) + }) + + it('should handle empty active list', () => { + const matchedToken = createToken('MATCHED', '0x1', 'Matched Token') + + const { result } = renderHook(() => + useSearchRows({ + isLoading: false, + matchedTokens: [matchedToken], + activeList: [], + }), + ) + + const tokenRows = result.current.filter((row) => row.type === 'token') + expect(tokenRows.length).toBe(1) + expect((tokenRows[0] as { type: 'token'; token: TokenWithLogo }).token).toBe(matchedToken) + }) + }) + + describe('Blockchain Import Section', () => { + it('should add blockchain tokens when provided', () => { + const blockchainToken = createToken('BLOCKCHAIN', '0x1', 'Blockchain Token') + + const { result } = renderHook(() => + useSearchRows({ + isLoading: false, + matchedTokens: [], + activeList: [], + blockchainResult: [blockchainToken], + }), + ) + + const importRows = result.current.filter((row) => row.type === 'import-token') as Array<{ + type: 'import-token' + token: TokenWithLogo + section: string + shadowed?: boolean + wrapperId?: string + }> + + expect(importRows.length).toBe(1) + expect(importRows[0].token).toBe(blockchainToken) + expect(importRows[0].section).toBe('blockchain') + }) + + it('should not add section title for blockchain section', () => { + const blockchainToken = createToken('BLOCKCHAIN', '0x1', 'Blockchain Token') + + const { result } = renderHook(() => + useSearchRows({ + isLoading: false, + matchedTokens: [], + activeList: [], + blockchainResult: [blockchainToken], + }), + ) + + const sectionTitles = result.current.filter((row) => row.type === 'section-title') + expect(sectionTitles.length).toBe(0) + }) + + it('should have shadowed false for blockchain tokens', () => { + const blockchainToken = createToken('BLOCKCHAIN', '0x1', 'Blockchain Token') + + const { result } = renderHook(() => + useSearchRows({ + isLoading: false, + matchedTokens: [], + activeList: [], + blockchainResult: [blockchainToken], + }), + ) + + const importRow = result.current.find((row) => row.type === 'import-token') as { + type: 'import-token' + shadowed?: boolean + } + + expect(importRow.shadowed).toBe(false) + }) + + it('should have wrapperId currency-import on first blockchain token', () => { + const blockchainToken = createToken('BLOCKCHAIN', '0x1', 'Blockchain Token') + + const { result } = renderHook(() => + useSearchRows({ + isLoading: false, + matchedTokens: [], + activeList: [], + blockchainResult: [blockchainToken], + }), + ) + + const importRow = result.current.find((row) => row.type === 'import-token') as { + type: 'import-token' + wrapperId?: string + } + + expect(importRow.wrapperId).toBe('currency-import') + }) + + it('should respect SEARCH_RESULTS_LIMIT for blockchain tokens', () => { + const blockchainTokens = createTokens(150) + + const { result } = renderHook(() => + useSearchRows({ + isLoading: false, + matchedTokens: [], + activeList: [], + blockchainResult: blockchainTokens, + }), + ) + + const importRows = result.current.filter((row) => row.type === 'import-token') + expect(importRows.length).toBe(100) + }) + }) + + describe('Inactive Lists Import Section', () => { + it('should add section title for inactive lists', () => { + const inactiveToken = createToken('INACTIVE', '0x1', 'Inactive Token') + + const { result } = renderHook(() => + useSearchRows({ + isLoading: false, + matchedTokens: [], + activeList: [], + inactiveListsResult: [inactiveToken], + }), + ) + + const sectionTitle = result.current.find((row) => row.type === 'section-title') as { + type: 'section-title' + text: string + tooltip?: string + } + + expect(sectionTitle).toBeDefined() + expect(sectionTitle.text).toBe('Expanded results from inactive Token Lists') + }) + + it('should include tooltip text for inactive lists', () => { + const inactiveToken = createToken('INACTIVE', '0x1', 'Inactive Token') + + const { result } = renderHook(() => + useSearchRows({ + isLoading: false, + matchedTokens: [], + activeList: [], + inactiveListsResult: [inactiveToken], + }), + ) + + const sectionTitle = result.current.find((row) => row.type === 'section-title') as { + type: 'section-title' + tooltip?: string + } + + expect(sectionTitle.tooltip).toBe( + 'Tokens from inactive lists. Import specific tokens below or click Manage to activate more lists.', + ) + }) + + it('should have shadowed true for inactive tokens', () => { + const inactiveToken = createToken('INACTIVE', '0x1', 'Inactive Token') + + const { result } = renderHook(() => + useSearchRows({ + isLoading: false, + matchedTokens: [], + activeList: [], + inactiveListsResult: [inactiveToken], + }), + ) + + const importRow = result.current.find((row) => row.type === 'import-token') as { + type: 'import-token' + shadowed?: boolean + } + + expect(importRow.shadowed).toBe(true) + }) + + it('should have correct section type inactive', () => { + const inactiveToken = createToken('INACTIVE', '0x1', 'Inactive Token') + + const { result } = renderHook(() => + useSearchRows({ + isLoading: false, + matchedTokens: [], + activeList: [], + inactiveListsResult: [inactiveToken], + }), + ) + + const importRow = result.current.find((row) => row.type === 'import-token') as { + type: 'import-token' + section: string + } + + expect(importRow.section).toBe('inactive') + }) + + it('should respect limit for inactive tokens', () => { + const inactiveTokens = createTokens(150) + + const { result } = renderHook(() => + useSearchRows({ + isLoading: false, + matchedTokens: [], + activeList: [], + inactiveListsResult: inactiveTokens, + }), + ) + + const importRows = result.current.filter((row) => row.type === 'import-token') + expect(importRows.length).toBe(100) + }) + }) + + describe('External API Import Section', () => { + it('should add section title for external API results', () => { + const externalToken = createToken('EXTERNAL', '0x1', 'External Token') + + const { result } = renderHook(() => + useSearchRows({ + isLoading: false, + matchedTokens: [], + activeList: [], + externalApiResult: [externalToken], + }), + ) + + const sectionTitle = result.current.find((row) => row.type === 'section-title') as { + type: 'section-title' + text: string + } + + expect(sectionTitle).toBeDefined() + expect(sectionTitle.text).toBe('Additional Results from External Sources') + }) + + it('should include tooltip text for external API results', () => { + const externalToken = createToken('EXTERNAL', '0x1', 'External Token') + + const { result } = renderHook(() => + useSearchRows({ + isLoading: false, + matchedTokens: [], + activeList: [], + externalApiResult: [externalToken], + }), + ) + + const sectionTitle = result.current.find((row) => row.type === 'section-title') as { + type: 'section-title' + tooltip?: string + } + + expect(sectionTitle.tooltip).toBe('Tokens from external sources.') + }) + + it('should have shadowed true for external tokens', () => { + const externalToken = createToken('EXTERNAL', '0x1', 'External Token') + + const { result } = renderHook(() => + useSearchRows({ + isLoading: false, + matchedTokens: [], + activeList: [], + externalApiResult: [externalToken], + }), + ) + + const importRow = result.current.find((row) => row.type === 'import-token') as { + type: 'import-token' + shadowed?: boolean + } + + expect(importRow.shadowed).toBe(true) + }) + + it('should have correct section type external', () => { + const externalToken = createToken('EXTERNAL', '0x1', 'External Token') + + const { result } = renderHook(() => + useSearchRows({ + isLoading: false, + matchedTokens: [], + activeList: [], + externalApiResult: [externalToken], + }), + ) + + const importRow = result.current.find((row) => row.type === 'import-token') as { + type: 'import-token' + section: string + } + + expect(importRow.section).toBe('external') + }) + + it('should respect limit for external tokens', () => { + const externalTokens = createTokens(150) + + const { result } = renderHook(() => + useSearchRows({ + isLoading: false, + matchedTokens: [], + activeList: [], + externalApiResult: externalTokens, + }), + ) + + const importRows = result.current.filter((row) => row.type === 'import-token') + expect(importRows.length).toBe(100) + }) + }) + + describe('Import Section Structure', () => { + it('should have isFirstInSection true on first token', () => { + const tokens = [createToken('TOKEN1', '0x1', 'Token 1'), createToken('TOKEN2', '0x2', 'Token 2')] + + const { result } = renderHook(() => + useSearchRows({ + isLoading: false, + matchedTokens: [], + activeList: [], + blockchainResult: tokens, + }), + ) + + const importRows = result.current.filter((row) => row.type === 'import-token') as Array<{ + type: 'import-token' + isFirstInSection: boolean + }> + + expect(importRows[0].isFirstInSection).toBe(true) + expect(importRows[1].isFirstInSection).toBe(false) + }) + + it('should have isLastInSection true on last token', () => { + const tokens = [createToken('TOKEN1', '0x1', 'Token 1'), createToken('TOKEN2', '0x2', 'Token 2')] + + const { result } = renderHook(() => + useSearchRows({ + isLoading: false, + matchedTokens: [], + activeList: [], + blockchainResult: tokens, + }), + ) + + const importRows = result.current.filter((row) => row.type === 'import-token') as Array<{ + type: 'import-token' + isLastInSection: boolean + }> + + expect(importRows[0].isLastInSection).toBe(false) + expect(importRows[1].isLastInSection).toBe(true) + }) + + it('should have both flags false on middle tokens', () => { + const tokens = [ + createToken('TOKEN1', '0x1', 'Token 1'), + createToken('TOKEN2', '0x2', 'Token 2'), + createToken('TOKEN3', '0x3', 'Token 3'), + ] + + const { result } = renderHook(() => + useSearchRows({ + isLoading: false, + matchedTokens: [], + activeList: [], + blockchainResult: tokens, + }), + ) + + const importRows = result.current.filter((row) => row.type === 'import-token') as Array<{ + type: 'import-token' + isFirstInSection: boolean + isLastInSection: boolean + }> + + expect(importRows[1].isFirstInSection).toBe(false) + expect(importRows[1].isLastInSection).toBe(false) + }) + + it('should have wrapperId only on first token', () => { + const tokens = [createToken('TOKEN1', '0x1', 'Token 1'), createToken('TOKEN2', '0x2', 'Token 2')] + + const { result } = renderHook(() => + useSearchRows({ + isLoading: false, + matchedTokens: [], + activeList: [], + blockchainResult: tokens, + }), + ) + + const importRows = result.current.filter((row) => row.type === 'import-token') as Array<{ + type: 'import-token' + wrapperId?: string + }> + + expect(importRows[0].wrapperId).toBe('currency-import') + expect(importRows[1].wrapperId).toBeUndefined() + }) + + it('should have correct section type on each import token', () => { + const blockchainToken = createToken('BLOCKCHAIN', '0x1', 'Blockchain Token') + const inactiveToken = createToken('INACTIVE', '0x2', 'Inactive Token') + const externalToken = createToken('EXTERNAL', '0x3', 'External Token') + + const { result } = renderHook(() => + useSearchRows({ + isLoading: false, + matchedTokens: [], + activeList: [], + blockchainResult: [blockchainToken], + inactiveListsResult: [inactiveToken], + externalApiResult: [externalToken], + }), + ) + + const importRows = result.current.filter((row) => row.type === 'import-token') as Array<{ + type: 'import-token' + section: string + }> + + expect(importRows.find((row) => row.section === 'blockchain')).toBeDefined() + expect(importRows.find((row) => row.section === 'inactive')).toBeDefined() + expect(importRows.find((row) => row.section === 'external')).toBeDefined() + }) + }) + + describe('Edge Cases', () => { + it('should handle undefined token arrays by skipping section', () => { + const { result } = renderHook(() => + useSearchRows({ + isLoading: false, + matchedTokens: [], + activeList: [], + blockchainResult: undefined, + inactiveListsResult: undefined, + externalApiResult: undefined, + }), + ) + + const importRows = result.current.filter((row) => row.type === 'import-token') + expect(importRows.length).toBe(0) + expect(result.current).toEqual([{ type: 'banner' }]) + }) + + it('should handle empty token arrays by skipping section', () => { + const { result } = renderHook(() => + useSearchRows({ + isLoading: false, + matchedTokens: [], + activeList: [], + blockchainResult: [], + inactiveListsResult: [], + externalApiResult: [], + }), + ) + + const importRows = result.current.filter((row) => row.type === 'import-token') + expect(importRows.length).toBe(0) + expect(result.current).toEqual([{ type: 'banner' }]) + }) + + it('should handle arrays exceeding limit by truncating to 100', () => { + const largeArray = createTokens(200) + + const { result } = renderHook(() => + useSearchRows({ + isLoading: false, + matchedTokens: [], + activeList: [], + blockchainResult: largeArray, + }), + ) + + const importRows = result.current.filter((row) => row.type === 'import-token') + expect(importRows.length).toBe(100) + }) + + it('should handle single token in section with both first and last flags true', () => { + const singleToken = createToken('SINGLE', '0x1', 'Single Token') + + const { result } = renderHook(() => + useSearchRows({ + isLoading: false, + matchedTokens: [], + activeList: [], + blockchainResult: [singleToken], + }), + ) + + const importRow = result.current.find((row) => row.type === 'import-token') as { + type: 'import-token' + isFirstInSection: boolean + isLastInSection: boolean + } + + expect(importRow.isFirstInSection).toBe(true) + expect(importRow.isLastInSection).toBe(true) + }) + }) + + describe('Memoization', () => { + it('should return same reference when dependencies do not change', () => { + const matchedTokens = [createToken('TOKEN1', '0x1', 'Token 1')] + const activeList = [createToken('TOKEN2', '0x2', 'Token 2')] + + const { result, rerender } = renderHook( + ({ matchedTokens, activeList }) => + useSearchRows({ + isLoading: false, + matchedTokens, + activeList, + }), + { + initialProps: { matchedTokens, activeList }, + }, + ) + + const firstResult = result.current + + rerender({ matchedTokens, activeList }) + + expect(result.current).toBe(firstResult) + }) + + it('should return new reference when dependencies change', () => { + const matchedTokens1 = [createToken('TOKEN1', '0x1', 'Token 1')] + const matchedTokens2 = [createToken('TOKEN2', '0x2', 'Token 2')] + const activeList = [createToken('TOKEN3', '0x3', 'Token 3')] + + const { result, rerender } = renderHook( + ({ matchedTokens, activeList }) => + useSearchRows({ + isLoading: false, + matchedTokens, + activeList, + }), + { + initialProps: { matchedTokens: matchedTokens1, activeList }, + }, + ) + + const firstResult = result.current + + rerender({ matchedTokens: matchedTokens2, activeList }) + + expect(result.current).not.toBe(firstResult) + expect(result.current.length).toBe(firstResult.length) + }) + + it('should return new reference when isLoading changes', () => { + const matchedTokens = [createToken('TOKEN1', '0x1', 'Token 1')] + const activeList = [createToken('TOKEN2', '0x2', 'Token 2')] + + const { result, rerender } = renderHook( + ({ isLoading, matchedTokens, activeList }) => + useSearchRows({ + isLoading, + matchedTokens, + activeList, + }), + { + initialProps: { isLoading: false, matchedTokens, activeList }, + }, + ) + + const firstResult = result.current + + rerender({ isLoading: true, matchedTokens, activeList }) + + expect(result.current).not.toBe(firstResult) + expect(result.current.length).toBe(0) + }) + }) +}) From 90ccb7986c657fe06cf59e02b915c75894041152 Mon Sep 17 00:00:00 2001 From: fairlighteth <31534717+fairlighteth@users.noreply.github.com> Date: Wed, 3 Dec 2025 15:47:04 +0000 Subject: [PATCH 046/100] refactor: simplify token creation in useSearchRows tests --- .../TokenSearchContent/useSearchRows.test.ts | 118 ++++++++---------- 1 file changed, 55 insertions(+), 63 deletions(-) diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/TokenSearchContent/useSearchRows.test.ts b/apps/cowswap-frontend/src/modules/tokensList/pure/TokenSearchContent/useSearchRows.test.ts index a394cbc2c5b..d62ccb5eeea 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/pure/TokenSearchContent/useSearchRows.test.ts +++ b/apps/cowswap-frontend/src/modules/tokensList/pure/TokenSearchContent/useSearchRows.test.ts @@ -5,30 +5,22 @@ import { renderHook } from '@testing-library/react' import { useSearchRows } from './useSearchRows' -// Helper to create test tokens with valid Ethereum addresses +// Helper to create valid Ethereum addresses +const toAddress = (suffix: string | number): string => `0x${suffix.toString().padStart(40, '0')}` + +// Helper to create test tokens function createToken( symbol: string, - address: string, + addressSuffix: string | number, name: string, chainId: SupportedChainId = SupportedChainId.SEPOLIA, ): TokenWithLogo { - // Ensure address is valid (42 chars: 0x + 40 hex chars) - // If address is too short, pad it with zeros - let validAddress = address - if (address.length < 42) { - const hexPart = address.startsWith('0x') ? address.slice(2) : address - validAddress = `0x${hexPart.padStart(40, '0')}` - } - return new TokenWithLogo(undefined, chainId, validAddress, 18, symbol, name) + return new TokenWithLogo(undefined, chainId, toAddress(addressSuffix), 18, symbol, name) } // Helper to create multiple tokens function createTokens(count: number, prefix = 'TOKEN'): TokenWithLogo[] { - return Array.from({ length: count }, (_, i) => { - // Generate valid 40-char hex address by padding the index - const hexIndex = i.toString(16).padStart(40, '0') - return createToken(`${prefix}${i}`, `0x${hexIndex}`, `${prefix} ${i}`) - }) + return Array.from({ length: count }, (_, i) => createToken(`${prefix}${i}`, i, `${prefix} ${i}`)) } // eslint-disable-next-line max-lines-per-function @@ -38,11 +30,11 @@ describe('useSearchRows', () => { const { result } = renderHook(() => useSearchRows({ isLoading: true, - matchedTokens: [createToken('TOKEN', '0x1', 'Test Token')], - activeList: [createToken('TOKEN2', '0x2', 'Test Token 2')], - blockchainResult: [createToken('TOKEN3', '0x3', 'Test Token 3')], - inactiveListsResult: [createToken('TOKEN4', '0x4', 'Test Token 4')], - externalApiResult: [createToken('TOKEN5', '0x5', 'Test Token 5')], + matchedTokens: [createToken('TOKEN', 1, 'Test Token')], + activeList: [createToken('TOKEN2', 2, 'Test Token 2')], + blockchainResult: [createToken('TOKEN3', 3, 'Test Token 3')], + inactiveListsResult: [createToken('TOKEN4', 4, 'Test Token 4')], + externalApiResult: [createToken('TOKEN5', 5, 'Test Token 5')], }), ) @@ -50,17 +42,17 @@ describe('useSearchRows', () => { }) it('should ignore all token arrays when loading', () => { - const matchedTokens = [createToken('MATCHED', '0x1', 'Matched Token')] - const activeList = [createToken('ACTIVE', '0x2', 'Active Token')] + const matchedTokens = [createToken('MATCHED', 1, 'Matched Token')] + const activeList = [createToken('ACTIVE', 2, 'Active Token')] const { result: loadingResult } = renderHook(() => useSearchRows({ isLoading: true, matchedTokens, activeList, - blockchainResult: [createToken('BLOCKCHAIN', '0x3', 'Blockchain Token')], - inactiveListsResult: [createToken('INACTIVE', '0x4', 'Inactive Token')], - externalApiResult: [createToken('EXTERNAL', '0x5', 'External Token')], + blockchainResult: [createToken('BLOCKCHAIN', 3, 'Blockchain Token')], + inactiveListsResult: [createToken('INACTIVE', 4, 'Inactive Token')], + externalApiResult: [createToken('EXTERNAL', 5, 'External Token')], }), ) @@ -108,8 +100,8 @@ describe('useSearchRows', () => { describe('Matched Tokens', () => { it('should add matched tokens with correct structure', () => { - const token1 = createToken('TOKEN1', '0x1', 'Token 1') - const token2 = createToken('TOKEN2', '0x2', 'Token 2') + const token1 = createToken('TOKEN1', 1, 'Token 1') + const token2 = createToken('TOKEN2', 2, 'Token 2') const { result } = renderHook(() => useSearchRows({ @@ -125,9 +117,9 @@ describe('useSearchRows', () => { }) it('should preserve order of matched tokens', () => { - const token1 = createToken('TOKEN1', '0x1', 'Token 1') - const token2 = createToken('TOKEN2', '0x2', 'Token 2') - const token3 = createToken('TOKEN3', '0x3', 'Token 3') + const token1 = createToken('TOKEN1', 1, 'Token 1') + const token2 = createToken('TOKEN2', 2, 'Token 2') + const token3 = createToken('TOKEN3', 3, 'Token 3') const { result } = renderHook(() => useSearchRows({ @@ -163,8 +155,8 @@ describe('useSearchRows', () => { describe('Active List Tokens', () => { it('should add active list tokens after matched tokens', () => { - const matchedToken = createToken('MATCHED', '0x1', 'Matched Token') - const activeToken = createToken('ACTIVE', '0x2', 'Active Token') + const matchedToken = createToken('MATCHED', 1, 'Matched Token') + const activeToken = createToken('ACTIVE', 2, 'Active Token') const { result } = renderHook(() => useSearchRows({ @@ -180,7 +172,7 @@ describe('useSearchRows', () => { }) it('should have correct structure for active list tokens', () => { - const activeToken = createToken('ACTIVE', '0x1', 'Active Token') + const activeToken = createToken('ACTIVE', 1, 'Active Token') const { result } = renderHook(() => useSearchRows({ @@ -195,7 +187,7 @@ describe('useSearchRows', () => { }) it('should handle empty active list', () => { - const matchedToken = createToken('MATCHED', '0x1', 'Matched Token') + const matchedToken = createToken('MATCHED', 1, 'Matched Token') const { result } = renderHook(() => useSearchRows({ @@ -213,7 +205,7 @@ describe('useSearchRows', () => { describe('Blockchain Import Section', () => { it('should add blockchain tokens when provided', () => { - const blockchainToken = createToken('BLOCKCHAIN', '0x1', 'Blockchain Token') + const blockchainToken = createToken('BLOCKCHAIN', 1, 'Blockchain Token') const { result } = renderHook(() => useSearchRows({ @@ -238,7 +230,7 @@ describe('useSearchRows', () => { }) it('should not add section title for blockchain section', () => { - const blockchainToken = createToken('BLOCKCHAIN', '0x1', 'Blockchain Token') + const blockchainToken = createToken('BLOCKCHAIN', 1, 'Blockchain Token') const { result } = renderHook(() => useSearchRows({ @@ -254,7 +246,7 @@ describe('useSearchRows', () => { }) it('should have shadowed false for blockchain tokens', () => { - const blockchainToken = createToken('BLOCKCHAIN', '0x1', 'Blockchain Token') + const blockchainToken = createToken('BLOCKCHAIN', 1, 'Blockchain Token') const { result } = renderHook(() => useSearchRows({ @@ -274,7 +266,7 @@ describe('useSearchRows', () => { }) it('should have wrapperId currency-import on first blockchain token', () => { - const blockchainToken = createToken('BLOCKCHAIN', '0x1', 'Blockchain Token') + const blockchainToken = createToken('BLOCKCHAIN', 1, 'Blockchain Token') const { result } = renderHook(() => useSearchRows({ @@ -312,7 +304,7 @@ describe('useSearchRows', () => { describe('Inactive Lists Import Section', () => { it('should add section title for inactive lists', () => { - const inactiveToken = createToken('INACTIVE', '0x1', 'Inactive Token') + const inactiveToken = createToken('INACTIVE', 1, 'Inactive Token') const { result } = renderHook(() => useSearchRows({ @@ -334,7 +326,7 @@ describe('useSearchRows', () => { }) it('should include tooltip text for inactive lists', () => { - const inactiveToken = createToken('INACTIVE', '0x1', 'Inactive Token') + const inactiveToken = createToken('INACTIVE', 1, 'Inactive Token') const { result } = renderHook(() => useSearchRows({ @@ -356,7 +348,7 @@ describe('useSearchRows', () => { }) it('should have shadowed true for inactive tokens', () => { - const inactiveToken = createToken('INACTIVE', '0x1', 'Inactive Token') + const inactiveToken = createToken('INACTIVE', 1, 'Inactive Token') const { result } = renderHook(() => useSearchRows({ @@ -376,7 +368,7 @@ describe('useSearchRows', () => { }) it('should have correct section type inactive', () => { - const inactiveToken = createToken('INACTIVE', '0x1', 'Inactive Token') + const inactiveToken = createToken('INACTIVE', 1, 'Inactive Token') const { result } = renderHook(() => useSearchRows({ @@ -414,7 +406,7 @@ describe('useSearchRows', () => { describe('External API Import Section', () => { it('should add section title for external API results', () => { - const externalToken = createToken('EXTERNAL', '0x1', 'External Token') + const externalToken = createToken('EXTERNAL', 1, 'External Token') const { result } = renderHook(() => useSearchRows({ @@ -435,7 +427,7 @@ describe('useSearchRows', () => { }) it('should include tooltip text for external API results', () => { - const externalToken = createToken('EXTERNAL', '0x1', 'External Token') + const externalToken = createToken('EXTERNAL', 1, 'External Token') const { result } = renderHook(() => useSearchRows({ @@ -455,7 +447,7 @@ describe('useSearchRows', () => { }) it('should have shadowed true for external tokens', () => { - const externalToken = createToken('EXTERNAL', '0x1', 'External Token') + const externalToken = createToken('EXTERNAL', 1, 'External Token') const { result } = renderHook(() => useSearchRows({ @@ -475,7 +467,7 @@ describe('useSearchRows', () => { }) it('should have correct section type external', () => { - const externalToken = createToken('EXTERNAL', '0x1', 'External Token') + const externalToken = createToken('EXTERNAL', 1, 'External Token') const { result } = renderHook(() => useSearchRows({ @@ -513,7 +505,7 @@ describe('useSearchRows', () => { describe('Import Section Structure', () => { it('should have isFirstInSection true on first token', () => { - const tokens = [createToken('TOKEN1', '0x1', 'Token 1'), createToken('TOKEN2', '0x2', 'Token 2')] + const tokens = [createToken('TOKEN1', 1, 'Token 1'), createToken('TOKEN2', 2, 'Token 2')] const { result } = renderHook(() => useSearchRows({ @@ -534,7 +526,7 @@ describe('useSearchRows', () => { }) it('should have isLastInSection true on last token', () => { - const tokens = [createToken('TOKEN1', '0x1', 'Token 1'), createToken('TOKEN2', '0x2', 'Token 2')] + const tokens = [createToken('TOKEN1', 1, 'Token 1'), createToken('TOKEN2', 2, 'Token 2')] const { result } = renderHook(() => useSearchRows({ @@ -556,9 +548,9 @@ describe('useSearchRows', () => { it('should have both flags false on middle tokens', () => { const tokens = [ - createToken('TOKEN1', '0x1', 'Token 1'), - createToken('TOKEN2', '0x2', 'Token 2'), - createToken('TOKEN3', '0x3', 'Token 3'), + createToken('TOKEN1', 1, 'Token 1'), + createToken('TOKEN2', 2, 'Token 2'), + createToken('TOKEN3', 3, 'Token 3'), ] const { result } = renderHook(() => @@ -581,7 +573,7 @@ describe('useSearchRows', () => { }) it('should have wrapperId only on first token', () => { - const tokens = [createToken('TOKEN1', '0x1', 'Token 1'), createToken('TOKEN2', '0x2', 'Token 2')] + const tokens = [createToken('TOKEN1', 1, 'Token 1'), createToken('TOKEN2', 2, 'Token 2')] const { result } = renderHook(() => useSearchRows({ @@ -602,9 +594,9 @@ describe('useSearchRows', () => { }) it('should have correct section type on each import token', () => { - const blockchainToken = createToken('BLOCKCHAIN', '0x1', 'Blockchain Token') - const inactiveToken = createToken('INACTIVE', '0x2', 'Inactive Token') - const externalToken = createToken('EXTERNAL', '0x3', 'External Token') + const blockchainToken = createToken('BLOCKCHAIN', 1, 'Blockchain Token') + const inactiveToken = createToken('INACTIVE', 2, 'Inactive Token') + const externalToken = createToken('EXTERNAL', 3, 'External Token') const { result } = renderHook(() => useSearchRows({ @@ -680,7 +672,7 @@ describe('useSearchRows', () => { }) it('should handle single token in section with both first and last flags true', () => { - const singleToken = createToken('SINGLE', '0x1', 'Single Token') + const singleToken = createToken('SINGLE', 1, 'Single Token') const { result } = renderHook(() => useSearchRows({ @@ -704,8 +696,8 @@ describe('useSearchRows', () => { describe('Memoization', () => { it('should return same reference when dependencies do not change', () => { - const matchedTokens = [createToken('TOKEN1', '0x1', 'Token 1')] - const activeList = [createToken('TOKEN2', '0x2', 'Token 2')] + const matchedTokens = [createToken('TOKEN1', 1, 'Token 1')] + const activeList = [createToken('TOKEN2', 2, 'Token 2')] const { result, rerender } = renderHook( ({ matchedTokens, activeList }) => @@ -727,9 +719,9 @@ describe('useSearchRows', () => { }) it('should return new reference when dependencies change', () => { - const matchedTokens1 = [createToken('TOKEN1', '0x1', 'Token 1')] - const matchedTokens2 = [createToken('TOKEN2', '0x2', 'Token 2')] - const activeList = [createToken('TOKEN3', '0x3', 'Token 3')] + const matchedTokens1 = [createToken('TOKEN1', 1, 'Token 1')] + const matchedTokens2 = [createToken('TOKEN2', 2, 'Token 2')] + const activeList = [createToken('TOKEN3', 3, 'Token 3')] const { result, rerender } = renderHook( ({ matchedTokens, activeList }) => @@ -752,8 +744,8 @@ describe('useSearchRows', () => { }) it('should return new reference when isLoading changes', () => { - const matchedTokens = [createToken('TOKEN1', '0x1', 'Token 1')] - const activeList = [createToken('TOKEN2', '0x2', 'Token 2')] + const matchedTokens = [createToken('TOKEN1', 1, 'Token 1')] + const activeList = [createToken('TOKEN2', 2, 'Token 2')] const { result, rerender } = renderHook( ({ isLoading, matchedTokens, activeList }) => From bcd476265652d75f0bb11d7c376ecd4f59930a4f Mon Sep 17 00:00:00 2001 From: fairlighteth <31534717+fairlighteth@users.noreply.github.com> Date: Wed, 3 Dec 2025 16:51:41 +0000 Subject: [PATCH 047/100] refactor: replace legacy chain selector function with a dedicated component --- .../pure/SelectTokenModal/TokenColumnContent.tsx | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/TokenColumnContent.tsx b/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/TokenColumnContent.tsx index 0d2c51b9de6..5949943c12a 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/TokenColumnContent.tsx +++ b/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/TokenColumnContent.tsx @@ -56,16 +56,18 @@ export function TokenColumnContent({ return ( <> - {renderLegacyChainSelector(chainsToSelect, onSelectChain)} + {children} ) } -function renderLegacyChainSelector( - chainsToSelect: ChainsToSelectState | undefined, - onSelectChain: (chain: ChainInfo) => void, -): ReactNode { +interface LegacyChainSelectorProps { + chainsToSelect: ChainsToSelectState | undefined + onSelectChain: (chain: ChainInfo) => void +} + +function LegacyChainSelector({ chainsToSelect, onSelectChain }: LegacyChainSelectorProps): ReactNode { if (!chainsToSelect?.chains?.length) { return null } From 9e5e616c7939b5302320b7cba44681efcacb62cd Mon Sep 17 00:00:00 2001 From: fairlighteth <31534717+fairlighteth@users.noreply.github.com> Date: Wed, 3 Dec 2025 17:19:09 +0000 Subject: [PATCH 048/100] refactor: streamline ChainsSelector component and enhance loading state management --- .../tokensList/pure/ChainsSelector/index.tsx | 153 ++++++------------ .../tokensList/pure/ChainsSelector/styled.tsx | 11 +- .../pure/SelectTokenModal/index.tsx | 13 +- .../tokensList/pure/TokensContent/index.tsx | 32 ++-- 4 files changed, 81 insertions(+), 128 deletions(-) diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/ChainsSelector/index.tsx b/apps/cowswap-frontend/src/modules/tokensList/pure/ChainsSelector/index.tsx index 7a12265d19f..89322ec525c 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/pure/ChainsSelector/index.tsx +++ b/apps/cowswap-frontend/src/modules/tokensList/pure/ChainsSelector/index.tsx @@ -3,7 +3,7 @@ import { ReactNode } from 'react' import OrderCheckIcon from '@cowprotocol/assets/cow-swap/order-check.svg' import { useTheme } from '@cowprotocol/common-hooks' import { ChainInfo, SupportedChainId } from '@cowprotocol/cow-sdk' -import { UI } from '@cowprotocol/ui' +import { getChainAccentColors } from '@cowprotocol/ui' import SVG from 'react-inlinesvg' @@ -14,64 +14,6 @@ import type { ChainAccentVars } from './styled' const LOADING_ITEMS_COUNT = 10 const LOADING_SKELETON_INDICES = Array.from({ length: LOADING_ITEMS_COUNT }, (_, index) => index) -const CHAIN_ACCENT_VAR_MAP: Record = { - [SupportedChainId.MAINNET]: { - backgroundVar: UI.COLOR_CHAIN_ETHEREUM_BG, - borderVar: UI.COLOR_CHAIN_ETHEREUM_BORDER, - accentColorVar: UI.COLOR_CHAIN_ETHEREUM_ACCENT, - }, - [SupportedChainId.BNB]: { - backgroundVar: UI.COLOR_CHAIN_BNB_BG, - borderVar: UI.COLOR_CHAIN_BNB_BORDER, - accentColorVar: UI.COLOR_CHAIN_BNB_ACCENT, - }, - [SupportedChainId.BASE]: { - backgroundVar: UI.COLOR_CHAIN_BASE_BG, - borderVar: UI.COLOR_CHAIN_BASE_BORDER, - accentColorVar: UI.COLOR_CHAIN_BASE_ACCENT, - }, - [SupportedChainId.ARBITRUM_ONE]: { - backgroundVar: UI.COLOR_CHAIN_ARBITRUM_BG, - borderVar: UI.COLOR_CHAIN_ARBITRUM_BORDER, - accentColorVar: UI.COLOR_CHAIN_ARBITRUM_ACCENT, - }, - [SupportedChainId.POLYGON]: { - backgroundVar: UI.COLOR_CHAIN_POLYGON_BG, - borderVar: UI.COLOR_CHAIN_POLYGON_BORDER, - accentColorVar: UI.COLOR_CHAIN_POLYGON_ACCENT, - }, - [SupportedChainId.AVALANCHE]: { - backgroundVar: UI.COLOR_CHAIN_AVALANCHE_BG, - borderVar: UI.COLOR_CHAIN_AVALANCHE_BORDER, - accentColorVar: UI.COLOR_CHAIN_AVALANCHE_ACCENT, - }, - [SupportedChainId.GNOSIS_CHAIN]: { - backgroundVar: UI.COLOR_CHAIN_GNOSIS_BG, - borderVar: UI.COLOR_CHAIN_GNOSIS_BORDER, - accentColorVar: UI.COLOR_CHAIN_GNOSIS_ACCENT, - }, - [SupportedChainId.LENS]: { - backgroundVar: UI.COLOR_CHAIN_LENS_BG, - borderVar: UI.COLOR_CHAIN_LENS_BORDER, - accentColorVar: UI.COLOR_CHAIN_LENS_ACCENT, - }, - [SupportedChainId.SEPOLIA]: { - backgroundVar: UI.COLOR_CHAIN_SEPOLIA_BG, - borderVar: UI.COLOR_CHAIN_SEPOLIA_BORDER, - accentColorVar: UI.COLOR_CHAIN_SEPOLIA_ACCENT, - }, - [SupportedChainId.LINEA]: { - backgroundVar: UI.COLOR_CHAIN_LINEA_BG, - borderVar: UI.COLOR_CHAIN_LINEA_BORDER, - accentColorVar: UI.COLOR_CHAIN_LINEA_ACCENT, - }, - [SupportedChainId.PLASMA]: { - backgroundVar: UI.COLOR_CHAIN_PLASMA_BG, - borderVar: UI.COLOR_CHAIN_PLASMA_BORDER, - accentColorVar: UI.COLOR_CHAIN_PLASMA_ACCENT, - }, -} - export interface ChainsSelectorProps { chains: ChainInfo[] onSelectChain: (chainId: ChainInfo) => void @@ -92,24 +34,24 @@ export function ChainsSelector({ chains, onSelectChain, defaultChainId, isLoadin } function ChainsLoadingList(): ReactNode { - const skeletonRows = renderChainSkeletonRows() - - return {skeletonRows} + return ( + + + + ) } -function renderChainSkeletonRows(): ReactNode[] { - const elements: ReactNode[] = [] - - for (const index of LOADING_SKELETON_INDICES) { - elements.push( - - - - , - ) - } - - return elements +function ChainsSkeletonList(): ReactNode { + return ( + <> + {LOADING_SKELETON_INDICES.map((index) => ( + + + + + ))} + + ) } interface ChainsListProps { @@ -120,38 +62,47 @@ interface ChainsListProps { } function ChainsList({ chains, defaultChainId, onSelectChain, isDarkMode }: ChainsListProps): ReactNode { - const chainButtons = renderChainButtons({ chains, defaultChainId, onSelectChain, isDarkMode }) - - return {chainButtons} -} - -interface ChainButtonsRenderProps extends ChainsListProps {} - -function renderChainButtons({ - chains, - defaultChainId, - onSelectChain, - isDarkMode, -}: ChainButtonsRenderProps): ReactNode[] { - const elements: ReactNode[] = [] - - for (const chain of chains) { - elements.push( - + , - ) - } + /> + + ) +} - return elements +function ChainsButtonsList({ chains, defaultChainId, onSelectChain, isDarkMode }: ChainsListProps): ReactNode { + return ( + <> + {chains.map((chain) => ( + + ))} + + ) } export function getChainAccent(chainId: ChainInfo['id']): ChainAccentVars | undefined { - return CHAIN_ACCENT_VAR_MAP[chainId as SupportedChainId] + + const accentConfig = getChainAccentColors(chainId as SupportedChainId) + if (!accentConfig) { + return undefined + } + + + return { + backgroundVar: accentConfig.bgVar, + borderVar: accentConfig.borderVar, + accentColorVar: accentConfig.accentVar, + } } interface ChainButtonProps { diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/ChainsSelector/styled.tsx b/apps/cowswap-frontend/src/modules/tokensList/pure/ChainsSelector/styled.tsx index 9391d6976f3..a446b5c62c9 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/pure/ChainsSelector/styled.tsx +++ b/apps/cowswap-frontend/src/modules/tokensList/pure/ChainsSelector/styled.tsx @@ -5,9 +5,9 @@ import styled from 'styled-components/macro' import { blankButtonMixin } from '../commonElements' export interface ChainAccentVars { - backgroundVar: UI - borderVar: UI - accentColorVar?: UI + backgroundVar: string + borderVar: string + accentColorVar: string } const fallbackBackground = `var(${UI.COLOR_PRIMARY_OPACITY_10})` @@ -21,7 +21,7 @@ const getBorder = (accent$?: ChainAccentVars, fallback = fallbackBorder): string accent$ ? `var(${accent$.borderVar})` : fallback const getAccentColor = (accent$?: ChainAccentVars): string | undefined => - accent$?.accentColorVar ? `var(${accent$.accentColorVar})` : undefined + accent$ ? `var(${accent$.accentColorVar})` : undefined export const List = styled.div` display: flex; @@ -98,7 +98,8 @@ export const ActiveIcon = styled.span<{ accent$?: ChainAccentVars; color$?: stri display: flex; align-items: center; justify-content: center; - color: ${({ color$, accent$ }) => getAccentColor(accent$) ?? color$ ?? getBorder(accent$, `var(${UI.COLOR_PRIMARY})`)}; + color: ${({ color$, accent$ }) => + getAccentColor(accent$) ?? color$ ?? getBorder(accent$, `var(${UI.COLOR_PRIMARY})`)}; > svg { width: 16px; diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/index.tsx b/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/index.tsx index 38442da13d1..ff4de3fa279 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/index.tsx +++ b/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/index.tsx @@ -1,4 +1,4 @@ -import { ReactNode } from 'react' +import { ReactNode, useMemo } from 'react' import { SearchInput } from '@cowprotocol/ui' @@ -188,10 +188,13 @@ function useSelectTokenModalLayout(props: SelectTokenModalProps): { const legacyChainsState = !showChainPanel && chainsToSelect && (chainsToSelect.chains?.length ?? 0) > 0 ? chainsToSelect : undefined const resolvedChainPanelTitle = chainsPanelTitle ?? t`Cross chain swap` - const chainPanel = - showChainPanel && chainsToSelect ? ( - - ) : null + const chainPanel = useMemo( + () => + showChainPanel && chainsToSelect ? ( + + ) : null, + [chainsToSelect, onSelectChain, resolvedChainPanelTitle, showChainPanel], + ) return { inputValue, diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/TokensContent/index.tsx b/apps/cowswap-frontend/src/modules/tokensList/pure/TokensContent/index.tsx index cd8bb607702..633e78de5c3 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/pure/TokensContent/index.tsx +++ b/apps/cowswap-frontend/src/modules/tokensList/pure/TokensContent/index.tsx @@ -75,24 +75,22 @@ export function TokensContent({ const favoriteTokensInline = shouldShowFavoritesInline ? favoriteTokens : undefined const recentTokensInline = shouldShowRecentsInline ? recentTokens : undefined - const tokensView = renderTokensView({ - areTokensLoading, - searchInput, - selectTokenContext, - areTokensFromBridge, - allTokens, - tokensWithoutPinned, - displayLpTokenLists, - favoriteTokens: favoriteTokensInline, - recentTokens: recentTokensInline, - hideFavoriteTokensTooltip, - selectedTargetChainId, - onClearRecentTokens, - }) - return ( <> - {tokensView} + {!standalone && ( <> @@ -125,7 +123,7 @@ interface TokensViewProps { onClearRecentTokens?: () => void } -function renderTokensView({ +function TokensView({ areTokensLoading, searchInput, selectTokenContext, From b533601fd4659c513869757e1a4de03d30b61778 Mon Sep 17 00:00:00 2001 From: fairlighteth <31534717+fairlighteth@users.noreply.github.com> Date: Wed, 3 Dec 2025 17:21:58 +0000 Subject: [PATCH 049/100] refactor: clean up getChainAccent function by removing unnecessary whitespace --- .../src/modules/tokensList/pure/ChainsSelector/index.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/ChainsSelector/index.tsx b/apps/cowswap-frontend/src/modules/tokensList/pure/ChainsSelector/index.tsx index 89322ec525c..6fb87f833ed 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/pure/ChainsSelector/index.tsx +++ b/apps/cowswap-frontend/src/modules/tokensList/pure/ChainsSelector/index.tsx @@ -91,13 +91,11 @@ function ChainsButtonsList({ chains, defaultChainId, onSelectChain, isDarkMode } } export function getChainAccent(chainId: ChainInfo['id']): ChainAccentVars | undefined { - const accentConfig = getChainAccentColors(chainId as SupportedChainId) if (!accentConfig) { return undefined } - return { backgroundVar: accentConfig.bgVar, borderVar: accentConfig.borderVar, From 2aff08664c14256ea850845eb372314909dd49b6 Mon Sep 17 00:00:00 2001 From: fairlighteth <31534717+fairlighteth@users.noreply.github.com> Date: Wed, 3 Dec 2025 17:27:01 +0000 Subject: [PATCH 050/100] test: add unit tests for getChainAccent function to validate chain accent color mappings --- .../ChainsSelector/getChainAccent.test.ts | 156 ++++++++++++++++++ 1 file changed, 156 insertions(+) create mode 100644 apps/cowswap-frontend/src/modules/tokensList/pure/ChainsSelector/getChainAccent.test.ts diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/ChainsSelector/getChainAccent.test.ts b/apps/cowswap-frontend/src/modules/tokensList/pure/ChainsSelector/getChainAccent.test.ts new file mode 100644 index 00000000000..69401f5cf6e --- /dev/null +++ b/apps/cowswap-frontend/src/modules/tokensList/pure/ChainsSelector/getChainAccent.test.ts @@ -0,0 +1,156 @@ +import { SupportedChainId } from '@cowprotocol/cow-sdk' +import { getChainAccentColors } from '@cowprotocol/ui' + +import { getChainAccent } from './index' + +jest.mock('@cowprotocol/ui', () => ({ + ...jest.requireActual('@cowprotocol/ui'), + getChainAccentColors: jest.fn(), +})) + +const mockGetChainAccentColors = getChainAccentColors as jest.MockedFunction + +describe('getChainAccent', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it('should return ChainAccentVars for valid chain ID', () => { + const mockAccentConfig = { + chainId: SupportedChainId.MAINNET, + bgVar: '--cow-color-chain-ethereum-bg', + borderVar: '--cow-color-chain-ethereum-border', + accentVar: '--cow-color-chain-ethereum-accent', + lightBg: 'rgba(98, 126, 234, 0.22)', + darkBg: 'rgba(98, 126, 234, 0.32)', + lightBorder: 'rgba(98, 126, 234, 0.45)', + darkBorder: 'rgba(98, 126, 234, 0.65)', + lightColor: '#627EEA', + darkColor: '#627EEA', + } + + mockGetChainAccentColors.mockReturnValue(mockAccentConfig) + + const result = getChainAccent(SupportedChainId.MAINNET) + + expect(result).toEqual({ + backgroundVar: '--cow-color-chain-ethereum-bg', + borderVar: '--cow-color-chain-ethereum-border', + accentColorVar: '--cow-color-chain-ethereum-accent', + }) + expect(mockGetChainAccentColors).toHaveBeenCalledWith(SupportedChainId.MAINNET) + }) + + it('should return ChainAccentVars for different chain IDs', () => { + const mockAccentConfig = { + chainId: SupportedChainId.POLYGON, + bgVar: '--cow-color-chain-polygon-bg', + borderVar: '--cow-color-chain-polygon-border', + accentVar: '--cow-color-chain-polygon-accent', + lightBg: 'rgba(130, 71, 229, 0.22)', + darkBg: 'rgba(130, 71, 229, 0.32)', + lightBorder: 'rgba(130, 71, 229, 0.45)', + darkBorder: 'rgba(130, 71, 229, 0.65)', + lightColor: '#8247E5', + darkColor: '#8247E5', + } + + mockGetChainAccentColors.mockReturnValue(mockAccentConfig) + + const result = getChainAccent(SupportedChainId.POLYGON) + + expect(result).toEqual({ + backgroundVar: '--cow-color-chain-polygon-bg', + borderVar: '--cow-color-chain-polygon-border', + accentColorVar: '--cow-color-chain-polygon-accent', + }) + expect(mockGetChainAccentColors).toHaveBeenCalledWith(SupportedChainId.POLYGON) + }) + + it('should return undefined when getChainAccentColors returns undefined', () => { + mockGetChainAccentColors.mockReturnValue(undefined as unknown as ReturnType) + + const result = getChainAccent(999 as SupportedChainId) + + expect(result).toBeUndefined() + expect(mockGetChainAccentColors).toHaveBeenCalledWith(999) + }) + + it('should return undefined when getChainAccentColors returns null', () => { + mockGetChainAccentColors.mockReturnValue(null as unknown as ReturnType) + + const result = getChainAccent(SupportedChainId.MAINNET) + + expect(result).toBeUndefined() + }) + + it('should correctly map all ChainAccentConfig properties to ChainAccentVars', () => { + const mockAccentConfig = { + chainId: SupportedChainId.ARBITRUM_ONE, + bgVar: '--cow-color-chain-arbitrum-bg', + borderVar: '--cow-color-chain-arbitrum-border', + accentVar: '--cow-color-chain-arbitrum-accent', + lightBg: 'rgba(27, 74, 221, 0.22)', + darkBg: 'rgba(27, 74, 221, 0.32)', + lightBorder: 'rgba(27, 74, 221, 0.45)', + darkBorder: 'rgba(27, 74, 221, 0.65)', + lightColor: '#1B4ADD', + darkColor: '#1B4ADD', + } + + mockGetChainAccentColors.mockReturnValue(mockAccentConfig) + + const result = getChainAccent(SupportedChainId.ARBITRUM_ONE) + + expect(result).toBeDefined() + expect(result).toHaveProperty('backgroundVar', mockAccentConfig.bgVar) + expect(result).toHaveProperty('borderVar', mockAccentConfig.borderVar) + expect(result).toHaveProperty('accentColorVar', mockAccentConfig.accentVar) + expect(result).not.toHaveProperty('chainId') + expect(result).not.toHaveProperty('lightBg') + expect(result).not.toHaveProperty('darkBg') + }) + + it('should handle all supported chain IDs', () => { + const supportedChains = [ + SupportedChainId.MAINNET, + SupportedChainId.BNB, + SupportedChainId.BASE, + SupportedChainId.ARBITRUM_ONE, + SupportedChainId.POLYGON, + SupportedChainId.AVALANCHE, + SupportedChainId.GNOSIS_CHAIN, + SupportedChainId.LENS, + SupportedChainId.SEPOLIA, + SupportedChainId.LINEA, + SupportedChainId.PLASMA, + ] + + supportedChains.forEach((chainId) => { + const mockAccentConfig = { + chainId, + bgVar: `--cow-color-chain-test-bg`, + borderVar: `--cow-color-chain-test-border`, + accentVar: `--cow-color-chain-test-accent`, + lightBg: 'rgba(0, 0, 0, 0.22)', + darkBg: 'rgba(0, 0, 0, 0.32)', + lightBorder: 'rgba(0, 0, 0, 0.45)', + darkBorder: 'rgba(0, 0, 0, 0.65)', + lightColor: '#000000', + darkColor: '#000000', + } + + mockGetChainAccentColors.mockReturnValue(mockAccentConfig) + + const result = getChainAccent(chainId) + + expect(result).toBeDefined() + expect(result).toHaveProperty('backgroundVar') + expect(result).toHaveProperty('borderVar') + expect(result).toHaveProperty('accentColorVar') + expect(typeof result?.backgroundVar).toBe('string') + expect(typeof result?.borderVar).toBe('string') + expect(typeof result?.accentColorVar).toBe('string') + }) + }) +}) From e51d246223364c982dcdd3d8f67fbf67c8d6217a Mon Sep 17 00:00:00 2001 From: fairlighteth <31534717+fairlighteth@users.noreply.github.com> Date: Wed, 3 Dec 2025 17:29:18 +0000 Subject: [PATCH 051/100] refactor: revert po changes --- apps/cowswap-frontend/src/locales/en-US.po | 26 +++------------------- 1 file changed, 3 insertions(+), 23 deletions(-) diff --git a/apps/cowswap-frontend/src/locales/en-US.po b/apps/cowswap-frontend/src/locales/en-US.po index a8e45ca0736..8c753759980 100644 --- a/apps/cowswap-frontend/src/locales/en-US.po +++ b/apps/cowswap-frontend/src/locales/en-US.po @@ -274,10 +274,6 @@ msgstr "replaced" msgid "Bridge via" msgstr "Bridge via" -#: apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/index.tsx -msgid "Select token" -msgstr "Select token" - #: apps/cowswap-frontend/src/modules/trade/pure/LimitOrdersPromoBanner/index.tsx msgid "Trade your way - personalize the interface and customize your limit orders" msgstr "Trade your way - personalize the interface and customize your limit orders" @@ -468,6 +464,7 @@ msgid "View details" msgstr "View details" #: apps/cowswap-frontend/src/modules/application/containers/App/menuConsts.tsx +#: apps/cowswap-frontend/src/modules/tokensList/pure/ChainsSelector/index.tsx msgid "More" msgstr "More" @@ -2249,11 +2246,6 @@ msgstr "Dismiss hiring message" msgid "dialog content" msgstr "dialog content" -#: apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/index.tsx -#: apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/index.tsx -msgid "Cross chain swap" -msgstr "Cross chain swap" - #: apps/cowswap-frontend/src/modules/accountProxy/pure/FAQContent/index.tsx msgid "Since {accountProxyLabelString} is not an upgradeable smart-contract, it can be versioned and there are" msgstr "Since {accountProxyLabelString} is not an upgradeable smart-contract, it can be versioned and there are" @@ -3742,10 +3734,6 @@ msgstr "Create LlamaPay Vesting" msgid "Transaction expiration" msgstr "Transaction expiration" -#: apps/cowswap-frontend/src/modules/tokensList/pure/ChainPanel/index.tsx -msgid "Search network" -msgstr "Search network" - #: apps/cowswap-frontend/src/legacy/components/ErrorBoundary/ChunkLoadError.tsx msgid "CowSwap no connection" msgstr "CowSwap no connection" @@ -4819,10 +4807,6 @@ msgstr "Execution price" msgid "No tokens found" msgstr "No tokens found" -#: apps/cowswap-frontend/src/modules/tokensList/pure/ChainPanel/index.tsx -msgid "No networks available for this trade." -msgstr "No networks available for this trade." - #: apps/cowswap-frontend/src/modules/tokensList/pure/TokenTags/index.tsx msgid "Unsupported" msgstr "Unsupported" @@ -4863,10 +4847,6 @@ msgstr "<0/><1/>Consider waiting for lower network costs.<2/><3/>You may still m msgid "Swapping on" msgstr "Swapping on" -#: apps/cowswap-frontend/src/modules/tokensList/pure/ChainPanel/index.tsx -msgid "No networks match \"{chainQuery}\"." -msgstr "No networks match \"{chainQuery}\"." - #: apps/cowswap-frontend/src/modules/ordersTable/pure/ReceiptModal/OrderTypeField.tsx msgid "(Fill or Kill)" msgstr "(Fill or Kill)" @@ -6008,8 +5988,8 @@ msgid "You sold <0/>" msgstr "You sold <0/>" #: apps/cowswap-frontend/src/modules/tokensList/pure/ChainsSelector/index.tsx -#~ msgid "Less" -#~ msgstr "Less" +msgid "Less" +msgstr "Less" #: libs/hook-dapp-lib/src/hookDappsRegistry.ts msgid "Claim your LlamaPay vesting contract funds" From 9817979d3a2459088ee7869c2dece61ca1f31e71 Mon Sep 17 00:00:00 2001 From: fairlighteth <31534717+fairlighteth@users.noreply.github.com> Date: Thu, 4 Dec 2025 09:43:58 +0000 Subject: [PATCH 052/100] refactor: update TokenLogo component --- libs/tokens/src/pure/TokenLogo/index.tsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/libs/tokens/src/pure/TokenLogo/index.tsx b/libs/tokens/src/pure/TokenLogo/index.tsx index f988f0cd9ff..2e6f7745aed 100644 --- a/libs/tokens/src/pure/TokenLogo/index.tsx +++ b/libs/tokens/src/pure/TokenLogo/index.tsx @@ -75,7 +75,9 @@ function StandardTokenLogo({ setInvalidUrls((state) => ({ ...state, [currentUrl]: true })) }, [currentUrl, setInvalidUrls]) - const actualTokenContent = renderTokenLogoContent({ currentUrl, onError, token, initial }) + const actualTokenContent = ( + + ) if (noWrap) { return actualTokenContent @@ -172,14 +174,14 @@ function useTokenLogoUrl({ token, logoURI, invalidUrls }: TokenLogoUrlOptions): return { currentUrl, initial } } -interface TokenLogoContentOptions { +interface TokenLogoContentProps { currentUrl?: string onError: () => void token?: TokenWithLogo | Currency | null initial?: string } -function renderTokenLogoContent({ currentUrl, onError, token, initial }: TokenLogoContentOptions): ReactNode { +function TokenLogoContent({ currentUrl, onError, token, initial }: TokenLogoContentProps): ReactNode { const address = token && 'address' in token ? token.address : '' if (currentUrl) { From fcae04edce445cbd326aa2308fd7d621ef823ce5 Mon Sep 17 00:00:00 2001 From: fairlighteth <31534717+fairlighteth@users.noreply.github.com> Date: Thu, 4 Dec 2025 09:44:07 +0000 Subject: [PATCH 053/100] fix: disable lint rule for useVirtualizer hook compliance --- apps/cowswap-frontend/src/common/pure/VirtualList/index.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/cowswap-frontend/src/common/pure/VirtualList/index.tsx b/apps/cowswap-frontend/src/common/pure/VirtualList/index.tsx index 41640698b7f..3099f840376 100644 --- a/apps/cowswap-frontend/src/common/pure/VirtualList/index.tsx +++ b/apps/cowswap-frontend/src/common/pure/VirtualList/index.tsx @@ -108,6 +108,8 @@ export function VirtualList({ }, scrollDelay) }, []) + // @tanstack/react-virtual's useVirtualizer hook doesn't fully comply with React hooks rules + // (e.g., it may call hooks conditionally or in a different order), so we need to disable the lint rule // eslint-disable-next-line react-hooks/incompatible-library const virtualizer = useVirtualizer({ getScrollElement: () => parentRef.current, From f86ef33e207a247385808ab393c60cd3b4729740 Mon Sep 17 00:00:00 2001 From: fairlighteth <31534717+fairlighteth@users.noreply.github.com> Date: Thu, 4 Dec 2025 10:53:50 +0000 Subject: [PATCH 054/100] refactor(tokensList): modularize FavoriteTokensList component --- .../pure/FavoriteTokensList/index.tsx | 83 ++++++++++++------- 1 file changed, 53 insertions(+), 30 deletions(-) diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/FavoriteTokensList/index.tsx b/apps/cowswap-frontend/src/modules/tokensList/pure/FavoriteTokensList/index.tsx index 103ceaa01b7..01660bf674d 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/pure/FavoriteTokensList/index.tsx +++ b/apps/cowswap-frontend/src/modules/tokensList/pure/FavoriteTokensList/index.tsx @@ -33,7 +33,9 @@ export function FavoriteTokensList(props: FavoriteTokensListProps): ReactNode { {!hideTooltip && } - {renderFavoriteTokenItems(tokens, selectTokenContext)} + + + ) } @@ -50,38 +52,59 @@ function FavoriteTokensTooltip(): ReactNode { ) } -function renderFavoriteTokenItems(tokens: TokenWithLogo[], context: SelectTokenContext): ReactNode[] { - const { selectedToken } = context +interface FavoriteTokenItemProps { + token: TokenWithLogo + selectTokenContext: SelectTokenContext +} + +function FavoriteTokenItem({ token, selectTokenContext }: FavoriteTokenItemProps): ReactNode { + const { selectedToken, onTokenListItemClick, onSelectToken } = selectTokenContext const selectedAddress = selectedToken ? getCurrencyAddress(selectedToken) : undefined - return tokens.map((token) => { - const isSelected = - !!selectedToken && - token.chainId === selectedToken.chainId && - !!selectedAddress && - areAddressesEqual(token.address, selectedAddress) + const isSelected = + !!selectedToken && + token.chainId === selectedToken.chainId && + !!selectedAddress && + areAddressesEqual(token.address, selectedAddress) - const handleClick = (): void => { - if (isSelected) { - return - } - context.onTokenListItemClick?.(token) - context.onSelectToken(token) + const handleClick = (): void => { + if (isSelected) { + return } + onTokenListItemClick?.(token) + onSelectToken(token) + } - return ( - - - - - ) - }) + return ( + + + + + ) +} + +interface FavoriteTokenItemsProps { + tokens: TokenWithLogo[] + selectTokenContext: SelectTokenContext +} + +function FavoriteTokenItems({ tokens, selectTokenContext }: FavoriteTokenItemsProps): ReactNode { + return ( + <> + {tokens.map((token) => ( + + ))} + + ) } From c03dfdeb81fa51658855ba474c0143491ea3c043 Mon Sep 17 00:00:00 2001 From: fairlighteth <31534717+fairlighteth@users.noreply.github.com> Date: Thu, 4 Dec 2025 10:57:24 +0000 Subject: [PATCH 055/100] refactor(tokensList): replace renderTokensView function with TokensView component --- .../tokensList/pure/TokensContent/index.tsx | 32 +++++++++---------- 1 file changed, 15 insertions(+), 17 deletions(-) diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/TokensContent/index.tsx b/apps/cowswap-frontend/src/modules/tokensList/pure/TokensContent/index.tsx index cd8bb607702..633e78de5c3 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/pure/TokensContent/index.tsx +++ b/apps/cowswap-frontend/src/modules/tokensList/pure/TokensContent/index.tsx @@ -75,24 +75,22 @@ export function TokensContent({ const favoriteTokensInline = shouldShowFavoritesInline ? favoriteTokens : undefined const recentTokensInline = shouldShowRecentsInline ? recentTokens : undefined - const tokensView = renderTokensView({ - areTokensLoading, - searchInput, - selectTokenContext, - areTokensFromBridge, - allTokens, - tokensWithoutPinned, - displayLpTokenLists, - favoriteTokens: favoriteTokensInline, - recentTokens: recentTokensInline, - hideFavoriteTokensTooltip, - selectedTargetChainId, - onClearRecentTokens, - }) - return ( <> - {tokensView} + {!standalone && ( <> @@ -125,7 +123,7 @@ interface TokensViewProps { onClearRecentTokens?: () => void } -function renderTokensView({ +function TokensView({ areTokensLoading, searchInput, selectTokenContext, From 374755b4ba4a568f130e39d2ae38ba0d1b84b014 Mon Sep 17 00:00:00 2001 From: fairlighteth <31534717+fairlighteth@users.noreply.github.com> Date: Thu, 4 Dec 2025 11:06:11 +0000 Subject: [PATCH 056/100] refactor: rename renderValue function to formatValue for clarity --- .../tokensList/containers/LpTokenPage/index.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/cowswap-frontend/src/modules/tokensList/containers/LpTokenPage/index.tsx b/apps/cowswap-frontend/src/modules/tokensList/containers/LpTokenPage/index.tsx index 49ce02305c1..e97ab3b699c 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/containers/LpTokenPage/index.tsx +++ b/apps/cowswap-frontend/src/modules/tokensList/containers/LpTokenPage/index.tsx @@ -20,7 +20,7 @@ import { Wrapper, } from './styled' -function renderValue(value: T | undefined, template: (v: T) => string, defaultValue?: string): string | undefined { +function formatValue(value: T | undefined, template: (v: T) => string, defaultValue?: string): string | undefined { return value ? template(value) : defaultValue } @@ -84,7 +84,7 @@ export function LpTokenPage({ poolAddress, onBack, onDismiss, onSelectToken }: L Fee tier
- {renderValue(info?.feeTier, (t) => `${t}%`, '-')} + {formatValue(info?.feeTier, (t) => `${t}%`, '-')}
@@ -92,7 +92,7 @@ export function LpTokenPage({ poolAddress, onBack, onDismiss, onSelectToken }: L Volume (24h)
- {renderValue(info?.volume24h, (t) => `$${t}`, '-')} + {formatValue(info?.volume24h, (t) => `$${t}`, '-')}
@@ -100,7 +100,7 @@ export function LpTokenPage({ poolAddress, onBack, onDismiss, onSelectToken }: L APR
- {renderValue(info?.apy, (t) => `${t}%`, '-')} + {formatValue(info?.apy, (t) => `${t}%`, '-')}
@@ -108,7 +108,7 @@ export function LpTokenPage({ poolAddress, onBack, onDismiss, onSelectToken }: L TVL
- {renderValue(info?.tvl, (t) => `$${t}`, '-')} + {formatValue(info?.tvl, (t) => `$${t}`, '-')}
From ab0d88fa757b792fdc1ffb6692371f051a34a6f0 Mon Sep 17 00:00:00 2001 From: fairlighteth <31534717+fairlighteth@users.noreply.github.com> Date: Thu, 4 Dec 2025 11:25:42 +0000 Subject: [PATCH 057/100] refactor: implement SelectTokenWidget helper functions --- .../containers/SelectTokenWidget/helpers.tsx | 213 ++++++++++++++++++ .../containers/SelectTokenWidget/index.tsx | 124 ++++------ .../containers/SelectTokenWidget/types.ts | 115 ++++++++++ 3 files changed, 368 insertions(+), 84 deletions(-) create mode 100644 apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/helpers.tsx create mode 100644 apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/types.ts diff --git a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/helpers.tsx b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/helpers.tsx new file mode 100644 index 00000000000..286c81e2cb2 --- /dev/null +++ b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/helpers.tsx @@ -0,0 +1,213 @@ +import { ReactNode } from 'react' + +import { TokenWithLogo } from '@cowprotocol/common-const' + +import { + GetSelectTokenWidgetContentProps, + RenderImportListModalProps, + RenderImportTokenModalProps, + RenderLpTokenPageProps, + RenderManageListsAndTokensProps, + RenderSelectTokenModalProps, +} from './types' + +import { ImportListModal } from '../../pure/ImportListModal' +import { ImportTokenModal } from '../../pure/ImportTokenModal' +import { SelectTokenModal } from '../../pure/SelectTokenModal' +import { LpTokenPage } from '../LpTokenPage' +import { ManageListsAndTokens } from '../ManageListsAndTokens' + +const EMPTY_FAV_TOKENS: TokenWithLogo[] = [] + +function renderImportTokenModal({ + tokenToImport, + onDismiss, + resetTokenImport, + importTokenAndClose, +}: RenderImportTokenModalProps): ReactNode { + return ( + + ) +} + +function renderImportListModal({ + listToImport, + onDismiss, + resetTokenImport, + importListAndBack, +}: RenderImportListModalProps): ReactNode { + return ( + + ) +} + +function renderManageListsAndTokens({ + allTokenLists, + userAddedTokens, + onDismiss, + setIsManageWidgetOpen, +}: RenderManageListsAndTokensProps): ReactNode { + return ( + setIsManageWidgetOpen(false)} + /> + ) +} + +function renderLpTokenPage({ + selectedPoolAddress, + onDismiss, + closePoolPage, + onSelectToken, +}: RenderLpTokenPageProps): ReactNode { + return ( + + ) +} + +function renderSelectTokenModal(props: RenderSelectTokenModalProps): ReactNode { + const { + standalone, + displayLpTokenLists, + unsupportedTokens, + selectedToken, + allTokens, + favoriteTokens, + recentTokens, + balancesState, + permitCompatibleTokens, + onSelectToken, + handleTokenListItemClick, + onInputPressEnter, + onDismiss, + setIsManageWidgetOpen, + isInjectedWidgetMode, + openPoolPage, + tokenListCategoryState, + disableErc20, + account, + chainsToSelect, + onSelectChain, + areTokensLoading, + tokenListTags, + areTokensFromBridge, + isRouteAvailable, + clearRecentTokens, + selectedTargetChainId, + } = props + + return ( + setIsManageWidgetOpen(true)} + hideFavoriteTokensTooltip={isInjectedWidgetMode} + openPoolPage={openPoolPage} + tokenListCategoryState={tokenListCategoryState} + disableErc20={disableErc20} + account={account} + chainsToSelect={chainsToSelect} + onSelectChain={onSelectChain} + areTokensLoading={areTokensLoading} + tokenListTags={tokenListTags} + areTokensFromBridge={areTokensFromBridge} + isRouteAvailable={isRouteAvailable} + onClearRecentTokens={clearRecentTokens} + selectedTargetChainId={selectedTargetChainId} + /> + ) +} + +export function getSelectTokenWidgetContent(props: GetSelectTokenWidgetContentProps): ReactNode { + const { standalone, tokenToImport, listToImport, isManageWidgetOpen, selectedPoolAddress } = props + + if (tokenToImport && !standalone) { + return renderImportTokenModal({ + tokenToImport, + onDismiss: props.onDismiss, + resetTokenImport: props.resetTokenImport, + importTokenAndClose: props.importTokenAndClose, + }) + } + + if (listToImport && !standalone) { + return renderImportListModal({ + listToImport, + onDismiss: props.onDismiss, + resetTokenImport: props.resetTokenImport, + importListAndBack: props.importListAndBack, + }) + } + + if (isManageWidgetOpen && !standalone) { + return renderManageListsAndTokens({ + allTokenLists: props.allTokenLists, + userAddedTokens: props.userAddedTokens, + onDismiss: props.onDismiss, + setIsManageWidgetOpen: props.setIsManageWidgetOpen, + }) + } + + if (selectedPoolAddress) { + return renderLpTokenPage({ + selectedPoolAddress, + onDismiss: props.onDismiss, + closePoolPage: props.closePoolPage, + onSelectToken: props.onSelectToken, + }) + } + + return renderSelectTokenModal({ + standalone: props.standalone, + displayLpTokenLists: props.displayLpTokenLists, + unsupportedTokens: props.unsupportedTokens, + selectedToken: props.selectedToken, + allTokens: props.allTokens, + favoriteTokens: props.favoriteTokens, + recentTokens: props.recentTokens, + balancesState: props.balancesState, + permitCompatibleTokens: props.permitCompatibleTokens, + onSelectToken: props.onSelectToken, + handleTokenListItemClick: props.handleTokenListItemClick, + onInputPressEnter: props.onInputPressEnter, + onDismiss: props.onDismiss, + setIsManageWidgetOpen: props.setIsManageWidgetOpen, + isInjectedWidgetMode: props.isInjectedWidgetMode, + openPoolPage: props.openPoolPage, + tokenListCategoryState: props.tokenListCategoryState, + disableErc20: props.disableErc20, + account: props.account, + chainsToSelect: props.chainsToSelect, + onSelectChain: props.onSelectChain, + areTokensLoading: props.areTokensLoading, + tokenListTags: props.tokenListTags, + areTokensFromBridge: props.areTokensFromBridge, + isRouteAvailable: props.isRouteAvailable, + clearRecentTokens: props.clearRecentTokens, + selectedTargetChainId: props.selectedTargetChainId, + }) +} diff --git a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/index.tsx b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/index.tsx index 6e374bfb0e1..5dcb55fc3eb 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/index.tsx +++ b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/index.tsx @@ -26,6 +26,7 @@ import { useLpTokensWithBalances } from 'modules/yield/shared' import { CowSwapAnalyticsCategory } from 'common/analytics/types' import { getDefaultTokenListCategories } from './getDefaultTokenListCategories' +import { getSelectTokenWidgetContent } from './helpers' import { useChainsToSelect } from '../../hooks/useChainsToSelect' import { useCloseTokenSelectWidget } from '../../hooks/useCloseTokenSelectWidget' @@ -35,11 +36,6 @@ import { useRecentTokens } from '../../hooks/useRecentTokens' import { useSelectTokenWidgetState } from '../../hooks/useSelectTokenWidgetState' import { useTokensToSelect } from '../../hooks/useTokensToSelect' import { useUpdateSelectTokenWidgetState } from '../../hooks/useUpdateSelectTokenWidgetState' -import { ImportListModal } from '../../pure/ImportListModal' -import { ImportTokenModal } from '../../pure/ImportTokenModal' -import { SelectTokenModal } from '../../pure/SelectTokenModal' -import { LpTokenPage } from '../LpTokenPage' -import { ManageListsAndTokens } from '../ManageListsAndTokens' const Wrapper = styled.div` width: 100%; @@ -50,8 +46,6 @@ const Wrapper = styled.div` } ` -const EMPTY_FAV_TOKENS: TokenWithLogo[] = [] - interface SelectTokenWidgetProps { displayLpTokenLists?: boolean standalone?: boolean @@ -182,83 +176,45 @@ export function SelectTokenWidget({ displayLpTokenLists, standalone }: SelectTok return ( - {(() => { - if (tokenToImport && !standalone) { - return ( - - ) - } - - if (listToImport && !standalone) { - return ( - - ) - } - - if (isManageWidgetOpen && !standalone) { - return ( - setIsManageWidgetOpen(false)} - /> - ) - } - - if (selectedPoolAddress) { - return ( - - ) - } - - return ( - setIsManageWidgetOpen(true)} - hideFavoriteTokensTooltip={isInjectedWidgetMode} - openPoolPage={openPoolPage} - tokenListCategoryState={tokenListCategoryState} - disableErc20={disableErc20} - account={account} - chainsToSelect={chainsToSelect} - onSelectChain={onSelectChain} - areTokensLoading={areTokensLoading} - tokenListTags={tokenListTags} - areTokensFromBridge={areTokensFromBridge} - isRouteAvailable={isRouteAvailable} - onClearRecentTokens={clearRecentTokens} - selectedTargetChainId={selectedTargetChainId} - /> - ) - })()} + {getSelectTokenWidgetContent({ + standalone, + tokenToImport, + listToImport, + isManageWidgetOpen, + selectedPoolAddress, + displayLpTokenLists, + unsupportedTokens, + selectedToken, + allTokens, + favoriteTokens, + recentTokens, + balancesState, + permitCompatibleTokens, + onSelectToken, + handleTokenListItemClick, + onInputPressEnter, + onDismiss, + setIsManageWidgetOpen, + resetTokenImport, + importTokenAndClose, + closePoolPage, + importListAndBack, + isInjectedWidgetMode, + openPoolPage, + tokenListCategoryState, + disableErc20, + account, + chainsToSelect, + onSelectChain, + areTokensLoading, + tokenListTags, + areTokensFromBridge, + isRouteAvailable, + clearRecentTokens, + selectedTargetChainId, + allTokenLists, + userAddedTokens, + })} ) } diff --git a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/types.ts b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/types.ts new file mode 100644 index 00000000000..35578288c2a --- /dev/null +++ b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/types.ts @@ -0,0 +1,115 @@ +import { BalancesState } from '@cowprotocol/balances-and-allowances' +import { TokenWithLogo } from '@cowprotocol/common-const' +import { ChainInfo } from '@cowprotocol/cow-sdk' +import { + ListState, + TokenListCategory, + TokenListTags, + UnsupportedTokensState, + useAllListsList, +} from '@cowprotocol/tokens' +import { Currency } from '@uniswap/sdk-core' + +import { Nullish } from 'types' + +import { PermitCompatibleTokens } from 'modules/permit' + +import { ChainsToSelectState } from '../../types' + +export interface GetSelectTokenWidgetContentProps { + standalone?: boolean + tokenToImport?: TokenWithLogo + listToImport?: ListState + isManageWidgetOpen: boolean + selectedPoolAddress?: string + displayLpTokenLists?: boolean + unsupportedTokens: UnsupportedTokensState + selectedToken?: Nullish + allTokens: TokenWithLogo[] + favoriteTokens: TokenWithLogo[] + recentTokens?: TokenWithLogo[] + balancesState: BalancesState + permitCompatibleTokens: PermitCompatibleTokens + onSelectToken: (token: TokenWithLogo) => void + handleTokenListItemClick: (token: TokenWithLogo) => void + onInputPressEnter?: () => void + onDismiss: () => void + setIsManageWidgetOpen: (open: boolean) => void + resetTokenImport: () => void + importTokenAndClose: (tokens: TokenWithLogo[]) => void + closePoolPage: () => void + importListAndBack: (list: ListState) => void + isInjectedWidgetMode: boolean + openPoolPage: (poolAddress: string) => void + tokenListCategoryState: [TokenListCategory[] | null, (category: TokenListCategory[] | null) => void] + disableErc20: boolean + account: string | undefined + chainsToSelect: ChainsToSelectState | undefined + onSelectChain: (chain: ChainInfo) => void + areTokensLoading: boolean + tokenListTags: TokenListTags + areTokensFromBridge: boolean + isRouteAvailable: boolean | undefined + clearRecentTokens: () => void + selectedTargetChainId?: number + allTokenLists: ReturnType + userAddedTokens: TokenWithLogo[] +} + +export interface RenderImportTokenModalProps { + tokenToImport: TokenWithLogo + onDismiss: () => void + resetTokenImport: () => void + importTokenAndClose: (tokens: TokenWithLogo[]) => void +} + +export interface RenderImportListModalProps { + listToImport: ListState + onDismiss: () => void + resetTokenImport: () => void + importListAndBack: (list: ListState) => void +} + +export interface RenderManageListsAndTokensProps { + allTokenLists: ReturnType + userAddedTokens: TokenWithLogo[] + onDismiss: () => void + setIsManageWidgetOpen: (open: boolean) => void +} + +export interface RenderLpTokenPageProps { + selectedPoolAddress: string + onDismiss: () => void + closePoolPage: () => void + onSelectToken: (token: TokenWithLogo) => void +} + +export interface RenderSelectTokenModalProps { + standalone?: boolean + displayLpTokenLists?: boolean + unsupportedTokens: UnsupportedTokensState + selectedToken?: Nullish + allTokens: TokenWithLogo[] + favoriteTokens: TokenWithLogo[] + recentTokens?: TokenWithLogo[] + balancesState: BalancesState + permitCompatibleTokens: PermitCompatibleTokens + onSelectToken: (token: TokenWithLogo) => void + handleTokenListItemClick: (token: TokenWithLogo) => void + onInputPressEnter?: () => void + onDismiss: () => void + setIsManageWidgetOpen: (open: boolean) => void + isInjectedWidgetMode: boolean + openPoolPage: (poolAddress: string) => void + tokenListCategoryState: [TokenListCategory[] | null, (category: TokenListCategory[] | null) => void] + disableErc20: boolean + account: string | undefined + chainsToSelect: ChainsToSelectState | undefined + onSelectChain: (chain: ChainInfo) => void + areTokensLoading: boolean + tokenListTags: TokenListTags + areTokensFromBridge: boolean + isRouteAvailable: boolean | undefined + clearRecentTokens: () => void + selectedTargetChainId?: number +} From b34da00edd2064fc63ff448a0de86e5666cc348d Mon Sep 17 00:00:00 2001 From: fairlighteth <31534717+fairlighteth@users.noreply.github.com> Date: Thu, 4 Dec 2025 12:00:04 +0000 Subject: [PATCH 058/100] feat(token-selector): implement SelectTokenWidget helper functions and types --- .../containers/SelectTokenWidget/helpers.tsx | 219 ++++++++++++++++++ .../containers/SelectTokenWidget/index.tsx | 129 ++++------- .../containers/SelectTokenWidget/types.ts | 119 ++++++++++ 3 files changed, 380 insertions(+), 87 deletions(-) create mode 100644 apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/helpers.tsx create mode 100644 apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/types.ts diff --git a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/helpers.tsx b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/helpers.tsx new file mode 100644 index 00000000000..acf335ac6b5 --- /dev/null +++ b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/helpers.tsx @@ -0,0 +1,219 @@ +import { ReactNode } from 'react' + +import { TokenWithLogo } from '@cowprotocol/common-const' + +import { + GetSelectTokenWidgetContentProps, + RenderImportListModalProps, + RenderImportTokenModalProps, + RenderLpTokenPageProps, + RenderManageListsAndTokensProps, + RenderSelectTokenModalProps, +} from './types' + +import { ImportListModal } from '../../pure/ImportListModal' +import { ImportTokenModal } from '../../pure/ImportTokenModal' +import { SelectTokenModal } from '../../pure/SelectTokenModal' +import { LpTokenPage } from '../LpTokenPage' +import { ManageListsAndTokens } from '../ManageListsAndTokens' + +const EMPTY_FAV_TOKENS: TokenWithLogo[] = [] + +function renderImportTokenModal({ + tokenToImport, + onDismiss, + resetTokenImport, + importTokenAndClose, +}: RenderImportTokenModalProps): ReactNode { + return ( + + ) +} + +function renderImportListModal({ + listToImport, + onDismiss, + resetTokenImport, + importListAndBack, +}: RenderImportListModalProps): ReactNode { + return ( + + ) +} + +function renderManageListsAndTokens({ + allTokenLists, + userAddedTokens, + onDismiss, + setIsManageWidgetOpen, +}: RenderManageListsAndTokensProps): ReactNode { + return ( + setIsManageWidgetOpen(false)} + /> + ) +} + +function renderLpTokenPage({ + selectedPoolAddress, + onDismiss, + closePoolPage, + onSelectToken, +}: RenderLpTokenPageProps): ReactNode { + return ( + + ) +} + +function renderSelectTokenModal(props: RenderSelectTokenModalProps): ReactNode { + const { + standalone, + displayLpTokenLists, + unsupportedTokens, + selectedToken, + allTokens, + favoriteTokens, + recentTokens, + balancesState, + permitCompatibleTokens, + onSelectToken, + handleTokenListItemClick, + onInputPressEnter, + onDismiss, + setIsManageWidgetOpen, + isInjectedWidgetMode, + openPoolPage, + tokenListCategoryState, + disableErc20, + account, + chainsToSelect, + onSelectChain, + areTokensLoading, + tokenListTags, + areTokensFromBridge, + isRouteAvailable, + clearRecentTokens, + selectedTargetChainId, + hasChainPanel, + chainsPanelTitle, + } = props + + return ( + setIsManageWidgetOpen(true)} + hideFavoriteTokensTooltip={isInjectedWidgetMode} + openPoolPage={openPoolPage} + tokenListCategoryState={tokenListCategoryState} + disableErc20={disableErc20} + account={account} + chainsToSelect={chainsToSelect} + hasChainPanel={hasChainPanel} + chainsPanelTitle={chainsPanelTitle} + onSelectChain={onSelectChain} + areTokensLoading={areTokensLoading} + tokenListTags={tokenListTags} + areTokensFromBridge={areTokensFromBridge} + isRouteAvailable={isRouteAvailable} + onClearRecentTokens={clearRecentTokens} + selectedTargetChainId={selectedTargetChainId} + /> + ) +} + +export function getSelectTokenWidgetContent(props: GetSelectTokenWidgetContentProps): ReactNode { + const { standalone, tokenToImport, listToImport, isManageWidgetOpen, selectedPoolAddress } = props + + if (tokenToImport && !standalone) { + return renderImportTokenModal({ + tokenToImport, + onDismiss: props.onDismiss, + resetTokenImport: props.resetTokenImport, + importTokenAndClose: props.importTokenAndClose, + }) + } + + if (listToImport && !standalone) { + return renderImportListModal({ + listToImport, + onDismiss: props.onDismiss, + resetTokenImport: props.resetTokenImport, + importListAndBack: props.importListAndBack, + }) + } + + if (isManageWidgetOpen && !standalone) { + return renderManageListsAndTokens({ + allTokenLists: props.allTokenLists, + userAddedTokens: props.userAddedTokens, + onDismiss: props.onDismiss, + setIsManageWidgetOpen: props.setIsManageWidgetOpen, + }) + } + + if (selectedPoolAddress) { + return renderLpTokenPage({ + selectedPoolAddress, + onDismiss: props.onDismiss, + closePoolPage: props.closePoolPage, + onSelectToken: props.onSelectToken, + }) + } + + return renderSelectTokenModal({ + standalone: props.standalone, + displayLpTokenLists: props.displayLpTokenLists, + unsupportedTokens: props.unsupportedTokens, + selectedToken: props.selectedToken, + allTokens: props.allTokens, + favoriteTokens: props.favoriteTokens, + recentTokens: props.recentTokens, + balancesState: props.balancesState, + permitCompatibleTokens: props.permitCompatibleTokens, + onSelectToken: props.onSelectToken, + handleTokenListItemClick: props.handleTokenListItemClick, + onInputPressEnter: props.onInputPressEnter, + onDismiss: props.onDismiss, + setIsManageWidgetOpen: props.setIsManageWidgetOpen, + isInjectedWidgetMode: props.isInjectedWidgetMode, + openPoolPage: props.openPoolPage, + tokenListCategoryState: props.tokenListCategoryState, + disableErc20: props.disableErc20, + account: props.account, + chainsToSelect: props.chainsToSelect, + onSelectChain: props.onSelectChain, + areTokensLoading: props.areTokensLoading, + tokenListTags: props.tokenListTags, + areTokensFromBridge: props.areTokensFromBridge, + isRouteAvailable: props.isRouteAvailable, + clearRecentTokens: props.clearRecentTokens, + selectedTargetChainId: props.selectedTargetChainId, + hasChainPanel: props.hasChainPanel, + chainsPanelTitle: props.chainsPanelTitle, + }) +} diff --git a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/index.tsx b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/index.tsx index 903ec8998f7..c6e1f155139 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/index.tsx +++ b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/index.tsx @@ -25,10 +25,10 @@ import { useTokensBalancesCombined } from 'modules/combinedBalances' import { usePermitCompatibleTokens } from 'modules/permit' import { useLpTokensWithBalances } from 'modules/yield/shared' - import { CowSwapAnalyticsCategory } from 'common/analytics/types' import { getDefaultTokenListCategories } from './getDefaultTokenListCategories' +import { getSelectTokenWidgetContent } from './helpers' import { useChainsToSelect } from '../../hooks/useChainsToSelect' import { useCloseTokenSelectWidget } from '../../hooks/useCloseTokenSelectWidget' @@ -38,11 +38,6 @@ import { useRecentTokens } from '../../hooks/useRecentTokens' import { useSelectTokenWidgetState } from '../../hooks/useSelectTokenWidgetState' import { useTokensToSelect } from '../../hooks/useTokensToSelect' import { useUpdateSelectTokenWidgetState } from '../../hooks/useUpdateSelectTokenWidgetState' -import { ImportListModal } from '../../pure/ImportListModal' -import { ImportTokenModal } from '../../pure/ImportTokenModal' -import { SelectTokenModal } from '../../pure/SelectTokenModal' -import { LpTokenPage } from '../LpTokenPage' -import { ManageListsAndTokens } from '../ManageListsAndTokens' const Wrapper = styled.div` width: 100%; @@ -53,8 +48,6 @@ const Wrapper = styled.div` } ` -const EMPTY_FAV_TOKENS: TokenWithLogo[] = [] - interface SelectTokenWidgetProps { displayLpTokenLists?: boolean standalone?: boolean @@ -189,85 +182,47 @@ export function SelectTokenWidget({ displayLpTokenLists, standalone }: SelectTok return ( - {(() => { - if (tokenToImport && !standalone) { - return ( - - ) - } - - if (listToImport && !standalone) { - return ( - - ) - } - - if (isManageWidgetOpen && !standalone) { - return ( - setIsManageWidgetOpen(false)} - /> - ) - } - - if (selectedPoolAddress) { - return ( - - ) - } - - return ( - setIsManageWidgetOpen(true)} - hideFavoriteTokensTooltip={isInjectedWidgetMode} - openPoolPage={openPoolPage} - tokenListCategoryState={tokenListCategoryState} - disableErc20={disableErc20} - account={account} - chainsToSelect={chainsToSelect} - hasChainPanel={showChainPanel} - chainsPanelTitle={chainsPanelTitle} - onSelectChain={onSelectChain} - areTokensLoading={areTokensLoading} - tokenListTags={tokenListTags} - areTokensFromBridge={areTokensFromBridge} - isRouteAvailable={isRouteAvailable} - onClearRecentTokens={clearRecentTokens} - selectedTargetChainId={selectedTargetChainId} - /> - ) - })()} + {getSelectTokenWidgetContent({ + standalone, + tokenToImport, + listToImport, + isManageWidgetOpen, + selectedPoolAddress, + displayLpTokenLists, + unsupportedTokens, + selectedToken, + allTokens, + favoriteTokens, + recentTokens, + balancesState, + permitCompatibleTokens, + onSelectToken, + handleTokenListItemClick, + onInputPressEnter, + onDismiss, + setIsManageWidgetOpen, + resetTokenImport, + importTokenAndClose, + closePoolPage, + importListAndBack, + isInjectedWidgetMode, + openPoolPage, + tokenListCategoryState, + disableErc20, + account, + chainsToSelect, + onSelectChain, + areTokensLoading, + tokenListTags, + areTokensFromBridge, + isRouteAvailable, + clearRecentTokens, + selectedTargetChainId, + allTokenLists, + userAddedTokens, + hasChainPanel: showChainPanel, + chainsPanelTitle, + })} ) } diff --git a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/types.ts b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/types.ts new file mode 100644 index 00000000000..0ce617f09d5 --- /dev/null +++ b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/types.ts @@ -0,0 +1,119 @@ +import { BalancesState } from '@cowprotocol/balances-and-allowances' +import { TokenWithLogo } from '@cowprotocol/common-const' +import { ChainInfo } from '@cowprotocol/cow-sdk' +import { + ListState, + TokenListCategory, + TokenListTags, + UnsupportedTokensState, + useAllListsList, +} from '@cowprotocol/tokens' +import { Currency } from '@uniswap/sdk-core' + +import { Nullish } from 'types' + +import { PermitCompatibleTokens } from 'modules/permit' + +import { ChainsToSelectState } from '../../types' + +export interface GetSelectTokenWidgetContentProps { + standalone?: boolean + tokenToImport?: TokenWithLogo + listToImport?: ListState + isManageWidgetOpen: boolean + selectedPoolAddress?: string + displayLpTokenLists?: boolean + unsupportedTokens: UnsupportedTokensState + selectedToken?: Nullish + allTokens: TokenWithLogo[] + favoriteTokens: TokenWithLogo[] + recentTokens?: TokenWithLogo[] + balancesState: BalancesState + permitCompatibleTokens: PermitCompatibleTokens + onSelectToken: (token: TokenWithLogo) => void + handleTokenListItemClick: (token: TokenWithLogo) => void + onInputPressEnter?: () => void + onDismiss: () => void + setIsManageWidgetOpen: (open: boolean) => void + resetTokenImport: () => void + importTokenAndClose: (tokens: TokenWithLogo[]) => void + closePoolPage: () => void + importListAndBack: (list: ListState) => void + isInjectedWidgetMode: boolean + openPoolPage: (poolAddress: string) => void + tokenListCategoryState: [TokenListCategory[] | null, (category: TokenListCategory[] | null) => void] + disableErc20: boolean + account: string | undefined + chainsToSelect: ChainsToSelectState | undefined + onSelectChain: (chain: ChainInfo) => void + areTokensLoading: boolean + tokenListTags: TokenListTags + areTokensFromBridge: boolean + isRouteAvailable: boolean | undefined + clearRecentTokens: () => void + selectedTargetChainId?: number + allTokenLists: ReturnType + userAddedTokens: TokenWithLogo[] + hasChainPanel?: boolean + chainsPanelTitle?: string +} + +export interface RenderImportTokenModalProps { + tokenToImport: TokenWithLogo + onDismiss: () => void + resetTokenImport: () => void + importTokenAndClose: (tokens: TokenWithLogo[]) => void +} + +export interface RenderImportListModalProps { + listToImport: ListState + onDismiss: () => void + resetTokenImport: () => void + importListAndBack: (list: ListState) => void +} + +export interface RenderManageListsAndTokensProps { + allTokenLists: ReturnType + userAddedTokens: TokenWithLogo[] + onDismiss: () => void + setIsManageWidgetOpen: (open: boolean) => void +} + +export interface RenderLpTokenPageProps { + selectedPoolAddress: string + onDismiss: () => void + closePoolPage: () => void + onSelectToken: (token: TokenWithLogo) => void +} + +export interface RenderSelectTokenModalProps { + standalone?: boolean + displayLpTokenLists?: boolean + unsupportedTokens: UnsupportedTokensState + selectedToken?: Nullish + allTokens: TokenWithLogo[] + favoriteTokens: TokenWithLogo[] + recentTokens?: TokenWithLogo[] + balancesState: BalancesState + permitCompatibleTokens: PermitCompatibleTokens + onSelectToken: (token: TokenWithLogo) => void + handleTokenListItemClick: (token: TokenWithLogo) => void + onInputPressEnter?: () => void + onDismiss: () => void + setIsManageWidgetOpen: (open: boolean) => void + isInjectedWidgetMode: boolean + openPoolPage: (poolAddress: string) => void + tokenListCategoryState: [TokenListCategory[] | null, (category: TokenListCategory[] | null) => void] + disableErc20: boolean + account: string | undefined + chainsToSelect: ChainsToSelectState | undefined + onSelectChain: (chain: ChainInfo) => void + areTokensLoading: boolean + tokenListTags: TokenListTags + areTokensFromBridge: boolean + isRouteAvailable: boolean | undefined + clearRecentTokens: () => void + selectedTargetChainId?: number + hasChainPanel?: boolean + chainsPanelTitle?: string +} From d77a522c53e29e9d66079bfca8fe40416e789d2b Mon Sep 17 00:00:00 2001 From: fairlighteth <31534717+fairlighteth@users.noreply.github.com> Date: Thu, 4 Dec 2025 15:42:33 +0000 Subject: [PATCH 059/100] fix: revert token list url --- apps/cow-fi/services/tokens/index.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/cow-fi/services/tokens/index.ts b/apps/cow-fi/services/tokens/index.ts index e451439c610..c224613ca93 100644 --- a/apps/cow-fi/services/tokens/index.ts +++ b/apps/cow-fi/services/tokens/index.ts @@ -8,11 +8,12 @@ import path from 'path' import { DATA_CACHE_TIME_SECONDS } from '@/const/meta' import { Network } from '@/const/networkMap' import { PlatformData, Platforms, TokenDetails, TokenInfo } from 'types' +import { COW_CDN } from '@cowprotocol/common-const' const NETWORKS: Network[] = ['ethereum', 'base', 'arbitrum-one', 'avalanche', 'polygon-pos', 'xdai'] const COW_TOKEN_ID = 'cow-protocol' -const TOKEN_LISTS_URL = 'https://files.cow.fi/tokens/cowFi-tokens.json' +const TOKEN_LISTS_URL = `${COW_CDN}/tokens/cowFi-tokens.json` const DESCRIPTIONS_DIR_PATH = path.join(process.cwd(), 'data', 'descriptions') /** From 05327ab9e4e7962f50b4d8cca8f3a1edf6ff5d6e Mon Sep 17 00:00:00 2001 From: fairlighteth <31534717+fairlighteth@users.noreply.github.com> Date: Thu, 4 Dec 2025 16:33:52 +0000 Subject: [PATCH 060/100] feat: enhance localization support in ChainPanel and SelectTokenModal components --- .../src/modules/tokensList/pure/ChainPanel/index.tsx | 7 ++++++- .../pure/SelectTokenModal/MobileChainSelector.tsx | 11 ++++++++--- .../tokensList/pure/SelectTokenModal/helpers.tsx | 6 ++++-- 3 files changed, 18 insertions(+), 6 deletions(-) diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/ChainPanel/index.tsx b/apps/cowswap-frontend/src/modules/tokensList/pure/ChainPanel/index.tsx index bc4ff079993..cfb2395429a 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/pure/ChainPanel/index.tsx +++ b/apps/cowswap-frontend/src/modules/tokensList/pure/ChainPanel/index.tsx @@ -4,6 +4,7 @@ import { ChainInfo } from '@cowprotocol/cow-sdk' import { BackButton } from '@cowprotocol/ui' import { t } from '@lingui/core/macro' +import { Trans } from '@lingui/react/macro' import * as styledEl from './styled' @@ -62,7 +63,11 @@ export function ChainPanel({ onSelectChain={onSelectChain} /> {showUnavailableState && {t`No networks available for this trade.`}} - {showSearchEmptyState && {t`No networks match "${chainQuery}".`}} + {showSearchEmptyState && ( + + No networks match {chainQuery}. + + )} ) diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/MobileChainSelector.tsx b/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/MobileChainSelector.tsx index bae7db6e408..31d8a2f9138 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/MobileChainSelector.tsx +++ b/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/MobileChainSelector.tsx @@ -3,6 +3,8 @@ import { ReactNode, useEffect, useMemo, useRef } from 'react' import { useTheme } from '@cowprotocol/common-hooks' import { ChainInfo } from '@cowprotocol/cow-sdk' +import { msg } from '@lingui/core/macro' +import { Trans, useLingui } from '@lingui/react/macro' import { ChevronDown } from 'react-feather' import * as styledEl from './mobileChainSelector.styled' @@ -24,6 +26,7 @@ export function MobileChainSelector({ onSelectChain, onOpenPanel, }: MobileChainSelectorProps): ReactNode { + const { i18n } = useLingui() const scrollRef = useRef(null) const orderedChains = useMemo( () => @@ -51,7 +54,7 @@ export function MobileChainSelector({ {label} {activeChainLabel ? ( - + {activeChainLabel} ) : null} @@ -72,8 +75,10 @@ export function MobileChainSelector({ ) : null} {totalChains > 0 ? ( - - View all ({totalChains}) + + + View all ({totalChains}) + diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/helpers.tsx b/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/helpers.tsx index 8793810e452..9040feb7968 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/helpers.tsx +++ b/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/helpers.tsx @@ -2,6 +2,8 @@ import { ReactNode, useMemo, useState } from 'react' import { BackButton } from '@cowprotocol/ui' +import { t } from '@lingui/core/macro' + import { SettingsIcon } from 'modules/trade/pure/Settings' import * as styledEl from './styled' @@ -124,8 +126,8 @@ export function TitleBarActions({ From 655226ababaf3c1dffeabd10365416c821d8a9f4 Mon Sep 17 00:00:00 2001 From: fairlighteth <31534717+fairlighteth@users.noreply.github.com> Date: Thu, 4 Dec 2025 16:34:08 +0000 Subject: [PATCH 061/100] fix: update ManageListsAndTokens component to include onDismiss prop --- .../tokensList/containers/SelectTokenWidget/index.tsx | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/index.tsx b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/index.tsx index 0557380f8fc..662d118483a 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/index.tsx +++ b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/index.tsx @@ -192,7 +192,14 @@ function getBlockingView( } if (isManageWidgetOpen && !standalone) { - return + return ( + + ) } if (selectedPoolAddress) { From 4e22948ae18d35f36f9a3778ae7d18e1d920575c Mon Sep 17 00:00:00 2001 From: fairlighteth <31534717+fairlighteth@users.noreply.github.com> Date: Thu, 4 Dec 2025 17:43:27 +0000 Subject: [PATCH 062/100] fix: correct Russian plural form for 'View all {totalChains} networks' MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix Russian translation to use genitive plural 'сетей' instead of 'сети' - Add i18n support for modal titles in controllerState.ts - Add Spanish translations for new token selector strings --- apps/cowswap-frontend/src/locales/es-ES.po | 64 +++++++++++++++++++ apps/cowswap-frontend/src/locales/ru-RU.po | 64 +++++++++++++++++++ .../SelectTokenWidget/controllerState.ts | 6 +- 3 files changed, 131 insertions(+), 3 deletions(-) diff --git a/apps/cowswap-frontend/src/locales/es-ES.po b/apps/cowswap-frontend/src/locales/es-ES.po index 9a8c0a3f783..af9e5001561 100644 --- a/apps/cowswap-frontend/src/locales/es-ES.po +++ b/apps/cowswap-frontend/src/locales/es-ES.po @@ -6294,3 +6294,67 @@ msgstr "Configuración de alertas de trading" msgid "View jobs (opens in a new tab)" msgstr "Ver trabajos (se abre en una pestaña nueva)" + +#: apps/cowswap-frontend/src/modules/tokensList/pure/ChainPanel/index.tsx +msgid "Search network" +msgstr "Buscar red" + +#: apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerState.ts +msgid "Select token" +msgstr "Seleccionar token" + +#: apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerState.ts +msgid "From network" +msgstr "Red de origen" + +#: apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerState.ts +msgid "To network" +msgstr "Red de destino" + +#: apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerState.ts +msgid "Select network" +msgstr "Seleccionar red" + +#: apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/index.tsx +msgid "Cross chain swap" +msgstr "Intercambio entre cadenas" + +#: apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerState.ts +msgid "Swap from" +msgstr "Intercambiar desde" + +#: apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerState.ts +msgid "Swap to" +msgstr "Intercambiar a" + +#: apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerState.ts +msgid "Sell token" +msgstr "Vender token" + +#: apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerState.ts +msgid "Buy token" +msgstr "Comprar token" + +#: apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/helpers.tsx +msgid "Manage token lists" +msgstr "Gestionar listas de tokens" + +#: apps/cowswap-frontend/src/modules/tokensList/pure/ChainPanel/index.tsx +msgid "No networks available for this trade." +msgstr "No hay redes disponibles para este intercambio." + +#: apps/cowswap-frontend/src/modules/tokensList/pure/ChainPanel/index.tsx +msgid "No networks match {chainQuery}." +msgstr "No hay redes que coincidan con {chainQuery}." + +#: apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/MobileChainSelector.tsx +msgid "View all ({totalChains})" +msgstr "Ver todas ({totalChains})" + +#: apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/MobileChainSelector.tsx +msgid "View all {totalChains} networks" +msgstr "Ver todas las {totalChains} redes" + +#: apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/MobileChainSelector.tsx +msgid "Selected network {activeChainLabel}" +msgstr "Red seleccionada {activeChainLabel}" diff --git a/apps/cowswap-frontend/src/locales/ru-RU.po b/apps/cowswap-frontend/src/locales/ru-RU.po index e10761044ca..1b6c84a7137 100644 --- a/apps/cowswap-frontend/src/locales/ru-RU.po +++ b/apps/cowswap-frontend/src/locales/ru-RU.po @@ -6294,3 +6294,67 @@ msgstr "Настройки уведомлений о сделках" msgid "View jobs (opens in a new tab)" msgstr "Просмотр вакансий (откроется в новой вкладке)" + +#: apps/cowswap-frontend/src/modules/tokensList/pure/ChainPanel/index.tsx +msgid "Search network" +msgstr "Поиск сети" + +#: apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerState.ts +msgid "Select token" +msgstr "Выбрать токен" + +#: apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerState.ts +msgid "From network" +msgstr "Исходная сеть" + +#: apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerState.ts +msgid "To network" +msgstr "Целевая сеть" + +#: apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerState.ts +msgid "Select network" +msgstr "Выбрать сеть" + +#: apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/index.tsx +msgid "Cross chain swap" +msgstr "Обмен между сетями" + +#: apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerState.ts +msgid "Swap from" +msgstr "Обменять из" + +#: apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerState.ts +msgid "Swap to" +msgstr "Обменять на" + +#: apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerState.ts +msgid "Sell token" +msgstr "Продать токен" + +#: apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerState.ts +msgid "Buy token" +msgstr "Купить токен" + +#: apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/helpers.tsx +msgid "Manage token lists" +msgstr "Управление списками токенов" + +#: apps/cowswap-frontend/src/modules/tokensList/pure/ChainPanel/index.tsx +msgid "No networks available for this trade." +msgstr "Нет доступных сетей для этой сделки." + +#: apps/cowswap-frontend/src/modules/tokensList/pure/ChainPanel/index.tsx +msgid "No networks match {chainQuery}." +msgstr "Нет сетей, соответствующих {chainQuery}." + +#: apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/MobileChainSelector.tsx +msgid "View all ({totalChains})" +msgstr "Показать все ({totalChains})" + +#: apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/MobileChainSelector.tsx +msgid "View all {totalChains} networks" +msgstr "Показать все {totalChains} сетей" + +#: apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/MobileChainSelector.tsx +msgid "Selected network {activeChainLabel}" +msgstr "Выбранная сеть {activeChainLabel}" diff --git a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerState.ts b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerState.ts index 4a600002a5f..fd62f75f7b5 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerState.ts +++ b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerState.ts @@ -159,14 +159,14 @@ function resolveModalTitle(field: Field, tradeType: TradeType | undefined): stri const isSwapTrade = !tradeType || tradeType === TradeType.SWAP if (field === Field.INPUT) { - return isSwapTrade ? 'Swap from' : 'Sell token' + return isSwapTrade ? t`Swap from` : t`Sell token` } if (field === Field.OUTPUT) { - return isSwapTrade ? 'Swap to' : 'Buy token' + return isSwapTrade ? t`Swap to` : t`Buy token` } - return 'Select token' + return t`Select token` } export function useDismissHandler( From 3ac256bc2988dc3ef24f834b884042e32f869ddb Mon Sep 17 00:00:00 2001 From: fairlighteth <31534717+fairlighteth@users.noreply.github.com> Date: Thu, 4 Dec 2025 17:54:08 +0000 Subject: [PATCH 063/100] fix: update Spanish and Russian translations for cross-chain swap terminology --- apps/cowswap-frontend/src/locales/es-ES.po | 8 ++++---- apps/cowswap-frontend/src/locales/ru-RU.po | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/apps/cowswap-frontend/src/locales/es-ES.po b/apps/cowswap-frontend/src/locales/es-ES.po index af9e5001561..e409add3cf6 100644 --- a/apps/cowswap-frontend/src/locales/es-ES.po +++ b/apps/cowswap-frontend/src/locales/es-ES.po @@ -5198,7 +5198,7 @@ msgstr "parte" #: apps/cowswap-frontend/src/modules/swap/pure/CrossChainUnlockScreen/index.tsx msgid "Cross-chain swaps are here" -msgstr "Los swaps de cadena media están aquí" +msgstr "Los swaps entre cadenas están aquí" #: apps/cowswap-frontend/src/modules/erc20Approve/containers/ApprovalAmountInput/ApprovalAmountInput.tsx #~ msgid "Approval amount:" @@ -6317,15 +6317,15 @@ msgstr "Seleccionar red" #: apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/index.tsx msgid "Cross chain swap" -msgstr "Intercambio entre cadenas" +msgstr "Swap entre cadenas" #: apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerState.ts msgid "Swap from" -msgstr "Intercambiar desde" +msgstr "Swap desde" #: apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerState.ts msgid "Swap to" -msgstr "Intercambiar a" +msgstr "Swap a" #: apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerState.ts msgid "Sell token" diff --git a/apps/cowswap-frontend/src/locales/ru-RU.po b/apps/cowswap-frontend/src/locales/ru-RU.po index 1b6c84a7137..ed4e6c0e990 100644 --- a/apps/cowswap-frontend/src/locales/ru-RU.po +++ b/apps/cowswap-frontend/src/locales/ru-RU.po @@ -5198,7 +5198,7 @@ msgstr "часть" #: apps/cowswap-frontend/src/modules/swap/pure/CrossChainUnlockScreen/index.tsx msgid "Cross-chain swaps are here" -msgstr "Перекрестные цепочки здесь" +msgstr "Межсетевые обмены здесь" #: apps/cowswap-frontend/src/modules/erc20Approve/containers/ApprovalAmountInput/ApprovalAmountInput.tsx #~ msgid "Approval amount:" @@ -6321,7 +6321,7 @@ msgstr "Обмен между сетями" #: apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerState.ts msgid "Swap from" -msgstr "Обменять из" +msgstr "Обменять с" #: apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerState.ts msgid "Swap to" From d4f74609af1288bba5b7d8c51f51b95ed34cf873 Mon Sep 17 00:00:00 2001 From: fairlighteth <31534717+fairlighteth@users.noreply.github.com> Date: Thu, 4 Dec 2025 18:06:27 +0000 Subject: [PATCH 064/100] fix: refine Russian translations for cross-chain swap terminology --- apps/cowswap-frontend/src/locales/ru-RU.po | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/cowswap-frontend/src/locales/ru-RU.po b/apps/cowswap-frontend/src/locales/ru-RU.po index ed4e6c0e990..228953869aa 100644 --- a/apps/cowswap-frontend/src/locales/ru-RU.po +++ b/apps/cowswap-frontend/src/locales/ru-RU.po @@ -6317,15 +6317,15 @@ msgstr "Выбрать сеть" #: apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/index.tsx msgid "Cross chain swap" -msgstr "Обмен между сетями" +msgstr "Свап между сетями" #: apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerState.ts msgid "Swap from" -msgstr "Обменять с" +msgstr "Свап из сети" #: apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerState.ts msgid "Swap to" -msgstr "Обменять на" +msgstr "Свап в сеть" #: apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerState.ts msgid "Sell token" @@ -6353,7 +6353,7 @@ msgstr "Показать все ({totalChains})" #: apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/MobileChainSelector.tsx msgid "View all {totalChains} networks" -msgstr "Показать все {totalChains} сетей" +msgstr "Показать все сети ({totalChains})" #: apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/MobileChainSelector.tsx msgid "Selected network {activeChainLabel}" From 50c46c31b93b28286fca68d76b322046e21eafe1 Mon Sep 17 00:00:00 2001 From: fairlighteth <31534717+fairlighteth@users.noreply.github.com> Date: Thu, 4 Dec 2025 18:08:34 +0000 Subject: [PATCH 065/100] fix: add Spanish and Russian translations for 'Recent' in TokensVirtualList component --- apps/cowswap-frontend/src/locales/es-ES.po | 4 ++++ apps/cowswap-frontend/src/locales/ru-RU.po | 4 ++++ .../tokensList/pure/TokensVirtualList/index.tsx | 14 +++++--------- 3 files changed, 13 insertions(+), 9 deletions(-) diff --git a/apps/cowswap-frontend/src/locales/es-ES.po b/apps/cowswap-frontend/src/locales/es-ES.po index e409add3cf6..178864423fb 100644 --- a/apps/cowswap-frontend/src/locales/es-ES.po +++ b/apps/cowswap-frontend/src/locales/es-ES.po @@ -4199,6 +4199,10 @@ msgstr "Habilitar aprobación parcial" msgid "Version" msgstr "Versión" +#: apps/cowswap-frontend/src/modules/tokensList/pure/TokensVirtualList/index.tsx +msgid "Recent" +msgstr "Recientes" + #: apps/cowswap-frontend/src/pages/Account/Tokens/TokensOverview.tsx msgid "All tokens" msgstr "Todos los tokens" diff --git a/apps/cowswap-frontend/src/locales/ru-RU.po b/apps/cowswap-frontend/src/locales/ru-RU.po index 228953869aa..e97ddbb262d 100644 --- a/apps/cowswap-frontend/src/locales/ru-RU.po +++ b/apps/cowswap-frontend/src/locales/ru-RU.po @@ -4199,6 +4199,10 @@ msgstr "Включить частичные утверждения" msgid "Version" msgstr "Версии" +#: apps/cowswap-frontend/src/modules/tokensList/pure/TokensVirtualList/index.tsx +msgid "Recent" +msgstr "Недавние" + #: apps/cowswap-frontend/src/pages/Account/Tokens/TokensOverview.tsx msgid "All tokens" msgstr "Все токены" diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/TokensVirtualList/index.tsx b/apps/cowswap-frontend/src/modules/tokensList/pure/TokensVirtualList/index.tsx index a4f65bfbaaf..8555d2a8407 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/pure/TokensVirtualList/index.tsx +++ b/apps/cowswap-frontend/src/modules/tokensList/pure/TokensVirtualList/index.tsx @@ -4,6 +4,7 @@ import { TokenWithLogo } from '@cowprotocol/common-const' import { useFeatureFlags } from '@cowprotocol/common-hooks' import { getIsNativeToken } from '@cowprotocol/common-utils' +import { t } from '@lingui/core/macro' import { VirtualItem } from '@tanstack/react-virtual' import { CoWAmmBanner } from 'common/containers/CoWAmmBanner' @@ -63,8 +64,7 @@ export function TokensVirtualList(props: TokensVirtualListProps): ReactNode { } } - const sortedPrioritized = - prioritized.length > 1 ? [...prioritized].sort(tokensListSorter(balances)) : prioritized + const sortedPrioritized = prioritized.length > 1 ? [...prioritized].sort(tokensListSorter(balances)) : prioritized return [...sortedPrioritized, ...remainder] }, [allTokens, balances]) @@ -84,7 +84,7 @@ export function TokensVirtualList(props: TokensVirtualListProps): ReactNode { if (recentTokens?.length) { composedRows.push({ type: 'title', - label: 'Recent', + label: t`Recent`, actionLabel: onClearRecentTokens ? 'Clear' : undefined, onAction: onClearRecentTokens, }) @@ -92,7 +92,7 @@ export function TokensVirtualList(props: TokensVirtualListProps): ReactNode { } if (favoriteTokens?.length || recentTokens?.length) { - composedRows.push({ type: 'title', label: 'All tokens' }) + composedRows.push({ type: 'title', label: t`All tokens` }) } return [...composedRows, ...tokenRows] @@ -129,11 +129,7 @@ function TokensVirtualRowRenderer({ row, selectTokenContext }: TokensVirtualRowR switch (row.type) { case 'favorite-section': return ( - + ) case 'title': return ( From 9a01bf31a97dabef54c6cb99c4675fa623e5b64a Mon Sep 17 00:00:00 2001 From: fairlighteth <31534717+fairlighteth@users.noreply.github.com> Date: Thu, 4 Dec 2025 18:10:09 +0000 Subject: [PATCH 066/100] fix: add Spanish and Russian translations for 'Clear' in TokensVirtualList component --- apps/cowswap-frontend/src/locales/es-ES.po | 4 ++++ apps/cowswap-frontend/src/locales/ru-RU.po | 4 ++++ .../src/modules/tokensList/pure/TokensVirtualList/index.tsx | 2 +- 3 files changed, 9 insertions(+), 1 deletion(-) diff --git a/apps/cowswap-frontend/src/locales/es-ES.po b/apps/cowswap-frontend/src/locales/es-ES.po index 178864423fb..34a64f8fe71 100644 --- a/apps/cowswap-frontend/src/locales/es-ES.po +++ b/apps/cowswap-frontend/src/locales/es-ES.po @@ -4203,6 +4203,10 @@ msgstr "Versión" msgid "Recent" msgstr "Recientes" +#: apps/cowswap-frontend/src/modules/tokensList/pure/TokensVirtualList/index.tsx +msgid "Clear" +msgstr "Borrar" + #: apps/cowswap-frontend/src/pages/Account/Tokens/TokensOverview.tsx msgid "All tokens" msgstr "Todos los tokens" diff --git a/apps/cowswap-frontend/src/locales/ru-RU.po b/apps/cowswap-frontend/src/locales/ru-RU.po index e97ddbb262d..f1e4fba8a4e 100644 --- a/apps/cowswap-frontend/src/locales/ru-RU.po +++ b/apps/cowswap-frontend/src/locales/ru-RU.po @@ -4203,6 +4203,10 @@ msgstr "Версии" msgid "Recent" msgstr "Недавние" +#: apps/cowswap-frontend/src/modules/tokensList/pure/TokensVirtualList/index.tsx +msgid "Clear" +msgstr "Очистить" + #: apps/cowswap-frontend/src/pages/Account/Tokens/TokensOverview.tsx msgid "All tokens" msgstr "Все токены" diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/TokensVirtualList/index.tsx b/apps/cowswap-frontend/src/modules/tokensList/pure/TokensVirtualList/index.tsx index 8555d2a8407..67ce39cf294 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/pure/TokensVirtualList/index.tsx +++ b/apps/cowswap-frontend/src/modules/tokensList/pure/TokensVirtualList/index.tsx @@ -85,7 +85,7 @@ export function TokensVirtualList(props: TokensVirtualListProps): ReactNode { composedRows.push({ type: 'title', label: t`Recent`, - actionLabel: onClearRecentTokens ? 'Clear' : undefined, + actionLabel: onClearRecentTokens ? t`Clear` : undefined, onAction: onClearRecentTokens, }) recentTokens.forEach((token) => composedRows.push({ type: 'token', token })) From 6af13056a9f318b5a8aa70f4634f07c6d978db8c Mon Sep 17 00:00:00 2001 From: fairlighteth <31534717+fairlighteth@users.noreply.github.com> Date: Fri, 5 Dec 2025 08:16:17 +0000 Subject: [PATCH 067/100] refactor: rename and modularize SelectTokenWidget helper functions --- .../containers/SelectTokenWidget/helpers.tsx | 148 ++++++++++-------- .../containers/SelectTokenWidget/index.tsx | 80 +++++----- .../containers/SelectTokenWidget/types.ts | 12 +- .../pure/TokensVirtualList/index.tsx | 13 +- 4 files changed, 129 insertions(+), 124 deletions(-) diff --git a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/helpers.tsx b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/helpers.tsx index 286c81e2cb2..6e46890d05d 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/helpers.tsx +++ b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/helpers.tsx @@ -3,12 +3,12 @@ import { ReactNode } from 'react' import { TokenWithLogo } from '@cowprotocol/common-const' import { - GetSelectTokenWidgetContentProps, - RenderImportListModalProps, - RenderImportTokenModalProps, - RenderLpTokenPageProps, - RenderManageListsAndTokensProps, - RenderSelectTokenModalProps, + ImportListModalContentProps, + ImportTokenModalContentProps, + LpTokenPageContentProps, + ManageListsAndTokensContentProps, + SelectTokenModalContentProps, + SelectTokenWidgetContentProps, } from './types' import { ImportListModal } from '../../pure/ImportListModal' @@ -19,12 +19,12 @@ import { ManageListsAndTokens } from '../ManageListsAndTokens' const EMPTY_FAV_TOKENS: TokenWithLogo[] = [] -function renderImportTokenModal({ +export function ImportTokenModalContent({ tokenToImport, onDismiss, resetTokenImport, importTokenAndClose, -}: RenderImportTokenModalProps): ReactNode { +}: ImportTokenModalContentProps): ReactNode { return ( ) } -function renderManageListsAndTokens({ +export function ManageListsAndTokensContent({ allTokenLists, userAddedTokens, onDismiss, setIsManageWidgetOpen, -}: RenderManageListsAndTokensProps): ReactNode { +}: ManageListsAndTokensContentProps): ReactNode { return ( + ) } if (listToImport && !standalone) { - return renderImportListModal({ - listToImport, - onDismiss: props.onDismiss, - resetTokenImport: props.resetTokenImport, - importListAndBack: props.importListAndBack, - }) + return ( + + ) } if (isManageWidgetOpen && !standalone) { - return renderManageListsAndTokens({ - allTokenLists: props.allTokenLists, - userAddedTokens: props.userAddedTokens, - onDismiss: props.onDismiss, - setIsManageWidgetOpen: props.setIsManageWidgetOpen, - }) + return ( + + ) } if (selectedPoolAddress) { - return renderLpTokenPage({ - selectedPoolAddress, - onDismiss: props.onDismiss, - closePoolPage: props.closePoolPage, - onSelectToken: props.onSelectToken, - }) + return ( + + ) } - return renderSelectTokenModal({ - standalone: props.standalone, - displayLpTokenLists: props.displayLpTokenLists, - unsupportedTokens: props.unsupportedTokens, - selectedToken: props.selectedToken, - allTokens: props.allTokens, - favoriteTokens: props.favoriteTokens, - recentTokens: props.recentTokens, - balancesState: props.balancesState, - permitCompatibleTokens: props.permitCompatibleTokens, - onSelectToken: props.onSelectToken, - handleTokenListItemClick: props.handleTokenListItemClick, - onInputPressEnter: props.onInputPressEnter, - onDismiss: props.onDismiss, - setIsManageWidgetOpen: props.setIsManageWidgetOpen, - isInjectedWidgetMode: props.isInjectedWidgetMode, - openPoolPage: props.openPoolPage, - tokenListCategoryState: props.tokenListCategoryState, - disableErc20: props.disableErc20, - account: props.account, - chainsToSelect: props.chainsToSelect, - onSelectChain: props.onSelectChain, - areTokensLoading: props.areTokensLoading, - tokenListTags: props.tokenListTags, - areTokensFromBridge: props.areTokensFromBridge, - isRouteAvailable: props.isRouteAvailable, - clearRecentTokens: props.clearRecentTokens, - selectedTargetChainId: props.selectedTargetChainId, - }) + return ( + + ) } diff --git a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/index.tsx b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/index.tsx index 5dcb55fc3eb..34473ae7693 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/index.tsx +++ b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/index.tsx @@ -26,7 +26,7 @@ import { useLpTokensWithBalances } from 'modules/yield/shared' import { CowSwapAnalyticsCategory } from 'common/analytics/types' import { getDefaultTokenListCategories } from './getDefaultTokenListCategories' -import { getSelectTokenWidgetContent } from './helpers' +import { SelectTokenWidgetContent } from './helpers' import { useChainsToSelect } from '../../hooks/useChainsToSelect' import { useCloseTokenSelectWidget } from '../../hooks/useCloseTokenSelectWidget' @@ -176,45 +176,45 @@ export function SelectTokenWidget({ displayLpTokenLists, standalone }: SelectTok return ( - {getSelectTokenWidgetContent({ - standalone, - tokenToImport, - listToImport, - isManageWidgetOpen, - selectedPoolAddress, - displayLpTokenLists, - unsupportedTokens, - selectedToken, - allTokens, - favoriteTokens, - recentTokens, - balancesState, - permitCompatibleTokens, - onSelectToken, - handleTokenListItemClick, - onInputPressEnter, - onDismiss, - setIsManageWidgetOpen, - resetTokenImport, - importTokenAndClose, - closePoolPage, - importListAndBack, - isInjectedWidgetMode, - openPoolPage, - tokenListCategoryState, - disableErc20, - account, - chainsToSelect, - onSelectChain, - areTokensLoading, - tokenListTags, - areTokensFromBridge, - isRouteAvailable, - clearRecentTokens, - selectedTargetChainId, - allTokenLists, - userAddedTokens, - })} + ) } diff --git a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/types.ts b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/types.ts index 35578288c2a..041adebed24 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/types.ts +++ b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/types.ts @@ -16,7 +16,7 @@ import { PermitCompatibleTokens } from 'modules/permit' import { ChainsToSelectState } from '../../types' -export interface GetSelectTokenWidgetContentProps { +export interface SelectTokenWidgetContentProps { standalone?: boolean tokenToImport?: TokenWithLogo listToImport?: ListState @@ -56,35 +56,35 @@ export interface GetSelectTokenWidgetContentProps { userAddedTokens: TokenWithLogo[] } -export interface RenderImportTokenModalProps { +export interface ImportTokenModalContentProps { tokenToImport: TokenWithLogo onDismiss: () => void resetTokenImport: () => void importTokenAndClose: (tokens: TokenWithLogo[]) => void } -export interface RenderImportListModalProps { +export interface ImportListModalContentProps { listToImport: ListState onDismiss: () => void resetTokenImport: () => void importListAndBack: (list: ListState) => void } -export interface RenderManageListsAndTokensProps { +export interface ManageListsAndTokensContentProps { allTokenLists: ReturnType userAddedTokens: TokenWithLogo[] onDismiss: () => void setIsManageWidgetOpen: (open: boolean) => void } -export interface RenderLpTokenPageProps { +export interface LpTokenPageContentProps { selectedPoolAddress: string onDismiss: () => void closePoolPage: () => void onSelectToken: (token: TokenWithLogo) => void } -export interface RenderSelectTokenModalProps { +export interface SelectTokenModalContentProps { standalone?: boolean displayLpTokenLists?: boolean unsupportedTokens: UnsupportedTokensState diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/TokensVirtualList/index.tsx b/apps/cowswap-frontend/src/modules/tokensList/pure/TokensVirtualList/index.tsx index a4f65bfbaaf..98b09635f8b 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/pure/TokensVirtualList/index.tsx +++ b/apps/cowswap-frontend/src/modules/tokensList/pure/TokensVirtualList/index.tsx @@ -63,8 +63,7 @@ export function TokensVirtualList(props: TokensVirtualListProps): ReactNode { } } - const sortedPrioritized = - prioritized.length > 1 ? [...prioritized].sort(tokensListSorter(balances)) : prioritized + const sortedPrioritized = prioritized.length > 1 ? [...prioritized].sort(tokensListSorter(balances)) : prioritized return [...sortedPrioritized, ...remainder] }, [allTokens, balances]) @@ -100,7 +99,7 @@ export function TokensVirtualList(props: TokensVirtualListProps): ReactNode { const virtualListKey = scrollResetKey ?? 'tokens-list' - const renderVirtualRow = useCallback( + const getItemView = useCallback( (virtualRows: TokensVirtualRow[], virtualItem: VirtualItem) => ( ), @@ -112,7 +111,7 @@ export function TokensVirtualList(props: TokensVirtualListProps): ReactNode { key={virtualListKey} id="tokens-list" items={rows} - getItemView={renderVirtualRow} + getItemView={getItemView} scrollResetKey={scrollResetKey} > {displayLpTokenLists || !isYieldEnabled ? null : } @@ -129,11 +128,7 @@ function TokensVirtualRowRenderer({ row, selectTokenContext }: TokensVirtualRowR switch (row.type) { case 'favorite-section': return ( - + ) case 'title': return ( From 4f175e1f7f13310cfa87ceb183e0a58aa0b367d1 Mon Sep 17 00:00:00 2001 From: fairlighteth <31534717+fairlighteth@users.noreply.github.com> Date: Fri, 5 Dec 2025 08:42:35 +0000 Subject: [PATCH 068/100] fix: conditionally render SelectTokenWidget based on token selection state --- .../src/modules/trade/containers/TradeWidget/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/cowswap-frontend/src/modules/trade/containers/TradeWidget/index.tsx b/apps/cowswap-frontend/src/modules/trade/containers/TradeWidget/index.tsx index 804ecf787b3..363c7990d99 100644 --- a/apps/cowswap-frontend/src/modules/trade/containers/TradeWidget/index.tsx +++ b/apps/cowswap-frontend/src/modules/trade/containers/TradeWidget/index.tsx @@ -23,7 +23,7 @@ export function TradeWidget(props: TradeWidgetProps): JSX.Element { const isTokenSelectWide = isTokenSelectOpen && !!chainsToSelect && (chainsToSelect.isLoading || (chainsToSelect.chains?.length ?? 0) > 0) - const selectTokenWidgetNode = slots.selectTokenWidget ?? + const selectTokenWidgetNode = isTokenSelectOpen ? (slots.selectTokenWidget ?? ) : null const setShouldUseAutoSlippage = useSetShouldUseAutoSlippage() From a1a9dcf2a6afb07c220cd8d9ec424668a5a85f1c Mon Sep 17 00:00:00 2001 From: fairlighteth <31534717+fairlighteth@users.noreply.github.com> Date: Fri, 5 Dec 2025 15:10:25 +0000 Subject: [PATCH 069/100] fix: update chain selection logic for advanced trade types to prevent unsupported operations --- .../modules/tokensList/hooks/useChainsToSelect.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/apps/cowswap-frontend/src/modules/tokensList/hooks/useChainsToSelect.ts b/apps/cowswap-frontend/src/modules/tokensList/hooks/useChainsToSelect.ts index f59b315a8d4..13efa115839 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/hooks/useChainsToSelect.ts +++ b/apps/cowswap-frontend/src/modules/tokensList/hooks/useChainsToSelect.ts @@ -50,15 +50,15 @@ export function useChainsToSelect(): ChainsToSelectState | undefined { const chainInfo = CHAIN_INFO[chainId] if (!chainInfo) return undefined - const currentChainInfo = mapChainInfo(chainId, chainInfo) - // Limit/TWAP buys must stay on the wallet chain, so skip bridge wiring entirely. - const shouldForceSingleChain = isAdvancedTradeType && field === Field.OUTPUT + // Limit/TWAP orders don't support chain selection - return undefined for both SELL and BUY + // These trade types rely on wallet/header network switcher instead + if (isAdvancedTradeType) { + return undefined + } - if (!isBridgingEnabled && !shouldForceSingleChain) return undefined + const currentChainInfo = mapChainInfo(chainId, chainInfo) - if (shouldForceSingleChain) { - return createSingleChainState(chainId, currentChainInfo) - } + if (!isBridgingEnabled) return undefined if (field === Field.INPUT) { return createInputChainsState(selectedTargetChainId, supportedChains) From 24c41b74bde60e9dafa3e35a797f32f2eaa7c963 Mon Sep 17 00:00:00 2001 From: fairlighteth <31534717+fairlighteth@users.noreply.github.com> Date: Fri, 5 Dec 2025 15:10:56 +0000 Subject: [PATCH 070/100] test: enhance useChainsToSelect tests for various trade types and chain selection scenarios --- .../hooks/useChainsToSelect.test.ts | 172 +++++++++++++++++- 1 file changed, 171 insertions(+), 1 deletion(-) diff --git a/apps/cowswap-frontend/src/modules/tokensList/hooks/useChainsToSelect.test.ts b/apps/cowswap-frontend/src/modules/tokensList/hooks/useChainsToSelect.test.ts index d6547eab754..ca2b35606c3 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/hooks/useChainsToSelect.test.ts +++ b/apps/cowswap-frontend/src/modules/tokensList/hooks/useChainsToSelect.test.ts @@ -1,9 +1,61 @@ import { SupportedChainId } from '@cowprotocol/cow-sdk' +import { useWalletInfo, WalletInfo } from '@cowprotocol/wallet' -import { createInputChainsState, createOutputChainsState } from './useChainsToSelect' +import { renderHook } from '@testing-library/react' +import { Field } from 'legacy/state/types' + +import { TradeType } from 'modules/trade/types' + +import { useChainsToSelect, createInputChainsState, createOutputChainsState } from './useChainsToSelect' +import { useSelectTokenWidgetState } from './useSelectTokenWidgetState' + +import { DEFAULT_SELECT_TOKEN_WIDGET_STATE } from '../state/selectTokenWidgetAtom' import { createChainInfoForTests } from '../test-utils/createChainInfoForTests' +jest.mock('@cowprotocol/wallet', () => ({ + ...jest.requireActual('@cowprotocol/wallet'), + useWalletInfo: jest.fn(), +})) + +jest.mock('@cowprotocol/common-hooks', () => ({ + ...jest.requireActual('@cowprotocol/common-hooks'), + useIsBridgingEnabled: jest.fn(), + useAvailableChains: jest.fn(), + useFeatureFlags: jest.fn(), +})) + +jest.mock('entities/bridgeProvider', () => ({ + ...jest.requireActual('entities/bridgeProvider'), + useBridgeSupportedNetworks: jest.fn(), +})) + +jest.mock('./useSelectTokenWidgetState', () => ({ + ...jest.requireActual('./useSelectTokenWidgetState'), + useSelectTokenWidgetState: jest.fn(), +})) + +const mockUseWalletInfo = useWalletInfo as jest.MockedFunction +const mockUseSelectTokenWidgetState = useSelectTokenWidgetState as jest.MockedFunction + +const { useIsBridgingEnabled, useAvailableChains, useFeatureFlags } = require('@cowprotocol/common-hooks') +const mockUseIsBridgingEnabled = useIsBridgingEnabled as jest.MockedFunction +const mockUseAvailableChains = useAvailableChains as jest.MockedFunction +const mockUseFeatureFlags = useFeatureFlags as jest.MockedFunction + +const { useBridgeSupportedNetworks } = require('entities/bridgeProvider') +const mockUseBridgeSupportedNetworks = useBridgeSupportedNetworks as jest.MockedFunction< + typeof useBridgeSupportedNetworks +> + +type WidgetState = ReturnType +const createWidgetState = (override: Partial): WidgetState => { + return { + ...DEFAULT_SELECT_TOKEN_WIDGET_STATE, + ...override, + } +} + describe('useChainsToSelect state builders', () => { it('sorts sell-side chains using the canonical order', () => { const supportedChains = [ @@ -63,3 +115,121 @@ describe('useChainsToSelect state builders', () => { expect(state.chains?.map((chain) => chain.id)).toEqual([SupportedChainId.SEPOLIA]) }) }) + +describe('useChainsToSelect hook', () => { + beforeEach(() => { + jest.clearAllMocks() + mockUseWalletInfo.mockReturnValue({ chainId: SupportedChainId.MAINNET } as WalletInfo) + mockUseIsBridgingEnabled.mockReturnValue(true) + mockUseAvailableChains.mockReturnValue([SupportedChainId.MAINNET, SupportedChainId.GNOSIS_CHAIN]) + mockUseFeatureFlags.mockReturnValue({ areUnsupportedChainsEnabled: false }) + mockUseBridgeSupportedNetworks.mockReturnValue({ + data: [createChainInfoForTests(SupportedChainId.GNOSIS_CHAIN)], + isLoading: false, + }) + }) + + it('returns undefined for LIMIT_ORDER + OUTPUT (buy token)', () => { + mockUseSelectTokenWidgetState.mockReturnValue( + createWidgetState({ + field: Field.OUTPUT, + tradeType: TradeType.LIMIT_ORDER, + selectedTargetChainId: SupportedChainId.MAINNET, + }), + ) + + const { result } = renderHook(() => useChainsToSelect()) + + expect(result.current).toBeUndefined() + }) + + it('returns undefined for ADVANCED_ORDERS + OUTPUT (buy token)', () => { + mockUseSelectTokenWidgetState.mockReturnValue( + createWidgetState({ + field: Field.OUTPUT, + tradeType: TradeType.ADVANCED_ORDERS, + selectedTargetChainId: SupportedChainId.MAINNET, + }), + ) + + const { result } = renderHook(() => useChainsToSelect()) + + expect(result.current).toBeUndefined() + }) + + it('returns undefined for LIMIT_ORDER + INPUT (sell token)', () => { + mockUseSelectTokenWidgetState.mockReturnValue( + createWidgetState({ + field: Field.INPUT, + tradeType: TradeType.LIMIT_ORDER, + selectedTargetChainId: SupportedChainId.MAINNET, + }), + ) + + const { result } = renderHook(() => useChainsToSelect()) + + expect(result.current).toBeUndefined() + }) + + it('returns undefined for ADVANCED_ORDERS + INPUT (sell token)', () => { + mockUseSelectTokenWidgetState.mockReturnValue( + createWidgetState({ + field: Field.INPUT, + tradeType: TradeType.ADVANCED_ORDERS, + selectedTargetChainId: SupportedChainId.MAINNET, + }), + ) + + const { result } = renderHook(() => useChainsToSelect()) + + expect(result.current).toBeUndefined() + }) + + it('returns chains for SWAP + OUTPUT (buy token)', () => { + // Include Mainnet in bridge data to exercise bridge destinations path + // Use mockReturnValueOnce for test isolation + mockUseBridgeSupportedNetworks.mockReturnValueOnce({ + data: [createChainInfoForTests(SupportedChainId.MAINNET), createChainInfoForTests(SupportedChainId.GNOSIS_CHAIN)], + isLoading: false, + }) + + mockUseSelectTokenWidgetState.mockReturnValue( + createWidgetState({ + field: Field.OUTPUT, + tradeType: TradeType.SWAP, + selectedTargetChainId: SupportedChainId.MAINNET, + }), + ) + + const { result } = renderHook(() => useChainsToSelect()) + + expect(result.current).toBeDefined() + expect(result.current?.chains).toBeDefined() + expect(result.current?.chains?.length).toBeGreaterThan(0) + // Verify defaultChainId matches selectedTargetChainId (confirms bridge path, not fallback) + expect(result.current?.defaultChainId).toBe(SupportedChainId.MAINNET) + // Verify it returns bridge destinations (Gnosis), not single-chain fallback + expect(result.current?.chains?.some((chain) => chain.id === SupportedChainId.GNOSIS_CHAIN)).toBe(true) + }) + + it('returns chains for SWAP + INPUT (sell token)', () => { + mockUseSelectTokenWidgetState.mockReturnValue( + createWidgetState({ + field: Field.INPUT, + tradeType: TradeType.SWAP, + selectedTargetChainId: SupportedChainId.MAINNET, + }), + ) + + const { result } = renderHook(() => useChainsToSelect()) + + expect(result.current).toBeDefined() + expect(result.current?.chains).toBeDefined() + expect(result.current?.chains?.length).toBeGreaterThan(0) + // Verify defaultChainId matches selectedTargetChainId + expect(result.current?.defaultChainId).toBe(SupportedChainId.MAINNET) + // Verify it returns supported chains (Mainnet, Gnosis) + expect(result.current?.chains?.some((chain) => chain.id === SupportedChainId.MAINNET)).toBe(true) + expect(result.current?.chains?.some((chain) => chain.id === SupportedChainId.GNOSIS_CHAIN)).toBe(true) + }) +}) From 0c1ce154e9e2241ec98595de24d16702d5e63607 Mon Sep 17 00:00:00 2001 From: fairlighteth <31534717+fairlighteth@users.noreply.github.com> Date: Fri, 5 Dec 2025 15:27:56 +0000 Subject: [PATCH 071/100] feat: integrate feature flag for bridging enablement in BridgingEnabledUpdater --- .../src/common/updaters/BridgingEnabledUpdater.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/apps/cowswap-frontend/src/common/updaters/BridgingEnabledUpdater.ts b/apps/cowswap-frontend/src/common/updaters/BridgingEnabledUpdater.ts index edfbb1c2812..eff9e558fde 100644 --- a/apps/cowswap-frontend/src/common/updaters/BridgingEnabledUpdater.ts +++ b/apps/cowswap-frontend/src/common/updaters/BridgingEnabledUpdater.ts @@ -1,6 +1,6 @@ import { useEffect } from 'react' -import { useSetIsBridgingEnabled } from '@cowprotocol/common-hooks' +import { useFeatureFlags, useSetIsBridgingEnabled } from '@cowprotocol/common-hooks' import { AccountType } from '@cowprotocol/types' import { useAccountType, useWalletInfo } from '@cowprotocol/wallet' @@ -13,11 +13,13 @@ export function BridgingEnabledUpdater(): null { const tradeTypeInfo = useTradeTypeInfo() const accountType = useAccountType() const setIsBridgingEnabled = useSetIsBridgingEnabled() + const { isBridgingEnabled: isBridgingEnabledFlag } = useFeatureFlags() const isSwapRoute = tradeTypeInfo?.route === Routes.SWAP - + // Feature flag off should disable bridging regardless of route/wallet compatibility. + const isFeatureFlagEnabled = isBridgingEnabledFlag === undefined ? true : Boolean(isBridgingEnabledFlag) const isWalletCompatible = Boolean(account ? accountType !== AccountType.SMART_CONTRACT : true) - const shouldEnableBridging = isWalletCompatible && isSwapRoute + const shouldEnableBridging = isWalletCompatible && isSwapRoute && isFeatureFlagEnabled useEffect(() => { setIsBridgingEnabled(shouldEnableBridging) From 9eea62d5ff449ef87a1f0c7e3e346e47b8c510a0 Mon Sep 17 00:00:00 2001 From: fairlighteth <31534717+fairlighteth@users.noreply.github.com> Date: Fri, 5 Dec 2025 15:31:40 +0000 Subject: [PATCH 072/100] test: add unit tests for BridgingEnabledUpdater component to validate feature flag behavior --- .../updaters/BridgingEnabledUpdater.test.tsx | 63 +++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 apps/cowswap-frontend/src/common/updaters/BridgingEnabledUpdater.test.tsx diff --git a/apps/cowswap-frontend/src/common/updaters/BridgingEnabledUpdater.test.tsx b/apps/cowswap-frontend/src/common/updaters/BridgingEnabledUpdater.test.tsx new file mode 100644 index 00000000000..549220a4881 --- /dev/null +++ b/apps/cowswap-frontend/src/common/updaters/BridgingEnabledUpdater.test.tsx @@ -0,0 +1,63 @@ +import { AccountType } from '@cowprotocol/types' + +import { render } from '@testing-library/react' + +import { BridgingEnabledUpdater } from './BridgingEnabledUpdater' + +import { Routes } from '../constants/routes' + +jest.mock('@cowprotocol/common-hooks', () => ({ + ...jest.requireActual('@cowprotocol/common-hooks'), + useSetIsBridgingEnabled: jest.fn(), + useFeatureFlags: jest.fn(), +})) + +jest.mock('@cowprotocol/wallet', () => ({ + ...jest.requireActual('@cowprotocol/wallet'), + useWalletInfo: jest.fn(), + useAccountType: jest.fn(), +})) + +jest.mock('modules/trade', () => ({ + ...jest.requireActual('modules/trade'), + useTradeTypeInfo: jest.fn(), +})) + +const { useSetIsBridgingEnabled, useFeatureFlags } = require('@cowprotocol/common-hooks') +const mockUseSetIsBridgingEnabled = useSetIsBridgingEnabled as jest.MockedFunction +const mockUseFeatureFlags = useFeatureFlags as jest.MockedFunction +const { useWalletInfo, useAccountType } = require('@cowprotocol/wallet') + +const mockUseWalletInfo = useWalletInfo as jest.MockedFunction +const mockUseAccountType = useAccountType as jest.MockedFunction +const { useTradeTypeInfo } = require('modules/trade') +const mockUseTradeTypeInfo = useTradeTypeInfo as jest.MockedFunction + +describe('BridgingEnabledUpdater', () => { + const setIsBridgingEnabled = jest.fn() + + beforeEach(() => { + jest.clearAllMocks() + mockUseSetIsBridgingEnabled.mockReturnValue(setIsBridgingEnabled) + mockUseFeatureFlags.mockReturnValue({}) + mockUseWalletInfo.mockReturnValue({ account: '0x123' }) + mockUseAccountType.mockReturnValue(AccountType.EOA) + mockUseTradeTypeInfo.mockReturnValue({ route: Routes.SWAP }) + }) + + it('disables bridging when the feature flag is false', () => { + mockUseFeatureFlags.mockReturnValue({ isBridgingEnabled: false }) + + render() + + expect(setIsBridgingEnabled).toHaveBeenCalledWith(false) + }) + + it('enables bridging on swap route when the feature flag is true or undefined', () => { + mockUseFeatureFlags.mockReturnValue({ isBridgingEnabled: true }) + + render() + + expect(setIsBridgingEnabled).toHaveBeenCalledWith(true) + }) +}) From 7ae47c0010e44657c1fd5a263ec1707e6c5d072a Mon Sep 17 00:00:00 2001 From: fairlighteth <31534717+fairlighteth@users.noreply.github.com> Date: Fri, 5 Dec 2025 18:11:25 +0000 Subject: [PATCH 073/100] feat: add support for disabled chain IDs in token selection logic --- .../hooks/useChainsToSelect.test.ts | 15 ++- .../tokensList/hooks/useChainsToSelect.ts | 119 +++++++++++------- .../tokensList/pure/ChainPanel/index.tsx | 1 + .../tokensList/pure/ChainsSelector/index.tsx | 56 +++++++-- .../tokensList/pure/ChainsSelector/styled.tsx | 18 +-- .../SelectTokenModal/MobileChainSelector.tsx | 16 ++- .../SelectTokenModal/TokenColumnContent.tsx | 1 + .../mobileChainSelector.styled.ts | 8 +- .../src/modules/tokensList/types.ts | 1 + 9 files changed, 166 insertions(+), 69 deletions(-) diff --git a/apps/cowswap-frontend/src/modules/tokensList/hooks/useChainsToSelect.test.ts b/apps/cowswap-frontend/src/modules/tokensList/hooks/useChainsToSelect.test.ts index ca2b35606c3..a9fb920a740 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/hooks/useChainsToSelect.test.ts +++ b/apps/cowswap-frontend/src/modules/tokensList/hooks/useChainsToSelect.test.ts @@ -86,7 +86,6 @@ describe('useChainsToSelect state builders', () => { chainId: SupportedChainId.MAINNET, currentChainInfo: createChainInfoForTests(SupportedChainId.MAINNET), bridgeSupportedNetworks: bridgeChains, - areUnsupportedChainsEnabled: true, isLoading: false, }) @@ -98,7 +97,7 @@ describe('useChainsToSelect state builders', () => { ]) }) - it('falls back to wallet chain when bridge does not support the source chain', () => { + it('returns all bridge destinations even when source chain is not supported by bridge', () => { const state = createOutputChainsState({ selectedTargetChainId: SupportedChainId.BASE, chainId: SupportedChainId.SEPOLIA, @@ -107,12 +106,20 @@ describe('useChainsToSelect state builders', () => { createChainInfoForTests(SupportedChainId.MAINNET), createChainInfoForTests(SupportedChainId.ARBITRUM_ONE), ], - areUnsupportedChainsEnabled: true, isLoading: false, }) + // Default to source chain when the selected target isn't available expect(state.defaultChainId).toBe(SupportedChainId.SEPOLIA) - expect(state.chains?.map((chain) => chain.id)).toEqual([SupportedChainId.SEPOLIA]) + // Should show all destinations plus source, but destinations disabled when source not supported + expect(state.chains?.map((chain) => chain.id)).toEqual([ + SupportedChainId.MAINNET, + SupportedChainId.ARBITRUM_ONE, + SupportedChainId.SEPOLIA, + ]) + expect(state.disabledChainIds?.has(SupportedChainId.MAINNET)).toBe(true) + expect(state.disabledChainIds?.has(SupportedChainId.ARBITRUM_ONE)).toBe(true) + expect(state.disabledChainIds?.has(SupportedChainId.SEPOLIA)).toBe(false) }) }) diff --git a/apps/cowswap-frontend/src/modules/tokensList/hooks/useChainsToSelect.ts b/apps/cowswap-frontend/src/modules/tokensList/hooks/useChainsToSelect.ts index 13efa115839..73982f63f43 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/hooks/useChainsToSelect.ts +++ b/apps/cowswap-frontend/src/modules/tokensList/hooks/useChainsToSelect.ts @@ -1,7 +1,7 @@ import { useMemo } from 'react' import { CHAIN_INFO } from '@cowprotocol/common-const' -import { useAvailableChains, useFeatureFlags, useIsBridgingEnabled } from '@cowprotocol/common-hooks' +import { useAvailableChains, useIsBridgingEnabled } from '@cowprotocol/common-hooks' import { ChainInfo, SupportedChainId } from '@cowprotocol/cow-sdk' import { useWalletInfo } from '@cowprotocol/wallet' @@ -27,7 +27,6 @@ export function useChainsToSelect(): ChainsToSelectState | undefined { const { chainId } = useWalletInfo() const { field, selectedTargetChainId = chainId, tradeType } = useSelectTokenWidgetState() const { data: bridgeSupportedNetworks, isLoading } = useBridgeSupportedNetworks() - const { areUnsupportedChainsEnabled } = useFeatureFlags() const isBridgingEnabled = useIsBridgingEnabled() const availableChains = useAvailableChains() const isAdvancedTradeType = tradeType === TradeType.LIMIT_ORDER || tradeType === TradeType.ADVANCED_ORDERS @@ -44,7 +43,8 @@ export function useChainsToSelect(): ChainsToSelectState | undefined { }, [] as ChainInfo[]) }, [availableChains]) - return useMemo(() => { + // Compute output chains state for BUY token selection + const outputChainsState = useMemo(() => { if (!field || !chainId) return undefined const chainInfo = CHAIN_INFO[chainId] @@ -56,20 +56,19 @@ export function useChainsToSelect(): ChainsToSelectState | undefined { return undefined } - const currentChainInfo = mapChainInfo(chainId, chainInfo) - if (!isBridgingEnabled) return undefined if (field === Field.INPUT) { - return createInputChainsState(selectedTargetChainId, supportedChains) + return undefined } + const currentChainInfo = mapChainInfo(chainId, chainInfo) + return createOutputChainsState({ selectedTargetChainId, chainId, currentChainInfo, bridgeSupportedNetworks, - areUnsupportedChainsEnabled, isLoading, }) }, [ @@ -79,36 +78,47 @@ export function useChainsToSelect(): ChainsToSelectState | undefined { bridgeSupportedNetworks, isLoading, isBridgingEnabled, - areUnsupportedChainsEnabled, - supportedChains, isAdvancedTradeType, ]) -} -function filterDestinationChains( - bridgeSupportedNetworks: ChainInfo[] | undefined, - areUnsupportedChainsEnabled: boolean | undefined, -): ChainInfo[] | undefined { - if (areUnsupportedChainsEnabled) { - // Nothing to filter, we return all bridge supported networks - return bridgeSupportedNetworks - } else { - // If unsupported chains are not enabled, we only return the supported networks - return bridgeSupportedNetworks?.filter((chain) => chain.id in SupportedChainId) - } + return useMemo(() => { + if (!field || !chainId) return undefined + + const chainInfo = CHAIN_INFO[chainId] + if (!chainInfo) return undefined + + // Limit/TWAP orders don't support chain selection - return undefined for both SELL and BUY + // These trade types rely on wallet/header network switcher instead + if (isAdvancedTradeType) { + return undefined + } + + if (!isBridgingEnabled) return undefined + + if (field === Field.INPUT) { + return createInputChainsState(selectedTargetChainId, supportedChains) + } + + // For BUY token selection, include disabled chains info + if (outputChainsState) { + return outputChainsState + } + + return undefined + }, [ + field, + selectedTargetChainId, + chainId, + isBridgingEnabled, + supportedChains, + isAdvancedTradeType, + outputChainsState, + ]) } -// Represents the “non-bridge” UX (bridging disabled or advanced-trade guardrail) where only the current chain is available. -function createSingleChainState( - defaultChainId: SupportedChainId | number, - chain: ChainInfo, - isLoading = false, -): ChainsToSelectState { - return { - defaultChainId, - chains: [chain], - isLoading, - } +function filterDestinationChains(bridgeSupportedNetworks: ChainInfo[] | undefined): ChainInfo[] | undefined { + // Show only chains the app supports. + return bridgeSupportedNetworks?.filter((chain) => chain.id in SupportedChainId) } // Sell-side selector intentionally limits chains to the wallet-supported list; bridge destinations never appear here. @@ -128,7 +138,6 @@ interface CreateOutputChainsOptions { chainId: SupportedChainId currentChainInfo: ChainInfo bridgeSupportedNetworks: ChainInfo[] | undefined - areUnsupportedChainsEnabled: boolean | undefined isLoading: boolean } @@ -137,24 +146,44 @@ export function createOutputChainsState({ chainId, currentChainInfo, bridgeSupportedNetworks, - areUnsupportedChainsEnabled, isLoading, }: CreateOutputChainsOptions): ChainsToSelectState { - const destinationChains = filterDestinationChains(bridgeSupportedNetworks, areUnsupportedChainsEnabled) ?? [] - const orderedDestinationChains = sortChainsByDisplayOrder(destinationChains) - const isSourceChainSupportedByBridge = Boolean( - bridgeSupportedNetworks?.some((bridgeChain) => bridgeChain.id === chainId), - ) - - if (!isSourceChainSupportedByBridge) { - // Source chain is unsupported by the bridge provider; fall back to non-bridge behavior. - return createSingleChainState(chainId, currentChainInfo) + const destinationChains = filterDestinationChains(bridgeSupportedNetworks) ?? [] + const isSourceChainSupportedByBridge = Boolean(destinationChains.some((chain) => chain.id === chainId)) + + if (process.env.NODE_ENV !== 'production') { + const destinationIds = destinationChains.map((c) => c.id) + const bridgeIds = bridgeSupportedNetworks?.map((c) => c.id) + + console.debug('[useChainsToSelect] output chains', { + sourceChainId: chainId, + selectedTargetChainId, + bridgeSupportedNetworkIds: bridgeIds, + filteredDestinationIds: destinationIds, + isSourceChainSupportedByBridge, + }) } + // Always include the current chain for same-chain swaps (no bridging required) + const chainSet = new Set(destinationChains.map((chain) => chain.id)) + const chainsWithCurrent = chainSet.has(chainId) ? destinationChains : [currentChainInfo, ...destinationChains] + + const orderedDestinationChains = sortChainsByDisplayOrder(chainsWithCurrent) + const hasSelectedTarget = orderedDestinationChains.some((chain) => chain.id === selectedTargetChainId) + const fallbackChainId = + orderedDestinationChains.find((chain) => chain.id === chainId)?.id ?? orderedDestinationChains[0]?.id + const resolvedDefaultChainId = hasSelectedTarget ? selectedTargetChainId : fallbackChainId + const disabledChainIds = isSourceChainSupportedByBridge + ? undefined + : new Set(destinationChains.map((c) => c.id)) + + // Always return bridgeSupportedNetworks (filtered by feature flag) for BUY, + // even if the source lacks bridge support, so all destinations show (disabled when unsupported). + // Current chain is always included for same-chain swaps. return { - defaultChainId: selectedTargetChainId, - // Bridge supports this chain, so expose the provider-supplied destinations. + defaultChainId: resolvedDefaultChainId, chains: orderedDestinationChains, isLoading, + disabledChainIds, } } diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/ChainPanel/index.tsx b/apps/cowswap-frontend/src/modules/tokensList/pure/ChainPanel/index.tsx index cfb2395429a..d893332aca9 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/pure/ChainPanel/index.tsx +++ b/apps/cowswap-frontend/src/modules/tokensList/pure/ChainPanel/index.tsx @@ -61,6 +61,7 @@ export function ChainPanel({ chains={filteredChains} defaultChainId={chainsState?.defaultChainId} onSelectChain={onSelectChain} + disabledChainIds={chainsState?.disabledChainIds} /> {showUnavailableState && {t`No networks available for this trade.`}} {showSearchEmptyState && ( diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/ChainsSelector/index.tsx b/apps/cowswap-frontend/src/modules/tokensList/pure/ChainsSelector/index.tsx index 6fb87f833ed..07ce95791ff 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/pure/ChainsSelector/index.tsx +++ b/apps/cowswap-frontend/src/modules/tokensList/pure/ChainsSelector/index.tsx @@ -5,6 +5,8 @@ import { useTheme } from '@cowprotocol/common-hooks' import { ChainInfo, SupportedChainId } from '@cowprotocol/cow-sdk' import { getChainAccentColors } from '@cowprotocol/ui' +import { msg } from '@lingui/core/macro' +import { useLingui } from '@lingui/react/macro' import SVG from 'react-inlinesvg' import * as styledEl from './styled' @@ -19,9 +21,16 @@ export interface ChainsSelectorProps { onSelectChain: (chainId: ChainInfo) => void defaultChainId?: ChainInfo['id'] isLoading: boolean + disabledChainIds?: Set } -export function ChainsSelector({ chains, onSelectChain, defaultChainId, isLoading }: ChainsSelectorProps): ReactNode { +export function ChainsSelector({ + chains, + onSelectChain, + defaultChainId, + isLoading, + disabledChainIds, +}: ChainsSelectorProps): ReactNode { const { darkMode } = useTheme() if (isLoading) { @@ -29,7 +38,13 @@ export function ChainsSelector({ chains, onSelectChain, defaultChainId, isLoadin } return ( - + ) } @@ -59,9 +74,16 @@ interface ChainsListProps { defaultChainId?: ChainInfo['id'] onSelectChain(chain: ChainInfo): void isDarkMode: boolean + disabledChainIds?: Set } -function ChainsList({ chains, defaultChainId, onSelectChain, isDarkMode }: ChainsListProps): ReactNode { +function ChainsList({ + chains, + defaultChainId, + onSelectChain, + isDarkMode, + disabledChainIds, +}: ChainsListProps): ReactNode { return ( ) } -function ChainsButtonsList({ chains, defaultChainId, onSelectChain, isDarkMode }: ChainsListProps): ReactNode { +function ChainsButtonsList({ + chains, + defaultChainId, + onSelectChain, + isDarkMode, + disabledChainIds, +}: ChainsListProps): ReactNode { return ( <> {chains.map((chain) => ( @@ -84,6 +113,7 @@ function ChainsButtonsList({ chains, defaultChainId, onSelectChain, isDarkMode } isActive={defaultChainId === chain.id} onSelectChain={onSelectChain} isDarkMode={isDarkMode} + isDisabled={disabledChainIds?.has(chain.id) ?? false} /> ))} @@ -108,24 +138,36 @@ interface ChainButtonProps { isActive: boolean isDarkMode: boolean onSelectChain(chain: ChainInfo): void + isDisabled: boolean } -function ChainButton({ chain, isActive, isDarkMode, onSelectChain }: ChainButtonProps): ReactNode { +function ChainButton({ chain, isActive, isDarkMode, onSelectChain, isDisabled }: ChainButtonProps): ReactNode { + const { i18n } = useLingui() const logoSrc = isDarkMode ? chain.logo.dark : chain.logo.light const accent = getChainAccent(chain.id) + const disabledTooltip = i18n._(msg`This destination is not supported for this source chain`) + + const handleClick = (): void => { + if (!isDisabled) { + onSelectChain(chain) + } + } return ( onSelectChain(chain)} + onClick={handleClick} active$={isActive} accent$={accent} aria-pressed={isActive} + aria-disabled={isDisabled} + disabled$={isDisabled} + title={isDisabled ? disabledTooltip : undefined} > {chain.label} - {chain.label} + {chain.label} {isActive && (