Skip to content

Commit

Permalink
add stories and unit tests for all the things
Browse files Browse the repository at this point in the history
  • Loading branch information
bdami-gavant committed Mar 18, 2024
1 parent 257b66a commit 3b8b47f
Show file tree
Hide file tree
Showing 23 changed files with 1,409 additions and 10 deletions.
7 changes: 6 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@
"build-storybook": "storybook build"
},
"dependencies": {
"@testing-library/jest-dom": "^6.0.0",
"@testing-library/react": "^14.0.0",
"@testing-library/user-event": "^14.4.3",
"react-number-format": "^5.3.3"
},
"peerDependencies": {
Expand Down Expand Up @@ -48,10 +51,12 @@
"prettier": "3.0.2",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-hook-form": "^7.51.1",
"storybook": "^8.0.0",
"styled-components": "^6.1.8",
"typescript": "^5.2.2",
"vite": "^5.0.8"
"vite": "^5.0.8",
"vitest": "^1.4.0"
},
"resolutions": {
"@mui/styled-engine": "npm:@mui/styled-engine-sc@latest"
Expand Down
41 changes: 41 additions & 0 deletions src/components/MaskedCurrencyInput/MaskedCurrencyInput.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import type { Meta, StoryObj } from '@storybook/react';
import { fn } from '@storybook/test';

import MaskedCurrencyInput from './MaskedCurrencyInput';

// More on how to set up stories at: https://storybook.js.org/docs/writing-stories#default-export
const meta = {
title: 'Components/MaskedCurrencyInput',
component: MaskedCurrencyInput,
parameters: {
// Optional parameter to center the component in the Canvas.
// More info: https://storybook.js.org/docs/configure/story-layout
layout: 'centered',
},
// This component will have an automatically generated Autodocs entry: https://storybook.js.org/docs/writing-docs/autodocs
tags: ['autodocs'],
// More on argTypes: https://storybook.js.org/docs/api/argtypes
argTypes: {
// backgroundColor: { control: 'color' },
// label: { control: '' }
},
// Use `fn` to spy on the onClick arg, which will appear in the actions panel once invoked: https://storybook.js.org/docs/essentials/actions#action-args
args: { onChange: fn() },
} satisfies Meta<typeof MaskedCurrencyInput>;

export default meta;
type Story = StoryObj<typeof meta>;

// More on writing stories with args: https://storybook.js.org/docs/writing-stories/args
export const Basic: Story = {
args: {
label: 'Dollar Amount',
},
};

// TODO Story for integration w/react-hook-form and <Controller>
export const ReactHookForm: Story = {
args: {
label: 'Dollar Amount',
},
};
78 changes: 78 additions & 0 deletions src/components/MaskedCurrencyInput/MaskedCurrencyInput.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import '@testing-library/jest-dom';
import { Controller, SubmitHandler, useForm } from 'react-hook-form';
import { describe, expect, it, vi } from 'vitest';

import MaskedCurrencyInput from './MaskedCurrencyInput';

describe('<MaskedCurrencyInput />', () => {
it('should render', async () => {
render(<MaskedCurrencyInput label="Label" />);
const element = screen.getByLabelText('Label');
expect(element).toBeInTheDocument();
expect(element).toBeVisible();
});

it('should format initial value as currency', async () => {
render(<MaskedCurrencyInput label="Label" value="12345.67'" />);
const element = screen.getByLabelText('Label');
expect(element).toHaveValue('$12,345.67');
});

it('should format user-entered numeric input as currency', async () => {
render(<MaskedCurrencyInput label="Label" />);
const element = screen.getByLabelText('Label');
await userEvent.type(element, '12345.67');
expect(element).toHaveValue('$12,345.67');
});

it('should not allow user-entered non-numeric input', async () => {
render(<MaskedCurrencyInput label="Label" />);
const element = screen.getByLabelText('Label');
await userEvent.type(element, '123.00abc');
expect(element).toHaveValue('$123.00');
});

it('should output the unformatted value on change', async () => {
const onChange = vi.fn();
render(<MaskedCurrencyInput label="Label" onChange={onChange} />);
const element = screen.getByLabelText('Label');
await userEvent.type(element, '$12,345.67');
expect(element).toHaveValue('$12,345.67');
expect(onChange).toHaveBeenCalled();
expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ target: expect.objectContaining({ value: '12345.67' }) }));
});

