-
Notifications
You must be signed in to change notification settings - Fork 6
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: APP-2619 - Implement InputNumberMax component (#54)
- Loading branch information
Showing
17 changed files
with
319 additions
and
6 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,2 @@ | ||
export { useInputProps, type IUseInputPropsResult } from './useInputProps'; | ||
export { useNumberMask, type IUseNumberMaskProps, type IUseNumberMaskResult } from './useNumberMask'; |
File renamed without changes.
2 changes: 1 addition & 1 deletion
2
src/components/input/useInputProps.ts → src/components/input/hooks/useInputProps.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,80 @@ | ||
import { renderHook } from '@testing-library/react'; | ||
import type { InputMask } from 'imask/esm/index'; | ||
import * as ReactIMask from 'react-imask'; | ||
import { formatterUtils } from '../../../utils'; | ||
import { useNumberMask, type IUseNumberMaskResult } from './useNumberMask'; | ||
|
||
// Mock react-imask library to be able to spy on the useIMask hook | ||
jest.mock('react-imask', () => ({ __esModule: true, ...jest.requireActual('react-imask') })); | ||
|
||
describe('useNumberMask hook', () => { | ||
const maskMock = jest.spyOn(ReactIMask, 'useIMask'); | ||
const formatNumberMock = jest.spyOn(formatterUtils, 'formatNumber'); | ||
|
||
beforeEach(() => { | ||
const maskResult = { setValue: jest.fn() } as unknown as IUseNumberMaskResult; | ||
maskMock.mockReturnValue(maskResult); | ||
}); | ||
|
||
afterEach(() => { | ||
maskMock.mockReset(); | ||
formatNumberMock.mockReset(); | ||
}); | ||
|
||
it('returns the result of the useIMask hook', () => { | ||
const maskResult = { setValue: jest.fn(), setUnmaskedValue: jest.fn() } as unknown as IUseNumberMaskResult; | ||
maskMock.mockReturnValue(maskResult); | ||
const { result } = renderHook(() => useNumberMask({})); | ||
expect(result.current).toEqual(maskResult); | ||
}); | ||
|
||
it('sets the mask to be a number mask with decimals', () => { | ||
renderHook(() => useNumberMask({})); | ||
expect(maskMock).toHaveBeenCalledWith( | ||
expect.objectContaining({ mask: Number, scale: expect.any(Number) }), | ||
expect.anything(), | ||
); | ||
}); | ||
|
||
it('sets the min and max params to the mask when defined', () => { | ||
const min = 0; | ||
const max = 100; | ||
renderHook(() => useNumberMask({ min, max })); | ||
expect(maskMock).toHaveBeenCalledWith(expect.objectContaining({ min, max }), expect.anything()); | ||
}); | ||
|
||
it('sets the thousand and decimal separator using the current locale', () => { | ||
const thousandsSeparator = ' '; | ||
const radix = '.'; | ||
const formattedNumber = `100${thousandsSeparator}000${radix}1`; | ||
formatNumberMock.mockReturnValue(formattedNumber); | ||
renderHook(() => useNumberMask({})); | ||
expect(maskMock).toHaveBeenCalledWith( | ||
expect.objectContaining({ thousandsSeparator, radix }), | ||
expect.anything(), | ||
); | ||
}); | ||
|
||
it('updates the mask value on value property change for controlled inputs', () => { | ||
const value = '100'; | ||
const setValue = jest.fn(); | ||
const maskResult = { setValue } as unknown as IUseNumberMaskResult; | ||
maskMock.mockReturnValue(maskResult); | ||
|
||
const { rerender } = renderHook((props) => useNumberMask(props), { initialProps: { value } }); | ||
expect(setValue).toHaveBeenCalledWith(value); | ||
|
||
const newValue = '101'; | ||
rerender({ value: newValue }); | ||
expect(setValue).toHaveBeenCalledWith(newValue); | ||
}); | ||
|
||
it('calls the onChange property with the unmasked value when value is valid', () => { | ||
const onChange = jest.fn(); | ||
const maskValue = { unmaskedValue: '291829' } as InputMask; | ||
renderHook(() => useNumberMask({ onChange })); | ||
const { onAccept } = maskMock.mock.calls[0][1] ?? {}; | ||
onAccept?.('', maskValue); | ||
expect(onChange).toHaveBeenCalledWith(maskValue.unmaskedValue); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,55 @@ | ||
import { useEffect, type ComponentProps } from 'react'; | ||
import { useIMask } from 'react-imask'; | ||
import { NumberFormat, formatterUtils } from '../../../utils'; | ||
|
||
export interface IUseNumberMaskProps extends Pick<ComponentProps<'input'>, 'min' | 'max' | 'value'> { | ||
/** | ||
* Callback called on value change. Override the default onChange callback to only emit the updated value because | ||
* the library in use formats the user input and emit the valid number when valid. | ||
*/ | ||
onChange?: (value: string) => void; | ||
} | ||
|
||
export interface IUseNumberMaskResult extends ReturnType<typeof useIMask<HTMLInputElement>> {} | ||
|
||
const getNumberSeparators = () => { | ||
const match = formatterUtils | ||
.formatNumber(100_000.1, { format: NumberFormat.TOKEN_AMOUNT_LONG }) | ||
?.match(/([^0-9])/g); | ||
|
||
const thousandsSeparator = match?.shift(); | ||
const radix = match?.pop(); | ||
|
||
return { thousandsSeparator, radix }; | ||
}; | ||
|
||
// The imask.js library requires us to set a "scale" property as max decimal places otherwise it defaults to 0. | ||
const maxDecimalPlaces = 30; | ||
|
||
export const useNumberMask = (props: IUseNumberMaskProps): IUseNumberMaskResult => { | ||
const { min, max, onChange, value } = props; | ||
|
||
const { thousandsSeparator, radix } = getNumberSeparators(); | ||
|
||
const result = useIMask<HTMLInputElement>( | ||
{ | ||
mask: Number, | ||
radix, | ||
thousandsSeparator, | ||
scale: maxDecimalPlaces, | ||
max: max != null ? Number(max) : undefined, | ||
min: min != null ? Number(min) : undefined, | ||
}, | ||
{ onAccept: (_value, mask) => onChange?.(mask.unmaskedValue) }, | ||
); | ||
|
||
const { setValue } = result; | ||
|
||
// Update the masked value on value property change | ||
useEffect(() => { | ||
const parsedValue = value?.toString() ?? ''; | ||
setValue(parsedValue); | ||
}, [setValue, value]); | ||
|
||
return result; | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,4 +1,5 @@ | ||
export * from './inputContainer'; | ||
export * from './inputDate'; | ||
export * from './inputNumberMax'; | ||
export * from './inputSearch'; | ||
export * from './inputText'; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
export { InputNumberMax, type IInputNumberMaxProps } from './inputNumberMax'; |
46 changes: 46 additions & 0 deletions
46
src/components/input/inputNumberMax/inputNumberMax.stories.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,46 @@ | ||
import type { Meta, StoryObj } from '@storybook/react'; | ||
import { useState } from 'react'; | ||
import { InputNumberMax, type IInputNumberMaxProps } from './inputNumberMax'; | ||
|
||
const meta: Meta<typeof InputNumberMax> = { | ||
title: 'components/Input/InputNumberMax', | ||
component: InputNumberMax, | ||
tags: ['autodocs'], | ||
parameters: { | ||
design: { | ||
type: 'figma', | ||
url: 'https://www.figma.com/file/jfKRr1V9evJUp1uBeyP3Zz/v1.0.0?type=design&node-id=17-292&mode=design&t=dehPZplRn0YEdOuB-4', | ||
}, | ||
}, | ||
}; | ||
|
||
type Story = StoryObj<typeof InputNumberMax>; | ||
|
||
/** | ||
* Default usage example of the InputNumberMax component. | ||
*/ | ||
export const Default: Story = { | ||
args: { | ||
placeholder: 'Placeholder', | ||
max: 54120, | ||
}, | ||
}; | ||
|
||
const ControlledComponent = (props: IInputNumberMaxProps) => { | ||
const [value, setValue] = useState<string>(); | ||
|
||
return <InputNumberMax value={value} onChange={setValue} {...props} />; | ||
}; | ||
|
||
/** | ||
* Usage example of a controlled InputNumberMax component. | ||
*/ | ||
export const Controlled: Story = { | ||
render: ({ onChange, ...props }) => <ControlledComponent {...props} />, | ||
args: { | ||
placeholder: 'Controlled input', | ||
max: 120500500.05, | ||
}, | ||
}; | ||
|
||
export default meta; |
51 changes: 51 additions & 0 deletions
51
src/components/input/inputNumberMax/inputNumberMax.test.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,51 @@ | ||
import { fireEvent, render, screen } from '@testing-library/react'; | ||
import { createRef } from 'react'; | ||
import * as InputHooks from '../hooks'; | ||
import { InputNumberMax, type IInputNumberMaxProps } from './inputNumberMax'; | ||
|
||
describe('<InputNumberMax /> component', () => { | ||
const useNumberMaskMock = jest.spyOn(InputHooks, 'useNumberMask'); | ||
|
||
beforeEach(() => { | ||
const numberMaskResult = { | ||
ref: createRef(), | ||
setValue: jest.fn(), | ||
} as unknown as InputHooks.IUseNumberMaskResult; | ||
useNumberMaskMock.mockReturnValue(numberMaskResult); | ||
}); | ||
|
||
afterEach(() => { | ||
useNumberMaskMock.mockReset(); | ||
}); | ||
|
||
const createTestComponent = (props?: Partial<IInputNumberMaxProps>) => { | ||
const completeProps: IInputNumberMaxProps = { | ||
max: 100, | ||
...props, | ||
}; | ||
|
||
return <InputNumberMax {...completeProps} />; | ||
}; | ||
|
||
it('renders an input with a max button', () => { | ||
render(createTestComponent()); | ||
expect(screen.getByRole('textbox')).toBeInTheDocument(); | ||
expect(screen.getByRole('button', { name: 'Max' })).toBeInTheDocument(); | ||
}); | ||
|
||
it('updates the mask value with the max property on max button click', () => { | ||
const max = 1_000_000; | ||
const setValue = jest.fn(); | ||
const hookResult = { setValue } as unknown as InputHooks.IUseNumberMaskResult; | ||
useNumberMaskMock.mockReturnValue(hookResult); | ||
render(createTestComponent({ max })); | ||
fireEvent.click(screen.getByRole('button')); | ||
expect(setValue).toHaveBeenCalledWith(max.toString()); | ||
}); | ||
|
||
it('does not render the max button when input is disabled', () => { | ||
const isDisabled = true; | ||
render(createTestComponent({ isDisabled })); | ||
expect(screen.queryByRole('button')).not.toBeInTheDocument(); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,47 @@ | ||
import classNames from 'classnames'; | ||
import { Button } from '../../button'; | ||
import { useInputProps, useNumberMask, type IUseNumberMaskProps } from '../hooks'; | ||
import { InputContainer, type IInputComponentProps } from '../inputContainer'; | ||
|
||
export interface IInputNumberMaxProps extends Omit<IInputComponentProps, 'maxLength' | 'onChange'> { | ||
/** | ||
* Maximum number set on max button click. | ||
*/ | ||
max: number; | ||
/** | ||
* @see IUseNumberMaskProps['onChange'] | ||
*/ | ||
onChange?: IUseNumberMaskProps['onChange']; | ||
} | ||
|
||
export const InputNumberMax: React.FC<IInputNumberMaxProps> = (props) => { | ||
const { max, onChange, ...otherProps } = props; | ||
const { containerProps, inputProps } = useInputProps(otherProps); | ||
|
||
const { variant, ...otherContainerProps } = containerProps; | ||
const { className: inputClassName, value, min, disabled, ...otherInputProps } = inputProps; | ||
|
||
const { ref, setValue } = useNumberMask({ min, max, value, onChange }); | ||
|
||
const handleMaxClick = () => setValue(max.toString()); | ||
|
||
return ( | ||
<InputContainer variant={variant} {...otherContainerProps}> | ||
<input | ||
className={classNames('spin-buttons:appearance-none', inputClassName)} | ||
ref={ref} | ||
max={max} | ||
min={min} | ||
inputMode="decimal" | ||
disabled={disabled} | ||
{...otherInputProps} | ||
/> | ||
{!disabled && ( | ||
<Button size="sm" variant="tertiary" className="mr-2" onClick={handleMaxClick}> | ||
{/* TODO: apply internationalisation to Max label [APP-2627] */} | ||
Max | ||
</Button> | ||
)} | ||
</InputContainer> | ||
); | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.