From ca4141983850f813b98f949d756ad89ad6662df1 Mon Sep 17 00:00:00 2001 From: Kacper Szarkiewicz Date: Tue, 25 Jun 2024 21:13:24 +0200 Subject: [PATCH 1/9] extract max decimals logic to maxDecimals.ts --- .../Form/From/NumericInput/index.tsx | 27 ++----------- src/components/LabelledInputField/index.tsx | 6 +-- .../nabla/common/AmountSelector.tsx | 2 +- src/pages/spacewalk/bridge/Issue/index.tsx | 2 +- src/pages/spacewalk/bridge/Redeem/index.tsx | 20 +++++----- src/shared/parseNumbers/decimal.ts | 11 ----- src/shared/parseNumbers/maxDecimals.ts | 40 +++++++++++++++++++ 7 files changed, 58 insertions(+), 50 deletions(-) create mode 100644 src/shared/parseNumbers/maxDecimals.ts diff --git a/src/components/Form/From/NumericInput/index.tsx b/src/components/Form/From/NumericInput/index.tsx index 6ff751dd..1a8c1e29 100644 --- a/src/components/Form/From/NumericInput/index.tsx +++ b/src/components/Form/From/NumericInput/index.tsx @@ -1,6 +1,6 @@ import { Input } from 'react-daisyui'; import { UseFormRegisterReturn } from 'react-hook-form'; -import { USER_INPUT_MAX_DECIMALS, exceedsMaxDecimals } from '../../../../shared/parseNumbers/decimal'; +import { handleOnKeyPressExceedsMaxDecimals, USER_INPUT_MAX_DECIMALS } from '../../../../shared/parseNumbers/maxDecimals'; interface NumericInputProps { register: UseFormRegisterReturn; @@ -11,32 +11,11 @@ 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, @@ -45,7 +24,7 @@ export const NumericInput = ({ defaultValue, autoFocus, }: NumericInputProps) => ( -
+
handleOnKeyPress(e, maxDecimals)} + onKeyPress={(e: KeyboardEvent) => handleOnKeyPressExceedsMaxDecimals(e, maxDecimals)} onInput={handleOnInput} pattern="^[0-9]*[.,]?[0-9]*$" placeholder="0.0" 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/common/AmountSelector.tsx b/src/components/nabla/common/AmountSelector.tsx index 3ef0d3d6..6425084f 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'; interface AmountSelectorProps> { maxBalance: ContractBalance | undefined; diff --git a/src/pages/spacewalk/bridge/Issue/index.tsx b/src/pages/spacewalk/bridge/Issue/index.tsx index eff4c57d..fd20b91a 100644 --- a/src/pages/spacewalk/bridge/Issue/index.tsx +++ b/src/pages/spacewalk/bridge/Issue/index.tsx @@ -28,7 +28,7 @@ import Disclaimer from './Disclaimer'; import { getIssueValidationSchema } from './IssueValidationSchema'; import { PAGES_PATHS } from '../../../../app'; import { isU128Compatible } from '../../../../shared/parseNumbers/isU128Compatible'; -import { USER_INPUT_MAX_DECIMALS } from '../../../../shared/parseNumbers/decimal'; +import { USER_INPUT_MAX_DECIMALS } from '../../../../shared/parseNumbers/maxDecimals'; interface IssueProps { network: string; diff --git a/src/pages/spacewalk/bridge/Redeem/index.tsx b/src/pages/spacewalk/bridge/Redeem/index.tsx index 6acf0d5c..6d54aa0e 100644 --- a/src/pages/spacewalk/bridge/Redeem/index.tsx +++ b/src/pages/spacewalk/bridge/Redeem/index.tsx @@ -1,27 +1,27 @@ -import { yupResolver } from '@hookform/resolvers/yup'; import Big from 'big.js'; +import { yupResolver } from '@hookform/resolvers/yup'; import { useEffect } from 'preact/compat'; import { useCallback, useMemo, useState } from 'preact/hooks'; import { Button } from 'react-daisyui'; import { useForm } from 'react-hook-form'; +import { useGlobalState } from '../../../../GlobalStateProvider'; +import { useNodeInfoState } from '../../../../NodeInfoProvider'; import From from '../../../../components/Form/From'; import LabelledInputField from '../../../../components/LabelledInputField'; import OpenWallet from '../../../../components/Wallet'; -import { useGlobalState } from '../../../../GlobalStateProvider'; import { assetDisplayName } from '../../../../helpers/spacewalk'; import { isPublicKey } from '../../../../helpers/stellar'; import { getErrors, getEventBySectionAndMethod } from '../../../../helpers/substrate'; import { RichRedeemRequest, useRedeemPallet } from '../../../../hooks/spacewalk/useRedeemPallet'; import useBridgeSettings from '../../../../hooks/spacewalk/useBridgeSettings'; import useBalances from '../../../../hooks/useBalances'; -import { useNodeInfoState } from '../../../../NodeInfoProvider'; import { decimalToStellarNative, nativeToDecimal } from '../../../../shared/parseNumbers/metric'; +import { USER_INPUT_MAX_DECIMALS } from '../../../../shared/parseNumbers/maxDecimals'; +import { ToastMessage, showToast } from '../../../../shared/showToast'; import { FeeBox } from '../FeeBox'; +import { prioritizeXLMAsset } from '../helpers'; import { ConfirmationDialog } from './ConfirmationDialog'; import { getRedeemValidationSchema } from './RedeemValidationSchema'; -import { ToastMessage, showToast } from '../../../../shared/showToast'; -import { prioritizeXLMAsset } from '../helpers'; -import { USER_INPUT_MAX_DECIMALS } from '../../../../shared/parseNumbers/decimal'; export type RedeemFormValues = { amount: number; @@ -43,7 +43,7 @@ function Redeem(props: RedeemProps): JSX.Element { const { createRedeemRequestExtrinsic, getRedeemRequest } = useRedeemPallet(); const { selectedVault, selectedAsset, wrappedAssets, setSelectedAsset } = useBridgeSettings(); - const { walletAccount, dAppName } = useGlobalState(); + const { walletAccount } = useGlobalState(); const { api } = useNodeInfoState().state; const { balances } = useBalances(); const { wrappedCurrencySuffix, nativeCurrency, network } = props; @@ -132,14 +132,14 @@ function Redeem(props: RedeemProps): JSX.Element { }, [api, getRedeemRequest, requestRedeemExtrinsic, selectedVault, walletAccount]); return ( -
+
setConfirmationDialogVisible(false)} />
-
+ -
- +
diff --git a/src/shared/parseNumbers/maxDecimals.ts b/src/shared/parseNumbers/maxDecimals.ts index 393e04f6..aef1698f 100644 --- a/src/shared/parseNumbers/maxDecimals.ts +++ b/src/shared/parseNumbers/maxDecimals.ts @@ -1,38 +1,41 @@ export enum USER_INPUT_MAX_DECIMALS { - PENDULUM = 12, - STELLAR = 7, + PENDULUM = 12, + STELLAR = 7, } export function exceedsMaxDecimals(value: unknown, maxDecimals: number) { - if (value === undefined || value === null) return true; - const decimalPlaces = value.toString().split('.')[1]; - return decimalPlaces ? decimalPlaces.length > maxDecimals : false; + if (value === undefined || value === null) return true; + const decimalPlaces = value.toString().split('.')[1]; + return decimalPlaces ? decimalPlaces.length > maxDecimals : false; +} + +export function trimMaxDecimals(value: string, maxDecimals: number): string { + const [integer, decimal] = value.split('.'); + return decimal ? `${integer}.${decimal.slice(0, maxDecimals)}` : value; } function isValidNumericInput(value: string): boolean { - return /^[0-9.,]*$/.test(value); + return /^[0-9.,]*$/.test(value); } function alreadyHasDecimal(e: KeyboardEvent) { - const decimalChars = ['.', ',']; + 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('.')); + // 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('.')); } -export function truncateIfExceedsMaxDecimals(value: string, maxDecimals: number): string { - if (exceedsMaxDecimals(value, maxDecimals)) { - return value.slice(0, -1); - } - return value; +function truncateIfExceedsMaxDecimals(value: string, maxDecimals: number): string { + if (exceedsMaxDecimals(value, maxDecimals - 1)) { + return value.slice(0, -1); + } + return value; } export function handleOnKeyPressExceedsMaxDecimals(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); - } -} \ No newline at end of file + if (!isValidNumericInput(e.key) || alreadyHasDecimal(e)) { + e.preventDefault(); + } + const target = e.target as HTMLInputElement; + target.value = truncateIfExceedsMaxDecimals(target.value, maxDecimals); +} From dab1c5e299e2ced1c8ca11dde64ff1d48f1d6d1f Mon Sep 17 00:00:00 2001 From: Kacper Szarkiewicz Date: Wed, 26 Jun 2024 12:51:04 +0200 Subject: [PATCH 4/9] improve code readability --- src/shared/parseNumbers/maxDecimals.ts | 28 ++++++++++++++------------ 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/src/shared/parseNumbers/maxDecimals.ts b/src/shared/parseNumbers/maxDecimals.ts index aef1698f..49e114f4 100644 --- a/src/shared/parseNumbers/maxDecimals.ts +++ b/src/shared/parseNumbers/maxDecimals.ts @@ -3,17 +3,6 @@ export enum USER_INPUT_MAX_DECIMALS { STELLAR = 7, } -export function exceedsMaxDecimals(value: unknown, maxDecimals: number) { - if (value === undefined || value === null) return true; - const decimalPlaces = value.toString().split('.')[1]; - return decimalPlaces ? decimalPlaces.length > maxDecimals : false; -} - -export function trimMaxDecimals(value: string, maxDecimals: number): string { - const [integer, decimal] = value.split('.'); - return decimal ? `${integer}.${decimal.slice(0, maxDecimals)}` : value; -} - function isValidNumericInput(value: string): boolean { return /^[0-9.,]*$/.test(value); } @@ -25,8 +14,18 @@ function alreadyHasDecimal(e: KeyboardEvent) { return decimalChars.some((char) => e.key === char && e.target && (e.target as HTMLInputElement).value.includes('.')); } +export function trimMaxDecimals(value: string, maxDecimals: number): string { + const [integer, decimal] = value.split('.'); + return decimal ? `${integer}.${decimal.slice(0, maxDecimals)}` : value; +} +export function exceedsMaxDecimals(value: unknown, maxDecimals: number) { + if (value === undefined || value === null) return true; + const decimalPlaces = value.toString().split('.')[1]; + return decimalPlaces ? decimalPlaces.length > maxDecimals : false; +} + function truncateIfExceedsMaxDecimals(value: string, maxDecimals: number): string { - if (exceedsMaxDecimals(value, maxDecimals - 1)) { + if (exceedsMaxDecimals(value, maxDecimals)) { return value.slice(0, -1); } return value; @@ -37,5 +36,8 @@ export function handleOnKeyPressExceedsMaxDecimals(e: KeyboardEvent, maxDecimals e.preventDefault(); } const target = e.target as HTMLInputElement; - target.value = truncateIfExceedsMaxDecimals(target.value, maxDecimals); + + // We subtract 1 from maxDecimals because the onKeyPress event is triggered before the new character is added to the target.value + const onKeyPressMaxDecimals = maxDecimals - 1; + target.value = truncateIfExceedsMaxDecimals(target.value, onKeyPressMaxDecimals); } From 3b7d9b84c114c7b996d10dd2afe59f13ab2527bb Mon Sep 17 00:00:00 2001 From: Kacper Szarkiewicz Date: Wed, 26 Jun 2024 17:16:03 +0200 Subject: [PATCH 5/9] fix NumericInput onKeyPress and onInput handlers --- .../From/NumericInput/NumericInput.test.tsx | 21 ++++- .../Form/From/NumericInput/index.tsx | 79 +++++++++++-------- src/shared/parseNumbers/maxDecimals.ts | 16 +--- 3 files changed, 72 insertions(+), 44 deletions(-) diff --git a/src/components/Form/From/NumericInput/NumericInput.test.tsx b/src/components/Form/From/NumericInput/NumericInput.test.tsx index c62c18ce..07e1a778 100644 --- a/src/components/Form/From/NumericInput/NumericInput.test.tsx +++ b/src/components/Form/From/NumericInput/NumericInput.test.tsx @@ -105,6 +105,25 @@ 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'); }); }); diff --git a/src/components/Form/From/NumericInput/index.tsx b/src/components/Form/From/NumericInput/index.tsx index 1a8c1e29..761cec48 100644 --- a/src/components/Form/From/NumericInput/index.tsx +++ b/src/components/Form/From/NumericInput/index.tsx @@ -1,6 +1,11 @@ import { Input } from 'react-daisyui'; import { UseFormRegisterReturn } from 'react-hook-form'; -import { handleOnKeyPressExceedsMaxDecimals, USER_INPUT_MAX_DECIMALS } from '../../../../shared/parseNumbers/maxDecimals'; + +import { + alreadyHasDecimal, + handleOnInputExceedsMaxDecimals, + USER_INPUT_MAX_DECIMALS, +} from '../../../../shared/parseNumbers/maxDecimals'; interface NumericInputProps { register: UseFormRegisterReturn; @@ -11,9 +16,12 @@ interface NumericInputProps { autoFocus?: boolean; } -function handleOnInput(e: KeyboardEvent): void { - const target = e.target as HTMLInputElement; - target.value = target.value.replace(/,/g, '.'); +const isValidNumericInput = (value: string): boolean => /^[0-9.,]*$/.test(value); + +function handleOnKeyPressNumericInput(e: KeyboardEvent): void { + if (!isValidNumericInput(e.key) || alreadyHasDecimal(e)) { + e.preventDefault(); + } } export const NumericInput = ({ @@ -23,31 +31,40 @@ export const NumericInput = ({ maxDecimals = USER_INPUT_MAX_DECIMALS.PENDULUM, defaultValue, autoFocus, -}: NumericInputProps) => ( -
-
- handleOnKeyPressExceedsMaxDecimals(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 handleOnInput(e: KeyboardEvent): void { + const target = e.target as HTMLInputElement; + target.value = target.value.replace(/,/g, '.'); + + handleOnInputExceedsMaxDecimals(e, maxDecimals); + } + + return ( +
+
+ +
-
-); + ); +}; diff --git a/src/shared/parseNumbers/maxDecimals.ts b/src/shared/parseNumbers/maxDecimals.ts index 49e114f4..378dc453 100644 --- a/src/shared/parseNumbers/maxDecimals.ts +++ b/src/shared/parseNumbers/maxDecimals.ts @@ -3,11 +3,7 @@ export enum USER_INPUT_MAX_DECIMALS { STELLAR = 7, } -function isValidNumericInput(value: string): boolean { - return /^[0-9.,]*$/.test(value); -} - -function alreadyHasDecimal(e: KeyboardEvent) { +export function alreadyHasDecimal(e: KeyboardEvent) { const decimalChars = ['.', ',']; // In the onInput event, "," is replaced by ".", so we check if the e.target.value already contains a "." @@ -18,6 +14,7 @@ export function trimMaxDecimals(value: string, maxDecimals: number): string { const [integer, decimal] = value.split('.'); return decimal ? `${integer}.${decimal.slice(0, maxDecimals)}` : value; } + export function exceedsMaxDecimals(value: unknown, maxDecimals: number) { if (value === undefined || value === null) return true; const decimalPlaces = value.toString().split('.')[1]; @@ -31,13 +28,8 @@ function truncateIfExceedsMaxDecimals(value: string, maxDecimals: number): strin return value; } -export function handleOnKeyPressExceedsMaxDecimals(e: KeyboardEvent, maxDecimals: number): void { - if (!isValidNumericInput(e.key) || alreadyHasDecimal(e)) { - e.preventDefault(); - } +export function handleOnInputExceedsMaxDecimals(e: KeyboardEvent, maxDecimals: number): void { const target = e.target as HTMLInputElement; - // We subtract 1 from maxDecimals because the onKeyPress event is triggered before the new character is added to the target.value - const onKeyPressMaxDecimals = maxDecimals - 1; - target.value = truncateIfExceedsMaxDecimals(target.value, onKeyPressMaxDecimals); + target.value = truncateIfExceedsMaxDecimals(target.value, maxDecimals); } From b2174b06a87e432ca3ebcff150f23c8714bf62b2 Mon Sep 17 00:00:00 2001 From: Kacper Szarkiewicz Date: Wed, 26 Jun 2024 18:52:51 +0200 Subject: [PATCH 6/9] refactor NumericInput events to one onChange event, handle onPaste, add important test cases for NumericInput --- .../Form/From/AvailableActions/index.tsx | 4 +-- .../From/NumericInput/NumericInput.test.tsx | 29 +++++++++++++++- .../Form/From/NumericInput/helpers.ts | 22 ++++++++++++ .../Form/From/NumericInput/index.tsx | 28 ++++----------- src/shared/parseNumbers/maxDecimals.ts | 34 +++++-------------- 5 files changed, 67 insertions(+), 50 deletions(-) create mode 100644 src/components/Form/From/NumericInput/helpers.ts diff --git a/src/components/Form/From/AvailableActions/index.tsx b/src/components/Form/From/AvailableActions/index.tsx index d3d10faa..a324a16c 100644 --- a/src/components/Form/From/AvailableActions/index.tsx +++ b/src/components/Form/From/AvailableActions/index.tsx @@ -1,4 +1,4 @@ -import { trimMaxDecimals, USER_INPUT_MAX_DECIMALS } from '../../../../shared/parseNumbers/maxDecimals'; +import { trimToMaxDecimals, USER_INPUT_MAX_DECIMALS } from '../../../../shared/parseNumbers/maxDecimals'; interface AvailableActionsProps { max?: number; @@ -13,7 +13,7 @@ export const AvailableActions = ({ }: AvailableActionsProps) => { const handleSetValue = (percentage: number) => { if (max !== undefined && setValue !== undefined) { - const trimmedValue = trimMaxDecimals(String(max * percentage), maxDecimals); + const trimmedValue = trimToMaxDecimals(String(max * percentage), maxDecimals); setValue(Number(trimmedValue)); } }; diff --git a/src/components/Form/From/NumericInput/NumericInput.test.tsx b/src/components/Form/From/NumericInput/NumericInput.test.tsx index 07e1a778..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', () => { @@ -126,4 +129,28 @@ describe('NumericInput Component', () => { 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 761cec48..1b8aacb8 100644 --- a/src/components/Form/From/NumericInput/index.tsx +++ b/src/components/Form/From/NumericInput/index.tsx @@ -1,11 +1,8 @@ import { Input } from 'react-daisyui'; import { UseFormRegisterReturn } from 'react-hook-form'; -import { - alreadyHasDecimal, - handleOnInputExceedsMaxDecimals, - USER_INPUT_MAX_DECIMALS, -} from '../../../../shared/parseNumbers/maxDecimals'; +import { USER_INPUT_MAX_DECIMALS } from '../../../../shared/parseNumbers/maxDecimals'; +import { handleOnChangeNumericInput } from './helpers'; interface NumericInputProps { register: UseFormRegisterReturn; @@ -16,14 +13,6 @@ interface NumericInputProps { autoFocus?: boolean; } -const isValidNumericInput = (value: string): boolean => /^[0-9.,]*$/.test(value); - -function handleOnKeyPressNumericInput(e: KeyboardEvent): void { - if (!isValidNumericInput(e.key) || alreadyHasDecimal(e)) { - e.preventDefault(); - } -} - export const NumericInput = ({ register, readOnly = false, @@ -32,17 +21,16 @@ export const NumericInput = ({ defaultValue, autoFocus, }: NumericInputProps) => { - function handleOnInput(e: KeyboardEvent): void { - const target = e.target as HTMLInputElement; - target.value = target.value.replace(/,/g, '.'); - - handleOnInputExceedsMaxDecimals(e, maxDecimals); + function handleOnChange(e: KeyboardEvent): void { + handleOnChangeNumericInput(e, maxDecimals); + register.onChange(e); } return (
diff --git a/src/shared/parseNumbers/maxDecimals.ts b/src/shared/parseNumbers/maxDecimals.ts index 378dc453..5c5fb074 100644 --- a/src/shared/parseNumbers/maxDecimals.ts +++ b/src/shared/parseNumbers/maxDecimals.ts @@ -3,33 +3,15 @@ export enum USER_INPUT_MAX_DECIMALS { STELLAR = 7, } -export function alreadyHasDecimal(e: KeyboardEvent) { - const decimalChars = ['.', ',']; +/** + * Trims the decimal part of a numeric string to a specified maximum number of decimal places. + * + * @param value - The numeric string to be trimmed. + * @param maxDecimals - The maximum number of decimal places allowed. + * @returns The trimmed numeric string with at most maxDecimals decimal places. + */ - // 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('.')); -} - -export function trimMaxDecimals(value: string, maxDecimals: number): string { +export function trimToMaxDecimals(value: string, maxDecimals: number): string { const [integer, decimal] = value.split('.'); return decimal ? `${integer}.${decimal.slice(0, maxDecimals)}` : value; } - -export function exceedsMaxDecimals(value: unknown, maxDecimals: number) { - if (value === undefined || value === null) return true; - const decimalPlaces = value.toString().split('.')[1]; - return decimalPlaces ? decimalPlaces.length > maxDecimals : false; -} - -function truncateIfExceedsMaxDecimals(value: string, maxDecimals: number): string { - if (exceedsMaxDecimals(value, maxDecimals)) { - return value.slice(0, -1); - } - return value; -} - -export function handleOnInputExceedsMaxDecimals(e: KeyboardEvent, maxDecimals: number): void { - const target = e.target as HTMLInputElement; - - target.value = truncateIfExceedsMaxDecimals(target.value, maxDecimals); -} From c1738e92f202d055b2b7a26e27a99f9fcf7a3f5b Mon Sep 17 00:00:00 2001 From: Kacper Szarkiewicz Date: Tue, 30 Jul 2024 11:49:22 +0200 Subject: [PATCH 7/9] implement test cases for handling exponential notation for StandardFrom (NumericInput 50percent MAX buttons click) --- .../Form/From/AvailableActions/index.tsx | 18 +++-- src/components/Form/From/index.tsx | 2 +- .../Form/From/variants/StandardFrom.test.tsx | 80 +++++++++++++++++++ src/components/nabla/Swap/From.tsx | 23 +++--- 4 files changed, 105 insertions(+), 18 deletions(-) create mode 100644 src/components/Form/From/variants/StandardFrom.test.tsx diff --git a/src/components/Form/From/AvailableActions/index.tsx b/src/components/Form/From/AvailableActions/index.tsx index f56a6083..a1be8a49 100644 --- a/src/components/Form/From/AvailableActions/index.tsx +++ b/src/components/Form/From/AvailableActions/index.tsx @@ -1,9 +1,11 @@ +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; + max?: number | Big; maxDecimals?: number; - setValue?: (n: number) => void; + setValue?: (n: string) => void; hideAvailableBalance?: boolean; } @@ -11,11 +13,13 @@ export const AvailableActions = ({ max, maxDecimals = USER_INPUT_MAX_DECIMALS.PENDULUM, setValue, + hideAvailableBalance, }: AvailableActionsProps) => { const handleSetValue = (percentage: number) => { if (max !== undefined && setValue !== undefined) { - const trimmedValue = trimToMaxDecimals(String(max * percentage), maxDecimals); - setValue(Number(trimmedValue)); + const maxBig = Big(max); + const trimmedValue = trimToMaxDecimals(maxBig.mul(percentage).toString(), maxDecimals); + setValue(trimmedValue); } }; @@ -26,7 +30,11 @@ export const AvailableActions = ({
{max !== undefined && setValue !== undefined && ( <> - Available: {max.toFixed(2)} + {hideAvailableBalance ? ( + <> + ) : ( + Available: {stringifyBigWithSignificantDecimals(Big(max), 2)} + )} 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/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 ? : '$ -'}
From ec4766cdea1b9e222aa4a8145c0dd18b4ebaa6a4 Mon Sep 17 00:00:00 2001 From: Kacper Szarkiewicz Date: Tue, 30 Jul 2024 12:03:36 +0200 Subject: [PATCH 8/9] fix types --- src/pages/spacewalk/bridge/Issue/index.tsx | 22 ++++++++++----------- src/pages/spacewalk/bridge/Redeem/index.tsx | 8 ++++---- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/src/pages/spacewalk/bridge/Issue/index.tsx b/src/pages/spacewalk/bridge/Issue/index.tsx index f4b6e125..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/maxDecimals'; -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 { }} /> -