describe('react-hook-form', () => {
function TestForm({ onSubmit }: { onSubmit: SubmitHandler<{ amount: string }> }) {
const {
control,
formState: { errors },
handleSubmit,
} = useForm<{ amount: string }>();

return (
<form noValidate onSubmit={handleSubmit(onSubmit)}>
<Controller
control={control}
name="amount"
render={({ field }) => <MaskedCurrencyInput error={!!errors.amount} label="Label" {...field} />}
rules={{ required: true }}
/>
<button type="submit">Submit</button>
</form>
);
}

it('should have inter-op with useForm()', async () => {
const onSubmit = vi.fn();
render(<TestForm onSubmit={onSubmit} />);
const element = screen.getByLabelText('Label');
await userEvent.type(element, '12345.67');
await userEvent.click(screen.getByText('Submit'));
expect(onSubmit).toHaveBeenCalledOnce();
expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({ amount: '12345.67' }), expect.anything());
});
});
});
29 changes: 29 additions & 0 deletions src/components/MaskedCurrencyInput/MaskedCurrencyInput.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import TextField, { TextFieldProps } from '@mui/material/TextField';
import { forwardRef } from 'react';
import { NumericFormat, NumericFormatProps } from 'react-number-format';
import { formatMaskedValueChangeEvent } from '../../utils/maskedInput';

type MaskedCurrencyInputProps = TextFieldProps;

const MaskedCurrencyInput = forwardRef<HTMLInputElement, NumericFormatProps<MaskedCurrencyInputProps>>(function MaskedCurrencyInput(
{ decimalScale = 2, fixedDecimalScale = true, onChange, prefix = '$', ...rest },
ref
) {
return (
<NumericFormat
customInput={TextField}
decimalScale={decimalScale}
fixedDecimalScale={fixedDecimalScale}
getInputRef={ref}
inputProps={{
inputMode: 'decimal',
}}
onValueChange={(vals, info) => onChange?.(formatMaskedValueChangeEvent(vals, info, rest.name))}
prefix={prefix}
thousandSeparator
{...rest}
/>
);
});

export default MaskedCurrencyInput;
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import type { Meta, StoryObj } from '@storybook/react';
import { fn } from '@storybook/test';

import MaskedCurrencyInputRTL from './MaskedCurrencyInputRTL';

// More on how to set up stories at: https://storybook.js.org/docs/writing-stories#default-export
const meta = {
title: 'Components/MaskedCurrencyInputRTL',
component: MaskedCurrencyInputRTL,
parameters: {
// Optional parameter to center the component in the Canvas.
// More info: https://storybook.js.org/docs/configure/story-layout
layout: 'centered',
},
// This component will have an automatically generated Autodocs entry: https://storybook.js.org/docs/writing-docs/autodocs
tags: ['autodocs'],
// More on argTypes: https://storybook.js.org/docs/api/argtypes
argTypes: {
// backgroundColor: { control: 'color' },
// label: { control: '' }
},
// Use `fn` to spy on the onClick arg, which will appear in the actions panel once invoked: https://storybook.js.org/docs/essentials/actions#action-args
args: { onChange: fn() },
} satisfies Meta<typeof MaskedCurrencyInputRTL>;

export default meta;
type Story = StoryObj<typeof meta>;

// More on writing stories with args: https://storybook.js.org/docs/writing-stories/args
export const Basic: Story = {
args: {
label: 'Dollar Amount (RTL)',
},
};

// TODO Story for integration w/react-hook-form and <Controller>
export const ReactHookForm: Story = {
args: {
label: 'Dollar Amount (RTL)',
},
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import '@testing-library/jest-dom';
import { Controller, SubmitHandler, useForm } from 'react-hook-form';
import { describe, expect, it, vi } from 'vitest';

import MaskedCurrencyInputRTL from './MaskedCurrencyInputRTL';

describe('<MaskedCurrencyInputRTL />', () => {
it('should render', async () => {
render(<MaskedCurrencyInputRTL label="Label" />);
const element = screen.getByLabelText('Label');
expect(element).toBeInTheDocument();
expect(element).toBeVisible();
});

it('should format initial value as currency', async () => {
render(<MaskedCurrencyInputRTL label="Label" value="12345.67'" />);
const element = screen.getByLabelText('Label');
expect(element).toHaveValue('$12,345.67');
});

it('should format user-entered numeric input as currency', async () => {
render(<MaskedCurrencyInputRTL label="Label" />);
const element = screen.getByLabelText('Label');
await userEvent.type(element, '12345.67');
expect(element).toHaveValue('$12,345.67');
});

it('should not allow user-entered non-numeric input', async () => {
render(<MaskedCurrencyInputRTL label="Label" />);
const element = screen.getByLabelText('Label');
await userEvent.type(element, '123.00abc');
expect(element).toHaveValue('$123.00');
});

it('should output the unformatted value on change', async () => {
const onChange = vi.fn();
render(<MaskedCurrencyInputRTL label="Label" onChange={onChange} />);
const element = screen.getByLabelText('Label');
await userEvent.type(element, '$12,345.67');
expect(element).toHaveValue('$12,345.67');
expect(onChange).toHaveBeenCalled();
expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ target: expect.objectContaining({ value: '12345.67' }) }));
});

