Skip to content

Commit

Permalink
fix APP 407 inputs logic (#2572)
Browse files Browse the repository at this point in the history
  • Loading branch information
r41ph authored Jan 8, 2025
1 parent 55d411d commit 83bcec0
Show file tree
Hide file tree
Showing 11 changed files with 291 additions and 60 deletions.
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

0 comments on commit 83bcec0

Please sign in to comment.