Skip to content
Draft
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
1 change: 1 addition & 0 deletions packages/form/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@
"@ultraviolet/icons": "workspace:*",
"@ultraviolet/themes": "workspace:*",
"@ultraviolet/ui": "workspace:*",
"awesome-phonenumber": "7.8.0",
"react-hook-form": "7.72.0"
},
"devDependencies": {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { Template } from './Template.stories'

export const WithDefaultCountry = Template.bind({})

WithDefaultCountry.args = {
...Template.args,
defaultCountry: 'US',
label: 'US Phone Number',
name: 'usPhone',
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { PhoneField } from '..'
import { Form } from '../..'
import { useForm } from '../../..'
import { mockErrors } from '../../../mocks'

import type { StoryFn } from '@storybook/react-vite'

export const WithError: StoryFn<typeof PhoneField> = () => {
const methods = useForm({
defaultValues: {
phone: 'invalid',
},
})

return (
<Form errors={mockErrors} methods={methods} onSubmit={() => {}}>
<PhoneField
defaultCountry="FR"
label="Phone Number"
name="phone"
parseNumberErrorMessage="This doesn't appear to be a valid phone number."
required
/>
</Form>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { Template } from './Template.stories'

export const Playground = Template.bind({})

Playground.args = {
label: 'Phone Number',
name: 'phone',
placeholder: 'Enter your phone number',
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { Template } from './Template.stories'

export const Required = Template.bind({})

Required.args = {
...Template.args,
label: 'Phone Number',
name: 'phone',
required: true,
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { PhoneField } from '..'
import { Form } from '../..'
import { useForm } from '../../..'
import { mockErrors } from '../../../mocks'

import type { StoryFn } from '@storybook/react-vite'

export const Template: StoryFn<typeof PhoneField> = ({ ...args }) => (
<Form errors={mockErrors} methods={useForm()} onSubmit={() => {}}>
<PhoneField
{...args}
parseNumberErrorMessage="This doesn't appear to be a valid phone number."
/>
</Form>
)

Template.args = {
label: 'Phone Number',
name: 'phone',
placeholder: 'Enter your phone number',
defaultCountry: 'FR',
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { Snippet, Stack, Text } from '@ultraviolet/ui'

import { PhoneField } from '..'
import { Form } from '../..'
import { useForm } from '../../..'
import { mockErrors } from '../../../mocks'

import type { Meta } from '@storybook/react-vite'

export default {
component: PhoneField,
decorators: [
ChildStory => {
const methods = useForm()
const {
errors,
isDirty,
isSubmitting,
touchedFields,
submitCount,
dirtyFields,
isValid,
isLoading,
isSubmitted,
isValidating,
isSubmitSuccessful,
} = methods.formState

return (
<Form errors={mockErrors} methods={methods} onSubmit={() => {}}>
<Stack gap={2}>
<ChildStory />
<Stack gap={1}>
<Text as="p" variant="bodyStrong">
Form input values:
</Text>
<Snippet initiallyExpanded prefix="lines">
{JSON.stringify(methods.watch(), null, 1)}
</Snippet>
</Stack>
<Stack gap={1}>
<Text as="p" variant="bodyStrong">
Form values:
</Text>
<Snippet prefix="lines">
{JSON.stringify(
{
errors,
isDirty,
isSubmitting,
touchedFields,
submitCount,
dirtyFields,
isValid,
isLoading,
isSubmitted,
isValidating,
isSubmitSuccessful,
},
null,
1,
)}
</Snippet>
</Stack>
</Stack>
</Form>
)
},
],
title: 'Form/Components/Fields/PhoneField',
} as Meta<typeof PhoneField>

export { Playground } from './Playground.stories'
export { Required } from './Required.stories'
export { WithDefaultCountry } from './DefaultCountry.stories'
export { WithError } from './Error.stories'
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import { renderHook, screen, waitFor } from '@testing-library/react'
import { userEvent } from '@testing-library/user-event'
import { mockFormErrors, renderWithForm } from '@utils/test'
import { useForm } from 'react-hook-form'
import { describe, expect, test, vi } from 'vitest'

import { PhoneField } from '..'
import { Submit } from '../..'
import { Form } from '../../Form'

describe('form - PhoneField', () => {
test('should render correctly', () => {
const { asFragment } = renderWithForm(
<PhoneField
label="Phone Number"
name="phone"
parseNumberErrorMessage="Invalid phone number"
/>,
)
expect(asFragment()).toMatchSnapshot()

Check failure on line 20 in packages/form/src/components/PhoneInputField/__tests__/index.test.tsx

View workflow job for this annotation

GitHub Actions / test (24)

[form] src/components/PhoneInputField/__tests__/index.test.tsx > form - PhoneField > should render correctly

Error: Snapshot `form - PhoneField > should render correctly 1` mismatched ❯ src/components/PhoneInputField/__tests__/index.test.tsx:20:26
})

test('should validate phone number', async () => {
const onSubmit = vi.fn()
const { result } = renderHook(() => useForm<{ phone: string }>())

renderWithForm(
<Form
errors={mockFormErrors}
methods={result.current}
onSubmit={onSubmit}
>
<PhoneField
label="Phone Number"
name="phone"
parseNumberErrorMessage="Invalid phone number"
required
/>
<Submit>Submit</Submit>
</Form>,
)

await userEvent.click(screen.getByText('Submit'))
await waitFor(() => {
expect(onSubmit).toHaveBeenCalledTimes(0)
})

const phoneInput = screen.getByRole('textbox')
await userEvent.type(phoneInput, '+33612345678')
await userEvent.click(screen.getByText('Submit'))
await waitFor(() => {
expect(onSubmit).toHaveBeenCalledOnce()
})
})

test('should work with default country', async () => {
const onSubmit = vi.fn()
const { result } = renderHook(() => useForm<{ phone: string }>())

const { asFragment } = renderWithForm(
<Form
errors={mockFormErrors}
methods={result.current}
onSubmit={onSubmit}
>
<PhoneField
defaultCountry="US"
label="Phone Number"
name="phone"
parseNumberErrorMessage="Invalid phone number"
/>
<Submit>Submit</Submit>
</Form>,
)

const phoneInput = screen.getByRole('textbox')
await userEvent.type(phoneInput, '+12025551234')
await userEvent.click(screen.getByText('Submit'))
await waitFor(() => {
expect(onSubmit.mock.calls[0][0]).toEqual({

Check failure on line 80 in packages/form/src/components/PhoneInputField/__tests__/index.test.tsx

View workflow job for this annotation

GitHub Actions / test (24)

[form] src/components/PhoneInputField/__tests__/index.test.tsx > form - PhoneField > should work with default country

AssertionError: expected { phone: '+1 202-555-1234' } to deeply equal { phone: '+12025551234' } Ignored nodes: comments, script, style <html> <head /> <body> <div /> <div /> <div> <div data-testid="testing" > <form novalidate="" style="display: contents;" > <form novalidate="" style="display: contents;" > <div> <label aria-disabled="false" class="uv_f9zn1x3" data-disabled="false" data-error="false" for="phone-phone" > <span class="uv_f9zn1x2" > Phone Number </span> <div class="uv_f9zn1x0" > 🇺🇸 </div> <input class="uv_f9zn1x5" id="phone-phone" maxlength="20" name="phone" placeholder="+1 201-555-0123" type="tel" value="+1 202-555-1234" /> </label> <div class="uv_mlltm91" data-is-animated="true" style="--uv_mlltm90: 300ms; max-height: 0px; overflow: hidden;" > <p class="uv_f9zn1x4 uv_m4c9ow0 uv_m4c9ow4 uv_m4c9ow9 uv_m4c9owp uv_m4c9ow1a uv_m4c9ow2m" /> </div> </div> <button class="uv_e1wcoe0 uv_e1wcoe1 uv_e1wcoe3 uv_e1wcoe9 uv_e1wcoee uv_e1wcoei uv_e1wcoel" type="submit" > Submit </button> </form> </form> </div> </div> </body> </html>... - Expected + Received { - "phone": "+12025551234", + "phone": "+1 202-555-1234", } ❯ src/components/PhoneInputField/__tests__/index.test.tsx:80:41 ❯ runWithExpensiveErrorDiagnosticsDisabled ../../node_modules/.pnpm/@testing-library+dom@10.4.1/node_modules/@testing-library/dom/dist/config.js:47:12 ❯ checkCallback ../../node_modules/.pnpm/@testing-library+dom@10.4.1/node_modules/@testing-library/dom/dist/wait-for.js:124:77 ❯ Timeout.checkRealTimersCallback ../../node_modules/.pnpm/@testing-library+dom@10.4.1/node_modules/@testing-library/dom/dist/wait-for.js:118:16
phone: '+12025551234',
})
})
expect(asFragment()).toMatchSnapshot()
})

test('should show error for invalid phone number', async () => {
const onSubmit = vi.fn()
const { result } = renderHook(() => useForm<{ phone: string }>())

renderWithForm(
<Form
errors={mockFormErrors}
methods={result.current}
onSubmit={onSubmit}
>
<PhoneField
label="Phone Number"
name="phone"
parseNumberErrorMessage="This doesn't appear to be a valid phone number."
required
/>
<Submit>Submit</Submit>
</Form>,
)

const phoneInput = screen.getByRole('textbox')
await userEvent.type(phoneInput, 'invalid')
await userEvent.click(screen.getByText('Submit'))
await waitFor(() => {
expect(onSubmit).toHaveBeenCalledTimes(0)
})
expect(
screen.getByText("This doesn't appear to be a valid phone number."),

Check failure on line 114 in packages/form/src/components/PhoneInputField/__tests__/index.test.tsx

View workflow job for this annotation

GitHub Actions / test (24)

[form] src/components/PhoneInputField/__tests__/index.test.tsx > form - PhoneField > should show error for invalid phone number

TestingLibraryElementError: Unable to find an element with the text: This doesn't appear to be a valid phone number.. This could be because the text is broken up by multiple elements. In this case, you can provide a function for your text matcher to make your matcher more flexible. Ignored nodes: comments, script, style <body> <div /> <div /> <div> <div data-testid="testing" > <form novalidate="" style="display: contents;" > <form novalidate="" style="display: contents;" > <div> <label aria-disabled="false" class="uv_f9zn1x3" data-disabled="false" data-error="false" for="phone-phone" > <span class="uv_f9zn1x2" > Phone Number </span> <div class="uv_f9zn1x0" > 🇫🇷 </div> <input class="uv_f9zn1x5" id="phone-phone" maxlength="20" name="phone" placeholder="+33 6 12 34 56 78" type="tel" value="invalid" /> <span class="uv_f9zn1x1 uv_m4c9ow0 uv_m4c9ow4 uv_m4c9ow9 uv_m4c9own uv_m4c9ow1a uv_m4c9ow2m" > * </span> </label> <div class="uv_mlltm91" data-is-animated="true" style="--uv_mlltm90: 300ms; max-height: 0px; overflow: hidden;" > <p class="uv_f9zn1x4 uv_m4c9ow0 uv_m4c9ow4 uv_m4c9ow9 uv_m4c9owp uv_m4c9ow1a uv_m4c9ow2m" /> </div> </div> <button class="uv_e1wcoe0 uv_e1wcoe2 uv_e1wcoe3 uv_e1wcoe9 uv_e1wcoee uv_e1wcoei uv_e1wcoel" disabled="" type="submit" > Submit </button> </form> </form> </div> </div> </body> ❯ Object.getElementError ../../node_modules/.pnpm/@testing-library+dom@10.4.1/node_modules/@testing-library/dom/dist/config.js:37:19 ❯ ../../node_modules/.pnpm/@testing-library+dom@10.4.1/node_modules/@testing-library/dom/dist/query-helpers.js:76:38 ❯ ../../node_modules/.pnpm/@testing-library+dom@10.4.1/node_modules/@testing-library/dom/dist/query-helpers.js:52:17 ❯ ../../node_modules/.pnpm/@testing-library+dom@10.4.1/node_modules/@testing-library/dom/dist/query-helpers.js:95:19 ❯ src/components/PhoneInputField/__tests__/index.test.tsx:114:14
).toBeDefined()
})

test('should be disabled when disabled prop is true', () => {
const { asFragment } = renderWithForm(
<PhoneField
disabled
label="Phone Number"
name="phone"
parseNumberErrorMessage="Invalid phone number"
/>,
)
expect(asFragment()).toMatchSnapshot()

Check failure on line 127 in packages/form/src/components/PhoneInputField/__tests__/index.test.tsx

View workflow job for this annotation

GitHub Actions / test (24)

[form] src/components/PhoneInputField/__tests__/index.test.tsx > form - PhoneField > should be disabled when disabled prop is true

Error: Snapshot `form - PhoneField > should be disabled when disabled prop is true 1` mismatched ❯ src/components/PhoneInputField/__tests__/index.test.tsx:127:26
})
})
99 changes: 99 additions & 0 deletions packages/form/src/components/PhoneInputField/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
'use client'

import { PhoneInput } from '@ultraviolet/ui'
import { parsePhoneNumber } from 'awesome-phonenumber'
import { useController } from 'react-hook-form'

import { useErrors } from '../../providers'

import type { BaseFieldProps, FieldPath, FieldValues } from '@ultraviolet/form'
import type { ComponentProps } from 'react'

type PhoneInputValue = NonNullable<ComponentProps<typeof PhoneInput>['value']>

type PhoneFieldProps<
TFieldValues extends FieldValues,
TName extends FieldPath<TFieldValues>,
> = BaseFieldProps<TFieldValues, TName> &
ComponentProps<typeof PhoneInput> & {
/**
* message show to the user when the number is not a correct phone number
*/
parseNumberErrorMessage: string
}

export const PhoneField = <
TFieldValues extends FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
>({
className,
disabled,
id,
label,
name,
onBlur,
onChange,
onFocus,
required,
defaultCountry,
placeholder,
'data-testid': dataTestId,
parseNumberErrorMessage = "This doesn't appear to be a valid phone number.",
onParsingError,
}: PhoneFieldProps<TFieldValues, TName>) => {
const { getError } = useErrors()
const {
field,
fieldState: { error: fieldError },
} = useController<TFieldValues>({
name,
rules: {
required,
validate: (phoneNumber: PhoneInputValue) => {
try {
return !!phoneNumber && !parsePhoneNumber(phoneNumber).valid
? parseNumberErrorMessage
: undefined
} catch (error: unknown) {
if (error instanceof Error) {
onParsingError?.({
error,
inputValue: phoneNumber,
})
}

return phoneNumber
}
},
},
})

return (
<PhoneInput
className={className}
data-testid={dataTestId}
defaultCountry={defaultCountry}
disabled={disabled}
error={getError({ label: label ?? '' }, fieldError)}
id={id}
label={label}
name={field.name}
onBlur={event => {
field.onBlur()
onBlur?.(event)
}}
onChange={event => {
field.onChange(event)
onChange?.(event)
}}
onFocus={event => {
onFocus?.(event)
}}
onParsingError={onParsingError}
placeholder={placeholder}
ref={field.ref}
required={required}
value={field.value}
/>
)
}
Loading
Loading