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

feat: APP-2619 - Implement InputNumberMax component #54

Merged
merged 15 commits into from
Jan 23, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),

### Added

- Implement `InputDate` and `Avatar` components
- Implement `InputDate`, `Avatar` and `InputNumberMax` components
- Add `AvatarIcon` documentation and tests

## [1.0.8] - 2024-01-17
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,8 @@
"classnames": "^2.0.0",
"react": "^18.0.0",
"react-dom": "^18.0.0",
"react-merge-refs": "^2.0.0"
"react-merge-refs": "^2.0.0",
"react-imask": "^7.3.0"
},
"peerDependencies": {
"tailwindcss": "^3.4.0"
Expand Down
2 changes: 2 additions & 0 deletions src/components/input/hooks/index.ts
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';
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import classNames from 'classnames';
import { useEffect, useId, useState, type ChangeEvent, type InputHTMLAttributes } from 'react';
import type { IInputComponentProps, IInputContainerProps } from './inputContainer';
import type { IInputComponentProps, IInputContainerProps } from '../inputContainer';

export interface IUseInputPropsResult {
/**
Expand Down
80 changes: 80 additions & 0 deletions src/components/input/hooks/useNumberMask.test.ts
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);
});
});
55 changes: 55 additions & 0 deletions src/components/input/hooks/useNumberMask.ts
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);
Fabricevladimir marked this conversation as resolved.
Show resolved Hide resolved

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) },
Fabricevladimir marked this conversation as resolved.
Show resolved Hide resolved
);

const { setValue } = result;

// Update the masked value on value property change
useEffect(() => {
const parsedValue = value?.toString() ?? '';
setValue(parsedValue);
}, [setValue, value]);

return result;
};
1 change: 1 addition & 0 deletions src/components/input/index.ts
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';
2 changes: 1 addition & 1 deletion src/components/input/inputDate/inputDate.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@ import { forwardRef, useRef } from 'react';
import { mergeRefs } from 'react-merge-refs';
import { Button } from '../../button';
import { IconType } from '../../icon';
import { useInputProps } from '../hooks';
import { InputContainer, type IInputComponentProps } from '../inputContainer';
import { useInputProps } from '../useInputProps';

export interface IInputDateProps extends Omit<IInputComponentProps, 'maxLength'> {}

Expand Down
1 change: 1 addition & 0 deletions src/components/input/inputNumberMax/index.ts
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 src/components/input/inputNumberMax/inputNumberMax.stories.tsx
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 src/components/input/inputNumberMax/inputNumberMax.test.tsx
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();
Fabricevladimir marked this conversation as resolved.
Show resolved Hide resolved
});

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();
});
});
47 changes: 47 additions & 0 deletions src/components/input/inputNumberMax/inputNumberMax.tsx
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>
);
};
2 changes: 1 addition & 1 deletion src/components/input/inputSearch/inputSearch.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ import classNames from 'classnames';
import { useRef, useState, type FocusEvent, type KeyboardEvent } from 'react';
import { Icon, IconType } from '../../icon';
import { Spinner } from '../../spinner';
import { useInputProps } from '../hooks';
import { InputContainer, type IInputComponentProps } from '../inputContainer';
import { useInputProps } from '../useInputProps';

export interface IInputSearchProps extends IInputComponentProps {
/**
Expand Down
2 changes: 1 addition & 1 deletion src/components/input/inputText/inputText.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useInputProps } from '../hooks';
import { InputContainer, type IInputComponentProps } from '../inputContainer';
import { useInputProps } from '../useInputProps';

export interface IInputTextProps extends IInputComponentProps {}

Expand Down
1 change: 1 addition & 0 deletions tailwind.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,7 @@ module.exports = {
require('tailwindcss/plugin')(({ addVariant }) => {
addVariant('search-cancel', '&::-webkit-search-cancel-button');
addVariant('calendar-icon', ['&::-webkit-calendar-picker-indicator', '&::-webkit-inner-spin-button']);
addVariant('spin-buttons', ['&::-webkit-inner-spin-button', '&::-webkit-outer-spin-button']);
}),
],
};
Loading
Loading