-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
add stories and unit tests for all the things
- Loading branch information
1 parent
257b66a
commit 3b8b47f
Showing
23 changed files
with
1,409 additions
and
10 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
41 changes: 41 additions & 0 deletions
41
src/components/MaskedCurrencyInput/MaskedCurrencyInput.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,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
78
src/components/MaskedCurrencyInput/MaskedCurrencyInput.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,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
29
src/components/MaskedCurrencyInput/MaskedCurrencyInput.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,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; |
41 changes: 41 additions & 0 deletions
41
src/components/MaskedCurrencyInputRTL/MaskedCurrencyInputRTL.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,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)', | ||
}, | ||
}; |
80 changes: 80 additions & 0 deletions
80
src/components/MaskedCurrencyInputRTL/MaskedCurrencyInputRTL.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,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
101
src/components/MaskedCurrencyInputRTL/MaskedCurrencyInputRTL.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,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; |
Oops, something went wrong.