diff --git a/packages/components/src/components/AssetLogo/AssetLogo.tsx b/packages/components/src/components/AssetLogo/AssetLogo.tsx index 4afbcb75632..bf1571a3796 100644 --- a/packages/components/src/components/AssetLogo/AssetLogo.tsx +++ b/packages/components/src/components/AssetLogo/AssetLogo.tsx @@ -63,7 +63,6 @@ export const AssetLogo = ({ const [isPlaceholder, setIsPlaceholder] = useState(!shouldTryToFetch); const fileName = contractAddress ? `${coingeckoId}--${contractAddress}` : coingeckoId; const logoUrl = getAssetLogoUrl(fileName); - const frameProps = pickAndPrepareFrameProps(rest, allowedAssetLogoFrameProps); const handleLoad = () => { @@ -73,8 +72,10 @@ export const AssetLogo = ({ setIsPlaceholder(true); }; useEffect(() => { - setIsPlaceholder(!shouldTryToFetch); - }, [shouldTryToFetch]); + if (shouldTryToFetch) { + setIsPlaceholder(false); + } + }, [shouldTryToFetch, fileName]); return ( diff --git a/packages/components/src/components/form/Select/Select.tsx b/packages/components/src/components/form/Select/Select.tsx index 623d09fc0c3..9e3db835987 100644 --- a/packages/components/src/components/form/Select/Select.tsx +++ b/packages/components/src/components/form/Select/Select.tsx @@ -109,6 +109,7 @@ type WrapperProps = TransientProps< $isWithPlaceholder: boolean; $hasBottomPadding: boolean; $elevation: Elevation; + $focusEnabled: boolean; }; const Wrapper = styled.div` @@ -152,9 +153,16 @@ const Wrapper = styled.div` } &:focus-within { - .${reactSelectClassNamePrefix}__dropdown-indicator { - transform: rotate(180deg); - } + ${({ $focusEnabled }) => + $focusEnabled + ? css` + .${reactSelectClassNamePrefix}__dropdown-indicator { + transform: rotate(180deg); + } + ` + : css` + border-color: transparent; + `} } } @@ -258,6 +266,7 @@ interface CommonProps extends Omit, 'onChange' | 'menuI * @description pass `null` if bottom text can be `undefined` */ bottomText?: ReactNode; + focusEnabled?: boolean; hasBottomPadding?: boolean; minValueWidth?: string; // TODO: should be probably removed inputState?: InputState; @@ -284,6 +293,7 @@ export const Select = ({ useKeyPressScroll, isSearchable = false, minValueWidth = 'initial', + focusEnabled = true, isMenuOpen, inputState, components, @@ -345,6 +355,7 @@ export const Select = ({ $minValueWidth={minValueWidth} $isDisabled={isDisabled} $isMenuOpen={isMenuOpen} + $focusEnabled={focusEnabled} $isWithLabel={!!label} $isWithPlaceholder={!!placeholder} $hasBottomPadding={hasBottomPadding === true && bottomText === null} diff --git a/packages/suite/src/components/suite/CoinBalance.tsx b/packages/suite/src/components/suite/CoinBalance.tsx index 9c19baf854f..be9c4c00042 100644 --- a/packages/suite/src/components/suite/CoinBalance.tsx +++ b/packages/suite/src/components/suite/CoinBalance.tsx @@ -3,7 +3,7 @@ import { FormattedCryptoAmount } from 'src/components/suite'; interface CoinBalanceProps { value: string; - symbol: Account['symbol']; + symbol: Account['symbol'] | (string & {}); } export const CoinBalance = ({ value, symbol }: CoinBalanceProps) => ( diff --git a/packages/suite/src/views/wallet/send/Outputs/Amount/Amount.tsx b/packages/suite/src/views/wallet/send/Outputs/Amount/Amount.tsx index 568e4b7413f..edbba0bb9f2 100644 --- a/packages/suite/src/views/wallet/send/Outputs/Amount/Amount.tsx +++ b/packages/suite/src/views/wallet/send/Outputs/Amount/Amount.tsx @@ -30,7 +30,6 @@ import { validateReserveOrBalance, } from 'src/utils/suite/validation'; import { formatTokenSymbol } from 'src/utils/wallet/tokenUtils'; -import { TokenSelect } from './TokenSelect'; import { FiatInput } from './FiatInput'; import { SendMaxSwitch } from './SendMaxSwitch'; @@ -277,7 +276,7 @@ export const Amount = ({ output, outputId }: AmountProps) => { control={control} innerAddon={ withTokens ? ( - + {token?.symbol?.toUpperCase()} ) : ( {symbolToUse} ) diff --git a/packages/suite/src/views/wallet/send/Outputs/Amount/TokenSelect.tsx b/packages/suite/src/views/wallet/send/Outputs/Amount/TokenSelect.tsx index df70bef983b..b6725fc04f1 100644 --- a/packages/suite/src/views/wallet/send/Outputs/Amount/TokenSelect.tsx +++ b/packages/suite/src/views/wallet/send/Outputs/Amount/TokenSelect.tsx @@ -1,18 +1,14 @@ -import { useMemo, useEffect } from 'react'; +import { useMemo, useEffect, useState } from 'react'; import { Controller } from 'react-hook-form'; -import { Select } from '@trezor/components'; -import styled from 'styled-components'; +import { AssetLogo, Column, Row, Select } from '@trezor/components'; import { useSendFormContext } from 'src/hooks/wallet'; import { Account } from 'src/types/wallet'; -import { Output } from '@suite-common/wallet-types'; import { useDispatch, useSelector } from 'src/hooks/suite'; import { updateFiatRatesThunk, selectCurrentFiatRates } from '@suite-common/wallet-core'; import { Timestamp, TokenAddress } from '@suite-common/wallet-types'; -import { TooltipSymbol, Translation } from 'src/components/suite'; -import { NetworkSymbol } from '@suite-common/wallet-config'; +import { networks, NetworkSymbol } from '@suite-common/wallet-config'; import { enhanceTokensWithRates, - formatTokenSymbol, getTokens, sortTokensWithRates, } from 'src/utils/wallet/tokenUtils'; @@ -20,29 +16,35 @@ import { selectLocalCurrency } from 'src/reducers/wallet/settingsReducer'; import { FiatCurrencyCode } from '@suite-common/suite-config'; import { TokenDefinitions, selectCoinDefinitions } from '@suite-common/token-definitions'; import { SUITE } from 'src/actions/suite/constants'; - -const UnrecognizedTokensHeading = styled.div` - display: flex; - align-items: center; -`; - -interface Option { - options: { - label: string; - value: string | null; - }[]; - label?: React.ReactNode; -} +import { + CoinLogo, + SelectAssetModal, + SelectAssetOptionCurrencyProps, +} from '@trezor/product-components'; +import { getCoingeckoId } from '@suite-common/wallet-config'; +import { getContractAddressForNetwork } from '@suite-common/wallet-utils'; +import { Card } from '@trezor/components'; +import { spacings } from '@trezor/theme'; +import { Text } from '@trezor/components'; +import { CoinBalance, FormattedCryptoAmount, Translation } from 'src/components/suite'; +import { ContractAddressWithTooltip } from '@trezor/components'; export const buildTokenOptions = ( accountTokens: Account['tokens'], symbol: Account['symbol'], coinDefinitions: TokenDefinitions['coin'], + nativeTokenBalance: Account['formattedBalance'], ) => { // native token option - const result: Option[] = [ + const result: SelectAssetOptionCurrencyProps[] = [ { - options: [{ value: null, label: symbol.toUpperCase() }], + type: 'currency', + symbol, + networkSymbol: symbol, + coingeckoId: networks[symbol].coingeckoNativeId || '', + contractAddress: null, + cryptoName: networks[symbol].name, + balance: nativeTokenBalance, }, ]; @@ -50,40 +52,44 @@ export const buildTokenOptions = ( const tokens = getTokens(accountTokens, symbol, coinDefinitions); tokens.shownWithBalance.forEach(token => { - result[0].options.push({ - value: token.contract, - label: formatTokenSymbol(token.symbol || token.contract), + result.push({ + type: 'currency', + symbol: token.symbol ?? symbol, + networkSymbol: symbol, + coingeckoId: getCoingeckoId(symbol) ?? '', + contractAddress: token.contract, + cryptoName: token.name, + balance: token.balance, }); }); if (tokens.hiddenWithBalance.length) { - result.push({ - label: ( - - - - ), - options: tokens.hiddenWithBalance.map(token => ({ - value: token.contract, - label: formatTokenSymbol(token.symbol || token.contract), - })), + tokens.hiddenWithBalance.forEach(token => { + result.push({ + type: 'currency', + symbol: token.symbol ?? symbol, + networkSymbol: symbol, + hidden: true, + coingeckoId: getCoingeckoId(symbol) ?? '', + contractAddress: token.contract, + cryptoName: token.name, + balance: token.balance, + }); }); } if (tokens.unverifiedWithBalance.length) { - result.push({ - label: ( - - - } - /> - - ), - options: tokens.unverifiedWithBalance.map(token => ({ - value: token.contract, - label: formatTokenSymbol(token.symbol || token.contract), - })), + tokens.unverifiedWithBalance.forEach(token => { + result.push({ + type: 'currency', + unverified: true, + symbol: token.symbol ?? symbol, + networkSymbol: symbol, + coingeckoId: getCoingeckoId(symbol) ?? '', + contractAddress: token.contract, + cryptoName: token.name, + balance: token.balance, + }); }); } } @@ -92,11 +98,10 @@ export const buildTokenOptions = ( }; interface TokenSelectProps { - output: Partial; outputId: number; } -export const TokenSelect = ({ output, outputId }: TokenSelectProps) => { +export const TokenSelect = ({ outputId }: TokenSelectProps) => { const { account, clearErrors, @@ -110,6 +115,7 @@ export const TokenSelect = ({ output, outputId }: TokenSelectProps) => { setValue, setDraftSaveRequest, } = useSendFormContext(); + const [isModalActive, setIsModalActive] = useState(false); const coinDefinitions = useSelector(state => selectCoinDefinitions(state, account.symbol)); const sendFormPrefill = useSelector(state => state.suite.prefillFields.sendForm); const localCurrency = useSelector(selectLocalCurrency); @@ -129,10 +135,16 @@ export const TokenSelect = ({ output, outputId }: TokenSelectProps) => { const tokenInputName = `outputs.${outputId}.token` as const; const amountInputName = `outputs.${outputId}.amount` as const; const currencyInputName = `outputs.${outputId}.currency` as const; - const tokenValue = getDefaultValue(tokenInputName, output.token); + const tokenContractAddress = watch(tokenInputName); + const isSetMaxActive = getDefaultValue('setMaxOutputId') === outputId; const dataEnabled = getDefaultValue('options', []).includes('ethereumData'); - const options = buildTokenOptions(sortedTokens, account.symbol, coinDefinitions); + const options = buildTokenOptions( + sortedTokens, + account.symbol, + coinDefinitions, + account.formattedBalance, + ); // Amount needs to be re-validated again AFTER token change propagation (decimal places, available balance) // watch token change and use "useSendFormFields.setAmount" util for validation (if amount is set) @@ -159,51 +171,135 @@ export const TokenSelect = ({ output, outputId }: TokenSelectProps) => { } }, [sendFormPrefill, setValue, tokenInputName, setDraftSaveRequest, dispatch]); + const findOption = options.find(option => { + return option.type === 'currency' && option.contractAddress === tokenContractAddress; + }) as SelectAssetOptionCurrencyProps | undefined; + + const selectedOption = findOption; + + const handleSelectChange = async (selectedAsset: SelectAssetOptionCurrencyProps) => { + const selectedOption = options.find( + option => option.contractAddress === selectedAsset.contractAddress, + ); + if (!selectedOption) return; + setValue(tokenInputName, selectedAsset.contractAddress); + + await dispatch( + updateFiatRatesThunk({ + tickers: [ + { + symbol: account.symbol as NetworkSymbol, + tokenAddress: selectedOption.contractAddress as TokenAddress, + }, + ], + localCurrency: currencyValue.value as FiatCurrencyCode, + rateType: 'current', + fetchAttemptTimestamp: Date.now() as Timestamp, + }), + ); + // clear errors in Amount input + clearErrors(amountInputName); + // remove Amount if isSetMaxActive or ETH data options are enabled + if (isSetMaxActive || dataEnabled) setAmount(outputId, ''); + // remove ETH data option + if (dataEnabled) toggleOption('ethereumData'); + // compose (could be prevented because of Amount error from re-validation above) + composeTransaction(amountInputName); + + setIsModalActive(false); + }; + return ( - ( - option.contractAddress === value)} + options={options.map(option => ({ + value: option.contractAddress, + label: option.symbol, + }))} + formatOptionLabel={(option: SelectAssetOptionCurrencyProps) => { + return ( + + {findOption ? ( + <> + + + ) : ( + + )} + + + + {option.cryptoName} + + + + + + + + + {option.contractAddress && ( + + )} + + + + ); + }} + styles={{ + valueContainer: base => ({ + ...base, + justifyContent: 'flex-start !important', + }), + }} + data-testid="@amount-select" + isDisabled={options.length === 1} + isClean + isClearable={false} + isMenuOpen={false} + /> + + )} + /> + ); }; diff --git a/packages/suite/src/views/wallet/send/Outputs/Outputs.tsx b/packages/suite/src/views/wallet/send/Outputs/Outputs.tsx index efae8549e88..82e455a5625 100644 --- a/packages/suite/src/views/wallet/send/Outputs/Outputs.tsx +++ b/packages/suite/src/views/wallet/send/Outputs/Outputs.tsx @@ -14,7 +14,7 @@ import { Address } from './Address'; import { Amount } from './Amount/Amount'; import { OpReturn } from './OpReturn'; import { CoinLogo } from '@trezor/product-components'; - +import { TokenSelect } from './Amount/TokenSelect'; const Container = styled.div<{ $height: number }>` height: ${({ $height }) => ($height ? `${$height}px` : 'auto')}; transition: height 0.2s ${motionEasingStrings.transition}; @@ -79,6 +79,7 @@ export const Outputs = ({ disableAnim }: OutputsProps) => { ease: motionEasing.transition, }} > +