From b488881632834c5d01c0f13a27c43bdf95a5a9fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Sim=C3=A3o?= Date: Mon, 6 May 2024 15:18:13 +0100 Subject: [PATCH] feat: improve TokenInput and use-form (#93) --- .changeset/six-weeks-hammer.md | 6 + .../src/TokenInput/BaseTokenInput.tsx | 46 +++--- .../src/TokenInput/FixedTokenInput.tsx | 6 +- .../src/TokenInput/SelectableTokenInput.tsx | 43 +++-- .../components/src/TokenInput/TokenInput.tsx | 134 ++++++++++----- .../src/TokenInput/TokenListItem.tsx | 8 +- .../components/src/TokenInput/TokenSelect.tsx | 9 +- .../TokenInput/__tests__/TokenInput.test.tsx | 155 +++++++++++------- .../stories/FixedTokenInput.stories.tsx | 3 +- .../stories/SelectableTokenInput.stories.tsx | 34 ++-- packages/components/src/utils/decimals.ts | 11 ++ packages/components/src/utils/index.ts | 1 + .../src/use-form/__tests__/use-form.test.tsx | 42 ++++- .../src/use-form/stories/use-form.stories.tsx | 51 +++++- packages/hooks/src/use-form/use-form.tsx | 33 +++- 15 files changed, 400 insertions(+), 182 deletions(-) create mode 100644 .changeset/six-weeks-hammer.md create mode 100644 packages/components/src/utils/decimals.ts create mode 100644 packages/components/src/utils/index.ts diff --git a/.changeset/six-weeks-hammer.md b/.changeset/six-weeks-hammer.md new file mode 100644 index 000000000..f36e00a6f --- /dev/null +++ b/.changeset/six-weeks-hammer.md @@ -0,0 +1,6 @@ +--- +"@interlay/ui": patch +"@interlay/hooks": patch +--- + +Fix/token input decimal undefined diff --git a/packages/components/src/TokenInput/BaseTokenInput.tsx b/packages/components/src/TokenInput/BaseTokenInput.tsx index 122ed8380..1ff39bb38 100644 --- a/packages/components/src/TokenInput/BaseTokenInput.tsx +++ b/packages/components/src/TokenInput/BaseTokenInput.tsx @@ -2,7 +2,7 @@ import { useCurrencyFormatter, useDOMRef } from '@interlay/hooks'; import { Spacing, TokenInputSize } from '@interlay/theme'; import { AriaTextFieldOptions, useTextField } from '@react-aria/textfield'; import { mergeProps } from '@react-aria/utils'; -import { ChangeEventHandler, FocusEvent, forwardRef, ReactNode, useCallback, useEffect, useState } from 'react'; +import { ChangeEventHandler, FocusEvent, forwardRef, ReactNode, useCallback } from 'react'; import { HelperTextProps } from '../HelperText'; import { LabelProps } from '../Label'; @@ -42,7 +42,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; @@ -75,7 +75,7 @@ const BaseTokenInput = forwardRef( size = 'md', defaultValue, inputMode, - value: valueProp, + value, endAdornment, currency, onChange, @@ -84,49 +84,43 @@ const BaseTokenInput = forwardRef( }, ref ): JSX.Element => { - const [value, setValue] = useState(defaultValue?.toString()); const inputRef = useDOMRef(ref); const format = useCurrencyFormatter(); + const { inputProps, descriptionProps, errorMessageProps, labelProps } = useTextField( + { + ...props, + label, + inputMode, + isInvalid: isInvalid || !!props.errorMessage, + value, + onChange: () => {}, + defaultValue, + placeholder, + autoComplete: 'off' + }, + inputRef + ); + const handleChange: ChangeEventHandler = useCallback( (e) => { const value = e.target.value; const isEmpty = value === ''; const hasValidDecimalFormat = RegExp(`^\\d*(?:\\\\[.])?\\d*$`).test(escapeRegExp(value)); - const hasValidDecimalsAmount = hasCorrectDecimals(value, currency.decimals); + const hasValidDecimalsAmount = currency ? hasCorrectDecimals(value, currency.decimals) : true; const isValid = hasValidDecimalFormat && hasValidDecimalsAmount; if (isEmpty || isValid) { onChange?.(e); onValueChange?.(value); - setValue(value); } }, - [onChange, onValueChange] - ); - - const { inputProps, descriptionProps, errorMessageProps, labelProps } = useTextField( - { - ...props, - label, - inputMode, - isInvalid: isInvalid || !!props.errorMessage, - value: value, - placeholder, - autoComplete: 'off' - }, - inputRef + [onChange, onValueChange, currency] ); - useEffect(() => { - if (valueProp === undefined) return; - - setValue(valueProp.toString()); - }, [valueProp]); - const hasLabel = !!label || !!balance; // FIXME: move this into Field 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..8bc86e556 100644 --- a/packages/components/src/TokenInput/SelectableTokenInput.tsx +++ b/packages/components/src/TokenInput/SelectableTokenInput.tsx @@ -1,18 +1,21 @@ import { chain, useId } from '@react-aria/utils'; -import { Key, ReactNode, forwardRef, useEffect, useState } from 'react'; +import { Key, ReactNode, forwardRef, useCallback, useEffect } 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; humanBalance?: string | number; balanceLabel?: ReactNode; + currency?: any; + items?: TokenData[]; onClickBalance?: (balance: string) => void; - selectProps: Omit; + onChangeCurrency?: (currency?: any) => void; + selectProps?: Omit; }; type InheritAttrs = Omit; @@ -31,7 +34,10 @@ const SelectableTokenInput = forwardRef { const selectHelperTextId = useId(); - const [ticker, setTicker] = useState(selectProps?.defaultValue?.toString()); - useEffect(() => { - const value = selectProps?.value; + if (selectProps?.value === undefined) return; + + const item = (items as TokenData[]).find((item) => item.currency.symbol === selectProps?.value); - if (value === undefined) return; + onChangeCurrency?.(item?.currency); + }, [selectProps?.value, onChangeCurrency]); - setTicker(value.toString()); - }, [selectProps?.value]); + const handleSelectionChange = useCallback( + (ticker: Key) => { + const tokenData = (items as TokenData[]).find((item) => item.currency.symbol === ticker); - const handleTokenChange = (ticker: Key) => setTicker(ticker as string); + onChangeCurrency?.(tokenData?.currency); + }, + [selectProps] + ); // Prioritise Number Input description and error message const hasNumberFieldMessages = !!(errorMessage || description); @@ -58,7 +69,7 @@ const SelectableTokenInput = forwardRef ); const balance = balanceProp !== undefined && ( @@ -88,6 +100,7 @@ const SelectableTokenInput = forwardRef { + switch (props.type) { + default: + case 'fixed': + return (props as FixedTokenInputProps).currency; + case 'selectable': + return (props.items || []).find((item) => item.currency.symbol === props.selectProps?.defaultValue); + } +}; + type Props = { onValueChange?: (value?: string | number) => void; - // TODO: define type when repo moved to bob-ui - currency: any; }; type FixedAttrs = Omit; -type SelectableAttrs = Omit; +type SelectableAttrs = Omit; type InheritAttrs = ({ type?: 'fixed' } & FixedAttrs) | ({ type?: 'selectable' } & SelectableAttrs); 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 inputRef = useDOMRef(ref); - const [value, setValue] = useState(defaultValue); + const [value, setValue] = useState(defaultValue); + const [currency, setCurrency] = useState(getDefaultCurrency(props)); - const inputId = useId(); + const inputId = useId(); - useEffect(() => { - if (valueProp === undefined) return; + useEffect(() => { + if (valueProp === undefined) return; - setValue(valueProp); - }, [valueProp]); + setValue(valueProp); + }, [valueProp]); + + useEffect(() => { + if (value && currency) { + const trimmedValue = trimDecimals(value, currency.decimals); + + if (value !== trimmedValue) { + setValue(trimmedValue); + onValueChange?.(trimmedValue); + } + } + }, [currency]); - const handleChange = (e: ChangeEvent) => { + const handleChange = useCallback( + (e: ChangeEvent) => { const value = e.target.value; onValueChange?.(value); setValue(value); - }; + }, + [onValueChange] + ); + + const handleClickBalance = useCallback( + (balance: string) => { + if (!currency) return; - const handleClickBalance = (balance: string) => { inputRef.current?.focus(); - setValue(balance); - onValueChange?.(balance); - }; - const handleBlur = (e: FocusEvent) => { + const value = trimDecimals(balance, currency.decimals); + + setValue(value); + onValueChange?.(value); + }, + [onValueChange, inputRef.current, currency] + ); + + const handleBlur = useCallback( + (e: FocusEvent) => { const relatedTargetEl = e.relatedTarget; if (!relatedTargetEl || !relatedTargetEl.getAttribute) { @@ -58,32 +93,41 @@ 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 handleChangeCurrency = useCallback((currency: any) => setCurrency(currency), []); + + 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..5f9210d7a 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,28 @@ describe('TokenInput', () => { expect(handleClickBalance).toHaveBeenCalledWith('0.167345554041665262'); }); + it('should apply max with correct amount decimals', async () => { + 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'); + }); + it('should not emit input onBlur when focus is in max btn', async () => { const handleClickBalance = jest.fn(); const handleBlur = jest.fn(); @@ -182,10 +205,9 @@ describe('TokenInput', () => { render( @@ -216,11 +238,10 @@ describe('TokenInput', () => { render( ); @@ -235,10 +256,9 @@ describe('TokenInput', () => { ); @@ -249,47 +269,37 @@ 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 currencies = [ + { decimals: 6, symbol: 'BTC' }, + { decimals: 18, symbol: 'ETH' } + ]; + const items = [ - { balance: 1, ticker: 'BTC', balanceUSD: 10000, logoUrl: '' }, - { balance: 2, ticker: 'ETH', balanceUSD: 900, logoUrl: '' } + { balance: 1, currency: currencies[0], balanceUSD: 10000, logoUrl: '' }, + { balance: 2, currency: currencies[1], 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,7 +311,7 @@ describe('TokenInput', () => { }); it('should render empty value', () => { - render(); + render(); expect(screen.getByRole('button', { name: /select token/i })).toHaveTextContent(/select token$/i); }); @@ -309,9 +319,9 @@ describe('TokenInput', () => { it('should render default value', () => { render( ); @@ -321,15 +331,16 @@ describe('TokenInput', () => { it('should control value', async () => { const Component = () => { - const [value, setValue] = useState('BTC'); + const [value, setValue] = useState(currencies[0]); - const handleSelectionChange = (key: Key) => setValue(key.toString()); + const handleSelectionChange = (key: Key) => + setValue(currencies.find((currency) => currency.symbol === key.toString())); return ( ); @@ -356,12 +367,42 @@ describe('TokenInput', () => { 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( ); @@ -374,9 +415,9 @@ describe('TokenInput', () => { it('should render select error message', () => { render( ); 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..9dfbc9673 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 } @@ -24,9 +34,7 @@ export default { args: { type: 'selectable', label: 'Amount', - selectProps: { - items - } + items } } as Meta; @@ -35,19 +43,18 @@ export const Selectable: StoryObj = {}; export const SelectableDefaultValue: StoryObj = { args: { selectProps: { - defaultValue: 'ETH', - items + defaultValue: 'ETH' } } }; const ControlledSelectComponent = (args: any) => { - const [state, setState] = useState(); + const [state, setState] = useState(items[0].currency); return ( ); @@ -67,7 +74,6 @@ export const SelectableWithBalance: StoryObj = { export const SelectableDescription: StoryObj = { args: { selectProps: { - items, description: 'Please select a token' } } @@ -75,17 +81,13 @@ export const SelectableDescription: StoryObj = { export const SelectableInputErrorMessage: StoryObj = { args: { - errorMessage: 'Token field is required', - selectProps: { - items - } + errorMessage: 'Token field is required' } }; export const SelectableErrorMessage: StoryObj = { args: { selectProps: { - items, errorMessage: 'Token field is required' } } 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'; 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 ff61a4f8b..d0a5683c2 100644 --- a/packages/hooks/src/use-form/__tests__/use-form.test.tsx +++ b/packages/hooks/src/use-form/__tests__/use-form.test.tsx @@ -182,16 +182,16 @@ describe('useForm', () => { }); describe('TokenInput', () => { - const commonProps = { initialValues: { amount: '' }, onSubmit: handleSubmit }; + const commonProps = { initialValues: { amount: '', currency: '' }, onSubmit: handleSubmit }; - it('should set field value', async () => { + it('should set amount field value', async () => { const { result } = renderHook(() => useForm<{ amount: string }>(commonProps)); const props = result.current.getTokenFieldProps('amount'); render(
- + ); @@ -204,6 +204,42 @@ describe('useForm', () => { expect(result.current.values.amount).toBe('1'); }); }); + + it('should set currency field value', async () => { + const { result } = renderHook(() => useForm<{ amount: string; currency: string }>(commonProps)); + + const props = result.current.getSelectableTokenFieldProps({ amount: 'amount', currency: 'currency' }); + + render( +
+ + + + ); + + const selectBtn = screen.getByRole('button', { name: /select token/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(result.current.values.currency).toBe('BTC'); + }); + }); }); describe('Select', () => { diff --git a/packages/hooks/src/use-form/stories/use-form.stories.tsx b/packages/hooks/src/use-form/stories/use-form.stories.tsx index b778fb0da..18b52c6f7 100644 --- a/packages/hooks/src/use-form/stories/use-form.stories.tsx +++ b/packages/hooks/src/use-form/stories/use-form.stories.tsx @@ -1,7 +1,7 @@ import { Meta, StoryObj } from '@storybook/react'; import { mergeProps } from '@react-aria/utils'; -import { Select, Item, Flex, SelectProps } from '../../../../components/src'; +import { Select, Item, Flex, SelectProps, TokenInputProps, TokenInput } from '../../../../components/src'; import { useForm } from '../use-form'; export default { @@ -49,3 +49,52 @@ export const SelectField: StoryObj = { }, render: RenderSelect }; + +const items = [ + { + 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, + currency: { symbol: 'USDC', decimals: 6 }, + logoUrl: 'https://ethereum-optimism.github.io/data/BridgedUSDC/logo.png', + balanceUSD: 100 + } +]; + +const RenderTokenInput = (args: TokenInputProps) => { + const form = useForm<{ amount: string; currency: '' }>({ + initialValues: { amount: '', currency: '' }, + onSubmit: alert + }); + + return ( + <> +

Form object

+

Touched: {JSON.stringify(form.touched)}

+

Dirty: {JSON.stringify(form.dirty)}

+

Values: {JSON.stringify(form.values)}

+ + + ); +}; + +export const TokenInputField: StoryObj = { + args: { + label: 'Amount' + }, + render: RenderTokenInput +}; diff --git a/packages/hooks/src/use-form/use-form.tsx b/packages/hooks/src/use-form/use-form.tsx index b34d6ded8..df0bb68b2 100644 --- a/packages/hooks/src/use-form/use-form.tsx +++ b/packages/hooks/src/use-form/use-form.tsx @@ -2,6 +2,8 @@ import { FieldInputProps, FormikConfig, FormikErrors as FormErrors, FormikValues import { FocusEvent, Key, useCallback } from 'react'; import { useDebounce } from 'react-use'; +import { TokenSelectProps } from '../../../components/src'; + const shouldEmitBlur = (relatedTargetEl?: HTMLElement, targetEl?: HTMLElement) => { if (!relatedTargetEl || !targetEl) { return true; @@ -33,9 +35,17 @@ type GetFieldProps = (nameOrOptions: any) => FieldInputProps & { errorMessage?: string | string[]; }; -type GetTokenFieldProps = ( - nameOrOptions: any -) => Omit, 'onChange'> & { onValueChange?: (value?: string | number) => void }; +type GetTokenFieldProps = (nameOrOptions: any) => Omit, 'onChange'> & { + onValueChange?: (value?: string | number) => void; +}; + +type GetSelectableTokenFieldProps = (fields: { amount: any; currency: any }) => Omit< + ReturnType, + 'onChange' +> & { + onValueChange?: (value?: string | number) => void; + selectProps: Omit; +}; type GetSelectFieldProps = ( nameOrOptions: any @@ -104,9 +114,9 @@ const useForm = ({ ); const getTokenFieldProps: GetTokenFieldProps = useCallback( - (nameOrOptions: any) => { - const props = getFieldProps(nameOrOptions); - const fieldName = getFieldName(nameOrOptions); + (amount) => { + const props = getFieldProps(amount); + const fieldName = getFieldName(amount); return { ...props, @@ -145,11 +155,22 @@ const useForm = ({ [getFieldProps, setFieldValue] ); + const getSelectableTokenFieldProps: GetSelectableTokenFieldProps = useCallback( + ({ amount, currency }) => { + return { + ...getTokenFieldProps(amount), + selectProps: getSelectFieldProps(currency) + }; + }, + [getFieldProps, setFieldValue, getTokenFieldProps, getSelectFieldProps] + ); + return { values, validateForm, getFieldProps, getSelectFieldProps, + getSelectableTokenFieldProps, setFieldTouched, setFieldValue, getTokenFieldProps,