bridge_ui: reset button in picker for tokens

fixes https://github.com/certusone/wormhole/issues/362

fixes https://github.com/certusone/wormhole/issues/394

Change-Id: Ib3619ac95e1bfda4b5d1b58840304d867f81305e
This commit is contained in:
chase-45 2021-09-08 15:57:35 -04:00 committed by Evan Gray
parent 9ea0369ab0
commit 8e251f4acc
6 changed files with 265 additions and 124 deletions

View File

@ -1,3 +1,4 @@
import { WormholeAbi__factory } from "@certusone/wormhole-sdk/lib/ethers-contracts/abi";
import {
CircularProgress,
createStyles,
@ -11,6 +12,7 @@ import { useEthereumProvider } from "../../contexts/EthereumProviderContext";
import { CovalentData } from "../../hooks/useGetSourceParsedTokenAccounts";
import { DataWrapper } from "../../store/helpers";
import { ParsedTokenAccount } from "../../store/transferSlice";
import { WORMHOLE_V1_ETH_ADDRESS } from "../../utils/consts";
import {
ethNFTToNFTParsedTokenAccount,
ethTokenToParsedTokenAccount,
@ -21,11 +23,10 @@ import {
} from "../../utils/ethereum";
import { shortenAddress } from "../../utils/solana";
import OffsetButton from "./OffsetButton";
import { WormholeAbi__factory } from "@certusone/wormhole-sdk/lib/ethers-contracts/abi";
import { WORMHOLE_V1_ETH_ADDRESS } from "../../utils/consts";
import { NFTParsedTokenAccount } from "../../store/nftSlice";
import NFTViewer from "./NFTViewer";
import { useDebounce } from "use-debounce/lib";
import RefreshButtonWrapper from "./RefreshButtonWrapper";
const useStyles = makeStyles(() =>
createStyles({
@ -56,6 +57,7 @@ type EthereumSourceTokenSelectorProps = {
covalent: DataWrapper<CovalentData[]> | undefined;
tokenAccounts: DataWrapper<ParsedTokenAccount[]> | undefined;
disabled: boolean;
resetAccounts: (() => void) | undefined;
nft?: boolean;
};
@ -116,7 +118,15 @@ const renderNFTAccount = (
export default function EthereumSourceTokenSelector(
props: EthereumSourceTokenSelectorProps
) {
const { value, onChange, covalent, tokenAccounts, disabled, nft } = props;
const {
value,
onChange,
covalent,
tokenAccounts,
disabled,
resetAccounts,
nft,
} = props;
const classes = useStyles();
const [advancedMode, setAdvancedMode] = useState(false);
const [advancedModeLoading, setAdvancedModeLoading] = useState(false);
@ -139,6 +149,14 @@ export default function EthereumSourceTokenSelector(
// const wrappedTestToken = "0x8bf3c393b588bb6ad021e154654493496139f06d";
// const notWrappedTestToken = "0xaaaebe6fe48e54f431b0c390cfaf0b017d09d42d";
const resetAccountWrapper = useCallback(() => {
setAdvancedModeHolderString("");
setAutocompleteHolder(null);
setAdvancedModeError("");
setAutocompleteError("");
resetAccounts && resetAccounts();
}, [resetAccounts]);
useEffect(() => {
//If we receive a push from our parent, usually on component mount, we set our internal value to synchronize
//This also kicks off the metadata load.
@ -394,6 +412,8 @@ export default function EthereumSourceTokenSelector(
setAdvancedModeHolderString("");
setAdvancedModeError("");
setAdvancedModeSymbol("");
setAutocompleteHolder(null);
setAutocompleteError("");
setAdvancedMode(!advancedMode);
};
@ -412,7 +432,7 @@ export default function EthereumSourceTokenSelector(
autoSelect
blurOnSelect
clearOnBlur
fullWidth={false}
fullWidth={true}
filterOptions={nft ? filterConfigNFT : filterConfig}
value={autocompleteHolder}
onChange={(event, newValue) => {
@ -454,15 +474,18 @@ export default function EthereumSourceTokenSelector(
})`;
}}
/>
{autocompleteError && (
<Typography color="error">{autocompleteError}</Typography>
)}
</>
);
const advancedModeToggleButton = (
<OffsetButton onClick={toggleAdvancedMode} disabled={disabled}>
{advancedMode ? "Toggle Token Picker" : "Toggle Override"}
{advancedMode ? "Toggle Token Picker" : "Toggle Manual Entry"}
</OffsetButton>
);
const clearButton = (
<OffsetButton onClick={handleClick} disabled={disabled}>
Clear
</OffsetButton>
);
@ -473,16 +496,12 @@ export default function EthereumSourceTokenSelector(
{nft ? (
<NFTViewer symbol={symbol} value={value} />
) : (
<Typography>{(symbol ? symbol + " " : "") + value.mintKey}</Typography>
<RefreshButtonWrapper callback={resetAccountWrapper}>
<Typography>
{(symbol ? symbol + " " : "") + value.mintKey}
</Typography>
</RefreshButtonWrapper>
)}
<OffsetButton onClick={handleClick} disabled={disabled}>
Clear
</OffsetButton>
{!advancedMode && autocompleteError ? (
<Typography color="error">{autocompleteError}</Typography>
) : advancedMode && advancedModeError ? (
<Typography color="error">{advancedModeError}</Typography>
) : null}
</>
) : advancedMode ? (
<>
@ -496,7 +515,11 @@ export default function EthereumSourceTokenSelector(
!isValidEthereumAddress(advancedModeHolderString)) ||
!!advancedModeError
}
helperText={advancedModeError === "" ? undefined : advancedModeError}
helperText={
advancedModeHolderString &&
!isValidEthereumAddress(advancedModeHolderString) &&
"Invalid Ethereum address"
}
disabled={disabled || advancedModeLoading}
/>
{nft ? (
@ -515,13 +538,20 @@ export default function EthereumSourceTokenSelector(
{nft ? "Loading (this may take a while)..." : "Loading..."}
</Typography>
) : (
autoComplete
<RefreshButtonWrapper callback={resetAccountWrapper}>
{autoComplete}
</RefreshButtonWrapper>
);
return (
<React.Fragment>
<>
{content}
{!value && advancedModeToggleButton}
</React.Fragment>
{!advancedMode && autocompleteError ? (
<Typography color="error">{autocompleteError}</Typography>
) : advancedMode && advancedModeError ? (
<Typography color="error">{advancedModeError}</Typography>
) : null}
{value ? clearButton : advancedModeToggleButton}
</>
);
}

View File

@ -0,0 +1,45 @@
import {
createStyles,
IconButton,
makeStyles,
Tooltip,
} from "@material-ui/core";
import RefreshIcon from "@material-ui/icons/Refresh";
const useStyles = makeStyles(() =>
createStyles({
inlineContentWrapper: {
display: "inline-block",
flexGrow: 1,
},
flexWrapper: {
"& > *": {
margin: ".5rem",
},
display: "flex",
},
})
);
export default function RefreshButtonWrapper({
children,
callback,
}: {
children: JSX.Element;
callback: () => any;
}) {
const classes = useStyles();
const refreshWrapper = (
<div className={classes.flexWrapper}>
<div className={classes.inlineContentWrapper}>{children}</div>
<Tooltip title="Reload Tokens">
<IconButton onClick={callback}>
<RefreshIcon />
</IconButton>
</Tooltip>
</div>
);
return refreshWrapper;
}

View File

@ -9,6 +9,7 @@ import { ParsedTokenAccount } from "../../store/transferSlice";
import { WORMHOLE_V1_MINT_AUTHORITY } from "../../utils/consts";
import { Metadata } from "../../utils/metaplex";
import { shortenAddress } from "../../utils/solana";
import RefreshButtonWrapper from "./RefreshButtonWrapper";
const useStyles = makeStyles((theme: Theme) =>
createStyles({
@ -33,15 +34,18 @@ type SolanaSourceTokenSelectorProps = {
metaplexData: any; //DataWrapper<(Metadata | undefined)[]> | undefined | null;
disabled: boolean;
mintAccounts: DataWrapper<Map<String, string | null>> | undefined;
resetAccounts: (() => void) | undefined;
nft?: boolean;
};
export default function SolanaSourceTokenSelector(
props: SolanaSourceTokenSelectorProps
) {
const { value, onChange, disabled, nft } = props;
const { value, onChange, disabled, resetAccounts, nft } = props;
const classes = useStyles();
const resetAccountWrapper = resetAccounts || (() => {}); //This should never happen.
const memoizedTokenMap: Map<String, TokenInfo> = useMemo(() => {
const output = new Map<String, TokenInfo>();
@ -245,9 +249,15 @@ export default function SolanaSourceTokenSelector(
/>
);
const wrappedContent = (
<RefreshButtonWrapper callback={resetAccountWrapper}>
{autoComplete}
</RefreshButtonWrapper>
);
return (
<React.Fragment>
{isLoading ? <CircularProgress /> : autoComplete}
{isLoading ? <CircularProgress /> : wrappedContent}
{error && <Typography color="error">{error}</Typography>}
</React.Fragment>
);

View File

@ -91,6 +91,7 @@ export const TokenSelector = (props: TokenSelectorProps) => {
solanaTokenMap={maps?.tokenMap}
metaplexData={maps?.metaplex}
mintAccounts={maps?.mintAccounts}
resetAccounts={maps?.resetAccounts}
nft={nft}
/>
) : lookupChain === CHAIN_ID_ETH ? (
@ -99,7 +100,8 @@ export const TokenSelector = (props: TokenSelectorProps) => {
disabled={disabled}
onChange={handleOnChange}
covalent={maps?.covalent || undefined}
tokenAccounts={maps?.tokenAccounts} //TODO standardize
tokenAccounts={maps?.tokenAccounts}
resetAccounts={maps?.resetAccounts}
nft={nft}
/>
) : lookupChain === CHAIN_ID_TERRA ? (
@ -108,6 +110,7 @@ export const TokenSelector = (props: TokenSelectorProps) => {
disabled={disabled}
onChange={handleOnChange}
tokenMap={maps?.terraTokenMap}
resetAccounts={maps?.resetAccounts}
/>
) : (
<TextField

View File

@ -1,4 +1,5 @@
import {
CircularProgress,
createStyles,
makeStyles,
TextField,
@ -22,6 +23,7 @@ import { ParsedTokenAccount } from "../../store/transferSlice";
import { TERRA_HOST } from "../../utils/consts";
import { shortenAddress } from "../../utils/solana";
import OffsetButton from "./OffsetButton";
import RefreshButtonWrapper from "./RefreshButtonWrapper";
const useStyles = makeStyles(() =>
createStyles({
@ -43,6 +45,7 @@ type TerraSourceTokenSelectorProps = {
onChange: (newValue: ParsedTokenAccount | null) => void;
disabled: boolean;
tokenMap: DataWrapper<TerraTokenMap> | undefined; //TODO better type
resetAccounts: (() => void) | undefined;
};
//TODO move elsewhere
@ -87,12 +90,28 @@ export default function TerraSourceTokenSelector(
props: TerraSourceTokenSelectorProps
) {
const classes = useStyles();
const { onChange, value, disabled, tokenMap } = props;
const { onChange, value, disabled, tokenMap, resetAccounts } = props;
const [advancedMode, setAdvancedMode] = useState(false);
const [advancedModeHolderString, setAdvancedModeHolderString] = useState("");
const [advancedModeError, setAdvancedModeError] = useState("");
const terraWallet = useConnectedWallet();
const [autocompleteString, setAutocompleteString] = useState("");
const handleAutocompleteChange = useCallback(
(event) => {
setAutocompleteString(event?.target?.value);
},
[setAutocompleteString]
);
const resetAccountWrapper = useCallback(() => {
setAdvancedModeHolderString("");
setAdvancedModeError("");
setAutocompleteString("");
resetAccounts && resetAccounts();
}, [resetAccounts]);
const isLoading = tokenMap?.isFetching || false;
const terraTokenArray = useMemo(() => {
@ -102,7 +121,7 @@ export default function TerraSourceTokenSelector(
}, [props.tokenMap]);
const valueToOption = (fromProps: ParsedTokenAccount | undefined | null) => {
if (!fromProps) return undefined;
if (!fromProps) return null;
else {
return terraTokenArray.find((x) => x.token === fromProps.mintKey);
}
@ -113,7 +132,7 @@ export default function TerraSourceTokenSelector(
}, [onChange]);
const handleOnChange = useCallback(
(event) => setAdvancedModeHolderString(event.target.value),
(event) => setAdvancedModeHolderString(event?.target?.value),
[]
);
@ -128,7 +147,7 @@ export default function TerraSourceTokenSelector(
onChange(result);
},
(error) => {
setAdvancedModeError("Unable to retrieve this address.");
setAdvancedModeError("Unable to retrieve that address.");
}
);
setAdvancedModeError("");
@ -172,10 +191,12 @@ export default function TerraSourceTokenSelector(
const advancedModeToggleButton = (
<OffsetButton onClick={toggleAdvancedMode} disabled={disabled}>
{advancedMode ? "Toggle Token Picker" : "Toggle Override"}
{advancedMode ? "Toggle Token Picker" : "Toggle Manual Entry"}
</OffsetButton>
);
const selectedValue = valueToOption(value);
const autoComplete = (
<>
<Autocomplete
@ -186,10 +207,12 @@ export default function TerraSourceTokenSelector(
clearOnBlur
fullWidth={false}
filterOptions={filterConfig}
value={valueToOption(value)}
value={selectedValue}
onChange={(event, newValue) => {
handleConfirm(newValue?.token);
}}
inputValue={autocompleteString}
onInputChange={handleAutocompleteChange}
disabled={disabled}
noOptionsText={"No CW20 tokens found at the moment."}
options={terraTokenArray}
@ -199,18 +222,18 @@ export default function TerraSourceTokenSelector(
renderOption={renderOption}
getOptionLabel={renderOptionLabel}
/>
{advancedModeError && (
<Typography color="error">{advancedModeError}</Typography>
)}
</>
);
const clearButton = (
<OffsetButton onClick={handleClick} disabled={disabled}>
Clear
</OffsetButton>
);
const content = value ? (
<>
<Typography>{value.mintKey}</Typography>
<OffsetButton onClick={handleClick} disabled={disabled}>
Clear
</OffsetButton>
</>
) : !advancedMode ? (
autoComplete
@ -223,21 +246,37 @@ export default function TerraSourceTokenSelector(
onChange={handleOnChange}
disabled={disabled}
error={advancedModeHolderString !== "" && !!advancedModeError}
helperText={advancedModeError === "" ? undefined : advancedModeError}
/>
</>
);
const wrappedContent = (
<RefreshButtonWrapper callback={resetAccountWrapper}>
{content}
</RefreshButtonWrapper>
);
const confirmButton = (
<OffsetButton
onClick={() => handleConfirm(advancedModeHolderString)}
disabled={disabled}
>
Confirm
</OffsetButton>
</>
);
return (
<React.Fragment>
{content}
{isLoading && <CircularProgress />}
{wrappedContent}
{advancedModeError && (
<Typography color="error">{advancedModeError}</Typography>
)}
<div>
{advancedMode && !value && confirmButton}
{!value && !isLoading && advancedModeToggleButton}
{value && clearButton}
</div>
</React.Fragment>
);
}

View File

@ -14,7 +14,7 @@ import {
} from "@solana/web3.js";
import axios from "axios";
import { formatUnits } from "ethers/lib/utils";
import { useEffect, useState } from "react";
import { useCallback, useEffect, useState } from "react";
import { useDispatch, useSelector } from "react-redux";
import { useEthereumProvider } from "../contexts/EthereumProviderContext";
import { useSolanaWallet } from "../contexts/SolanaWalletContext";
@ -381,14 +381,7 @@ function useGetAvailableTokens(nft: boolean = false) {
? solPK?.toString()
: undefined;
//TODO this useEffect could be somewhere else in the codebase
//It resets the SourceParsedTokens accounts when the wallet changes
useEffect(() => {
if (
selectedSourceWalletAddress !== undefined &&
currentSourceWalletAddress !== undefined &&
currentSourceWalletAddress !== selectedSourceWalletAddress
) {
const resetSourceAccounts = useCallback(() => {
dispatch(
nft
? setSourceWalletAddressNFT(undefined)
@ -405,10 +398,29 @@ function useGetAvailableTokens(nft: boolean = false) {
: setSourceParsedTokenAccounts(undefined)
);
!nft && dispatch(setAmount(""));
setCovalent(undefined); //These need to be included in the reset because they have balances on them.
setCovalentLoading(false);
setCovalentError("");
}, [setCovalent, dispatch, nft]);
//TODO this useEffect could be somewhere else in the codebase
//It resets the SourceParsedTokens accounts when the wallet changes
useEffect(() => {
if (
selectedSourceWalletAddress !== undefined &&
currentSourceWalletAddress !== undefined &&
currentSourceWalletAddress !== selectedSourceWalletAddress
) {
resetSourceAccounts();
return;
} else {
}
}, [selectedSourceWalletAddress, currentSourceWalletAddress, dispatch, nft]);
}, [
selectedSourceWalletAddress,
currentSourceWalletAddress,
dispatch,
resetSourceAccounts,
]);
// Solana metaplex load
useEffect(() => {
@ -526,9 +538,7 @@ function useGetAvailableTokens(nft: boolean = false) {
// const nftTestWallet2 = "0x98ed231428088eb440e8edb5cc8d66dcf913b86e";
let cancelled = false;
const walletAddress = signerAddress;
if (!walletAddress || lookupChain !== CHAIN_ID_ETH) {
return;
}
if (walletAddress && lookupChain === CHAIN_ID_ETH && !tokenAccounts.data) {
//TODO less cancel
!cancelled && setCovalentLoading(true);
!cancelled &&
@ -588,7 +598,8 @@ function useGetAvailableTokens(nft: boolean = false) {
return () => {
cancelled = true;
};
}, [lookupChain, provider, signerAddress, dispatch, nft]);
}
}, [lookupChain, provider, signerAddress, dispatch, nft, tokenAccounts.data]);
//Terra accounts load
//At present, we don't have any mechanism for doing this.
@ -640,6 +651,7 @@ function useGetAvailableTokens(nft: boolean = false) {
error: solanaMintAccountsError,
receivedAt: null, //TODO
},
resetAccounts: resetSourceAccounts,
}
: lookupChain === CHAIN_ID_ETH
? {
@ -650,10 +662,12 @@ function useGetAvailableTokens(nft: boolean = false) {
error: covalentError,
receivedAt: null, //TODO
},
resetAccounts: resetSourceAccounts,
}
: lookupChain === CHAIN_ID_TERRA
? {
terraTokenMap: terraTokenMap,
resetAccounts: resetSourceAccounts,
}
: undefined;
}