diff --git a/src/components/Form/From/AvailableActions/index.tsx b/src/components/Form/From/AvailableActions/index.tsx index 987a192d..a1be8a49 100644 --- a/src/components/Form/From/AvailableActions/index.tsx +++ b/src/components/Form/From/AvailableActions/index.tsx @@ -1,21 +1,48 @@ +import Big from 'big.js'; +import { trimToMaxDecimals, USER_INPUT_MAX_DECIMALS } from '../../../../shared/parseNumbers/maxDecimals'; +import { stringifyBigWithSignificantDecimals } from '../../../../shared/parseNumbers/metric'; + interface AvailableActionsProps { - max?: number; - setValue?: (n: number) => void; + max?: number | Big; + maxDecimals?: number; + setValue?: (n: string) => void; hideAvailableBalance?: boolean; } -export const AvailableActions = ({ max, setValue, hideAvailableBalance = false }: AvailableActionsProps) => ( -
- {max !== undefined && setValue !== undefined && ( - <> - {hideAvailableBalance ? <> : Available: {max.toFixed(2)}} - - - - )} -
-); +export const AvailableActions = ({ + max, + maxDecimals = USER_INPUT_MAX_DECIMALS.PENDULUM, + setValue, + hideAvailableBalance, +}: AvailableActionsProps) => { + const handleSetValue = (percentage: number) => { + if (max !== undefined && setValue !== undefined) { + const maxBig = Big(max); + const trimmedValue = trimToMaxDecimals(maxBig.mul(percentage).toString(), maxDecimals); + setValue(trimmedValue); + } + }; + + const handleSetHalf = () => handleSetValue(0.5); + const handleSetMax = () => handleSetValue(1); + + return ( +
+ {max !== undefined && setValue !== undefined && ( + <> + {hideAvailableBalance ? ( + <> + ) : ( + Available: {stringifyBigWithSignificantDecimals(Big(max), 2)} + )} + + + + )} +
+ ); +}; diff --git a/src/components/Form/From/NumericInput/NumericInput.test.tsx b/src/components/Form/From/NumericInput/NumericInput.test.tsx index c62c18ce..1771b930 100644 --- a/src/components/Form/From/NumericInput/NumericInput.test.tsx +++ b/src/components/Form/From/NumericInput/NumericInput.test.tsx @@ -50,11 +50,14 @@ describe('NumericInput Component', () => { expect(inputElement.value).toBe('1.1'); }); - it('should work with readOnly prop', () => { + it('should work with readOnly prop', async () => { const { getByPlaceholderText } = render(); const inputElement = getByPlaceholderText('0.0') as HTMLInputElement; expect(inputElement).toHaveAttribute('readOnly'); + + await userEvent.type(inputElement, '123'); + expect(inputElement.value).toBe(''); }); it('should apply additional styles', () => { @@ -105,6 +108,49 @@ describe('NumericInput Component', () => { const inputElement = getByPlaceholderText('0.0') as HTMLInputElement; await userEvent.type(inputElement, '123.4567890123456789abcgdehyu0123456.2746472.93.2.7.3.5.3'); - expect(inputElement.value).toBe('123.456789012343'); + expect(inputElement.value).toBe('123.456789012345'); + }); + + it('should allow replace any digit user wants', async () => { + const { getByPlaceholderText } = render(); + const inputElement = getByPlaceholderText('0.0') as HTMLInputElement; + + await userEvent.type(inputElement, '123.421'); + await userEvent.keyboard('{arrowleft}{arrowleft}{arrowleft}{arrowleft}{arrowleft}{backspace}'); + // The keyboard is being reset to the end of the input, so we need to move it back to the left + await userEvent.keyboard('{arrowleft}{arrowleft}{arrowleft}{arrowleft}{arrowleft}4'); + expect(inputElement.value).toBe('143.421'); + + await userEvent.keyboard('{arrowleft}{arrowleft}{backspace}'); + await userEvent.keyboard('{arrowleft}{arrowleft}7'); + expect(inputElement.value).toBe('143.721'); + + await userEvent.keyboard('{arrowleft}{arrowleft}{arrowleft}{backspace}'); + await userEvent.keyboard('{arrowleft}{arrowleft}{arrowleft}9'); + expect(inputElement.value).toBe('1439721'); + }); + + it('should initialize with default value', () => { + const { getByPlaceholderText } = render(); + const inputElement = getByPlaceholderText('0.0') as HTMLInputElement; + + expect(inputElement.value).toBe('123.45'); + }); + + it('should remain unchanged on invalid input', async () => { + const { getByPlaceholderText } = render(); + const inputElement = getByPlaceholderText('0.0') as HTMLInputElement; + + await userEvent.type(inputElement, '!!!'); + expect(inputElement.value).toBe('123.45'); + }); + + it('should handle paste invalid characters', async () => { + const { getByPlaceholderText } = render(); + const inputElement = getByPlaceholderText('0.0') as HTMLInputElement; + + inputElement.focus(); + await userEvent.paste('123.4567890123456789abcgdehyu0123456.2746472.93.2.7.3.5.3'); + expect(inputElement.value).toBe('123.456789012345'); }); }); diff --git a/src/components/Form/From/NumericInput/helpers.ts b/src/components/Form/From/NumericInput/helpers.ts new file mode 100644 index 00000000..77df3f36 --- /dev/null +++ b/src/components/Form/From/NumericInput/helpers.ts @@ -0,0 +1,22 @@ +import { trimToMaxDecimals } from '../../../../shared/parseNumbers/maxDecimals'; + +const removeNonNumericCharacters = (value: string): string => value.replace(/[^0-9.]/g, ''); + +const replaceCommasWithDots = (value: string): string => value.replace(/,/g, '.'); + +/** + * Handles the input change event to ensure the value does not exceed the maximum number of decimal places, + * replaces commas with dots, and removes invalid non-numeric characters. + * + * @param e - The keyboard event triggered by the input. + * @param maxDecimals - The maximum number of decimal places allowed. + */ +export function handleOnChangeNumericInput(e: KeyboardEvent, maxDecimals: number): void { + const target = e.target as HTMLInputElement; + + target.value = replaceCommasWithDots(target.value); + + target.value = removeNonNumericCharacters(target.value); + + target.value = trimToMaxDecimals(target.value, maxDecimals); +} diff --git a/src/components/Form/From/NumericInput/index.tsx b/src/components/Form/From/NumericInput/index.tsx index 6ff751dd..1b8aacb8 100644 --- a/src/components/Form/From/NumericInput/index.tsx +++ b/src/components/Form/From/NumericInput/index.tsx @@ -1,6 +1,8 @@ import { Input } from 'react-daisyui'; import { UseFormRegisterReturn } from 'react-hook-form'; -import { USER_INPUT_MAX_DECIMALS, exceedsMaxDecimals } from '../../../../shared/parseNumbers/decimal'; + +import { USER_INPUT_MAX_DECIMALS } from '../../../../shared/parseNumbers/maxDecimals'; +import { handleOnChangeNumericInput } from './helpers'; interface NumericInputProps { register: UseFormRegisterReturn; @@ -11,32 +13,6 @@ interface NumericInputProps { autoFocus?: boolean; } -function isValidNumericInput(value: string): boolean { - return /^[0-9.,]*$/.test(value); -} - -function alreadyHasDecimal(e: KeyboardEvent) { - const decimalChars = ['.', ',']; - - // In the onInput event, "," is replaced by ".", so we check if the e.target.value already contains a "." - return decimalChars.some((char) => e.key === char && e.target && (e.target as HTMLInputElement).value.includes('.')); -} - -function handleOnInput(e: KeyboardEvent): void { - const target = e.target as HTMLInputElement; - target.value = target.value.replace(/,/g, '.'); -} - -function handleOnKeyPress(e: KeyboardEvent, maxDecimals: number): void { - if (!isValidNumericInput(e.key) || alreadyHasDecimal(e)) { - e.preventDefault(); - } - const target = e.target as HTMLInputElement; - if (exceedsMaxDecimals(target.value, maxDecimals - 1)) { - target.value = target.value.slice(0, -1); - } -} - export const NumericInput = ({ register, readOnly = false, @@ -44,31 +20,37 @@ export const NumericInput = ({ maxDecimals = USER_INPUT_MAX_DECIMALS.PENDULUM, defaultValue, autoFocus, -}: NumericInputProps) => ( -
-
- handleOnKeyPress(e, maxDecimals)} - onInput={handleOnInput} - pattern="^[0-9]*[.,]?[0-9]*$" - placeholder="0.0" - readOnly={readOnly} - spellcheck="false" - step="any" - type="text" - inputmode="decimal" - value={defaultValue} - autoFocus={autoFocus} - {...register} - /> +}: NumericInputProps) => { + function handleOnChange(e: KeyboardEvent): void { + handleOnChangeNumericInput(e, maxDecimals); + register.onChange(e); + } + + return ( +
+
+ +
-
-); + ); +}; diff --git a/src/components/Form/From/index.tsx b/src/components/Form/From/index.tsx index 1f7146eb..de473eaa 100644 --- a/src/components/Form/From/index.tsx +++ b/src/components/Form/From/index.tsx @@ -17,7 +17,7 @@ export interface FromProps { error?: string; readOnly?: boolean; disabled?: boolean; - setValue?: (n: number) => void; + setValue?: (n: string) => void; maxDecimals?: number; }; asset: { diff --git a/src/components/Form/From/variants/StandardFrom.test.tsx b/src/components/Form/From/variants/StandardFrom.test.tsx new file mode 100644 index 00000000..5aac21e9 --- /dev/null +++ b/src/components/Form/From/variants/StandardFrom.test.tsx @@ -0,0 +1,80 @@ +import '@testing-library/jest-dom'; +import Big from 'big.js'; +import userEvent from '@testing-library/user-event'; +import { render } from '@testing-library/preact'; +import { SpacewalkPrimitivesCurrencyId } from '@polkadot/types/lookup'; +import { useForm } from 'react-hook-form'; + +import { BlockchainAsset } from '../../../Selector/AssetSelector/helpers'; +import { stringifyBigWithSignificantDecimals } from '../../../../shared/parseNumbers/metric'; +import { USER_INPUT_MAX_DECIMALS } from '../../../../shared/parseNumbers/maxDecimals'; +import { StandardFrom } from './StandardFrom'; +import { FromProps } from '..'; + +jest.mock('../../../../shared/AssetIcons', () => ({ + getIcon: () => 'icon', +})); + +const mockAsset: BlockchainAsset = { + metadata: { + decimals: 10, + name: 'name', + symbol: 'symbol', + additional: { diaKeys: { blockchain: 'blockchain', symbol: 'symbol' } }, + }, + currencyId: { XCM: '1' } as unknown as SpacewalkPrimitivesCurrencyId, +}; + +const assets: BlockchainAsset[] = [mockAsset]; + +// Values that Javascript shows in exponential notation +const edgeCaseMaxBalances = [9e-7, 1e-7, 1e-10, 1e21, 1e22, 0.000000025164, 0.0000000025164]; + +const TestingComponent = ({ max }: { max: number }) => { + const { setValue, register } = useForm(); + + const defaultProps: FromProps = { + formControl: { + max: max, + register: register('amount'), + setValue: (n: string) => setValue('amount', n), + error: '', + maxDecimals: 12, + }, + asset: { + assets: assets, + selectedAsset: assets[0], + setSelectedAsset: jest.fn(), + assetSuffix: 'USD', + }, + description: { + network: 'Network', + }, + badges: {}, + }; + + return ; +}; + +describe('StandardFrom Component', () => { + it.each(edgeCaseMaxBalances)( + 'Should set numbers with default exponential notation to be decimal notation', + async (maxBalance) => { + const { getByText, getByPlaceholderText } = render(); + expect(getByText('From Network')).toBeInTheDocument(); + + const maxButton = getByText('MAX'); + expect(maxButton).toBeInTheDocument(); + expect(getByText(`Available: ${stringifyBigWithSignificantDecimals(Big(maxBalance), 2)}`)).toBeInTheDocument(); + + await userEvent.click(maxButton); + + const inputElement = getByPlaceholderText('0.0'); + expect(inputElement).toBeInTheDocument(); + + expect(getByPlaceholderText('0.0')).toHaveValue( + Big(maxBalance.toFixed(USER_INPUT_MAX_DECIMALS.PENDULUM)).toString(), + ); + }, + ); +}); diff --git a/src/components/Form/From/variants/StandardFrom.tsx b/src/components/Form/From/variants/StandardFrom.tsx index 8f1e7fdb..4b65e15c 100644 --- a/src/components/Form/From/variants/StandardFrom.tsx +++ b/src/components/Form/From/variants/StandardFrom.tsx @@ -37,7 +37,7 @@ export const StandardFrom = ({
- +
diff --git a/src/components/LabelledInputField/index.tsx b/src/components/LabelledInputField/index.tsx index 5b40ce92..947fd9d3 100644 --- a/src/components/LabelledInputField/index.tsx +++ b/src/components/LabelledInputField/index.tsx @@ -26,8 +26,8 @@ const LabelledInputField = forwardRef((props: Props & InputProps) => { return ( <> -
-
+
+
- + ); }); diff --git a/src/components/nabla/Swap/From.tsx b/src/components/nabla/Swap/From.tsx index d93b2513..c4a69313 100644 --- a/src/components/nabla/Swap/From.tsx +++ b/src/components/nabla/Swap/From.tsx @@ -2,17 +2,16 @@ import { ChevronDownIcon } from '@heroicons/react/20/solid'; import { Button } from 'react-daisyui'; import { FieldPath, FieldValues, UseFormReturn, useFormContext } from 'react-hook-form'; -import pendulumIcon from '../../../assets/pendulum-icon.svg'; -import { SwapFormValues } from './schema'; -import { NablaInstanceToken } from '../../../hooks/nabla/useNablaInstance'; import { NablaTokenPrice } from '../common/NablaTokenPrice'; import { fractionOfValue } from '../../../shared/parseNumbers/metric'; import { AmountSelector } from '../common/AmountSelector'; +import { TokenBalance } from '../common/TokenBalance'; +import { NablaInstanceToken } from '../../../hooks/nabla/useNablaInstance'; import { UseContractReadResult } from '../../../hooks/nabla/useContractRead'; import { ContractBalance } from '../../../helpers/contracts'; -import { TokenBalance } from '../common/TokenBalance'; import { getIcon } from '../../../shared/AssetIcons'; import { useGlobalState } from '../../../GlobalStateProvider'; +import { SwapFormValues } from './schema'; interface FromProps> { fromToken: NablaInstanceToken | undefined; @@ -37,10 +36,10 @@ export function From -
-
+
+
-
-
+
+
{fromToken ? : '$ -'}
diff --git a/src/components/nabla/common/AmountSelector.tsx b/src/components/nabla/common/AmountSelector.tsx index f459a6b4..891627d7 100644 --- a/src/components/nabla/common/AmountSelector.tsx +++ b/src/components/nabla/common/AmountSelector.tsx @@ -8,7 +8,7 @@ import { fractionOfValue } from '../../../shared/parseNumbers/metric'; import { ContractBalance } from '../../../helpers/contracts'; import { calcSharePercentageNumber } from '../../../helpers/calc'; import { NumericInput } from '../../Form/From/NumericInput'; -import { USER_INPUT_MAX_DECIMALS } from '../../../shared/parseNumbers/decimal'; +import { USER_INPUT_MAX_DECIMALS } from '../../../shared/parseNumbers/maxDecimals'; import { AvailableActions } from '../../Form/From/AvailableActions'; interface AmountSelectorProps> { @@ -79,7 +79,7 @@ export function AmountSelector +
{showAvailableActions ? ( -
+
setValue(formFieldName, n as K)} max={maxBalance?.approximateNumber} diff --git a/src/pages/spacewalk/bridge/Issue/index.tsx b/src/pages/spacewalk/bridge/Issue/index.tsx index 89528536..378debf3 100644 --- a/src/pages/spacewalk/bridge/Issue/index.tsx +++ b/src/pages/spacewalk/bridge/Issue/index.tsx @@ -19,17 +19,17 @@ import { decimalToStellarNative, nativeToDecimal } from '../../../../shared/pars import { useAccountBalance } from '../../../../shared/useAccountBalance'; import { TenantName } from '../../../../models/Tenant'; import { ToastMessage, showToast } from '../../../../shared/showToast'; +import { isU128Compatible } from '../../../../shared/parseNumbers/isU128Compatible'; +import { USER_INPUT_MAX_DECIMALS } from '../../../../shared/parseNumbers/maxDecimals'; +import { PENDULUM_SUPPORT_CHAT_URL } from '../../../../shared/constants'; +import { PAGES_PATHS } from '../../../../app'; import { FeeBox } from '../FeeBox'; import { prioritizeXLMAsset } from '../helpers'; -import { ConfirmationDialog } from './ConfirmationDialog'; import Disclaimer from './Disclaimer'; +import { ConfirmationDialog } from './ConfirmationDialog'; import { getIssueValidationSchema } from './IssueValidationSchema'; -import { isU128Compatible } from '../../../../shared/parseNumbers/isU128Compatible'; -import { USER_INPUT_MAX_DECIMALS } from '../../../../shared/parseNumbers/decimal'; -import { PENDULUM_SUPPORT_CHAT_URL } from '../../../../shared/constants'; -import { PAGES_PATHS } from '../../../../app'; interface IssueProps { network: string; @@ -38,7 +38,7 @@ interface IssueProps { } export type IssueFormValues = { - amount: number; + amount: string; securityDeposit: number; to: number; }; @@ -101,7 +101,7 @@ function Issue(props: IssueProps): JSX.Element { operator program, more @@ -194,7 +194,7 @@ function Issue(props: IssueProps): JSX.Element { }, [trigger, selectedAsset, maxIssuable]); return ( -
+
-
undefined)}> + undefined)}> setValue('amount', n), + setValue: (n: string) => setValue('amount', n), error: getFirstErrorMessage(formState, ['amount', 'securityDeposit']) || (!isU128Compatible(amountNative) ? 'Exceeds the max allowed value.' : ''), @@ -228,7 +228,7 @@ function Issue(props: IssueProps): JSX.Element { }} /> -