Skip to content

Commit

Permalink
Allow custom css classes for the form itself
Browse files Browse the repository at this point in the history
  • Loading branch information
timomeh committed Mar 1, 2024
1 parent dbc9b93 commit 059caed
Show file tree
Hide file tree
Showing 11 changed files with 797 additions and 129 deletions.
9 changes: 9 additions & 0 deletions lib/PortingEmbed/Options.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,20 @@ type FieldState = {
valid: boolean
}

type FormState = {
name: string
dirty: boolean
valid: boolean
submitting: boolean
touched: boolean
}

export const defaultFormId = 'gigsPortingEmbedForm'

export type EmbedOptions = {
formId?: string
className?: {
form?: (state: FormState) => string
field?: (state: FieldState) => string
input?: (state: FieldState) => string
label?: (state: FieldState) => string
Expand Down
14 changes: 12 additions & 2 deletions lib/PortingEmbed/StepAddressForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ export function StepAddressForm({
onSubmit,
}: Props) {
const options = useEmbedOptions()
const [portingForm, { Form, Field }] = useForm<StepAddressFormData>({
const [form, { Form, Field }] = useForm<StepAddressFormData>({
initialValues: {
line1: porting.address?.line1 ?? '',
line2: porting.address?.line2 ?? null,
Expand All @@ -47,15 +47,25 @@ export function StepAddressForm({
validateOn: 'blur',
})

const customClassName =
options.className?.form?.({
name: 'address',
dirty: form.dirty.value,
valid: !form.invalid.value,
submitting: form.submitting.value,
touched: form.touched.value,
}) || ''

useSignalEffect(() => {
const isValid = !portingForm.invalid.value
const isValid = !form.invalid.value
onValidationChange?.({ isValid })
})

return (
<Form
id={options.formId || defaultFormId}
role="form"
className={`GigsEmbeds GigsPortingEmbed GigsEmbeds-form ${customClassName}`}
onSubmit={(data) => {
// The address form is always submitted as a whole, never partially.
// line2 and state are optional and must be converted to null if empty.
Expand Down
10 changes: 10 additions & 0 deletions lib/PortingEmbed/StepCarrierDetailsForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,15 @@ export function StepCarrierDetailsForm({
validateOn: 'blur',
})

const customClassName =
options.className?.form?.({
name: 'carrierDetails',
dirty: form.dirty.value,
valid: !form.invalid.value,
submitting: form.submitting.value,
touched: form.touched.value,
}) || ''

useSignalEffect(() => {
const isValid = !form.invalid.value
onValidationChange?.({ isValid })
Expand All @@ -42,6 +51,7 @@ export function StepCarrierDetailsForm({
<Form
id={options.formId || defaultFormId}
role="form"
className={`GigsEmbeds GigsPortingEmbed GigsEmbeds-form ${customClassName}`}
shouldDirty // only include changed fields in the onSubmit handler
onSubmit={(data) => {
const existingAccountPinWasTouched =
Expand Down
25 changes: 17 additions & 8 deletions lib/PortingEmbed/StepDonorProviderApprovalForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,23 +25,32 @@ export function StepDonorProviderApprovalForm({
onSubmit,
}: Props) {
const options = useEmbedOptions()
const [portingForm, { Form, Field }] =
useForm<StepDonorProviderApprovalFormData>({
initialValues: {
donorProviderApproval: porting.donorProviderApproval ?? false,
},
validateOn: 'change',
})
const [form, { Form, Field }] = useForm<StepDonorProviderApprovalFormData>({
initialValues: {
donorProviderApproval: porting.donorProviderApproval ?? false,
},
validateOn: 'change',
})

useSignalEffect(() => {
const isValid = !portingForm.invalid.value
const isValid = !form.invalid.value
onValidationChange?.({ isValid })
})

const customClassName =
options.className?.form?.({
name: 'donorProviderApproval',
dirty: form.dirty.value,
valid: !form.invalid.value,
submitting: form.submitting.value,
touched: form.touched.value,
}) || ''

return (
<Form
id={options.formId || defaultFormId}
role="form"
className={`GigsEmbeds GigsPortingEmbed GigsEmbeds-form ${customClassName}`}
shouldActive={false}
onSubmit={async (data) => {
const sanitizedData = sanitizeSubmitData(data)
Expand Down
14 changes: 12 additions & 2 deletions lib/PortingEmbed/StepHolderDetailsForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ export function StepHolderDetailsForm({
onSubmit,
}: Props) {
const options = useEmbedOptions()
const [portingForm, { Form, Field }] = useForm<StepHolderDetailsFormData>({
const [form, { Form, Field }] = useForm<StepHolderDetailsFormData>({
initialValues: {
firstName: porting.firstName ?? '',
lastName: porting.lastName ?? '',
Expand All @@ -37,14 +37,24 @@ export function StepHolderDetailsForm({
})

useSignalEffect(() => {
const isValid = !portingForm.invalid.value
const isValid = !form.invalid.value
onValidationChange?.({ isValid })
})

const customClassName =
options.className?.form?.({
name: 'holderDetails',
dirty: form.dirty.value,
valid: !form.invalid.value,
submitting: form.submitting.value,
touched: form.touched.value,
}) || ''

return (
<Form
id={options.formId || defaultFormId}
role="form"
className={`GigsEmbeds GigsPortingEmbed GigsEmbeds-form ${customClassName}`}
shouldDirty // only include changed fields in the onSubmit handler
onSubmit={(data) => {
const sanitizedData = sanitizeSubmitData(data)
Expand Down
197 changes: 171 additions & 26 deletions lib/PortingEmbed/__tests__/StepAddressForm.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,32 +24,6 @@ const address = {
country: 'CO',
}

describe('form id', () => {
it('has the default form id', () => {
const porting = portingFactory.build({ required: ['address'] })
render(<StepAddressForm porting={porting} onSubmit={vi.fn()} />, {
wrapper,
})
expect(screen.getByRole('form')).toHaveAttribute(
'id',
'gigsPortingEmbedForm',
)
})

it('uses a custom form id', () => {
const porting = portingFactory.build({ required: ['address'] })
render(
<OptionsContext.Provider value={{ formId: 'customFormId' }}>
<StepAddressForm porting={porting} onSubmit={vi.fn()} />
</OptionsContext.Provider>,
{
wrapper,
},
)
expect(screen.getByRole('form')).toHaveAttribute('id', 'customFormId')
})
})

describe('line1', () => {
it('exists', async () => {
const porting = portingFactory.build({ required: ['address'] })
Expand Down Expand Up @@ -435,3 +409,174 @@ describe('country', () => {
expect(submit).toHaveBeenCalledWith({ ...address, country: 'NC' })
})
})

describe('form id', () => {
it('has the default form id', () => {
const porting = portingFactory.build({ required: ['address'] })
render(<StepAddressForm porting={porting} onSubmit={vi.fn()} />, {
wrapper,
})
expect(screen.getByRole('form')).toHaveAttribute(
'id',
'gigsPortingEmbedForm',
)
})

it('uses a custom form id', () => {
const porting = portingFactory.build({ required: ['address'] })
render(
<OptionsContext.Provider value={{ formId: 'customFormId' }}>
<StepAddressForm porting={porting} onSubmit={vi.fn()} />
</OptionsContext.Provider>,
{
wrapper,
},
)
expect(screen.getByRole('form')).toHaveAttribute('id', 'customFormId')
})
})

describe('form class names', () => {
it('includes default class names', () => {
const porting = portingFactory.build({ required: ['address'] })
render(<StepAddressForm porting={porting} onSubmit={vi.fn()} />, {
wrapper,
})
expect(screen.getByRole('form')).toHaveClass(
'GigsEmbeds',
'GigsPortingEmbed',
'GigsEmbeds-form',
)
})

it('allows to specify a custom class name', () => {
const porting = portingFactory.build({ required: ['address'] })
render(
<OptionsContext.Provider
value={{ className: { form: () => 'custom-class-name' } }}
>
<StepAddressForm porting={porting} onSubmit={vi.fn()} />
</OptionsContext.Provider>,
{
wrapper,
},
)
expect(screen.getByRole('form')).toHaveClass('custom-class-name')
})

it('passes the form name to the custom class name', () => {
const porting = portingFactory.build({ required: ['address'] })
render(
<OptionsContext.Provider
value={{ className: { form: ({ name }) => `custom-class-${name}` } }}
>
<StepAddressForm porting={porting} onSubmit={vi.fn()} />
</OptionsContext.Provider>,
{
wrapper,
},
)
expect(screen.getByRole('form')).toHaveClass('custom-class-address')
})

it('allows a custom class for the touched state', async () => {
const user = userEvent.setup()
const porting = portingFactory.build({ required: ['address'] })
render(
<OptionsContext.Provider
value={{
className: {
form: ({ touched }) =>
`custom-class-${touched ? 'touched' : 'untouched'}`,
},
}}
>
<StepAddressForm porting={porting} onSubmit={vi.fn()} />
</OptionsContext.Provider>,
{
wrapper,
},
)
expect(screen.getByRole('form')).toHaveClass('custom-class-untouched')
await user.click(screen.getByLabelText('Line 1'))
await user.tab()
expect(screen.getByRole('form')).toHaveClass('custom-class-touched')
})

it('allows a custom class for the dirty state', async () => {
const user = userEvent.setup()
const porting = portingFactory.build({ required: ['address'] })
render(
<OptionsContext.Provider
value={{
className: {
form: ({ dirty }) => `custom-class-${dirty ? 'dirty' : 'undirty'}`,
},
}}
>
<StepAddressForm porting={porting} onSubmit={vi.fn()} />
</OptionsContext.Provider>,
{
wrapper,
},
)
expect(screen.getByRole('form')).toHaveClass('custom-class-undirty')
await user.type(screen.getByLabelText('Line 1'), 'a')
expect(screen.getByRole('form')).toHaveClass('custom-class-dirty')
})

it('allows a custom class for the valid state', async () => {
const user = userEvent.setup()
const porting = portingFactory.build({ required: ['address'] })
render(
<OptionsContext.Provider
value={{
className: {
form: ({ valid }) => `custom-class-${valid ? 'valid' : 'invalid'}`,
},
}}
>
<StepAddressForm porting={porting} onSubmit={vi.fn()} />
</OptionsContext.Provider>,
{
wrapper,
},
)
expect(screen.getByRole('form')).toHaveClass('custom-class-valid')
await user.click(screen.getByLabelText('Line 1'))
await user.tab()
expect(screen.getByRole('form')).toHaveClass('custom-class-invalid')
})

it('allows a custom class for the submitting state', async () => {
const user = userEvent.setup()
const porting = portingFactory.build({ required: ['address'] })
render(
<OptionsContext.Provider
value={{
className: {
form: ({ submitting }) =>
`custom-class-${submitting ? 'submitting' : 'idle'}`,
},
}}
>
<StepAddressForm
porting={porting}
onSubmit={async () => {
await new Promise<1>((resolve) => setTimeout(() => resolve(1), 1))
}}
/>
</OptionsContext.Provider>,
{
wrapper,
},
)
expect(screen.getByRole('form')).toHaveClass('custom-class-idle')
await user.type(screen.getByLabelText('Line 1'), 'line1')
await user.type(screen.getByLabelText('City'), 'city')
await user.type(screen.getByLabelText('Postal Code'), 'pc123')
await user.type(screen.getByLabelText(/Country/), 'CO')
await user.click(screen.getByRole('button'))
expect(screen.getByRole('form')).toHaveClass('custom-class-submitting')
})
})
Loading

0 comments on commit 059caed

Please sign in to comment.