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

fix APP 407 inputs logic #2572

Merged
merged 9 commits into from
Jan 8, 2025
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ export default {
type Story = StoryObj<typeof EditableInput>;

const args = {
value: 5,
value: '5',
maxValue: 10,
onChange: action('onChange'),
inputAriaLabel: 'Editable credits',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ describe('EditableInput', () => {
const onChangeMock = vi.fn();
render(
<EditableInput
value={100}
value={'100'}
maxValue={1000}
onChange={onChangeMock}
inputAriaLabel="testEditableInput"
Expand All @@ -31,7 +31,7 @@ describe('EditableInput', () => {
const onChangeMock = vi.fn();
render(
<EditableInput
value={100}
value={'100'}
maxValue={1000}
onChange={onChangeMock}
inputAriaLabel="testEditableInput"
Expand Down Expand Up @@ -60,7 +60,7 @@ describe('EditableInput', () => {
const onChangeMock = vi.fn();
render(
<EditableInput
value={100}
value={'100'}
maxValue={1000}
onChange={onChangeMock}
inputAriaLabel="testEditableInput"
Expand Down Expand Up @@ -88,4 +88,105 @@ describe('EditableInput', () => {

expect(onChangeMock).toHaveBeenCalledWith(200);
});

it('calls onInvalidValue when input exceeds maxValue', async () => {
const onChangeMock = vi.fn();
const onInvalidValueMock = vi.fn();
render(
<EditableInput
value={'100'}
maxValue={1000}
onChange={onChangeMock}
inputAriaLabel="testEditableInput"
editButtonAriaLabel="Edit"
updateButtonText="Update"
cancelButtonText="Cancel"
isEditable
onInvalidValue={onInvalidValueMock}
/>,
);

const editButton = await screen.queryByRole('button', {
name: 'Edit',
});
if (editButton) {
fireEvent.click(editButton);
}

const input = screen.getByTestId('editable-input');
fireEvent.change(input, { target: { value: '2000' } });

expect(onInvalidValueMock).toHaveBeenCalled();
});

it('resets value and exits edit mode when cancel is clicked', async () => {
const onChangeMock = vi.fn();
render(
<EditableInput
value={'100'}
maxValue={1000}
onChange={onChangeMock}
inputAriaLabel="testEditableInput"
editButtonAriaLabel="Edit"
updateButtonText="Update"
cancelButtonText="Cancel"
isEditable
/>,
);

const editButton = await screen.queryByRole('button', {
name: 'Edit',
});
if (editButton) {
fireEvent.click(editButton);
}

const input = screen.getByTestId('editable-input');
fireEvent.change(input, { target: { value: '200' } });

const cancelButton = screen.getByRole('button', {
name: /cancel/i,
});
fireEvent.click(cancelButton);

const amount = screen.getByText('100');
expect(amount).toBeInTheDocument();
expect(input).not.toBeInTheDocument();
});

it('updates value on Enter key press and cancels on Escape key press', async () => {
const onChangeMock = vi.fn();
render(
<EditableInput
value={'100'}
maxValue={1000}
onChange={onChangeMock}
inputAriaLabel="testEditableInput"
editButtonAriaLabel="Edit"
updateButtonText="Update"
cancelButtonText="Cancel"
isEditable
/>,
);

const editButton = await screen.queryByRole('button', {
name: 'Edit',
});
if (editButton) {
fireEvent.click(editButton);
}

const input = screen.getByTestId('editable-input');
fireEvent.change(input, { target: { value: '200' } });
fireEvent.keyDown(input, { key: 'Enter', code: 'Enter' });

expect(onChangeMock).toHaveBeenCalledWith(200);

fireEvent.change(input, { target: { value: '300' } });
fireEvent.keyDown(input, { key: 'Escape', code: 'Escape' });

const amount = screen.getByText('200');
expect(amount).toBeInTheDocument();
expect(input).not.toBeInTheDocument();
});
});
Original file line number Diff line number Diff line change
@@ -1,18 +1,11 @@
import {
ChangeEvent,
KeyboardEvent,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { ChangeEvent, KeyboardEvent, useEffect, useRef, useState } from 'react';
import { EditButtonIcon } from 'web-components/src/components/buttons/EditButtonIcon';
import { TextButton } from 'web-components/src/components/buttons/TextButton';

import { sanitizeValue } from './EditableInput.utils';

interface EditableInputProps {
value: number;
value: string;
maxValue: number;
onChange: (amount: number) => void;
name?: string;
Expand Down Expand Up @@ -46,17 +39,14 @@ export const EditableInput = ({
isEditable,
}: EditableInputProps) => {
const [editable, setEditable] = useState(false);
const [initialValue, setInitialValue] = useState(value);
const [initialValue, setInitialValue] = useState(value.toString());
const [currentValue, setCurrentValue] = useState(value);
const wrapperRef = useRef(null);

const amountValid = useMemo(
() => currentValue <= maxValue && currentValue > 0,
[currentValue, maxValue],
);
const amountValid = +currentValue <= maxValue && +currentValue > 0;

const isUpdateDisabled =
!amountValid || error?.hasError || initialValue === currentValue;
!amountValid || error?.hasError || +initialValue === +currentValue;

useEffect(() => {
setInitialValue(value);
Expand Down Expand Up @@ -85,17 +75,24 @@ export const EditableInput = ({
}, [editable, initialValue]);

const toggleEditable = () => {
// If the value is '0', clear the input field when it becomes editable
// so the user can start typing a new value with the cursor before the '0' (placeholder)
if (!editable && currentValue === '0') {
setCurrentValue('');
}
setEditable(!editable);
};

const handleOnChange = (e: ChangeEvent<HTMLInputElement>) => {
e.preventDefault();
const value = e.target.value;
const sanitizedValue = sanitizeValue(value);
if (sanitizedValue > maxValue && onInvalidValue) {
if (+sanitizedValue > maxValue && onInvalidValue) {
onInvalidValue();
setCurrentValue(maxValue.toString());
} else {
setCurrentValue(sanitizedValue);
}
setCurrentValue(Math.min(sanitizedValue, maxValue));
};

const handleOnCancel = () => {
Expand All @@ -105,7 +102,7 @@ export const EditableInput = ({

const handleOnUpdate = () => {
if (isUpdateDisabled) return;
onChange(currentValue);
onChange(+currentValue);
toggleEditable();
};

Expand All @@ -121,7 +118,7 @@ export const EditableInput = ({
};

useEffect(() => {
onKeyDown && onKeyDown(currentValue);
onKeyDown && onKeyDown(+currentValue);
}, [currentValue, onKeyDown]);

return (
Expand All @@ -134,14 +131,16 @@ export const EditableInput = ({
>
<input
type="text"
className="[&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none h-50 py-20 px-15 w-[100px] border border-solid border-grey-300 text-base font-normal font-sans focus:outline-none"
className="h-50 py-20 px-15 w-[100px] border border-solid border-grey-300 text-base font-normal font-sans focus:outline-none"
value={currentValue}
onChange={handleOnChange}
onKeyDown={handleKeyDown}
aria-label={inputAriaLabel}
name={name}
autoFocus
data-testid="editable-input"
placeholder="0"
inputMode="decimal"
/>
<div className="flex flex-row max-[450px]:flex-col max-[450px]:items-start max-[450px]:ml-15">
<TextButton
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { sanitizeValue } from './EditableInput.utils';

describe('sanitizeValue', () => {
it('should return "0." if value starts with "."', () => {
expect(sanitizeValue('.')).toBe('0.');
});

it('should return "0." if value is "0.a"', () => {
expect(sanitizeValue('0.a')).toBe('0.');
});

it('should return "0." if value is "0."', () => {
expect(sanitizeValue('0.')).toBe('0.');
});

it('should return "0.1" if value is "0.1"', () => {
expect(sanitizeValue('0.1')).toBe('0.1');
});

it('should strip leading zeros', () => {
expect(sanitizeValue('00123')).toBe('123');
});

it('should strip non-digit characters', () => {
expect(sanitizeValue('123abc')).toBe('123');
});

it('should strip multiple dots', () => {
expect(sanitizeValue('123.45.67')).toBe('123.45');
});

it('should return empty string if value is empty', () => {
expect(sanitizeValue('')).toBe('');
});

it('should return empty string if value contains only non-digit characters', () => {
expect(sanitizeValue('abc')).toBe('');
});

it('should handle complex cases', () => {
expect(sanitizeValue('0.0.0')).toBe('0.0');
expect(sanitizeValue('0.123.456')).toBe('0.123');
expect(sanitizeValue('00123.45.67abc')).toBe('123.45');
});
});
Original file line number Diff line number Diff line change
@@ -1,8 +1,19 @@
export const sanitizeValue = (value: string): number => {
export const sanitizeValue = (value: string): string => {
if (value.startsWith('.')) {
return '0.';
}
if (value === '0' || value.startsWith('0.')) {
return Number(value);
// Disallow 0.[a-z]
if (/^0\.[a-zA-Z]/.test(value)) {
return '0.';
}
return value.replace(/(\..*?)\..*/g, '$1');
}
// Strip leading zeros
const sanitized = value.replace(/^0+/, '');
return sanitized ? Number(sanitized) : 0;
// Strip leading zeros, non digits and multiple dots
const sanitized = value
.replace(/[^0-9.]/g, '')
.replace(/^0+/, '')
.replace(/(\..*?)\..*/g, '$1');

return sanitized ? sanitized : '';
};
Original file line number Diff line number Diff line change
Expand Up @@ -50,10 +50,12 @@ describe('CreditsAmount', () => {
jotaiDefaultValues: [[paymentOptionAtom, 'card']],
});

const currencyInput = screen.getByLabelText(/Currency Input/i);
userEvent.clear(currencyInput);
await userEvent.type(currencyInput, '50');
expect(currencyInput).toHaveValue(50);
const currencyInput = await screen.findByLabelText(/Currency Input/i);
if (currencyInput) {
userEvent.clear(currencyInput);
await userEvent.type(currencyInput, '50');
expect(currencyInput).toHaveValue(50);
}
});

it('updates currency amount when credits amount changes', async () => {
Expand All @@ -63,7 +65,7 @@ describe('CreditsAmount', () => {
});

const creditsInput = screen.getByLabelText(/Credits Input/i);
const currencyInput = screen.getByLabelText(/Currency Input/i);
const currencyInput = await screen.findByLabelText(/Currency Input/i);

userEvent.clear(creditsInput);
await userEvent.type(creditsInput, '101');
Expand All @@ -78,7 +80,7 @@ describe('CreditsAmount', () => {
});

const creditsInput = screen.getByLabelText(/Credits Input/i);
const currencyInput = screen.getByLabelText(/Currency Input/i);
const currencyInput = await screen.findByLabelText(/Currency Input/i);

userEvent.clear(currencyInput);
await userEvent.type(currencyInput, '102');
Expand All @@ -96,7 +98,7 @@ describe('CreditsAmount', () => {
name: /Max Credits/i,
});
const creditsInput = screen.getByLabelText(/Credits Input/i);
const currencyInput = screen.getByLabelText(/Currency Input/i);
const currencyInput = await screen.findByLabelText(/Currency Input/i);

await userEvent.click(maxCreditsButton);

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { formatCurrencyAmount } from './CreditsAmount.utils';

describe('formatCurrencyAmount', () => {
it('should format a number to max two decimals', () => {
expect(formatCurrencyAmount(123.456)).toBe(123.45);
expect(formatCurrencyAmount(123)).toBe(123);
expect(formatCurrencyAmount(123.4)).toBe(123.4);
});

it('should format a string to two decimals', () => {
expect(formatCurrencyAmount('123.456')).toBe(123.45);
expect(formatCurrencyAmount('123')).toBe(123);
expect(formatCurrencyAmount('123.4')).toBe(123.4);
});

it('should round up to two decimals if roundUpDecimal is true', () => {
expect(formatCurrencyAmount(123.456, true)).toBe(123.46);
expect(formatCurrencyAmount(123.451, true)).toBe(123.46);
expect(formatCurrencyAmount(123.4, true)).toBe(123.4);
});

it('should return 0 for invalid numeric values', () => {
expect(formatCurrencyAmount('abc')).toBe(0);
expect(formatCurrencyAmount(NaN)).toBe(0);
});

it('should handle edge cases', () => {
expect(formatCurrencyAmount(0)).toBe(0);
expect(formatCurrencyAmount('0')).toBe(0);
expect(formatCurrencyAmount('')).toBe(0);
});
});
Loading
Loading