it.todo('should enter numbers from right to left');

describe('react-hook-form', () => {
function TestForm({ onSubmit }: { onSubmit: SubmitHandler<{ amount: string }> }) {
const {
control,
formState: { errors },
handleSubmit,
} = useForm<{ amount: string }>();

return (
<form noValidate onSubmit={handleSubmit(onSubmit)}>
<Controller
control={control}
name="amount"
render={({ field }) => <MaskedCurrencyInputRTL error={!!errors.amount} label="Label" {...field} />}
rules={{ required: true }}
/>
<button type="submit">Submit</button>
</form>
);
}

it('should have inter-op with useForm()', async () => {
const onSubmit = vi.fn();
render(<TestForm onSubmit={onSubmit} />);
const element = screen.getByLabelText('Label');
await userEvent.type(element, '12345.67');
await userEvent.click(screen.getByText('Submit'));
expect(onSubmit).toHaveBeenCalledOnce();
expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({ amount: '12345.67' }), expect.anything());
});
});
});
101 changes: 101 additions & 0 deletions src/components/MaskedCurrencyInputRTL/MaskedCurrencyInputRTL.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import TextField, { TextFieldProps } from '@mui/material/TextField';
import { ChangeEvent, forwardRef } from 'react';
import { NumberFormatBase, NumberFormatBaseProps } from 'react-number-format';

type MaskedCurrencyInputRTLProps = TextFieldProps & {
currency?: string;
decimalScale?: number | null;
fixedDecimalScale?: boolean;
locale?: string;
};

function formatter(
value: string = '',
{
currency,
locale,
maximumFractionDigits,
minimumFractionDigits,
}: { currency: string; locale: string; maximumFractionDigits?: number; minimumFractionDigits?: number }
) {
if (!value) {
return '';
}

return new Intl.NumberFormat(locale, {
currency,
maximumFractionDigits,
minimumFractionDigits,
style: 'currency',
}).format(Number(value) / 100);
}

// @see https://github.com/s-yadav/react-number-format/blob/8af37958c69ff1e0252282d37e922a913e52e046/lib/utils.js#L146C2-L170C2
function setCaretPosition(el: HTMLInputElement, caretPos: number) {
// this is used to not only get "focus", but
// to make sure we don't have it everything -selected-
// (it causes an issue in chrome, and having it doesn't hurt any other browser)
// eslint-disable-next-line
el.value = el.value;

if (el !== null) {
// @ts-expect-error these are universally adopted DOM APIs
if (el.createTextRange) {
// @ts-expect-error these are universally adopted DOM APIs
const range = el.createTextRange();
range.move('character', caretPos);
range.select();
return true;
} // (el.selectionStart === 0 added for Firefox bug)

if (el.selectionStart || el.selectionStart === 0) {
el.focus();
el.setSelectionRange(caretPos, caretPos);
return true;
} // fail city, fortunately this never happens (as far as I've tested) :)

el.focus();
return false;
}
}

const MaskedCurrencyInputRTL = forwardRef<HTMLInputElement, NumberFormatBaseProps<MaskedCurrencyInputRTLProps>>(
function MaskedCurrencyInputRTL(
{ currency = 'USD', decimalScale = 2, fixedDecimalScale = true, locale = 'en-US', onChange, onFocus, ...rest },
ref
) {
const minimumFractionDigits = decimalScale ?? undefined;
const maximumFractionDigits = fixedDecimalScale && minimumFractionDigits ? minimumFractionDigits : undefined;

return (
<NumberFormatBase
customInput={TextField}
format={(value) => formatter(value, { currency, locale, maximumFractionDigits, minimumFractionDigits })}
getInputRef={ref}
inputProps={{
inputMode: 'decimal',
}}
onValueChange={(values, sourceInfo) => {
onChange?.({
...(sourceInfo.event ?? {}),
target: {
...(sourceInfo.event?.target ?? {}),
name: rest.name ?? '',
value: ((values.floatValue ?? 0) / 100).toFixed(decimalScale ?? undefined),
},
} as ChangeEvent<HTMLInputElement>);
}}
{...rest}
onFocus={(event) => {
if (event.target?.value) {
setTimeout(() => setCaretPosition(event.target, event.target.value.length), 100);
}

return onFocus?.(event);
}}
/>
);
}
);

export default MaskedCurrencyInputRTL;
Loading

0 comments on commit 3b8b47f

Please sign in to comment.