Skip to content

Commit

Permalink
feat: added swap loading state and spinner to SwapButton (#633)
Browse files Browse the repository at this point in the history
  • Loading branch information
abcrane123 authored Jun 20, 2024
1 parent e629107 commit ac33e28
Show file tree
Hide file tree
Showing 10 changed files with 154 additions and 34 deletions.
5 changes: 5 additions & 0 deletions .changeset/ten-buckets-lick.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@coinbase/onchainkit": patch
---

- **feat**: Add loading state and spinner to SwapButton. By @abcrane123 #633
18 changes: 18 additions & 0 deletions src/internal/loading/Spinner.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { cn } from '../../styles/theme';

/* istanbul ignore next */
export function Spinner() {
return (
<div
className="flex h-full items-center justify-center"
data-testid="ockSpinner"
>
<div
className={cn(
'animate-spin border-4 border-gray-200 border-t-3',
'rounded-full border-t-blue-500 px-2.5 py-2.5',
)}
/>
</div>
);
}
7 changes: 6 additions & 1 deletion src/swap/components/Swap.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
44 changes: 26 additions & 18 deletions src/swap/components/Swap.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -22,11 +22,11 @@ export function Swap({ address, children, title = 'Swap' }: SwapReact) {
const [fromToken, setFromToken] = useState<Token>();
const [toAmount, setToAmount] = useState('');
const [toToken, setToToken] = useState<Token>();
const [swapQuoteLoadingState, setSwapQuoteLoadingState] =
useState<SwapQuoteLoadingState>({
isFromQuoteLoading: false,
isToQuoteLoading: false,
});
const [swapLoadingState, setSwapLoadingState] = useState<SwapLoadingState>({
isFromQuoteLoading: false,
isSwapLoading: false,
isToQuoteLoading: false,
});

// returns ETH balance
const ethBalanceResponse: UseBalanceReturnType = useBalance({
Expand Down Expand Up @@ -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,
Expand All @@ -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 */
Expand All @@ -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({
Expand All @@ -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 */
Expand All @@ -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,
Expand All @@ -196,7 +197,8 @@ export function Swap({ address, children, title = 'Swap' }: SwapReact) {
setFromToken,
setToToken,
setToAmount,
swapQuoteLoadingState,
setSwapLoadingState,
swapLoadingState,
toAmount,
toToken,
};
Expand All @@ -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,
]);
Expand Down
7 changes: 6 additions & 1 deletion src/swap/components/SwapAmountInput.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
8 changes: 4 additions & 4 deletions src/swap/components/SwapAmountInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ export function SwapAmountInput({
setFromToken,
setToAmount,
setToToken,
swapQuoteLoadingState,
swapLoadingState,
toAmount,
toToken,
} = useSwapContext();
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -78,7 +78,7 @@ export function SwapAmountInput({
setFromToken,
setToAmount,
setToToken,
swapQuoteLoadingState,
swapLoadingState,
toAmount,
toToken,
type,
Expand Down
39 changes: 39 additions & 0 deletions src/swap/components/SwapButton.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
});
Expand All @@ -52,6 +61,9 @@ describe('SwapButton', () => {
address: '0x123',
fromAmount: 100,
fromToken: 'ETH',
swapLoadingState: mockSwapLoadingState,
setSwapLoadingState: jest.fn(),
toAmount: 5,
toToken: 'DAI',
setError,
});
Expand All @@ -76,6 +88,9 @@ describe('SwapButton', () => {
address: '0x123',
fromAmount: 100,
fromToken: 'ETH',
setSwapLoadingState: jest.fn(),
swapLoadingState: mockSwapLoadingState,
toAmount: 5,
toToken: 'DAI',
setError,
});
Expand All @@ -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(
<SwapButton disabled={false} onSubmit={onSubmit} />,
);

const spinner = getByTestId('ockSpinner');

expect(spinner).toBeInTheDocument();
});
});
47 changes: 40 additions & 7 deletions src/swap/components/SwapButton.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -25,22 +35,45 @@ 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 (
<button
type="button"
className={cn(
'w-full rounded-xl bg-indigo-600',
'mt-4 px-4 py-3 font-medium text-base text-white leading-6',
disabled ? 'opacity-[0.38]' : '',
isDisabled && !swapLoadingState?.isSwapLoading ? 'opacity-[0.38]' : '',
)}
onClick={handleSubmit}
disabled={!fromAmount || !fromToken || !toToken || disabled}
disabled={isDisabled}
>
<span className={cn(text.headline, 'text-inverse')}>Swap</span>
{swapLoadingState?.isSwapLoading ? (
<Spinner />
) : (
<span className={cn(text.headline, 'text-inverse')}>Swap</span>
)}
</button>
);
}
7 changes: 6 additions & 1 deletion src/swap/components/SwapMessage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
6 changes: 4 additions & 2 deletions src/swap/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};

/**
Expand Down

0 comments on commit ac33e28

Please sign in to comment.