From f99474c2e9c9876139d117b0ad192c105cba2428 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Sim=C3=A3o?= Date: Thu, 2 May 2024 17:45:23 +0100 Subject: [PATCH] fix: token input decimals handling (#91) --- .changeset/strong-bananas-breathe.md | 5 + .../src/TokenInput/BaseTokenInput.tsx | 21 ++- .../src/TokenInput/FixedTokenInput.tsx | 2 +- .../src/TokenInput/SelectableTokenInput.tsx | 6 +- .../components/src/TokenInput/TokenInput.tsx | 4 +- .../src/TokenInput/TokenInputBalance.tsx | 6 +- .../TokenInput/__tests__/TokenInput.test.tsx | 152 +++++++++++++++--- .../stories/FixedTokenInput.stories.tsx | 5 +- .../src/use-form/__tests__/use-form.test.tsx | 2 +- 9 files changed, 163 insertions(+), 40 deletions(-) create mode 100644 .changeset/strong-bananas-breathe.md diff --git a/.changeset/strong-bananas-breathe.md b/.changeset/strong-bananas-breathe.md new file mode 100644 index 000000000..3b68a59ee --- /dev/null +++ b/.changeset/strong-bananas-breathe.md @@ -0,0 +1,5 @@ +--- +"@interlay/ui": patch +--- + +fix: token input decimals handling diff --git a/packages/components/src/TokenInput/BaseTokenInput.tsx b/packages/components/src/TokenInput/BaseTokenInput.tsx index cb05be868..122ed8380 100644 --- a/packages/components/src/TokenInput/BaseTokenInput.tsx +++ b/packages/components/src/TokenInput/BaseTokenInput.tsx @@ -24,6 +24,12 @@ const escapeRegExp = (string: string): string => { return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); }; +const hasCorrectDecimals = (value: string, decimals: number) => { + const decimalGroups = value.split('.'); + + return decimalGroups.length > 1 ? decimalGroups[1].length <= decimals : true; +}; + type Props = { valueUSD?: number; balance?: ReactNode; @@ -33,8 +39,10 @@ type Props = { size?: TokenInputSize; isInvalid?: boolean; minHeight?: Spacing; - value?: string | number; - defaultValue?: string | number; + value?: string; + defaultValue?: string; + // TODO: use Currency from bob-ui + currency: { decimals: number }; onValueChange?: (value: string | number) => void; onChange?: (e: React.ChangeEvent) => void; onFocus?: (e: FocusEvent) => void; @@ -69,6 +77,7 @@ const BaseTokenInput = forwardRef( inputMode, value: valueProp, endAdornment, + currency, onChange, onValueChange, ...props @@ -84,9 +93,13 @@ const BaseTokenInput = forwardRef( (e) => { const value = e.target.value; - const isValid = value === '' || RegExp(`^\\d*(?:\\\\[.])?\\d*$`).test(escapeRegExp(value)); + const isEmpty = value === ''; + const hasValidDecimalFormat = RegExp(`^\\d*(?:\\\\[.])?\\d*$`).test(escapeRegExp(value)); + const hasValidDecimalsAmount = hasCorrectDecimals(value, currency.decimals); + + const isValid = hasValidDecimalFormat && hasValidDecimalsAmount; - if (isValid) { + if (isEmpty || isValid) { onChange?.(e); onValueChange?.(value); setValue(value); diff --git a/packages/components/src/TokenInput/FixedTokenInput.tsx b/packages/components/src/TokenInput/FixedTokenInput.tsx index 1ad51d2aa..84b36e8df 100644 --- a/packages/components/src/TokenInput/FixedTokenInput.tsx +++ b/packages/components/src/TokenInput/FixedTokenInput.tsx @@ -5,7 +5,7 @@ import { BaseTokenInput, BaseTokenInputProps } from './BaseTokenInput'; import { TokenInputBalance } from './TokenInputBalance'; type Props = { - balance?: string | number; + balance?: string; humanBalance?: string | number; balanceLabel?: ReactNode; onClickBalance?: (balance: string | number) => void; diff --git a/packages/components/src/TokenInput/SelectableTokenInput.tsx b/packages/components/src/TokenInput/SelectableTokenInput.tsx index 47a8f79c2..58cacd98f 100644 --- a/packages/components/src/TokenInput/SelectableTokenInput.tsx +++ b/packages/components/src/TokenInput/SelectableTokenInput.tsx @@ -8,10 +8,10 @@ import { TokenInputBalance } from './TokenInputBalance'; import { TokenSelect, TokenSelectProps } from './TokenSelect'; type Props = { - balance?: string | number; + balance?: string; humanBalance?: string | number; balanceLabel?: ReactNode; - onClickBalance?: (balance: string | number) => void; + onClickBalance?: (balance: string) => void; selectProps: Omit; }; @@ -75,7 +75,7 @@ const SelectableTokenInput = forwardRef void; + // TODO: define type when repo moved to bob-ui + currency: any; }; type FixedAttrs = Omit; @@ -38,7 +40,7 @@ const TokenInput = forwardRef( setValue(value); }; - const handleClickBalance = (balance: string | number) => { + const handleClickBalance = (balance: string) => { inputRef.current?.focus(); setValue(balance); onValueChange?.(balance); diff --git a/packages/components/src/TokenInput/TokenInputBalance.tsx b/packages/components/src/TokenInput/TokenInputBalance.tsx index cc8511b45..47ba00eb1 100644 --- a/packages/components/src/TokenInput/TokenInputBalance.tsx +++ b/packages/components/src/TokenInput/TokenInputBalance.tsx @@ -6,9 +6,9 @@ import { StyledBalance, StyledMaxButton } from './TokenInput.style'; type TokenInputBalanceProps = { inputId?: string; - balance: string | number; + balance: string; balanceHuman?: string | number; - onClickBalance?: (balance: string | number) => void; + onClickBalance?: (balance: string) => void; isDisabled?: boolean; label?: ReactNode; }; @@ -21,7 +21,7 @@ const TokenInputBalance = ({ isDisabled: isDisabledProp, label = 'Balance' }: TokenInputBalanceProps): JSX.Element => { - const isDisabled = isDisabledProp || balanceProp === 0; + const isDisabled = isDisabledProp || Number(balanceProp) === 0; const ariaLabel = typeof label === 'string' ? `apply max ${label}` : 'apply max'; diff --git a/packages/components/src/TokenInput/__tests__/TokenInput.test.tsx b/packages/components/src/TokenInput/__tests__/TokenInput.test.tsx index e68edf3f9..d55c5d9ba 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,39 +15,72 @@ 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(); + + const input = screen.getByRole('textbox', { name: /label/i }); + + userEvent.type(input, '0.0.1'); + + await waitFor(() => { + expect(input).toHaveValue('0.01'); + }); + }); + + it('should display max decimals', async () => { + render(); + + const input = screen.getByRole('textbox', { name: /label/i }); + + userEvent.type(input, '0.0000001'); + + await waitFor(() => { + expect(input).toHaveValue('0.000000'); + }); + }); + it('should control value', async () => { const Component = () => { const [value, setValue] = useState('1'); const handleValueChange = (value?: string | number) => setValue(value?.toString() || ''); - return ; + return ( + + ); }; render(); @@ -64,20 +97,24 @@ describe('TokenInput', () => { }); it('should render description', () => { - render(); + render( + + ); expect(screen.getByRole('textbox', { name: /label/i })).toHaveAccessibleDescription(/please select token$/i); }); describe('balance', () => { it('should render', () => { - render(); + render(); expect(screen.getByRole('definition')).toHaveTextContent('10'); }); it('should render human value', () => { - render(); + render( + + ); expect(screen.getByRole('definition')).toHaveTextContent('11'); }); @@ -88,7 +125,8 @@ describe('TokenInput', () => { render( { }); expect(handleValueChange).toHaveBeenCalledTimes(1); - expect(handleValueChange).toHaveBeenCalledWith(10); + expect(handleValueChange).toHaveBeenCalledWith('10'); expect(handleClickBalance).toHaveBeenCalledTimes(1); - expect(handleClickBalance).toHaveBeenCalledWith(10); + expect(handleClickBalance).toHaveBeenCalledWith('10'); + }); + + it('should apply max with correct 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.167345554041665262'); + }); + + expect(handleValueChange).toHaveBeenCalledWith('0.167345554041665262'); + expect(handleClickBalance).toHaveBeenCalledWith('0.167345554041665262'); }); it('should not emit input onBlur when focus is in max btn', async () => { @@ -116,7 +181,8 @@ describe('TokenInput', () => { render( { render( { const handleClickBalance = jest.fn(); render( - + ); expect(screen.getByRole('button', { name: /max/i })).toBeDisabled(); @@ -174,13 +249,13 @@ 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(); + render(); expect(screen.getByText(/abc/i)).toBeInTheDocument(); }); @@ -193,19 +268,28 @@ describe('TokenInput', () => { ]; 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 })); @@ -217,13 +301,20 @@ 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'); }); @@ -236,6 +327,7 @@ describe('TokenInput', () => { return ( { it('should render description', () => { render( - + ); expect(screen.getByRole('button', { name: /select token/i })).toHaveAccessibleDescription( @@ -276,7 +373,12 @@ 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 09cfdbbaf..cc4d513ac 100644 --- a/packages/components/src/TokenInput/stories/FixedTokenInput.stories.tsx +++ b/packages/components/src/TokenInput/stories/FixedTokenInput.stories.tsx @@ -12,7 +12,8 @@ export default { args: { ticker: 'ETH', logoUrl: 'https://ethereum-optimism.github.io/data/ETH/logo.svg', - label: 'Amount' + label: 'Amount', + currency: { decimals: 6 } } } as Meta; @@ -25,7 +26,7 @@ export const DefaultValue: StoryObj = { }; const ControlledComponent = ({ value, valueUSD: valueUSDProp, ...args }: TokenInputProps) => { - const [state, setState] = useState(value?.toString()); + const [state, setState] = useState(value); const valueUSD = valueUSDProp === undefined ? undefined : isNaN(state as any) ? 0 : Number(state) * 10; diff --git a/packages/hooks/src/use-form/__tests__/use-form.test.tsx b/packages/hooks/src/use-form/__tests__/use-form.test.tsx index cde4e08fe..ff61a4f8b 100644 --- a/packages/hooks/src/use-form/__tests__/use-form.test.tsx +++ b/packages/hooks/src/use-form/__tests__/use-form.test.tsx @@ -191,7 +191,7 @@ describe('useForm', () => { render(
- + );