From f6bdbd351fb1fa64e9047d8ee52b4723d30e1444 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rui=20Sim=C3=A3o?= Date: Fri, 3 May 2024 19:10:49 +0100 Subject: [PATCH] wip --- .../src/TokenInput/BaseTokenInput.tsx | 53 ++++--- .../src/TokenInput/FixedTokenInput.tsx | 6 +- .../src/TokenInput/SelectableTokenInput.tsx | 30 +++- .../components/src/TokenInput/TokenInput.tsx | 91 ++++++----- .../src/TokenInput/TokenListItem.tsx | 8 +- .../components/src/TokenInput/TokenSelect.tsx | 9 +- .../TokenInput/__tests__/TokenInput.test.tsx | 150 ++++++++++-------- .../stories/FixedTokenInput.stories.tsx | 3 +- .../stories/SelectableTokenInput.stories.tsx | 16 +- packages/components/src/utils/decimals.ts | 11 ++ packages/components/src/utils/index.ts | 1 + 11 files changed, 225 insertions(+), 153 deletions(-) create mode 100644 packages/components/src/utils/decimals.ts create mode 100644 packages/components/src/utils/index.ts diff --git a/packages/components/src/TokenInput/BaseTokenInput.tsx b/packages/components/src/TokenInput/BaseTokenInput.tsx index 122ed8380..d04486495 100644 --- a/packages/components/src/TokenInput/BaseTokenInput.tsx +++ b/packages/components/src/TokenInput/BaseTokenInput.tsx @@ -4,6 +4,7 @@ import { AriaTextFieldOptions, useTextField } from '@react-aria/textfield'; import { mergeProps } from '@react-aria/utils'; import { ChangeEventHandler, FocusEvent, forwardRef, ReactNode, useCallback, useEffect, useState } from 'react'; +import { trimDecimals } from '../utils'; import { HelperTextProps } from '../HelperText'; import { LabelProps } from '../Label'; import { Field, FieldProps, useFieldProps } from '../Field'; @@ -42,7 +43,7 @@ type Props = { value?: string; defaultValue?: string; // TODO: use Currency from bob-ui - currency: { decimals: number }; + currency?: { decimals: number }; onValueChange?: (value: string | number) => void; onChange?: (e: React.ChangeEvent) => void; onFocus?: (e: FocusEvent) => void; @@ -89,24 +90,23 @@ const BaseTokenInput = forwardRef( const format = useCurrencyFormatter(); - const handleChange: ChangeEventHandler = useCallback( - (e) => { - const value = e.target.value; + // Observes currency field and correct decimals places if needed + useEffect(() => { + if (value && currency) { + const trimmedValue = trimDecimals(value, currency.decimals); - const isEmpty = value === ''; - const hasValidDecimalFormat = RegExp(`^\\d*(?:\\\\[.])?\\d*$`).test(escapeRegExp(value)); - const hasValidDecimalsAmount = hasCorrectDecimals(value, currency.decimals); + if (value !== trimmedValue) { + setValue(trimmedValue); + onValueChange?.(trimmedValue); + } + } + }, [currency]); - const isValid = hasValidDecimalFormat && hasValidDecimalsAmount; + useEffect(() => { + if (valueProp === undefined) return; - if (isEmpty || isValid) { - onChange?.(e); - onValueChange?.(value); - setValue(value); - } - }, - [onChange, onValueChange] - ); + setValue(valueProp.toString()); + }, [valueProp]); const { inputProps, descriptionProps, errorMessageProps, labelProps } = useTextField( { @@ -121,11 +121,24 @@ const BaseTokenInput = forwardRef( inputRef ); - useEffect(() => { - if (valueProp === undefined) return; + const handleChange: ChangeEventHandler = useCallback( + (e) => { + const value = e.target.value; - setValue(valueProp.toString()); - }, [valueProp]); + const isEmpty = value === ''; + const hasValidDecimalFormat = RegExp(`^\\d*(?:\\\\[.])?\\d*$`).test(escapeRegExp(value)); + const hasValidDecimalsAmount = currency ? hasCorrectDecimals(value, currency.decimals) : true; + + const isValid = hasValidDecimalFormat && hasValidDecimalsAmount; + + if (isEmpty || isValid) { + onChange?.(e); + onValueChange?.(value); + setValue(value); + } + }, + [onChange, onValueChange, currency] + ); const hasLabel = !!label || !!balance; diff --git a/packages/components/src/TokenInput/FixedTokenInput.tsx b/packages/components/src/TokenInput/FixedTokenInput.tsx index 84b36e8df..aa847ff59 100644 --- a/packages/components/src/TokenInput/FixedTokenInput.tsx +++ b/packages/components/src/TokenInput/FixedTokenInput.tsx @@ -5,11 +5,11 @@ import { BaseTokenInput, BaseTokenInputProps } from './BaseTokenInput'; import { TokenInputBalance } from './TokenInputBalance'; type Props = { + currency: any; balance?: string; humanBalance?: string | number; balanceLabel?: ReactNode; onClickBalance?: (balance: string | number) => void; - ticker: string; logoUrl: string; }; @@ -24,11 +24,11 @@ const FixedTokenInput = forwardRef( humanBalance, balanceLabel, onClickBalance, - ticker: tickerProp, logoUrl, isDisabled, id, size = 'md', + currency, ...props }, ref @@ -49,7 +49,7 @@ const FixedTokenInput = forwardRef( {...props} ref={ref} balance={balance} - endAdornment={} + endAdornment={} id={id} isDisabled={isDisabled} size={size} diff --git a/packages/components/src/TokenInput/SelectableTokenInput.tsx b/packages/components/src/TokenInput/SelectableTokenInput.tsx index 58cacd98f..345e8448c 100644 --- a/packages/components/src/TokenInput/SelectableTokenInput.tsx +++ b/packages/components/src/TokenInput/SelectableTokenInput.tsx @@ -1,11 +1,11 @@ import { chain, useId } from '@react-aria/utils'; -import { Key, ReactNode, forwardRef, useEffect, useState } from 'react'; +import { Key, ReactNode, forwardRef, useCallback, useEffect, useState } from 'react'; import { HelperText } from '../HelperText'; import { BaseTokenInput, BaseTokenInputProps } from './BaseTokenInput'; import { TokenInputBalance } from './TokenInputBalance'; -import { TokenSelect, TokenSelectProps } from './TokenSelect'; +import { TokenData, TokenSelect, TokenSelectProps } from './TokenSelect'; type Props = { balance?: string; @@ -39,17 +39,30 @@ const SelectableTokenInput = forwardRef { const selectHelperTextId = useId(); - const [ticker, setTicker] = useState(selectProps?.defaultValue?.toString()); + const defaultCurrency = (selectProps.items as TokenData[]).find( + (item) => item.currency.symbol === selectProps.defaultValue + ); + + const [currency, setCurrency] = useState(defaultCurrency); useEffect(() => { const value = selectProps?.value; if (value === undefined) return; - setTicker(value.toString()); + const tokenData = (selectProps.items as TokenData[]).find((item) => item.currency.symbol === value); + + setCurrency(tokenData?.currency); }, [selectProps?.value]); - const handleTokenChange = (ticker: Key) => setTicker(ticker as string); + const handleTokenChange = useCallback( + (ticker: Key) => { + const tokenData = (selectProps.items as TokenData[]).find((item) => item.currency.symbol === ticker); + + setCurrency(tokenData?.currency); + }, + [selectProps] + ); // Prioritise Number Input description and error message const hasNumberFieldMessages = !!(errorMessage || description); @@ -68,17 +81,17 @@ const SelectableTokenInput = forwardRef ); const balance = balanceProp !== undefined && ( @@ -88,6 +101,7 @@ const SelectableTokenInput = forwardRef void; // TODO: define type when repo moved to bob-ui - currency: any; }; type FixedAttrs = Omit; @@ -19,34 +18,43 @@ type InheritAttrs = ({ type?: 'fixed' } & FixedAttrs) | ({ type?: 'selectable' } type TokenInputProps = Props & InheritAttrs; -const TokenInput = forwardRef( - ({ type = 'fixed', defaultValue, value: valueProp, onValueChange, balance, onBlur, ...props }, ref): JSX.Element => { - const inputRef = useDOMRef(ref); +const TokenInput = forwardRef((props, ref): JSX.Element => { + const { defaultValue, value: valueProp, onValueChange, balance, onBlur, ...otherProps } = props; - const [value, setValue] = useState(defaultValue); + const inputRef = useDOMRef(ref); - const inputId = useId(); + const [value, setValue] = useState(defaultValue); - useEffect(() => { - if (valueProp === undefined) return; + const inputId = useId(); - setValue(valueProp); - }, [valueProp]); + useEffect(() => { + if (valueProp === undefined) return; - const handleChange = (e: ChangeEvent) => { + setValue(valueProp); + }, [valueProp]); + + const handleChange = useCallback( + (e: ChangeEvent) => { const value = e.target.value; onValueChange?.(value); setValue(value); - }; + }, + [onValueChange] + ); - const handleClickBalance = (balance: string) => { + const handleClickBalance = useCallback( + (balance: string) => { inputRef.current?.focus(); + setValue(balance); onValueChange?.(balance); - }; + }, + [onValueChange, inputRef.current] + ); - const handleBlur = (e: FocusEvent) => { + const handleBlur = useCallback( + (e: FocusEvent) => { const relatedTargetEl = e.relatedTarget; if (!relatedTargetEl || !relatedTargetEl.getAttribute) { @@ -58,32 +66,33 @@ const TokenInput = forwardRef( if (!isTargetingMaxBtn) { onBlur?.(e); } - }; - - const numberInputProps: Partial = - balance !== undefined - ? { - id: inputId, - balance, - onBlur: handleBlur, - onClickBalance: handleClickBalance - } - : { onBlur }; - - const commonProps = { - ...numberInputProps, - ref: inputRef, - value, - onChange: handleChange - }; - - if (type === 'selectable') { - return ; - } - - return ; + }, + [onBlur] + ); + + const numberInputProps: Partial = + balance !== undefined + ? { + id: inputId, + balance, + onBlur: handleBlur, + onClickBalance: handleClickBalance + } + : { onBlur }; + + const commonProps = { + ...numberInputProps, + ref: inputRef, + value, + onChange: handleChange + }; + + if (props.type === 'selectable') { + return ; } -); + + return ; +}); TokenInput.displayName = 'TokenInput'; diff --git a/packages/components/src/TokenInput/TokenListItem.tsx b/packages/components/src/TokenInput/TokenListItem.tsx index cde246d24..28eb0e64f 100644 --- a/packages/components/src/TokenInput/TokenListItem.tsx +++ b/packages/components/src/TokenInput/TokenListItem.tsx @@ -8,15 +8,15 @@ import { TokenData } from './TokenSelect'; type TokenListItemProps = { isDisabled?: boolean } & TokenData; -const TokenListItem = ({ balance, balanceUSD, ticker, logoUrl, isDisabled }: TokenListItemProps): JSX.Element => { - const isSelected = useSelectModalContext().selectedItem?.key === ticker && !isDisabled; +const TokenListItem = ({ balance, balanceUSD, currency, logoUrl, isDisabled }: TokenListItemProps): JSX.Element => { + const isSelected = useSelectModalContext().selectedItem?.value.currency.symbol === currency.symbol && !isDisabled; const format = useCurrencyFormatter(); return ( <> - - {ticker} + + {currency.symbol} {balance} diff --git a/packages/components/src/TokenInput/TokenSelect.tsx b/packages/components/src/TokenInput/TokenSelect.tsx index c59f55341..056e0c287 100644 --- a/packages/components/src/TokenInput/TokenSelect.tsx +++ b/packages/components/src/TokenInput/TokenSelect.tsx @@ -9,13 +9,13 @@ import { TokenListItem } from './TokenListItem'; const Value = ({ data }: { data: TokenData }) => ( - - {data.ticker} + + {data.currency.symbol} ); type TokenData = { - ticker: string; + currency: any; logoUrl: string; balance: string | number; balanceUSD: number; @@ -23,6 +23,7 @@ type TokenData = { type TokenSelectProps = Omit, 'children' | 'type'>; +// TODO: value control from currency object const TokenSelect = ({ modalProps, size, ...props }: TokenSelectProps): JSX.Element => { return ( @@ -40,7 +41,7 @@ const TokenSelect = ({ modalProps, size, ...props }: TokenSelectProps): JSX.Elem type='modal' > {(data: TokenData) => ( - + )} diff --git a/packages/components/src/TokenInput/__tests__/TokenInput.test.tsx b/packages/components/src/TokenInput/__tests__/TokenInput.test.tsx index d55c5d9ba..f1a92317e 100644 --- a/packages/components/src/TokenInput/__tests__/TokenInput.test.tsx +++ b/packages/components/src/TokenInput/__tests__/TokenInput.test.tsx @@ -7,7 +7,7 @@ import { TokenInput } from '..'; describe('TokenInput', () => { it('should render correctly', () => { - const wrapper = render(); + const wrapper = render(); expect(() => wrapper.unmount()).not.toThrow(); }); @@ -15,34 +15,34 @@ describe('TokenInput', () => { it('ref should be forwarded', () => { const ref = createRef(); - render(); + render(); expect(ref.current).not.toBeNull(); }); it('should pass a11y', async () => { - await testA11y(); + await testA11y(); }); it('should render with placeholder', () => { - render(); + render(); expect(screen.getByPlaceholderText('0.00')).toBeInTheDocument(); }); it('should render with usd value', () => { - render(); + render(); expect(screen.getByText('$10.00')).toBeInTheDocument(); }); it('should render with default value', () => { - render(); + render(); expect(screen.getByRole('textbox', { name: /label/i })).toHaveValue('10'); }); it('should display 0.01 when 0.0.1 is typed', async () => { - render(); + render(); const input = screen.getByRole('textbox', { name: /label/i }); @@ -54,7 +54,7 @@ describe('TokenInput', () => { }); it('should display max decimals', async () => { - render(); + render(); const input = screen.getByRole('textbox', { name: /label/i }); @@ -73,10 +73,9 @@ describe('TokenInput', () => { return ( @@ -98,7 +97,12 @@ describe('TokenInput', () => { it('should render description', () => { render( - + ); expect(screen.getByRole('textbox', { name: /label/i })).toHaveAccessibleDescription(/please select token$/i); @@ -106,14 +110,14 @@ describe('TokenInput', () => { describe('balance', () => { it('should render', () => { - render(); + render(); expect(screen.getByRole('definition')).toHaveTextContent('10'); }); it('should render human value', () => { render( - + ); expect(screen.getByRole('definition')).toHaveTextContent('11'); @@ -126,11 +130,10 @@ describe('TokenInput', () => { render( @@ -148,18 +151,16 @@ describe('TokenInput', () => { expect(handleClickBalance).toHaveBeenCalledWith('10'); }); - it('should apply max with correct decimals', async () => { + it('should apply max with exact decimals', async () => { const handleClickBalance = jest.fn(); const handleValueChange = jest.fn(); render( @@ -175,6 +176,31 @@ describe('TokenInput', () => { expect(handleClickBalance).toHaveBeenCalledWith('0.167345554041665262'); }); + it.only('should apply max with correct amount decimals', async () => { + const handleClickBalance = jest.fn(); + const handleValueChange = jest.fn(); + + render( + + ); + + userEvent.click(screen.getByRole('button', { name: /max/i })); + + await waitFor(() => { + expect(screen.getByRole('textbox', { name: /label/i })).toHaveValue('0.16734555'); + }); + + expect(handleValueChange).toHaveBeenCalledWith('0.16734555'); + expect(handleClickBalance).toHaveBeenCalledWith('0.16734555'); + }); + it('should not emit input onBlur when focus is in max btn', async () => { const handleClickBalance = jest.fn(); const handleBlur = jest.fn(); @@ -182,10 +208,9 @@ describe('TokenInput', () => { render( @@ -216,11 +241,10 @@ describe('TokenInput', () => { render( ); @@ -235,10 +259,9 @@ describe('TokenInput', () => { ); @@ -249,47 +272,32 @@ describe('TokenInput', () => { describe('fixed type', () => { it('should render with ticker adornment', () => { - render(); + render(); expect(screen.getByText(/btc/i)).toBeInTheDocument(); }); - - it('should render with unknown ticker', () => { - render(); - - expect(screen.getByText(/abc/i)).toBeInTheDocument(); - }); }); describe('selectable type', () => { const items = [ - { balance: 1, ticker: 'BTC', balanceUSD: 10000, logoUrl: '' }, - { balance: 2, ticker: 'ETH', balanceUSD: 900, logoUrl: '' } + { balance: 1, currency: { decimals: 6, symbol: 'BTC' }, balanceUSD: 10000, logoUrl: '' }, + { balance: 2, currency: { decimals: 18, symbol: 'ETH' }, balanceUSD: 900, logoUrl: '' } ]; it('should render correctly', async () => { - const wrapper = render( - - ); + const wrapper = render(); expect(() => wrapper.unmount()).not.toThrow(); }); it('should pass a11y', async () => { - await testA11y(); + await testA11y(); }); it('ref should be forwarded to the modal', async () => { const ref = createRef(); - render( - - ); + render(); userEvent.click(screen.getByRole('button', { name: /select token/i })); @@ -301,20 +309,13 @@ describe('TokenInput', () => { }); it('should render empty value', () => { - render(); + render(); expect(screen.getByRole('button', { name: /select token/i })).toHaveTextContent(/select token$/i); }); it('should render default value', () => { - render( - - ); + render(); expect(screen.getByRole('button', { name: /select token/i })).toHaveTextContent('BTC'); }); @@ -327,7 +328,6 @@ describe('TokenInput', () => { return ( { expect(screen.getByRole('button', { name: /select token/i })).toHaveTextContent('ETH'); }); + it('should apply correct decimals when switching currency', async () => { + render( + + ); + + const selectBtn = screen.getByRole('button', { name: /ETH/i }); + + userEvent.click(selectBtn); + + await waitFor(() => { + expect(screen.getByRole('dialog', { name: /select token/i })).toBeInTheDocument(); + }); + + const dialog = within(screen.getByRole('dialog', { name: /select token/i })); + + userEvent.click(dialog.getByRole('row', { name: 'BTC' })); + + await waitForElementToBeRemoved(screen.getByRole('dialog', { name: /select token/i })); + + await waitFor(() => { + expect(screen.getByRole('textbox', { name: /label/i })).toHaveValue('0.000000'); + }); + }); + it('should render description', () => { render( - + ); expect(screen.getByRole('button', { name: /select token/i })).toHaveAccessibleDescription( @@ -373,12 +392,7 @@ describe('TokenInput', () => { it('should render select error message', () => { render( - + ); expect(screen.getByRole('button', { name: /select token/i })).toHaveAccessibleDescription( diff --git a/packages/components/src/TokenInput/stories/FixedTokenInput.stories.tsx b/packages/components/src/TokenInput/stories/FixedTokenInput.stories.tsx index cc4d513ac..8ebb57f82 100644 --- a/packages/components/src/TokenInput/stories/FixedTokenInput.stories.tsx +++ b/packages/components/src/TokenInput/stories/FixedTokenInput.stories.tsx @@ -10,10 +10,9 @@ export default { layout: 'centered' }, args: { - ticker: 'ETH', logoUrl: 'https://ethereum-optimism.github.io/data/ETH/logo.svg', label: 'Amount', - currency: { decimals: 6 } + currency: { decimals: 6, symbol: 'ETH' } } } as Meta; diff --git a/packages/components/src/TokenInput/stories/SelectableTokenInput.stories.tsx b/packages/components/src/TokenInput/stories/SelectableTokenInput.stories.tsx index 7b03f7304..5be0e6a6b 100644 --- a/packages/components/src/TokenInput/stories/SelectableTokenInput.stories.tsx +++ b/packages/components/src/TokenInput/stories/SelectableTokenInput.stories.tsx @@ -5,11 +5,21 @@ import { mergeProps } from '@react-aria/utils'; import { TokenInput, TokenInputProps } from '..'; const items = [ - { balance: 2, ticker: 'ETH', logoUrl: 'https://ethereum-optimism.github.io/data/ETH/logo.svg', balanceUSD: 900 }, - { balance: 500, ticker: 'USDT', logoUrl: 'https://ethereum-optimism.github.io/data/USDT/logo.png', balanceUSD: 500 }, + { + balance: 2, + currency: { symbol: 'ETH', decimals: 18 }, + logoUrl: 'https://ethereum-optimism.github.io/data/ETH/logo.svg', + balanceUSD: 900 + }, + { + balance: 500, + currency: { symbol: 'USDT', decimals: 6 }, + logoUrl: 'https://ethereum-optimism.github.io/data/USDT/logo.png', + balanceUSD: 500 + }, { balance: 100, - ticker: 'USDC', + currency: { symbol: 'USDC', decimals: 6 }, logoUrl: 'https://ethereum-optimism.github.io/data/BridgedUSDC/logo.png', balanceUSD: 100 } diff --git a/packages/components/src/utils/decimals.ts b/packages/components/src/utils/decimals.ts new file mode 100644 index 000000000..fc067dc69 --- /dev/null +++ b/packages/components/src/utils/decimals.ts @@ -0,0 +1,11 @@ +export const trimDecimals = (value: string, decimals: number) => { + const decimalGroups = value.split('.'); + + if (decimalGroups.length <= 1) { + return value; + } + + const offsetDecimals = decimalGroups[1].length - decimals; + + return offsetDecimals > 0 ? value.slice(0, value.length - offsetDecimals) : value; +}; diff --git a/packages/components/src/utils/index.ts b/packages/components/src/utils/index.ts new file mode 100644 index 000000000..19a5fa3a9 --- /dev/null +++ b/packages/components/src/utils/index.ts @@ -0,0 +1 @@ +export * from './decimals';