From 3d3ebc778366a413315e8120f4241d7e66d73360 Mon Sep 17 00:00:00 2001 From: Alissa Crane Date: Mon, 10 Jun 2024 19:15:15 -0700 Subject: [PATCH] feat: added SwapAmountInput (#504) --- .../components/SwapAmountInputContainer.tsx | 27 ++++ site/docs/pages/swap/swap-amount-input.mdx | 75 +++++++++ site/docs/pages/swap/types.mdx | 15 ++ site/sidebar.ts | 9 ++ src/swap/components/SwapAmountInput.test.tsx | 145 ++++++++++++++++++ src/swap/components/SwapAmountInput.tsx | 63 ++++++++ src/swap/types.ts | 11 ++ src/swap/utils.test.ts | 39 +++++ src/swap/utils.ts | 11 ++ 9 files changed, 395 insertions(+) create mode 100644 site/docs/components/SwapAmountInputContainer.tsx create mode 100644 site/docs/pages/swap/swap-amount-input.mdx create mode 100644 src/swap/components/SwapAmountInput.test.tsx create mode 100644 src/swap/components/SwapAmountInput.tsx create mode 100644 src/swap/utils.test.ts create mode 100644 src/swap/utils.ts diff --git a/site/docs/components/SwapAmountInputContainer.tsx b/site/docs/components/SwapAmountInputContainer.tsx new file mode 100644 index 0000000000..3fd1544c35 --- /dev/null +++ b/site/docs/components/SwapAmountInputContainer.tsx @@ -0,0 +1,27 @@ +import { ReactElement, useState } from 'react'; +import type { Token } from '@coinbase/onchainkit/token'; + +type SwapAmountInputContainer = { + children: ( + amount: string, + setAmount: (a: string) => void, + setToken: (t: Token) => void, + token: Token, + tokenBalance: string, + ) => ReactElement; +}; + +const TOKEN_BALANCE_MAP: Record = { + ETH: '3.5', + USDC: '2.77', + DAI: '4.9', +}; + +export default function SwapAmountInputContainer({ children }: SwapAmountInputContainer) { + const [token, setToken] = useState(); + const [amount, setAmount] = useState(''); + + const tokenBalance = TOKEN_BALANCE_MAP[token?.symbol]; + + return children(amount, setAmount, setToken, token, tokenBalance); +} diff --git a/site/docs/pages/swap/swap-amount-input.mdx b/site/docs/pages/swap/swap-amount-input.mdx new file mode 100644 index 0000000000..642bc828da --- /dev/null +++ b/site/docs/pages/swap/swap-amount-input.mdx @@ -0,0 +1,75 @@ +{/* import { SwapAmountInput } from '../../../../src/swap'; */} +import App from '../App'; +import SwapAmountInputContainer from '../../components/SwapAmountInputContainer.tsx'; + +# `` + +The `SwapAmountInput` component is a stylized input field designed for users to specify the amount of a particular token they wish to swap. This component integrates the `TokenSelector` component to allow users to select different tokens. + +## Usage + +:::code-group + +```tsx [code] + +``` + +```html [return html] +
+
+ +
+
+
+ +
+ +
+ +
+``` + +::: + +## Props + +[`SwapAmountInputReact`](/swap/types#SwapAmountInputReact) diff --git a/site/docs/pages/swap/types.mdx b/site/docs/pages/swap/types.mdx index 0cf726de84..8e77b70884 100644 --- a/site/docs/pages/swap/types.mdx +++ b/site/docs/pages/swap/types.mdx @@ -4,3 +4,18 @@ description: Glossary of Types in Swap Kit. --- # Types [Glossary of Types in Swap Kit.] + +## `SwapAmountInputReact` + +```ts +type SwapAmountInputReact = { + amount?: string; // Token amount + disabled?: boolean; // Whether the input is disabled + label: string; // Descriptive label for the input field + setAmount: (amount: string) => void; // Callback function when the amount changes + setToken: () => void; // Callback function when the token selector is clicked + swappableTokens: Token[]; // Tokens available for swap + token?: Token; // Selected token + tokenBalance?: string; // Amount of selected token user owns +}; +``` diff --git a/site/sidebar.ts b/site/sidebar.ts index 3b822a1898..a13646638d 100644 --- a/site/sidebar.ts +++ b/site/sidebar.ts @@ -140,6 +140,15 @@ export const sidebar = [ text: 'Swap', items: [ { text: 'Introduction', link: '/swap/introduction' }, + { + text: 'Components', + items: [ + { + text: 'SwapAmountInput', + link: '/swap/swap-amount-input', + }, + ], + }, { text: 'Types', link: '/swap/types', diff --git a/src/swap/components/SwapAmountInput.test.tsx b/src/swap/components/SwapAmountInput.test.tsx new file mode 100644 index 0000000000..af7a4a5064 --- /dev/null +++ b/src/swap/components/SwapAmountInput.test.tsx @@ -0,0 +1,145 @@ +/** + * @jest-environment jsdom + */ +import React from 'react'; +import '@testing-library/jest-dom'; +import { fireEvent, render, screen, within } from '@testing-library/react'; +import { Address } from 'viem'; +import { SwapAmountInput } from './SwapAmountInput'; +import { Token } from '../../token'; + +const setAmountMock = jest.fn(); +const selectTokenClickMock = jest.fn(); + +const token = { + address: '0x123' as Address, + chainId: 1, + decimals: 2, + image: 'imageURL', + name: 'Ether', + symbol: 'ETH', +}; + +const swappableTokens: Token[] = [ + { + name: 'Ethereum', + address: '', + symbol: 'ETH', + decimals: 18, + image: 'https://wallet-api-production.s3.amazonaws.com/uploads/tokens/eth_288.png', + chainId: 8453, + }, + { + name: 'USDC', + address: '0x833589fcd6edb6e08f4c7c32d4f71b54bda02913', + symbol: 'USDC', + decimals: 6, + image: + 'https://d3r81g40ycuhqg.cloudfront.net/wallet/wais/44/2b/442b80bd16af0c0d9b22e03a16753823fe826e5bfd457292b55fa0ba8c1ba213-ZWUzYjJmZGUtMDYxNy00NDcyLTg0NjQtMWI4OGEwYjBiODE2', + chainId: 8453, + }, + { + name: 'Dai', + address: '0x50c5725949a6f0c72e6c4a641f24049a917db0cb', + symbol: 'DAI', + decimals: 18, + image: + 'https://d3r81g40ycuhqg.cloudfront.net/wallet/wais/d0/d7/d0d7784975771dbbac9a22c8c0c12928cc6f658cbcf2bbbf7c909f0fa2426dec-NmU4ZWViMDItOTQyYy00Yjk5LTkzODUtNGJlZmJiMTUxOTgy', + chainId: 8453, + }, +]; + +describe('SwapAmountInput Component', () => { + it('should render', async () => { + render( + , + ); + + const amountInput = screen.getByTestId('ockSwapAmountInput_Container'); + expect(amountInput).toBeInTheDocument(); + + const labelElement = within(amountInput).getByText('Sell'); + expect(labelElement).toBeInTheDocument(); + + const balanceElement = within(amountInput).getByText('Balance: 100'); + expect(balanceElement).toBeInTheDocument(); + }); + + it('should update the amount if a user enters a number', async () => { + render( + , + ); + + const input = screen.getByTestId('ockSwapAmountInput_Input') as HTMLInputElement; + fireEvent.change(input, { target: { value: '2' } }); + expect(setAmountMock).toHaveBeenCalledWith('2'); + expect(input.value).toBe('2'); + }); + + it('should not update the amount if a user enters a non-number', async () => { + render( + , + ); + + const input = screen.getByTestId('ockSwapAmountInput_Input') as HTMLInputElement; + fireEvent.change(input, { target: { value: 'a' } }); + expect(setAmountMock).not.toHaveBeenCalledWith('a'); + expect(input.value).toBe('1'); + }); + + it('should call setAmount with tokenBalance when the max button is clicked', async () => { + render( + , + ); + + const maxButton = screen.getByTestId('ockSwapAmountInput_MaxButton'); + fireEvent.click(maxButton); + expect(setAmountMock).toHaveBeenCalledWith('100'); + }); + + it('should disable the input when disabled prop is true', async () => { + render( + , + ); + + const input = screen.getByTestId('ockSwapAmountInput_Input') as HTMLInputElement; + expect(input).toBeDisabled(); + }); +}); diff --git a/src/swap/components/SwapAmountInput.tsx b/src/swap/components/SwapAmountInput.tsx new file mode 100644 index 0000000000..2356630455 --- /dev/null +++ b/src/swap/components/SwapAmountInput.tsx @@ -0,0 +1,63 @@ +import { useCallback } from 'react'; + +import { isValidAmount } from '../utils'; +import type { SwapAmountInputReact } from '../types'; + +export function SwapAmountInput({ + amount, + disabled = false, + label, + setAmount, + tokenBalance, +}: SwapAmountInputReact) { + const handleAmountChange = useCallback( + (event: React.ChangeEvent) => { + if (isValidAmount(event.target.value)) { + setAmount(event.target.value); + } + }, + [setAmount], + ); + + const handleMaxButtonClick = useCallback(() => { + if (tokenBalance && isValidAmount(tokenBalance)) { + setAmount(tokenBalance); + } + }, [tokenBalance, setAmount]); + + return ( +
+
+ + {tokenBalance && ( + + )} +
+
+ {/* TODO: add back in when TokenSelector is complete */} + {/* + + */} + +
+ +
+ ); +} diff --git a/src/swap/types.ts b/src/swap/types.ts index 5e73f0365b..684ba8cb30 100644 --- a/src/swap/types.ts +++ b/src/swap/types.ts @@ -82,3 +82,14 @@ export type Transaction = { to: string; // The recipient address value: string; // The value of the transaction }; + +export type SwapAmountInputReact = { + amount?: string; // Token amount + disabled?: boolean; // Whether the input is disabled + label: string; // Descriptive label for the input field + setAmount: (amount: string) => void; // Callback function when the amount changes + setToken: () => void; // Callback function when the token selector is clicked + swappableTokens: Token[]; // Tokens available for swap + token?: Token; // Selected token + tokenBalance?: string; // Amount of selected token user owns +}; diff --git a/src/swap/utils.test.ts b/src/swap/utils.test.ts new file mode 100644 index 0000000000..5982a45094 --- /dev/null +++ b/src/swap/utils.test.ts @@ -0,0 +1,39 @@ +import { isValidAmount } from './utils'; // Adjust the import path as needed + +describe('isValidAmount', () => { + it('should return true for an empty string', () => { + expect(isValidAmount('')).toBe(true); + }); + + it('should return true for a valid number string with less than 11 characters', () => { + expect(isValidAmount('12345')).toBe(true); + }); + + it('should return false for a number string with more than 11 characters', () => { + expect(isValidAmount('123456789012')).toBe(false); + }); + + it('should return true for a valid number string with a decimal point', () => { + expect(isValidAmount('123.45')).toBe(true); + }); + + it('should return false for a string with multiple decimal points', () => { + expect(isValidAmount('123.45.67')).toBe(false); + }); + + it('should return false for a string with non-numeric characters', () => { + expect(isValidAmount('123a45')).toBe(false); + }); + + it('should return true for a valid number string with no integer part but a decimal point', () => { + expect(isValidAmount('.12345')).toBe(true); + }); + + it('should return true for a valid number string with no decimal part', () => { + expect(isValidAmount('12345.')).toBe(true); + }); + + it('should return false for a string with spaces', () => { + expect(isValidAmount('123 45')).toBe(false); + }); +}); diff --git a/src/swap/utils.ts b/src/swap/utils.ts new file mode 100644 index 0000000000..6275805179 --- /dev/null +++ b/src/swap/utils.ts @@ -0,0 +1,11 @@ +// checks that input is a number +export function isValidAmount(value: string) { + if (value.length > 11) { + return false; + } + if (value === '') { + return true; + } + const regex = /^[0-9]*\.?[0-9]*$/; + return regex.test(value); +}