Skip to content

Commit

Permalink
Update a porting in the Porting Embed (#2)
Browse files Browse the repository at this point in the history
- Renders a form to update the porting (currently still a single form,
splitting the form in multiple steps is in an upcoming PR).
- Uses
[`@modular-forms/preact`](https://modularforms.dev/preact/guides/introduction)
for form state handling and validation. It has a much smaller footprint
compared to react-hook-form + zod, and RHF doesn't work well with
preact.
- Updates the porting after submit.
- Allows to use your own submit button, with a simple `<button
form="gigsPortingEmbedForm" />` anywhere on the page.
- Adds an event `validationChange` which returns `true` or `false` when
the validation of the form changed.
- Adds an event `submitStatus` which sends the status when the porting
is submitted:
  - `{ status: 'loading' }`
  - `{ status: 'success', porting }`
  - `{ status: 'error', error }`
  • Loading branch information
timomeh authored Feb 26, 2024
1 parent 86edb2d commit a4c02e5
Show file tree
Hide file tree
Showing 18 changed files with 654 additions and 63 deletions.
30 changes: 25 additions & 5 deletions lib/PortingEmbed/PortingEmbed.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Porting } from '../types'
import { Porting, UpdatePortingBody } from '../types'
import { PortingForm } from './PortingForm'

export type CustomizableEmbedProps = {
// TODO: add styling options
Expand All @@ -7,13 +8,32 @@ export type CustomizableEmbedProps = {
}
}

export type ValidationChangeEvent = {
isValid: boolean
}

type CoreEmbedProps = {
token: string
initialPorting: Porting
porting: Porting
onValidationChange?: (event: ValidationChangeEvent) => unknown
onPortingUpdate?: (updatedFields: UpdatePortingBody) => unknown
}

type PortingEmbedProps = CoreEmbedProps & CustomizableEmbedProps

export function PortingEmbed({ token: _, initialPorting }: PortingEmbedProps) {
return <div className="__gigsPortingEmbed">Hello {initialPorting.id}!</div>
export function PortingEmbed({
porting,
onPortingUpdate,
onValidationChange,
}: PortingEmbedProps) {
return (
<div className="__gigsPortingEmbed">
<PortingForm
porting={porting}
onValidationChange={onValidationChange}
onSubmit={async (updatedFields) => {
await onPortingUpdate?.(updatedFields)
}}
/>
</div>
)
}
112 changes: 112 additions & 0 deletions lib/PortingEmbed/PortingForm.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import { required, useForm } from '@modular-forms/preact'
import { useSignalEffect } from '@preact/signals'

import { Porting } from '../types'
import { sanitizeSubmitData } from './sanitizeSubmitData'

type Props = {
porting: Porting
onValidationChange?: (event: { isValid: boolean }) => unknown
onSubmit: (data: Partial<PortingForm>) => unknown
}

type PortingForm = {
accountPin: string
accountNumber: string
birthday: string
firstName: string
lastName: string
}

export function PortingForm({ porting, onValidationChange, onSubmit }: Props) {
const [portingForm, { Form, Field }] = useForm<PortingForm>({
initialValues: {
accountNumber: porting.accountNumber || '',
accountPin: '',
birthday: porting.birthday || '',
firstName: porting.firstName || '',
lastName: porting.lastName || '',
},
validateOn: 'blur',
})

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

return (
<Form
id="gigsPortingEmbedForm" // TODO: make customizable
role="form"
shouldDirty // only include changed fields in the onSubmit handler
onSubmit={(data) => {
const sanitizedData = sanitizeSubmitData(data)
return onSubmit(sanitizedData)
}}
>
<Field name="accountNumber" validate={[required('Please enter')]}>
{(field, props) => (
<div>
<label for="accountNumber">Account Number</label>
<input
id="accountNumber"
type="text"
value={field.value}
{...props}
/>
{field.error && <div>{field.error}</div>}
</div>
)}
</Field>

<Field
name="accountPin"
validate={porting.accountPinExists ? [] : [required('Please enter')]}
>
{(field, props) => (
<div>
<label for="accountPin">Account PIN</label>
<input
id="accountPin"
type="text"
placeholder={porting.accountPinExists ? '••••' : undefined}
{...props}
/>
{field.error && <div>{field.error}</div>}
</div>
)}
</Field>

<Field name="birthday" validate={[required('Please enter')]}>
{(field, props) => (
<div>
<label for="birthday">Birthday</label>
<input id="birthday" type="text" value={field.value} {...props} />
{field.error && <div>{field.error}</div>}
</div>
)}
</Field>

<Field name="firstName" validate={[required('Please enter')]}>
{(field, props) => (
<div>
<label for="firstName">First Name</label>
<input id="firstName" type="text" value={field.value} {...props} />
{field.error && <div>{field.error}</div>}
</div>
)}
</Field>

<Field name="lastName" validate={[required('Please enter')]}>
{(field, props) => (
<div>
<label for="lastName">Last Name</label>
<input id="lastName" type="text" value={field.value} {...props} />
{field.error && <div>{field.error}</div>}
</div>
)}
</Field>
</Form>
)
}
39 changes: 34 additions & 5 deletions lib/PortingEmbed/__stories__/PortingEmbed.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,49 @@
import { Meta } from '@storybook/preact'

import { portingFactory } from '@/testing/factories/porting'

import { PortingEmbed } from '../PortingEmbed'

export default {
title: 'Porting Embed/Base',
const meta: Meta<typeof PortingEmbed> = {
title: 'Porting/Embed',
component: PortingEmbed,
tags: ['autodocs'],
argTypes: {
token: { control: 'text' },
initialPorting: { control: 'object' },
onPortingUpdate: { action: 'onPortingUpdate' },
onValidationChange: { action: 'onValidationChange' },
},
decorators: [
(Story) => (
<div>
{Story()}
<button form="gigsPortingEmbedForm" type="submit">
Submit!
</button>
</div>
),
],
}

export default meta

export const EmptyPorting = {
args: {
porting: portingFactory.build(),
},
}

export const Primary = {
export const PrefilledPorting = {
args: {
token: 'abc:123',
initialPorting: portingFactory.build(),
porting: portingFactory
.params({
accountNumber: '1234',
accountPinExists: true,
birthday: '01.01.1990',
firstName: 'Jane',
lastName: 'Doe',
})
.build(),
},
}
10 changes: 6 additions & 4 deletions lib/PortingEmbed/__tests__/PortingEmbed.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,11 @@ import { portingFactory } from '@/testing/factories/porting'

import { PortingEmbed } from '../PortingEmbed'

it('gets the porting', () => {
it('renders a form', () => {
const porting = portingFactory.params({ id: 'prt_123' }).build()
render(<PortingEmbed initialPorting={porting} token="abc:123" />)
const greeting = screen.getByText(/Hello prt_123/i)
expect(greeting).toBeInTheDocument()
render(<PortingEmbed porting={porting} />)
const form = screen.getByRole('form')
expect(form).toBeInTheDocument()
})

// TODO: different forms based on required fields
85 changes: 85 additions & 0 deletions lib/PortingEmbed/__tests__/PortingForm.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { render, screen } from '@testing-library/preact'
import userEvent from '@testing-library/user-event'

import { portingFactory } from '@/testing/factories/porting'

import { PortingForm } from '../PortingForm'

const wrapper = ({ children }: { children: React.ReactNode }) => {
return (
<div>
{children}
<button form="gigsPortingEmbedForm">Submit</button>
</div>
)
}

it('can enter and submit', async () => {
const user = userEvent.setup()
const porting = portingFactory.build()
const submit = vi.fn()
render(<PortingForm porting={porting} onSubmit={submit} />, { wrapper })

await user.type(screen.getByLabelText('Account Number'), '1234')
await user.type(screen.getByLabelText('Account PIN'), '0000')
await user.type(screen.getByLabelText('Birthday'), '01.01.1990')
await user.type(screen.getByLabelText('First Name'), 'Jerry')
await user.type(screen.getByLabelText('Last Name'), 'Seinfeld')
await user.click(screen.getByRole('button', { name: 'Submit' }))

expect(submit).toHaveBeenCalledWith({
accountNumber: '1234',
accountPin: '0000',
birthday: '01.01.1990',
firstName: 'Jerry',
lastName: 'Seinfeld',
})
})

describe('with existing porting fields', () => {
it('prefills the inputs', async () => {
const porting = portingFactory.build({
accountNumber: '1234',
accountPinExists: true,
birthday: '01.01.1990',
firstName: 'Jerry',
lastName: 'Seinfeld',
})
const submit = vi.fn()
render(<PortingForm porting={porting} onSubmit={submit} />, { wrapper })

expect(screen.getByLabelText('Account Number')).toHaveValue('1234')
expect(screen.getByLabelText('Birthday')).toHaveValue('01.01.1990')
expect(screen.getByLabelText('First Name')).toHaveValue('Jerry')
expect(screen.getByLabelText('Last Name')).toHaveValue('Seinfeld')

// The account pin is not stored and cannot be pre-filled.
// The presence of the account pin is instead indicated with a placeholder.
expect(screen.getByLabelText('Account PIN')).toHaveValue('')
expect(screen.getByLabelText('Account PIN')).toHaveAttribute(
'placeholder',
'••••',
)
})

it('only submits changed fields', async () => {
const user = userEvent.setup()
const porting = portingFactory.build({
accountNumber: '1234',
accountPinExists: true,
birthday: '01.01.1990',
firstName: 'Jerry',
lastName: 'Seinfeld',
})
const submit = vi.fn()
render(<PortingForm porting={porting} onSubmit={submit} />, { wrapper })

await user.clear(screen.getByLabelText('Account Number'))
await user.type(screen.getByLabelText('Account Number'), '5678')
await user.click(screen.getByRole('button', { name: 'Submit' }))

expect(submit).toHaveBeenCalledWith({
accountNumber: '5678',
})
})
})
Loading

0 comments on commit a4c02e5

Please sign in to comment.