Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Bridge back to stellar - max amount chosen by the user allow for the amount that is incompatible #506

Merged
61 changes: 44 additions & 17 deletions src/components/Form/From/AvailableActions/index.tsx
Original file line number Diff line number Diff line change
@@ -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) => (
<div className="flex gap-1 text-sm">
{max !== undefined && setValue !== undefined && (
<>
{hideAvailableBalance ? <></> : <span className="mr-1">Available: {max.toFixed(2)}</span>}
<button className="text-primary hover:underline" onClick={() => setValue(Number(max) * 0.5)} type="button">
50%
</button>
<button className="text-primary hover:underline" onClick={() => setValue(Number(max))} type="button">
MAX
</button>
</>
)}
</div>
);
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 (
<div className="flex gap-1 text-sm">
{max !== undefined && setValue !== undefined && (
<>
{hideAvailableBalance ? (
<></>
) : (
<span className="mr-1">Available: {stringifyBigWithSignificantDecimals(Big(max), 2)}</span>
)}
<button className="text-primary hover:underline" onClick={handleSetHalf} type="button">
50%
</button>
<button className="text-primary hover:underline" onClick={handleSetMax} type="button">
MAX
</button>
</>
)}
</div>
);
};
50 changes: 48 additions & 2 deletions src/components/Form/From/NumericInput/NumericInput.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<NumericInput register={mockRegister} readOnly={true} />);
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', () => {
Expand Down Expand Up @@ -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(<NumericInput register={mockRegister} maxDecimals={3} />);
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(<NumericInput register={mockRegister} defaultValue="123.45" />);
const inputElement = getByPlaceholderText('0.0') as HTMLInputElement;

expect(inputElement.value).toBe('123.45');
});

it('should remain unchanged on invalid input', async () => {
const { getByPlaceholderText } = render(<NumericInput register={mockRegister} defaultValue="123.45" />);
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(<NumericInput register={mockRegister} />);
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');
});
});
22 changes: 22 additions & 0 deletions src/components/Form/From/NumericInput/helpers.ts
Original file line number Diff line number Diff line change
@@ -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);
}
90 changes: 36 additions & 54 deletions src/components/Form/From/NumericInput/index.tsx
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -11,64 +13,44 @@ 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,
additionalStyle,
maxDecimals = USER_INPUT_MAX_DECIMALS.PENDULUM,
defaultValue,
autoFocus,
}: NumericInputProps) => (
<div className="w-full flex justify-between">
<div className="flex-grow text-4xl text-black font-outfit">
<Input
autocomplete="off"
autocorrect="off"
autocapitalize="none"
className={
'input-ghost w-full text-4xl font-outfit pl-0 focus:outline-none focus:text-accent-content text-accent-content ' +
additionalStyle
}
minlength="1"
onKeyPress={(e: KeyboardEvent) => 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 (
<div className="flex justify-between w-full">
<div className="flex-grow text-4xl text-black font-outfit">
<Input
{...register}
autocomplete="off"
autocorrect="off"
autocapitalize="none"
className={
'input-ghost w-full text-4xl font-outfit pl-0 focus:outline-none focus:text-accent-content text-accent-content ' +
additionalStyle
}
minlength="1"
onChange={handleOnChange}
pattern="^[0-9]*[.,]?[0-9]*$"
placeholder="0.0"
readOnly={readOnly}
spellcheck="false"
step="any"
type="text"
inputmode="decimal"
value={defaultValue}
autoFocus={autoFocus}
/>
</div>
</div>
</div>
);
);
};
2 changes: 1 addition & 1 deletion src/components/Form/From/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ export interface FromProps {
error?: string;
readOnly?: boolean;
disabled?: boolean;
setValue?: (n: number) => void;
setValue?: (n: string) => void;
maxDecimals?: number;
};
asset: {
Expand Down
80 changes: 80 additions & 0 deletions src/components/Form/From/variants/StandardFrom.test.tsx
Original file line number Diff line number Diff line change
@@ -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 <StandardFrom {...defaultProps} />;
};

describe('StandardFrom Component', () => {
it.each(edgeCaseMaxBalances)(
'Should set numbers with default exponential notation to be decimal notation',
async (maxBalance) => {
const { getByText, getByPlaceholderText } = render(<TestingComponent max={maxBalance} />);
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(),
);
},
);
});
2 changes: 1 addition & 1 deletion src/components/Form/From/variants/StandardFrom.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ export const StandardFrom = ({
</div>
<div className="flex justify-between items-center mt-1 dark:text-neutral-400 text-neutral-500">
<FromDescription network={network} customText={customText} />
<AvailableActions max={max} setValue={setValue} />
<AvailableActions max={max} setValue={setValue} maxDecimals={maxDecimals} />
</div>
</div>
<label className="label">{error && <span className="label-text text-red-400">{error}</span>}</label>
Expand Down
6 changes: 3 additions & 3 deletions src/components/LabelledInputField/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,8 @@ const LabelledInputField = forwardRef((props: Props & InputProps) => {

return (
<>
<div className="flex w-full component-preview items-center justify-center gap-2" style={style}>
<div className="form-control w-full">
<div className="flex items-center justify-center w-full gap-2 component-preview" style={style}>
<div className="w-full form-control">
<label className="label">
{label && <span className="label-text">{label}</span>}
{secondaryLabel && <span className="label-text-alt">{secondaryLabel}</span>}
Expand Down Expand Up @@ -57,7 +57,7 @@ const LabelledInputField = forwardRef((props: Props & InputProps) => {
</div>
</div>
</div>
<label className="label">{error && <span className="label-text text-red-400">{error}</span>}</label>
<label className="label">{error && <span className="text-red-400 label-text">{error}</span>}</label>
</>
);
});
Expand Down
Loading
Loading