Skip to content

Commit

Permalink
Rostislav / WALL-361 / Cashier Fiat Transfer amount input field (#8442)
Browse files Browse the repository at this point in the history
* refactor: init pr

* feat: transfer amount input field core logic

* refactor: core logic changed a bit

* refactor: basic styles

* refactor: basic styles (the right one)

* refactor: tests

* refactor: backspacing test

* refactor: fireEvent -> userEvent

* refactor: add the export in index.js

* refactor: currency issue fixed

* refactor: tests fix

* Update packages/components/src/components/transfer-amount-input/__tests__/transfer-amount-input.test.tsx

Co-authored-by: George Usynin <103181646+heorhi-deriv@users.noreply.github.com>

* refactor: TransferAmountInput -> AmountInput

* refactor: onChange prop added to AmountInput

* Update packages/components/src/components/amount-input/amount-input.tsx

Co-authored-by: Farzin Mirzaie <72082844+farzin-deriv@users.noreply.github.com>

* refactor: suggestions

* refactor: suggestions

* Update packages/components/src/components/amount-input/amount-input.scss

Co-authored-by: Farzin Mirzaie <72082844+farzin-deriv@users.noreply.github.com>

* refactor: props naming

* Update packages/components/src/components/amount-input/amount-input.tsx

Co-authored-by: Farzin Mirzaie <72082844+farzin-deriv@users.noreply.github.com>

* fix: import added

* refactor: removed redundant Input prop for max_characters

* refactor: useCallback

* fix: tests and logic as well

* refactor: using isMobile() for responsive design for AmountInput

* refactor: toLocaleString locale changed to `undefined`

* refactor: test

* Update packages/components/src/components/amount-input/amount-input.tsx

Co-authored-by: Farzin Mirzaie <72082844+farzin-deriv@users.noreply.github.com>

* feat: amount-input-for-testing.tsx

* fix: amount-input-for-testing.tsx

* fix: amount-input-for-testing.tsx

* Update packages/components/src/components/amount-input/amount-input.tsx

Co-authored-by: Farzin Mirzaie <72082844+farzin-deriv@users.noreply.github.com>

* fix: tests fix by rollback

* refactor: cursor/caret issue resolved

* refactor: pasting behavior resolved

* refactor: separators on paste should remain now

* fix: fixed the behaviour except ctrl + a; added comments

* refactor: isPasting -> is_pasting

* fix: all behaviour seems to be as expected now

* test: added more tests

* refactor: input type changed to number

* refactor: minor tests changes; also inputMode added to force keyboard on mobiles to be numeric

* refactor: toggles

* refactor: cursor/caret fix using state

* fix: wring pasting behaviour fixed

* refactor: naming change for clarity

* refactor: added a handler and comments

* fix: tests and a logical flaw

* refactor: added deps to useEffect

* refactor: ChatGPT's solution to the type thing

* refactor: newValue -> new_value as @heorhi-deriv suggested

* refactor: remove the demo component for QA

---------

Co-authored-by: George Usynin <103181646+heorhi-deriv@users.noreply.github.com>
Co-authored-by: Farzin Mirzaie <72082844+farzin-deriv@users.noreply.github.com>
  • Loading branch information
3 people authored May 23, 2023
1 parent 5bd2789 commit 35bc18e
Show file tree
Hide file tree
Showing 6 changed files with 268 additions and 5 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import AmountInput from '../amount-input';

describe('<AmountInput/>', () => {
it('should render with the initial value of "0.00" if {initial_value} is not specified', () => {
render(<AmountInput currency='USD' />);
const input = screen.getByTestId('dt_amount-input');
expect(input).toHaveDisplayValue('0.00');
});

it('should render with the correct initial value if {initial_value} was supplied', () => {
render(<AmountInput currency='USD' initial_value={42} />);
const input = screen.getByTestId('dt_amount-input');
expect(input).toHaveDisplayValue('42.00');
});

it('should not change the value on non-numeric and non-"." inputs', () => {
render(<AmountInput currency='USD' />);
const input = screen.getByTestId('dt_amount-input');
userEvent.type(input, 'abcdef!@#$%^&*()_+-={}[];\'"|\\/,.<>');
expect(input).toHaveDisplayValue('0.00');
});

it('should change the value like an ATM, i.e. from right to left, when entering digits', () => {
render(<AmountInput currency='USD' />);
const input = screen.getByTestId('dt_amount-input');
userEvent.type(input, '1');
expect(input).toHaveDisplayValue('0.01');
userEvent.type(input, '2');
expect(input).toHaveDisplayValue('0.12');
userEvent.type(input, '3');
expect(input).toHaveDisplayValue('1.23');
});

it('should add commas for big values', () => {
render(<AmountInput currency='USD' max_digits={9} />);
const input = screen.getByTestId('dt_amount-input');
userEvent.type(input, '123456789');
expect(input).toHaveDisplayValue('1,234,567.89');
});

it('should not remove "0.00" when backspacing', () => {
render(<AmountInput currency='USD' />);
const input = screen.getByTestId('dt_amount-input');
userEvent.type(input, '100');
expect(input).toHaveDisplayValue('1.00');
userEvent.clear(input);
expect(input).toHaveDisplayValue('0.00');
});

it('should not accept more than {maxDigits} digits', () => {
render(<AmountInput currency='USD' max_digits={9} />);
const input = screen.getByTestId('dt_amount-input');
userEvent.type(input, '1234567890987654321');
expect(input).toHaveDisplayValue('1,234,567.89');
});

it('should work correctly with explicitly set {decimal_points}', () => {
render(<AmountInput currency='USD' decimal_places={5} />);
const input = screen.getByTestId('dt_amount-input');
expect(input).toHaveDisplayValue('0.00000');
userEvent.type(input, '12345678');
expect(input).toHaveDisplayValue('123.45678');
});

it('should allow pasting numbers and then interpret those correctly', () => {
render(<AmountInput currency='USD' />);
const input = screen.getByTestId('dt_amount-input');
const values = [
['123', '123.00'],
['123.42', '123.42'],
['123,42', '123.42'],
['123,000.00', '123,000.00'],
];
values.forEach(pair => {
userEvent.clear(input);
userEvent.click(input);
userEvent.paste(input, pair[0]);
expect(input).toHaveDisplayValue(pair[1]);
});
});
});
30 changes: 30 additions & 0 deletions packages/components/src/components/amount-input/amount-input.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
.amount-input-wrapper {
display: flex;
flex-direction: column;
padding: 0.8rem;
height: 6.6rem;
}

.amount-input-container {
position: relative;
flex: 1;
}

.amount-input {
position: absolute;
border: none;
flex: 1;
margin: 0;
padding: 0;
height: 100%;
width: 100%;

input {
font-size: var(--text-size-sm);
font-weight: var(--text-weight-bold);

@include mobile {
font-size: var(--text-size-s);
}
}
}
137 changes: 137 additions & 0 deletions packages/components/src/components/amount-input/amount-input.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import React, { useCallback, useEffect, useState } from 'react';
import { isMobile } from '@deriv/shared';
import Input from '../input';
import Text from '../text';

type TAmountInput = {
currency: string;
decimal_places?: number;
disabled?: boolean;
initial_value?: number;
label?: string;
locale?: Intl.LocalesArgument;
max_digits?: number;
onChange?: (value: number) => void;
};

const AmountInput = ({
currency,
decimal_places = 2,
disabled = false,
initial_value = 0,
label,
locale,
max_digits = 8,
onChange,
}: TAmountInput) => {
const [value, setValue] = useState(initial_value);
const [focus, setFocus] = useState(false);
const [is_pasting, setIsPasting] = useState(false);
const [caret_right_offset, setCaretRightOffset] = useState(0);
const [selection, setSelection] = useState<{
selectionStart: number;
selectionEnd: number;
}>({ selectionStart: 0, selectionEnd: 0 });
const [target, setTarget] = useState<React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>['target']>();

const displayNumber = useCallback(
(number: number) => number.toLocaleString(locale, { minimumFractionDigits: decimal_places }),
[decimal_places, locale]
);

useEffect(() => {
// update caret position every time the value changes (this happens after onChange)
const updated_caret_position = displayNumber(value).length - caret_right_offset;
target?.setSelectionRange(updated_caret_position, updated_caret_position);
setSelection({ selectionStart: updated_caret_position, selectionEnd: updated_caret_position });
}, [value, target, displayNumber]);

const onChangeHandler: React.ComponentProps<typeof Input>['onChange'] = e => {
if (!target) setTarget(e.target);
let new_value = value;
if (!is_pasting) {
// handle ATM typing:
// remove all characters that are not digit / point / comma:
const input_value = e.target.value.replace(/[^\d.,]/g, '');
if (input_value.replace(/[.,]/g, '').replace(/^0+/g, '').length <= max_digits)
new_value = Number(input_value.replace(/[.,]/g, '')) / Math.pow(10, decimal_places);
} else {
// handle pasting:
const selection_length = selection.selectionEnd - selection.selectionStart;
const pasted_string_length = e.target.value.length + selection_length - displayNumber(value).length;
const pasted_string = e.target.value.substring(
selection.selectionStart,
selection.selectionStart + pasted_string_length
);
// remove all characters that are not digit / point / comma:
const input_value = e.target.value.replace(/[^\d.,]/g, '');
// understand the value user wants to paste:
const pasted_value = pasted_string
.replace(/[^\d.,]/g, '') // remove all characters that are not digit / point / comma
.replace(/[,.](?=.*[,.])/g, '') // leave only the last point / comma
.replace(',', '.'); // make the last point / comma separator a point
if (pasted_value.replace('.', '')) {
// if the value is a valid non-empty string, handle the two scenarios:
if ((value === 0 && caret_right_offset === 0) || selection_length === displayNumber(value).length) {
// handle pasting when there's nothing entered before it, or it is overridden (intention: reset value):
new_value = Number(
pasted_value.substring(
0,
pasted_value.includes('.') ? max_digits + 1 : max_digits - decimal_places
)
);
} else if (input_value.replace(/[.,]/g, '').replace(/^0+/g, '').length <= max_digits) {
// handle pasting when there's something entered before it and there's space for the pasted value (intention: add to value):
new_value = Number(input_value.replace(/[.,]/g, '')) / Math.pow(10, decimal_places);
}
}
}
setValue(new_value);
setIsPasting(false);
onChange?.(new_value);
};

const inputActionHandler: React.ComponentProps<typeof Input>['onMouseDown'] &
React.ComponentProps<typeof Input>['onMouseUp'] &
React.ComponentProps<typeof Input>['onKeyDown'] = e => {
if (e.currentTarget.selectionStart !== null && e.currentTarget.selectionEnd !== null) {
setCaretRightOffset(e.currentTarget.value.length - e.currentTarget.selectionEnd);
setSelection({
selectionStart: e.currentTarget.selectionStart,
selectionEnd: e.currentTarget.selectionEnd,
});
}
};

return (
<div className='amount-input-wrapper'>
<Text size={isMobile() ? 'xxs' : 'xs'}>{label}</Text>
<div className='amount-input-container'>
<Input
className='amount-input'
disabled={disabled || focus}
type='text'
inputMode='numeric'
value={`${displayNumber(value)} ${currency}`}
/>
<Input
className='amount-input'
data-testid='dt_amount-input'
disabled={disabled}
onFocus={() => setFocus(true)}
onBlur={() => setFocus(false)}
onChange={onChangeHandler}
onKeyDown={inputActionHandler}
onMouseDown={inputActionHandler}
onMouseUp={inputActionHandler}
onPaste={() => setIsPasting(true)}
type='text'
inputMode='numeric'
value={displayNumber(value)}
/>
</div>
</div>
);
};

export default AmountInput;
4 changes: 4 additions & 0 deletions packages/components/src/components/amount-input/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import AmountInput from './amount-input';
import './amount-input.scss';

export default AmountInput;
17 changes: 12 additions & 5 deletions packages/components/src/components/input/input.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import classNames from 'classnames';
import React from 'react';
import React, { HTMLAttributes } from 'react';
import Field from '../field';
import Text from '../text/text';

Expand All @@ -18,6 +18,7 @@ export type TInputProps = {
hint?: React.ReactNode;
id?: string;
initial_character_count?: number;
inputMode?: HTMLAttributes<HTMLInputElement | HTMLTextAreaElement>['inputMode'];
input_id?: string;
is_relative_hint?: boolean;
label_className?: string;
Expand All @@ -26,10 +27,13 @@ export type TInputProps = {
max_characters?: number;
maxLength?: number;
name?: string;
onBlur?: (e: React.FocusEvent<HTMLInputElement | HTMLTextAreaElement>) => void;
onChange?: (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => void;
onFocus?: (e: React.FocusEvent<HTMLInputElement | HTMLTextAreaElement>) => void;
onPaste?: (e: React.ClipboardEvent<HTMLInputElement | HTMLTextAreaElement>) => void;
onBlur?: React.FocusEventHandler<HTMLInputElement | HTMLTextAreaElement>;
onChange?: React.ChangeEventHandler<HTMLInputElement | HTMLTextAreaElement>;
onMouseDown?: React.MouseEventHandler<HTMLInputElement | HTMLTextAreaElement>;
onMouseUp?: React.MouseEventHandler<HTMLInputElement | HTMLTextAreaElement>;
onFocus?: React.FocusEventHandler<HTMLInputElement | HTMLTextAreaElement>;
onKeyDown?: React.KeyboardEventHandler<HTMLInputElement | HTMLTextAreaElement>;
onPaste?: React.ClipboardEventHandler<HTMLInputElement | HTMLTextAreaElement>;
placeholder?: string;
required?: boolean;
trailing_icon?: React.ReactElement;
Expand Down Expand Up @@ -137,6 +141,9 @@ const Input = React.forwardRef<HTMLInputElement & HTMLTextAreaElement, TInputPro
onFocus={props.onFocus}
onBlur={props.onBlur}
onChange={props.onChange}
onKeyDown={props.onKeyDown}
onMouseDown={props.onMouseDown}
onMouseUp={props.onMouseUp}
onPaste={props.onPaste}
disabled={disabled}
data-lpignore={props.type === 'password' ? undefined : true}
Expand Down
1 change: 1 addition & 0 deletions packages/components/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
// export default { Label, Button };

export { default as Accordion } from './components/accordion';
export { default as AmountInput } from './components/amount-input';
export { default as AutoHeightWrapper, TAutoHeightWrapperChildProps } from './components/auto-height-wrapper';
export { default as Autocomplete } from './components/autocomplete';
export { default as AutoSizer } from './components/autosizer';
Expand Down

0 comments on commit 35bc18e

Please sign in to comment.