diff --git a/packages/components/src/TokenInput/BaseTokenInput.tsx b/packages/components/src/TokenInput/BaseTokenInput.tsx index d04486495..1ff39bb38 100644 --- a/packages/components/src/TokenInput/BaseTokenInput.tsx +++ b/packages/components/src/TokenInput/BaseTokenInput.tsx @@ -2,9 +2,8 @@ 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 { trimDecimals } from '../utils'; import { HelperTextProps } from '../HelperText'; import { LabelProps } from '../Label'; import { Field, FieldProps, useFieldProps } from '../Field'; @@ -76,7 +75,7 @@ const BaseTokenInput = forwardRef( size = 'md', defaultValue, inputMode, - value: valueProp, + value, endAdornment, currency, onChange, @@ -85,36 +84,19 @@ const BaseTokenInput = forwardRef( }, ref ): JSX.Element => { - const [value, setValue] = useState(defaultValue?.toString()); const inputRef = useDOMRef(ref); const format = useCurrencyFormatter(); - // Observes currency field and correct decimals places if needed - useEffect(() => { - if (value && currency) { - const trimmedValue = trimDecimals(value, currency.decimals); - - if (value !== trimmedValue) { - setValue(trimmedValue); - onValueChange?.(trimmedValue); - } - } - }, [currency]); - - useEffect(() => { - if (valueProp === undefined) return; - - setValue(valueProp.toString()); - }, [valueProp]); - const { inputProps, descriptionProps, errorMessageProps, labelProps } = useTextField( { ...props, label, inputMode, isInvalid: isInvalid || !!props.errorMessage, - value: value, + value, + onChange: () => {}, + defaultValue, placeholder, autoComplete: 'off' }, @@ -134,7 +116,6 @@ const BaseTokenInput = forwardRef( if (isEmpty || isValid) { onChange?.(e); onValueChange?.(value); - setValue(value); } }, [onChange, onValueChange, currency] diff --git a/packages/components/src/TokenInput/SelectableTokenInput.tsx b/packages/components/src/TokenInput/SelectableTokenInput.tsx index 345e8448c..8bc86e556 100644 --- a/packages/components/src/TokenInput/SelectableTokenInput.tsx +++ b/packages/components/src/TokenInput/SelectableTokenInput.tsx @@ -1,5 +1,5 @@ import { chain, useId } from '@react-aria/utils'; -import { Key, ReactNode, forwardRef, useCallback, useEffect, useState } from 'react'; +import { Key, ReactNode, forwardRef, useCallback, useEffect } from 'react'; import { HelperText } from '../HelperText'; @@ -11,8 +11,11 @@ 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 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; + if (selectProps?.value === undefined) return; - const tokenData = (selectProps.items as TokenData[]).find((item) => item.currency.symbol === value); + const item = (items as TokenData[]).find((item) => item.currency.symbol === selectProps?.value); - setCurrency(tokenData?.currency); - }, [selectProps?.value]); + onChangeCurrency?.(item?.currency); + }, [selectProps?.value, onChangeCurrency]); - const handleTokenChange = useCallback( + const handleSelectionChange = useCallback( (ticker: Key) => { - const tokenData = (selectProps.items as TokenData[]).find((item) => item.currency.symbol === ticker); + const tokenData = (items as TokenData[]).find((item) => item.currency.symbol === ticker); - setCurrency(tokenData?.currency); + onChangeCurrency?.(tokenData?.currency); }, [selectProps] ); @@ -71,7 +69,7 @@ const SelectableTokenInput = forwardRef ); diff --git a/packages/components/src/TokenInput/TokenInput.tsx b/packages/components/src/TokenInput/TokenInput.tsx index e57050bdb..f32be1816 100644 --- a/packages/components/src/TokenInput/TokenInput.tsx +++ b/packages/components/src/TokenInput/TokenInput.tsx @@ -2,17 +2,28 @@ import { useDOMRef } from '@interlay/hooks'; import { mergeProps, useId } from '@react-aria/utils'; import { ChangeEvent, FocusEvent, forwardRef, useCallback, useEffect, useState } from 'react'; +import { trimDecimals } from '../utils'; + import { FixedTokenInput, FixedTokenInputProps } from './FixedTokenInput'; import { SelectableTokenInput, SelectableTokenInputProps } from './SelectableTokenInput'; +const getDefaultCurrency = (props: TokenInputProps) => { + 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 }; type FixedAttrs = Omit; -type SelectableAttrs = Omit; +type SelectableAttrs = Omit; type InheritAttrs = ({ type?: 'fixed' } & FixedAttrs) | ({ type?: 'selectable' } & SelectableAttrs); @@ -24,6 +35,7 @@ const TokenInput = forwardRef((props, ref): J const inputRef = useDOMRef(ref); const [value, setValue] = useState(defaultValue); + const [currency, setCurrency] = useState(getDefaultCurrency(props)); const inputId = useId(); @@ -33,6 +45,17 @@ const TokenInput = forwardRef((props, ref): J setValue(valueProp); }, [valueProp]); + useEffect(() => { + if (value && currency) { + const trimmedValue = trimDecimals(value, currency.decimals); + + if (value !== trimmedValue) { + setValue(trimmedValue); + onValueChange?.(trimmedValue); + } + } + }, [currency]); + const handleChange = useCallback( (e: ChangeEvent) => { const value = e.target.value; @@ -45,12 +68,16 @@ const TokenInput = forwardRef((props, ref): J const handleClickBalance = useCallback( (balance: string) => { + if (!currency) return; + inputRef.current?.focus(); - setValue(balance); - onValueChange?.(balance); + const value = trimDecimals(balance, currency.decimals); + + setValue(value); + onValueChange?.(value); }, - [onValueChange, inputRef.current] + [onValueChange, inputRef.current, currency] ); const handleBlur = useCallback( @@ -70,6 +97,8 @@ const TokenInput = forwardRef((props, ref): J [onBlur] ); + const handleChangeCurrency = useCallback((currency: any) => setCurrency(currency), []); + const numberInputProps: Partial = balance !== undefined ? { @@ -88,10 +117,16 @@ const TokenInput = forwardRef((props, ref): J }; if (props.type === 'selectable') { - return ; + return ( + + ); } - return ; + return ; }); TokenInput.displayName = 'TokenInput'; diff --git a/packages/components/src/TokenInput/__tests__/TokenInput.test.tsx b/packages/components/src/TokenInput/__tests__/TokenInput.test.tsx index f1a92317e..5f9210d7a 100644 --- a/packages/components/src/TokenInput/__tests__/TokenInput.test.tsx +++ b/packages/components/src/TokenInput/__tests__/TokenInput.test.tsx @@ -176,8 +176,7 @@ describe('TokenInput', () => { expect(handleClickBalance).toHaveBeenCalledWith('0.167345554041665262'); }); - it.only('should apply max with correct amount decimals', async () => { - const handleClickBalance = jest.fn(); + it('should apply max with correct amount decimals', async () => { const handleValueChange = jest.fn(); render( @@ -186,7 +185,6 @@ describe('TokenInput', () => { currency={{ decimals: 8, symbol: 'BTC' }} label='label' logoUrl='' - onClickBalance={handleClickBalance} onValueChange={handleValueChange} /> ); @@ -198,7 +196,6 @@ describe('TokenInput', () => { }); expect(handleValueChange).toHaveBeenCalledWith('0.16734555'); - expect(handleClickBalance).toHaveBeenCalledWith('0.16734555'); }); it('should not emit input onBlur when focus is in max btn', async () => { @@ -279,25 +276,30 @@ describe('TokenInput', () => { }); describe('selectable type', () => { + const currencies = [ + { decimals: 6, symbol: 'BTC' }, + { decimals: 18, symbol: 'ETH' } + ]; + const items = [ - { balance: 1, currency: { decimals: 6, symbol: 'BTC' }, balanceUSD: 10000, logoUrl: '' }, - { balance: 2, currency: { decimals: 18, symbol: '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 })); @@ -309,27 +311,36 @@ 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'); }); 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 ( ); @@ -358,7 +369,13 @@ describe('TokenInput', () => { it('should apply correct decimals when switching currency', async () => { render( - + ); const selectBtn = screen.getByRole('button', { name: /ETH/i }); @@ -382,7 +399,12 @@ describe('TokenInput', () => { it('should render description', () => { render( - + ); expect(screen.getByRole('button', { name: /select token/i })).toHaveAccessibleDescription( @@ -392,7 +414,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/SelectableTokenInput.stories.tsx b/packages/components/src/TokenInput/stories/SelectableTokenInput.stories.tsx index 5be0e6a6b..9dfbc9673 100644 --- a/packages/components/src/TokenInput/stories/SelectableTokenInput.stories.tsx +++ b/packages/components/src/TokenInput/stories/SelectableTokenInput.stories.tsx @@ -34,9 +34,7 @@ export default { args: { type: 'selectable', label: 'Amount', - selectProps: { - items - } + items } } as Meta; @@ -45,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 ( ); @@ -77,7 +74,6 @@ export const SelectableWithBalance: StoryObj = { export const SelectableDescription: StoryObj = { args: { selectProps: { - items, description: 'Please select a token' } } @@ -85,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/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,