diff --git a/.changeset/ten-buckets-lick.md b/.changeset/ten-buckets-lick.md new file mode 100644 index 0000000000..e6f816e275 --- /dev/null +++ b/.changeset/ten-buckets-lick.md @@ -0,0 +1,5 @@ +--- +"@coinbase/onchainkit": patch +--- + +- **feat**: Add loading state and spinner to SwapButton. By @abcrane123 #633 diff --git a/src/internal/loading/Spinner.tsx b/src/internal/loading/Spinner.tsx new file mode 100644 index 0000000000..1058f46982 --- /dev/null +++ b/src/internal/loading/Spinner.tsx @@ -0,0 +1,18 @@ +import { cn } from '../../styles/theme'; + +/* istanbul ignore next */ +export function Spinner() { + return ( +
+
+
+ ); +} diff --git a/src/swap/components/Swap.test.tsx b/src/swap/components/Swap.test.tsx index 9c75c863f6..0de4c27c80 100644 --- a/src/swap/components/Swap.test.tsx +++ b/src/swap/components/Swap.test.tsx @@ -62,7 +62,12 @@ const mockContextValue = { setFromToken: jest.fn(), setToAmount: jest.fn(), setToToken: jest.fn(), - swapQuoteLoadingState: { isFromQuoteLoading: false, isToQuoteLoading: false }, + setSwapLoadingState: jest.fn(), + swapLoadingState: { + isFromQuoteLoading: false, + isSwapLoading: false, + isToQuoteLoading: false, + }, toAmount: '20', toToken: mockToken, fromToken: mockETHToken, diff --git a/src/swap/components/Swap.tsx b/src/swap/components/Swap.tsx index f2a1562f83..72a0303a48 100644 --- a/src/swap/components/Swap.tsx +++ b/src/swap/components/Swap.tsx @@ -11,7 +11,7 @@ import { useBalance, useReadContract } from 'wagmi'; import { getTokenBalances } from '../core/getTokenBalances'; import { erc20Abi } from 'viem'; import type { Address } from 'viem'; -import type { SwapError, SwapQuoteLoadingState, SwapReact } from '../types'; +import type { SwapError, SwapLoadingState, SwapReact } from '../types'; import type { Token } from '../../token'; import type { UseBalanceReturnType, UseReadContractReturnType } from 'wagmi'; import { text } from '../../styles/theme'; @@ -22,11 +22,11 @@ export function Swap({ address, children, title = 'Swap' }: SwapReact) { const [fromToken, setFromToken] = useState(); const [toAmount, setToAmount] = useState(''); const [toToken, setToToken] = useState(); - const [swapQuoteLoadingState, setSwapQuoteLoadingState] = - useState({ - isFromQuoteLoading: false, - isToQuoteLoading: false, - }); + const [swapLoadingState, setSwapLoadingState] = useState({ + isFromQuoteLoading: false, + isSwapLoading: false, + isToQuoteLoading: false, + }); // returns ETH balance const ethBalanceResponse: UseBalanceReturnType = useBalance({ @@ -100,9 +100,9 @@ export function Swap({ address, children, title = 'Swap' }: SwapReact) { return; } try { - setSwapQuoteLoadingState({ + setSwapLoadingState({ + ...swapLoadingState, isToQuoteLoading: true, - isFromQuoteLoading: false, }); const response = await getSwapQuote({ from: fromToken, @@ -122,13 +122,13 @@ export function Swap({ address, children, title = 'Swap' }: SwapReact) { } catch (err) { setError(err as SwapError); } finally { - setSwapQuoteLoadingState({ + setSwapLoadingState({ + ...swapLoadingState, isToQuoteLoading: false, - isFromQuoteLoading: false, }); } }, - [fromToken, toToken], + [fromToken, swapLoadingState, toToken], ); /* istanbul ignore next */ @@ -139,8 +139,8 @@ export function Swap({ address, children, title = 'Swap' }: SwapReact) { return; } try { - setSwapQuoteLoadingState({ - isToQuoteLoading: false, + setSwapLoadingState({ + ...swapLoadingState, isFromQuoteLoading: true, }); const response = await getSwapQuote({ @@ -161,13 +161,13 @@ export function Swap({ address, children, title = 'Swap' }: SwapReact) { } catch (err) { setError(err as SwapError); } finally { - setSwapQuoteLoadingState({ - isToQuoteLoading: false, + setSwapLoadingState({ + ...swapLoadingState, isFromQuoteLoading: false, }); } }, - [fromToken, toToken], + [fromToken, swapLoadingState, toToken], ); /* istanbul ignore next */ @@ -178,6 +178,7 @@ export function Swap({ address, children, title = 'Swap' }: SwapReact) { setFromToken(toToken); }, [fromAmount, fromToken, toAmount, toToken]); + /* biome-ignore lint: need setState funcs */ const value = useMemo(() => { return { address, @@ -196,7 +197,8 @@ export function Swap({ address, children, title = 'Swap' }: SwapReact) { setFromToken, setToToken, setToAmount, - swapQuoteLoadingState, + setSwapLoadingState, + swapLoadingState, toAmount, toToken, }; @@ -212,7 +214,13 @@ export function Swap({ address, children, title = 'Swap' }: SwapReact) { handleToggle, roundedFromTokenBalance, roundedToTokenBalance, - swapQuoteLoadingState, + swapLoadingState, + setError, + setFromAmount, + setFromToken, + setSwapLoadingState, + setToAmount, + setToToken, toAmount, toToken, ]); diff --git a/src/swap/components/SwapAmountInput.test.tsx b/src/swap/components/SwapAmountInput.test.tsx index 9c60a66da6..729f57962d 100644 --- a/src/swap/components/SwapAmountInput.test.tsx +++ b/src/swap/components/SwapAmountInput.test.tsx @@ -59,7 +59,12 @@ const mockContextValue = { setFromToken: jest.fn(), setToAmount: jest.fn(), setToToken: jest.fn(), - swapQuoteLoadingState: { isFromQuoteLoading: false, isToQuoteLoading: false }, + setSwapLoadingState: jest.fn(), + swapLoadingState: { + isFromQuoteLoading: false, + isSwapLoading: false, + isToQuoteLoading: false, + }, toAmount: '20', toToken: mockToken, fromToken: mockETHToken, diff --git a/src/swap/components/SwapAmountInput.tsx b/src/swap/components/SwapAmountInput.tsx index ae95816f68..9d47a04573 100644 --- a/src/swap/components/SwapAmountInput.tsx +++ b/src/swap/components/SwapAmountInput.tsx @@ -28,7 +28,7 @@ export function SwapAmountInput({ setFromToken, setToAmount, setToToken, - swapQuoteLoadingState, + swapLoadingState, toAmount, toToken, } = useSwapContext(); @@ -48,7 +48,7 @@ export function SwapAmountInput({ amount: toAmount, convertedBalance: convertedToTokenBalance, handleAmountChange: handleToAmountChange, - isSwapQuoteLoading: swapQuoteLoadingState.isToQuoteLoading, + isSwapQuoteLoading: swapLoadingState.isToQuoteLoading, roundedBalance: roundedToTokenBalance, selectedToken: toToken, setAmount: setToAmount, @@ -59,7 +59,7 @@ export function SwapAmountInput({ amount: fromAmount, convertedBalance: convertedFromTokenBalance, handleAmountChange: handleFromAmountChange, - isSwapQuoteLoading: swapQuoteLoadingState.isFromQuoteLoading, + isSwapQuoteLoading: swapLoadingState.isFromQuoteLoading, roundedBalance: roundedFromTokenBalance, selectedToken: fromToken, setAmount: setFromAmount, @@ -78,7 +78,7 @@ export function SwapAmountInput({ setFromToken, setToAmount, setToToken, - swapQuoteLoadingState, + swapLoadingState, toAmount, toToken, type, diff --git a/src/swap/components/SwapButton.test.tsx b/src/swap/components/SwapButton.test.tsx index fcb7023e3e..1964345594 100644 --- a/src/swap/components/SwapButton.test.tsx +++ b/src/swap/components/SwapButton.test.tsx @@ -20,12 +20,21 @@ const mockedIsSwapError = isSwapError as jest.MockedFunction< (response: unknown) => response is SwapError >; +const mockSwapLoadingState = { + isFromQuoteLoading: false, + isSwapLoading: false, + isToQuoteLoading: false, +}; + describe('SwapButton', () => { beforeEach(() => { mockedUseSwapContext.mockReturnValue({ address: '0x123', fromAmount: 100, fromToken: 'ETH', + toAmount: 5, + setSwapLoadingState: jest.fn(), + swapLoadingState: mockSwapLoadingState, toToken: 'DAI', setError: jest.fn(), }); @@ -52,6 +61,9 @@ describe('SwapButton', () => { address: '0x123', fromAmount: 100, fromToken: 'ETH', + swapLoadingState: mockSwapLoadingState, + setSwapLoadingState: jest.fn(), + toAmount: 5, toToken: 'DAI', setError, }); @@ -76,6 +88,9 @@ describe('SwapButton', () => { address: '0x123', fromAmount: 100, fromToken: 'ETH', + setSwapLoadingState: jest.fn(), + swapLoadingState: mockSwapLoadingState, + toAmount: 5, toToken: 'DAI', setError, }); @@ -102,4 +117,28 @@ describe('SwapButton', () => { expect(onSubmit).not.toHaveBeenCalled(); }); + + it('does renders a loading icon when swap is loading', () => { + const onSubmit = jest.fn(); + mockedUseSwapContext.mockReturnValueOnce({ + address: '0x123', + fromAmount: 100, + fromToken: 'ETH', + setSwapLoadingState: jest.fn(), + swapLoadingState: { + isFromQuoteLoading: false, + isSwapLoading: true, + isToQuoteLoading: false, + }, + toAmount: 5, + toToken: 'DAI', + }); + const { getByTestId } = render( + , + ); + + const spinner = getByTestId('ockSpinner'); + + expect(spinner).toBeInTheDocument(); + }); }); diff --git a/src/swap/components/SwapButton.tsx b/src/swap/components/SwapButton.tsx index 85d02434dd..3087bfec4b 100644 --- a/src/swap/components/SwapButton.tsx +++ b/src/swap/components/SwapButton.tsx @@ -1,17 +1,27 @@ import { useCallback } from 'react'; -import type { SwapButtonReact, SwapError } from '../types'; import { useSwapContext } from '../context'; import { buildSwapTransaction } from '../core/buildSwapTransaction'; import { cn, text } from '../../styles/theme'; import { isSwapError } from '../core/isSwapError'; +import { Spinner } from '../../internal/loading/Spinner'; +import type { SwapButtonReact, SwapError } from '../types'; export function SwapButton({ disabled = false, onSubmit }: SwapButtonReact) { - const { address, fromAmount, fromToken, toToken, setError } = - useSwapContext(); + const { + address, + fromAmount, + fromToken, + toAmount, + setError, + setSwapLoadingState, + swapLoadingState, + toToken, + } = useSwapContext(); const handleSubmit = useCallback(async () => { if (address && fromToken && toToken && fromAmount) { try { + setSwapLoadingState({ ...swapLoadingState, isSwapLoading: true }); const response = await buildSwapTransaction({ amount: fromAmount, fromAddress: address, @@ -25,9 +35,28 @@ export function SwapButton({ disabled = false, onSubmit }: SwapButtonReact) { } } catch (error) { setError(error as SwapError); + } finally { + setSwapLoadingState({ ...swapLoadingState, isSwapLoading: false }); } } - }, [address, fromAmount, fromToken, setError, onSubmit, toToken]); + }, [ + address, + fromAmount, + fromToken, + onSubmit, + setError, + setSwapLoadingState, + swapLoadingState, + toToken, + ]); + + const isDisabled = + !fromAmount || + !fromToken || + !toAmount || + !toToken || + disabled || + swapLoadingState?.isSwapLoading; return ( ); } diff --git a/src/swap/components/SwapMessage.test.tsx b/src/swap/components/SwapMessage.test.tsx index 7868a6170d..cfddab7795 100644 --- a/src/swap/components/SwapMessage.test.tsx +++ b/src/swap/components/SwapMessage.test.tsx @@ -41,7 +41,12 @@ const mockContextValue = { setToAmount: jest.fn(), setToToken: jest.fn(), setError: jest.fn(), - swapQuoteLoadingState: { isFromQuoteLoading: false, isToQuoteLoading: false }, + setSwapLoadingState: jest.fn(), + swapLoadingState: { + isFromQuoteLoading: false, + isSwapLoading: false, + isToQuoteLoading: false, + }, toAmount: '20', toToken: mockToken, fromToken: mockETHToken, diff --git a/src/swap/types.ts b/src/swap/types.ts index 247724f9c8..4ab2696062 100644 --- a/src/swap/types.ts +++ b/src/swap/types.ts @@ -134,14 +134,16 @@ export type SwapContextType = { setFromToken: (t: Token) => void; setToAmount: (a: string) => void; setToToken: (t: Token) => void; - swapQuoteLoadingState: SwapQuoteLoadingState; + setSwapLoadingState: (s: SwapLoadingState) => void; + swapLoadingState: SwapLoadingState; toAmount: string; toToken?: Token; }; -export type SwapQuoteLoadingState = { +export type SwapLoadingState = { isFromQuoteLoading: boolean; isToQuoteLoading: boolean; + isSwapLoading: boolean; }; /**