From a9a00afff1a32ac4c668d32f18b2cbee7a654b14 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timo=20M=C3=A4mecke?= Date: Tue, 20 Feb 2024 16:52:42 +0100 Subject: [PATCH 01/11] WIP --- lib/PortingEmbed/PortingEmbed.tsx | 6 +- lib/PortingEmbed/__tests__/wizard.test.tsx | 103 +++++++++++++++++++++ lib/PortingEmbed/wizard.tsx | 63 +++++++++++++ lib/types/porting.ts | 2 +- 4 files changed, 172 insertions(+), 2 deletions(-) create mode 100644 lib/PortingEmbed/__tests__/wizard.test.tsx create mode 100644 lib/PortingEmbed/wizard.tsx diff --git a/lib/PortingEmbed/PortingEmbed.tsx b/lib/PortingEmbed/PortingEmbed.tsx index 6b46dca..60742c6 100644 --- a/lib/PortingEmbed/PortingEmbed.tsx +++ b/lib/PortingEmbed/PortingEmbed.tsx @@ -1,3 +1,5 @@ +import { useState } from 'preact/hooks' + import { Porting } from '../types' export type CustomizableEmbedProps = { @@ -15,5 +17,7 @@ type CoreEmbedProps = { type PortingEmbedProps = CoreEmbedProps & CustomizableEmbedProps export function PortingEmbed({ token: _, initialPorting }: PortingEmbedProps) { - return
Hello {initialPorting.id}!
+ const [porting, _setPorting] = useState(initialPorting) + + return
Hello {porting.id}!
} diff --git a/lib/PortingEmbed/__tests__/wizard.test.tsx b/lib/PortingEmbed/__tests__/wizard.test.tsx new file mode 100644 index 0000000..cb16f86 --- /dev/null +++ b/lib/PortingEmbed/__tests__/wizard.test.tsx @@ -0,0 +1,103 @@ +import { portingFactory } from '@/testing/factories/porting' + +import { currentStep } from '../wizard' + +const basePorting = portingFactory + +describe('for carrier details', () => { + it('detects required and missing account number', () => { + const porting = basePorting + .params({ required: ['accountNumber'], accountNumber: null }) + .build() + expect(currentStep(porting)).toBe('carrierDetails') + }) + + it('detects required and missing account pin', () => { + const porting = basePorting + .params({ required: ['accountPin'], accountPinExists: false }) + .build() + expect(currentStep(porting)).toBe('carrierDetails') + }) + + it('skips if fields are filled out', () => { + const porting = basePorting + .params({ + required: ['accountNumber', 'accountPin'], + accountNumber: '123', + accountPinExists: true, + }) + .build() + expect(currentStep(porting)).toBe(null) + }) + + it('is before holder details', () => { + const porting = basePorting + .params({ + required: ['accountPin', 'firstName'], + accountPinExists: false, + firstName: null, + }) + .build() + expect(currentStep(porting)).toBe('carrierDetails') + }) +}) + +describe('for holder details', () => { + it('detects required and missing firstName', () => { + const porting = basePorting + .params({ required: ['firstName'], firstName: null }) + .build() + expect(currentStep(porting)).toBe('holderDetails') + }) + + it('detects required and missing lastName', () => { + const porting = basePorting + .params({ required: ['lastName'], lastName: null }) + .build() + expect(currentStep(porting)).toBe('holderDetails') + }) + + it('detects required and missing birthday', () => { + const porting = basePorting + .params({ required: ['birthday'], birthday: null }) + .build() + expect(currentStep(porting)).toBe('holderDetails') + }) + + it('skips if fields are filled out', () => { + const porting = basePorting + .params({ + required: ['firstName', 'lastName', 'birthday'], + firstName: 'first', + lastName: 'last', + birthday: 'birth', + }) + .build() + expect(currentStep(porting)).toBe(null) + }) + + it('is after carrier details', () => { + const porting = basePorting + .params({ + required: ['accountNumber', 'accountPin', 'firstName'], + accountNumber: '123', + accountPinExists: true, + firstName: null, + }) + .build() + expect(currentStep(porting)).toBe('holderDetails') + }) + + it('is before address', () => { + const porting = basePorting + .params({ + required: ['firstName', 'lastName', 'birthday', 'address'], + firstName: null, + lastName: null, + birthday: null, + address: null, + }) + .build() + expect(currentStep(porting)).toBe('holderDetails') + }) +}) diff --git a/lib/PortingEmbed/wizard.tsx b/lib/PortingEmbed/wizard.tsx new file mode 100644 index 0000000..5349207 --- /dev/null +++ b/lib/PortingEmbed/wizard.tsx @@ -0,0 +1,63 @@ +import { Porting, PortingRequiredField } from '../types' + +export function currentStep(porting: Porting) { + if (requiresCarrierDetails(porting)) { + return 'carrierDetails' as const + } + + if (requiresHolderDetails(porting)) { + return 'holderDetails' as const + } + + if (requiresAddress(porting)) { + return 'address' as const + } + + if (requiresDonorApproval(porting)) { + return 'donorApproval' as const + } + + return null +} + +function requiresCarrierDetails(porting: Porting) { + if (requires(porting, 'accountPin') && !porting.accountPinExists) { + return true + } + + if (requires(porting, 'accountNumber') && !porting.accountNumber) { + return true + } + + return false +} + +function requiresHolderDetails(porting: Porting) { + if (requires(porting, 'firstName') && !porting.firstName) { + return true + } + + if (requires(porting, 'lastName') && !porting.lastName) { + return true + } + + if (requires(porting, 'birthday') && !porting.birthday) { + return true + } + + return false +} + +function requiresAddress(porting: Porting) { + return requires(porting, 'address') && !porting.address +} + +function requiresDonorApproval(porting: Porting) { + return ( + requires(porting, 'donorProviderApproval') && !porting.donorProviderApproval + ) +} + +function requires(porting: Porting, field: PortingRequiredField) { + return porting.required.includes(field) +} diff --git a/lib/types/porting.ts b/lib/types/porting.ts index 5330131..2b90db6 100644 --- a/lib/types/porting.ts +++ b/lib/types/porting.ts @@ -31,7 +31,7 @@ export type PortingAddress = { city: string country: string line1: string - line2: string + line2: string | null postalCode: string state: string | null } From b5bf0028cc502de6f9cb260477cb9bc494a9af2d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timo=20M=C3=A4mecke?= Date: Fri, 23 Feb 2024 14:44:18 +0100 Subject: [PATCH 02/11] Remove wizard, wrong ticket --- lib/PortingEmbed/__tests__/wizard.test.tsx | 103 --------------------- lib/PortingEmbed/wizard.tsx | 63 ------------- lib/core/subscription.ts | 2 +- 3 files changed, 1 insertion(+), 167 deletions(-) delete mode 100644 lib/PortingEmbed/__tests__/wizard.test.tsx delete mode 100644 lib/PortingEmbed/wizard.tsx diff --git a/lib/PortingEmbed/__tests__/wizard.test.tsx b/lib/PortingEmbed/__tests__/wizard.test.tsx deleted file mode 100644 index cb16f86..0000000 --- a/lib/PortingEmbed/__tests__/wizard.test.tsx +++ /dev/null @@ -1,103 +0,0 @@ -import { portingFactory } from '@/testing/factories/porting' - -import { currentStep } from '../wizard' - -const basePorting = portingFactory - -describe('for carrier details', () => { - it('detects required and missing account number', () => { - const porting = basePorting - .params({ required: ['accountNumber'], accountNumber: null }) - .build() - expect(currentStep(porting)).toBe('carrierDetails') - }) - - it('detects required and missing account pin', () => { - const porting = basePorting - .params({ required: ['accountPin'], accountPinExists: false }) - .build() - expect(currentStep(porting)).toBe('carrierDetails') - }) - - it('skips if fields are filled out', () => { - const porting = basePorting - .params({ - required: ['accountNumber', 'accountPin'], - accountNumber: '123', - accountPinExists: true, - }) - .build() - expect(currentStep(porting)).toBe(null) - }) - - it('is before holder details', () => { - const porting = basePorting - .params({ - required: ['accountPin', 'firstName'], - accountPinExists: false, - firstName: null, - }) - .build() - expect(currentStep(porting)).toBe('carrierDetails') - }) -}) - -describe('for holder details', () => { - it('detects required and missing firstName', () => { - const porting = basePorting - .params({ required: ['firstName'], firstName: null }) - .build() - expect(currentStep(porting)).toBe('holderDetails') - }) - - it('detects required and missing lastName', () => { - const porting = basePorting - .params({ required: ['lastName'], lastName: null }) - .build() - expect(currentStep(porting)).toBe('holderDetails') - }) - - it('detects required and missing birthday', () => { - const porting = basePorting - .params({ required: ['birthday'], birthday: null }) - .build() - expect(currentStep(porting)).toBe('holderDetails') - }) - - it('skips if fields are filled out', () => { - const porting = basePorting - .params({ - required: ['firstName', 'lastName', 'birthday'], - firstName: 'first', - lastName: 'last', - birthday: 'birth', - }) - .build() - expect(currentStep(porting)).toBe(null) - }) - - it('is after carrier details', () => { - const porting = basePorting - .params({ - required: ['accountNumber', 'accountPin', 'firstName'], - accountNumber: '123', - accountPinExists: true, - firstName: null, - }) - .build() - expect(currentStep(porting)).toBe('holderDetails') - }) - - it('is before address', () => { - const porting = basePorting - .params({ - required: ['firstName', 'lastName', 'birthday', 'address'], - firstName: null, - lastName: null, - birthday: null, - address: null, - }) - .build() - expect(currentStep(porting)).toBe('holderDetails') - }) -}) diff --git a/lib/PortingEmbed/wizard.tsx b/lib/PortingEmbed/wizard.tsx deleted file mode 100644 index 5349207..0000000 --- a/lib/PortingEmbed/wizard.tsx +++ /dev/null @@ -1,63 +0,0 @@ -import { Porting, PortingRequiredField } from '../types' - -export function currentStep(porting: Porting) { - if (requiresCarrierDetails(porting)) { - return 'carrierDetails' as const - } - - if (requiresHolderDetails(porting)) { - return 'holderDetails' as const - } - - if (requiresAddress(porting)) { - return 'address' as const - } - - if (requiresDonorApproval(porting)) { - return 'donorApproval' as const - } - - return null -} - -function requiresCarrierDetails(porting: Porting) { - if (requires(porting, 'accountPin') && !porting.accountPinExists) { - return true - } - - if (requires(porting, 'accountNumber') && !porting.accountNumber) { - return true - } - - return false -} - -function requiresHolderDetails(porting: Porting) { - if (requires(porting, 'firstName') && !porting.firstName) { - return true - } - - if (requires(porting, 'lastName') && !porting.lastName) { - return true - } - - if (requires(porting, 'birthday') && !porting.birthday) { - return true - } - - return false -} - -function requiresAddress(porting: Porting) { - return requires(porting, 'address') && !porting.address -} - -function requiresDonorApproval(porting: Porting) { - return ( - requires(porting, 'donorProviderApproval') && !porting.donorProviderApproval - ) -} - -function requires(porting: Porting, field: PortingRequiredField) { - return porting.required.includes(field) -} diff --git a/lib/core/subscription.ts b/lib/core/subscription.ts index ab29cb9..aba4f40 100644 --- a/lib/core/subscription.ts +++ b/lib/core/subscription.ts @@ -16,7 +16,7 @@ type FetchSubOptions = { export async function fetchSubscription(sub: string, opts: FetchSubOptions) { const res = await fetch( `https://api.gigs.com/projects/${opts.project}/subscriptions/${sub}`, - { headers: { authorization: `bearer ${opts.token} ` } }, + { headers: { authorization: `Bearer ${opts.token}` } }, ) const body = await res.json().catch(() => res.text()) assert( From 904dbfac4838454af02736dd3fd3b054d6f9449e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timo=20M=C3=A4mecke?= Date: Fri, 23 Feb 2024 18:48:23 +0100 Subject: [PATCH 03/11] Add form and porting update --- lib/PortingEmbed/PortingEmbed.tsx | 34 +++++-- lib/PortingEmbed/PortingForm.tsx | 95 +++++++++++++++++++ .../__stories__/PortingEmbed.stories.tsx | 39 +++++++- .../__tests__/PortingEmbed.test.tsx | 8 +- lib/PortingEmbed/__tests__/index.test.tsx | 82 +++++++++++++++- lib/PortingEmbed/index.tsx | 43 +++++++-- lib/core/__tests__/porting.test.ts | 22 +++++ lib/core/porting.ts | 39 ++++++++ lib/types/porting.ts | 10 ++ package-lock.json | 58 ++++++++++- package.json | 5 + src/App.tsx | 5 + testing/factories/porting.ts | 67 +++++++------ testing/http.ts | 32 +++++++ 14 files changed, 474 insertions(+), 65 deletions(-) create mode 100644 lib/PortingEmbed/PortingForm.tsx create mode 100644 lib/core/__tests__/porting.test.ts create mode 100644 lib/core/porting.ts diff --git a/lib/PortingEmbed/PortingEmbed.tsx b/lib/PortingEmbed/PortingEmbed.tsx index 60742c6..cd44c14 100644 --- a/lib/PortingEmbed/PortingEmbed.tsx +++ b/lib/PortingEmbed/PortingEmbed.tsx @@ -1,6 +1,5 @@ -import { useState } from 'preact/hooks' - -import { Porting } from '../types' +import { Porting, UpdatePortingBody } from '../types' +import { PortingForm } from './PortingForm' export type CustomizableEmbedProps = { // TODO: add styling options @@ -9,15 +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) { - const [porting, _setPorting] = useState(initialPorting) - - return
Hello {porting.id}!
+export function PortingEmbed({ + porting, + onPortingUpdate, + onValidationChange, +}: PortingEmbedProps) { + return ( +
+ { + await onPortingUpdate?.(updatedFields) + }} + /> +
+ ) } diff --git a/lib/PortingEmbed/PortingForm.tsx b/lib/PortingEmbed/PortingForm.tsx new file mode 100644 index 0000000..3933be7 --- /dev/null +++ b/lib/PortingEmbed/PortingForm.tsx @@ -0,0 +1,95 @@ +import { required, useForm } from '@modular-forms/preact' +import { useSignalEffect } from '@preact/signals' + +import { Porting } from '../types' + +type Props = { + porting: Porting + onValidationChange?: (event: { isValid: boolean }) => unknown + onSubmit: (data: 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({ + initialValues: { + accountNumber: porting.accountNumber || '', + accountPin: porting.accountPinExists ? '[***]' : '', + birthday: porting.birthday || '', + firstName: porting.firstName || '', + lastName: porting.lastName || '', + }, + validateOn: 'blur', + }) + + useSignalEffect(() => { + const isValid = !portingForm.invalid.value + onValidationChange?.({ isValid }) + }) + + return ( +
+ + {(field, props) => ( +
+ + + {field.error &&
{field.error}
} +
+ )} +
+ + + {(field, props) => ( +
+ + + {field.error &&
{field.error}
} +
+ )} +
+ + + {(field, props) => ( +
+ + + {field.error &&
{field.error}
} +
+ )} +
+ + + {(field, props) => ( +
+ + + {field.error &&
{field.error}
} +
+ )} +
+ + + {(field, props) => ( +
+ + + {field.error &&
{field.error}
} +
+ )} +
+
+ ) +} diff --git a/lib/PortingEmbed/__stories__/PortingEmbed.stories.tsx b/lib/PortingEmbed/__stories__/PortingEmbed.stories.tsx index b4ce8ca..6302571 100644 --- a/lib/PortingEmbed/__stories__/PortingEmbed.stories.tsx +++ b/lib/PortingEmbed/__stories__/PortingEmbed.stories.tsx @@ -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 = { + title: 'Porting/Embed', component: PortingEmbed, tags: ['autodocs'], argTypes: { token: { control: 'text' }, initialPorting: { control: 'object' }, + onPortingUpdate: { action: 'onPortingUpdate' }, + onValidationChange: { action: 'onValidationChange' }, + }, + decorators: [ + (Story) => ( +
+ {Story()} + +
+ ), + ], +} + +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(), }, } diff --git a/lib/PortingEmbed/__tests__/PortingEmbed.test.tsx b/lib/PortingEmbed/__tests__/PortingEmbed.test.tsx index e4e113e..b0e8557 100644 --- a/lib/PortingEmbed/__tests__/PortingEmbed.test.tsx +++ b/lib/PortingEmbed/__tests__/PortingEmbed.test.tsx @@ -4,9 +4,9 @@ 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() - const greeting = screen.getByText(/Hello prt_123/i) - expect(greeting).toBeInTheDocument() + render() + const form = screen.getByRole('form') + expect(form).toBeInTheDocument() }) diff --git a/lib/PortingEmbed/__tests__/index.test.tsx b/lib/PortingEmbed/__tests__/index.test.tsx index 36c54f5..bfc6a70 100644 --- a/lib/PortingEmbed/__tests__/index.test.tsx +++ b/lib/PortingEmbed/__tests__/index.test.tsx @@ -1,5 +1,7 @@ -import { render } from '@testing-library/preact' +import { render, screen, waitFor } from '@testing-library/preact' +import userEvent from '@testing-library/user-event' +import { db } from '@/testing/db' import { connectSessionFactory } from '@/testing/factories/connectSession' import { portingFactory } from '@/testing/factories/porting' import { subscriptionFactory } from '@/testing/factories/subscription' @@ -18,7 +20,14 @@ async function createFixtures() { } beforeEach(() => { - render(
) + render( + <> +
+ + , + ) }) describe('mounting', () => { @@ -142,10 +151,10 @@ describe('initialization', () => { expect(init).resolves.toBeDefined() }) - it('initializes with declined', async () => { + it('throws with declined', async () => { const csn = await createWithStatus('declined') const init = PortingEmbed(csn, { project }) - expect(init).resolves.toBeDefined() + expect(init).rejects.toThrow(/UNSUPPORTED/) }) it('throws with draft', async () => { @@ -179,3 +188,68 @@ describe('initialization', () => { }) }) }) + +describe('updating a porting', () => { + it('sends the updated data', async () => { + const user = userEvent.setup() + const updatedFn = vitest.fn() + + const csn = await createFixtures() + const embed = await PortingEmbed(csn, { project }) + embed.mount('#mount') + embed.on('portingUpdated', updatedFn) + + await user.type(screen.getByLabelText('Account Number'), '11880') + await user.type(screen.getByLabelText('Account PIN'), '1337') + await user.type(screen.getByLabelText('Birthday'), '01.01.1990') + await user.type(screen.getByLabelText('First Name'), 'Jane') + await user.type(screen.getByLabelText('Last Name'), 'Doe') + await user.click(screen.getByRole('button', { name: 'Submit' })) + + await waitFor(() => expect(updatedFn).toHaveBeenCalled()) + + const sub = db.subscriptions.find( + (s) => s.id === csn.intent.completePorting.subscription, + ) + const prt = db.portings.find((p) => p.id === sub!.porting!.id) + + expect(prt).toMatchObject({ + accountPinExists: true, + accountNumber: '11880', + birthday: '01.01.1990', + firstName: 'Jane', + lastName: 'Doe', + }) + }) +}) + +describe('validationChange event', () => { + it('fires initially always valid', async () => { + const event = vitest.fn() + + const csn = await createFixtures() + const embed = await PortingEmbed(csn, { project }) + embed.mount('#mount') + embed.on('validationChange', event) + + await waitFor(() => expect(event).toHaveBeenCalledWith({ isValid: true })) + }) + + it('fires with invalid field', async () => { + const event = vitest.fn() + const user = userEvent.setup() + + const csn = await createFixtures() + const embed = await PortingEmbed(csn, { project }) + embed.mount('#mount') + embed.on('validationChange', event) + + await user.type(screen.getByLabelText('Account Number'), '123') + await user.clear(screen.getByLabelText('Account Number')) + await user.click(screen.getByLabelText('Account PIN')) + + await waitFor(() => + expect(event).toHaveBeenLastCalledWith({ isValid: false }), + ) + }) +}) diff --git a/lib/PortingEmbed/index.tsx b/lib/PortingEmbed/index.tsx index a472a9c..dc79ccf 100644 --- a/lib/PortingEmbed/index.tsx +++ b/lib/PortingEmbed/index.tsx @@ -2,12 +2,14 @@ import mitt from 'mitt' import { render } from 'preact' import { assert } from '../core/assert' +import { patchPorting } from '../core/porting' import { fetchSubscription } from '../core/subscription' import { exchangeSessionWithToken } from '../core/token' -import { PortingStatus } from '../types' +import { Porting, PortingStatus, UpdatePortingBody } from '../types' import { CustomizableEmbedProps, PortingEmbed as PortingEmbedComponent, + ValidationChangeEvent, } from './PortingEmbed' type PortingEmbedInit = { @@ -16,7 +18,14 @@ type PortingEmbedInit = { } export type PortingEmbedOptions = CustomizableEmbedProps -type Events = never +type PortingUpdatedEvent = { + porting: Porting +} + +type Events = { + validationChange: ValidationChangeEvent + portingUpdated: PortingUpdatedEvent +} /** * Initializes an embed to complete a porting (port-in a number). Requires an @@ -64,26 +73,40 @@ export async function PortingEmbed( connectSession.intent.completePorting.subscription, { project, token }, ) - const { porting } = subscription - assert(porting, 'NOT_FOUND: The given subscription has no porting.') + assert( + subscription.porting, + 'NOT_FOUND: The given subscription has no porting.', + ) + let porting = subscription.porting - const supportedPortingStatus: PortingStatus[] = [ - 'informationRequired', - 'declined', - ] + const supportedPortingStatus: PortingStatus[] = ['informationRequired'] assert( supportedPortingStatus.includes(porting.status), `UNSUPPORTED: Porting status "${porting.status}" is not supported by the embed.`, ) + const handleValidationChange = (event: ValidationChangeEvent) => { + emitter.emit('validationChange', event) + } + + const handlePortingUpdate = async (updatedFields: UpdatePortingBody) => { + porting = await patchPorting(porting.id, updatedFields, { + token, + project, + }) + emitter.emit('portingUpdated', { porting }) + renderWithCurrentOptions() + } + const renderWithCurrentOptions = () => { assert(element, 'No element present to render embed into.') render( , element, ) diff --git a/lib/core/__tests__/porting.test.ts b/lib/core/__tests__/porting.test.ts new file mode 100644 index 0000000..43e989f --- /dev/null +++ b/lib/core/__tests__/porting.test.ts @@ -0,0 +1,22 @@ +import { portingFactory } from '@/testing/factories/porting' + +import { patchPorting } from '../porting' + +const project = 'test_project' +const token = 'test_token' + +it('returns an existing subscription', async () => { + const prt = await portingFactory.create() + const result = await patchPorting( + prt.id, + { accountNumber: '123' }, + { project, token }, + ) + expect(result.accountNumber).toBe('123') +}) + +it('throws if the porting does not exist', async () => { + expect(patchPorting('prt_not_found', {}, { project, token })).rejects.toThrow( + /PRT_NOT_FOUND/, + ) +}) diff --git a/lib/core/porting.ts b/lib/core/porting.ts new file mode 100644 index 0000000..7872ff1 --- /dev/null +++ b/lib/core/porting.ts @@ -0,0 +1,39 @@ +import { Porting, UpdatePortingBody } from '../types' +import { assert } from './assert' + +type FetchSubOptions = { + /** Project id of the porting. */ + project: string + /** User token. */ + token: string +} + +/** + * Patches the user's porting. + * @param prt The porting id. + * @param opts Additional options. + */ +export async function patchPorting( + prt: string, + data: UpdatePortingBody, + opts: FetchSubOptions, +) { + const res = await fetch( + `https://api.gigs.com/projects/${opts.project}/portings/${prt}`, + { + method: 'PATCH', + headers: { + authorization: `Bearer ${opts.token}`, + 'content-type': 'application/json', + accept: 'application/json', + }, + body: JSON.stringify(data), + }, + ) + const body = await res.json().catch(() => res.text()) + assert(res.status !== 404, 'PRT_NOT_FOUND: Porting could not be found.') + assert(res.ok, `FETCH_FAILED: ${body?.message || body?.toString()}`) + const porting = body as Porting + + return porting +} diff --git a/lib/types/porting.ts b/lib/types/porting.ts index 2b90db6..e4e7796 100644 --- a/lib/types/porting.ts +++ b/lib/types/porting.ts @@ -62,3 +62,13 @@ export type PortingStatus = | 'completed' | 'canceled' | 'expired' + +export type UpdatePortingBody = { + accountNumber?: string + accountPin?: string + address?: PortingAddress | null + birthday?: string + donorProviderApproval?: boolean | null + firstName?: string + lastName?: string +} diff --git a/package-lock.json b/package-lock.json index b5a319a..2e873fc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,6 +7,10 @@ "": { "name": "gigs-embeds-js", "version": "0.0.0", + "dependencies": { + "@modular-forms/preact": "^0.7.0", + "@preact/signals": "^1.2.2" + }, "devDependencies": { "@preact/preset-vite": "^2.8.1", "@release-it/conventional-changelog": "^8.0.1", @@ -19,6 +23,7 @@ "@storybook/test": "^7.6.15", "@testing-library/jest-dom": "^6.4.2", "@testing-library/preact": "^3.2.3", + "@testing-library/user-event": "^14.5.2", "@types/node": "^20.11.16", "@typescript-eslint/eslint-plugin": "^6.14.0", "@typescript-eslint/parser": "^6.14.0", @@ -3345,6 +3350,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/@modular-forms/preact": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@modular-forms/preact/-/preact-0.7.0.tgz", + "integrity": "sha512-5foVL0SMAwCLjj/pM5RiUf3Zx8co7AgfvwtpACHTwOtZI+8fHt+Ya71pHxfXuAeAJLndkGgYtlCw2KhcDmhgsw==", + "peerDependencies": { + "@preact/signals": "^1.0.0", + "preact": "^10.0.0" + } + }, "node_modules/@mswjs/cookies": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@mswjs/cookies/-/cookies-1.1.0.tgz", @@ -3704,6 +3718,30 @@ "node": ">=12" } }, + "node_modules/@preact/signals": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@preact/signals/-/signals-1.2.2.tgz", + "integrity": "sha512-ColCqdo4cRP18bAuIR4Oik5rDpiyFtPIJIygaYPMEAwTnl4buWkBOflGBSzhYyPyJfKpkwlekrvK+1pzQ2ldWw==", + "dependencies": { + "@preact/signals-core": "^1.4.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + }, + "peerDependencies": { + "preact": "10.x" + } + }, + "node_modules/@preact/signals-core": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@preact/signals-core/-/signals-core-1.5.1.tgz", + "integrity": "sha512-dE6f+WCX5ZUDwXzUIWNMhhglmuLpqJhuy3X3xHrhZYI0Hm2LyQwOu0l9mdPiWrVNsE+Q7txOnJPgtIqHCYoBVA==", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + } + }, "node_modules/@prefresh/babel-plugin": { "version": "0.5.1", "resolved": "https://registry.npmjs.org/@prefresh/babel-plugin/-/babel-plugin-0.5.1.tgz", @@ -6551,6 +6589,19 @@ "url": "https://opencollective.com/storybook" } }, + "node_modules/@storybook/test/node_modules/@testing-library/user-event": { + "version": "14.3.0", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.3.0.tgz", + "integrity": "sha512-P02xtBBa8yMaLhK8CzJCIns8rqwnF6FxhR9zs810flHOBXUYCFjLd8Io1rQrAkQRWEmW2PGdZIEdMxf/KLsqFA==", + "dev": true, + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, "node_modules/@storybook/theming": { "version": "7.6.15", "resolved": "https://registry.npmjs.org/@storybook/theming/-/theming-7.6.15.tgz", @@ -6717,9 +6768,9 @@ } }, "node_modules/@testing-library/user-event": { - "version": "14.3.0", - "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.3.0.tgz", - "integrity": "sha512-P02xtBBa8yMaLhK8CzJCIns8rqwnF6FxhR9zs810flHOBXUYCFjLd8Io1rQrAkQRWEmW2PGdZIEdMxf/KLsqFA==", + "version": "14.5.2", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.5.2.tgz", + "integrity": "sha512-YAh82Wh4TIrxYLmfGcixwD18oIjyC1pFQC2Y01F2lzV2HTMiYrI0nze0FD0ocB//CKS/7jIUgae+adPqxK5yCQ==", "dev": true, "engines": { "node": ">=12", @@ -15503,7 +15554,6 @@ "version": "10.19.4", "resolved": "https://registry.npmjs.org/preact/-/preact-10.19.4.tgz", "integrity": "sha512-dwaX5jAh0Ga8uENBX1hSOujmKWgx9RtL80KaKUFLc6jb4vCEAc3EeZ0rnQO/FO4VgjfPMfoLFWnNG8bHuZ9VLw==", - "dev": true, "funding": { "type": "opencollective", "url": "https://opencollective.com/preact" diff --git a/package.json b/package.json index e1ec15a..93f9f05 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ "@storybook/test": "^7.6.15", "@testing-library/jest-dom": "^6.4.2", "@testing-library/preact": "^3.2.3", + "@testing-library/user-event": "^14.5.2", "@types/node": "^20.11.16", "@typescript-eslint/eslint-plugin": "^6.14.0", "@typescript-eslint/parser": "^6.14.0", @@ -89,5 +90,9 @@ "preset": "angular" } } + }, + "dependencies": { + "@modular-forms/preact": "^0.7.0", + "@preact/signals": "^1.2.2" } } diff --git a/src/App.tsx b/src/App.tsx index 2882945..3fa93e8 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -21,6 +21,8 @@ function App() { const project = formData.get('project')!.toString() const embed = await PortingEmbed(JSON.parse(csn), { project }) + setLoading('loaded') + embed.mount($portingEmbedEl.current!) } catch (error) { console.error(error) @@ -46,6 +48,9 @@ function App() { {loading === 'idle' &&
Fill out form first
} {loading === 'loading' &&
Loading...
}
+ ) } diff --git a/testing/factories/porting.ts b/testing/factories/porting.ts index e3db637..9d7f4b9 100644 --- a/testing/factories/porting.ts +++ b/testing/factories/porting.ts @@ -1,6 +1,7 @@ import { Factory } from 'fishery' import { Porting } from '../../lib/types' +import { db } from '../db' import { serviceProviderFactory } from './serviceProvider' type PortingTransientParams = never @@ -12,33 +13,41 @@ class PortingFactory extends Factory { } export const portingFactory = PortingFactory.define( - ({ sequence, associations }) => ({ - object: 'porting' as const, - id: `prt_${sequence}`, - accountNumber: null, - accountPinExists: false, - address: null, - birthday: null, - declinedAttempts: 0, - declinedCode: null, - declinedMessage: null, - donorProvider: associations.donorProvider || serviceProviderFactory.build(), - donorProviderApproval: null, - firstName: null, - lastName: null, - phoneNumber: '+19591234567', - provider: 'p7', - recipientProvider: - associations.recipientProvider || serviceProviderFactory.build(), - required: [], - status: 'informationRequired' as const, - subscription: null, - user: 'usr_123', - canceledAt: null, - completedAt: null, - createdAt: new Date().toISOString(), - expiredAt: null, - lastDeclinedAt: null, - lastRequestedAt: null, - }), + ({ sequence, associations, onCreate }) => { + onCreate((prt) => { + db.portings.push(prt) + return prt + }) + + return { + object: 'porting' as const, + id: `prt_${sequence}`, + accountNumber: null, + accountPinExists: false, + address: null, + birthday: null, + declinedAttempts: 0, + declinedCode: null, + declinedMessage: null, + donorProvider: + associations.donorProvider || serviceProviderFactory.build(), + donorProviderApproval: null, + firstName: null, + lastName: null, + phoneNumber: '+19591234567', + provider: 'p7', + recipientProvider: + associations.recipientProvider || serviceProviderFactory.build(), + required: [], + status: 'informationRequired' as const, + subscription: null, + user: 'usr_123', + canceledAt: null, + completedAt: null, + createdAt: new Date().toISOString(), + expiredAt: null, + lastDeclinedAt: null, + lastRequestedAt: null, + } + }, ) diff --git a/testing/http.ts b/testing/http.ts index bebe420..2c65884 100644 --- a/testing/http.ts +++ b/testing/http.ts @@ -1,6 +1,7 @@ import { http, HttpHandler, HttpResponse } from 'msw' import { setupServer } from 'msw/node' +import { UpdatePortingBody } from '../lib/types' import { db } from './db' export const handlers: HttpHandler[] = [ @@ -24,6 +25,37 @@ export const handlers: HttpHandler[] = [ }, ), + http.patch<{ project: string; id: string }, UpdatePortingBody>( + 'https://api.gigs.com/projects/:project/portings/:id', + async ({ params, request }) => { + const prt = db.portings.find((prt) => prt.id === params.id) + + if (!prt) { + return HttpResponse.json( + { object: 'error', type: 'notFound' }, + { status: 404 }, + ) + } + + const body = await request.json() + + const newPrt = { + ...prt, + ...body, + } + + if (newPrt.accountPin) { + newPrt.accountPinExists = true + delete newPrt.accountPin + } + + const index = db.portings.findIndex((prt) => prt.id === params.id) + db.portings[index] = newPrt + + return HttpResponse.json(newPrt) + }, + ), + http.post( 'https://connect.gigs.com/api/embeds/auth', async ({ request }) => { From 2a7b4b5871540ff9ff773f21bc6f4574834e1a6e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timo=20M=C3=A4mecke?= Date: Mon, 26 Feb 2024 12:24:40 +0100 Subject: [PATCH 04/11] Handle accountPin correctly and ensure sanitized data --- lib/PortingEmbed/PortingForm.tsx | 27 ++++-- .../__tests__/PortingForm.test.tsx | 85 +++++++++++++++++++ lib/PortingEmbed/__tests__/index.test.tsx | 31 ------- .../__tests__/sanitizeSubmitData.test.ts | 31 +++++++ lib/PortingEmbed/sanitizeSubmitData.ts | 14 +++ 5 files changed, 152 insertions(+), 36 deletions(-) create mode 100644 lib/PortingEmbed/__tests__/PortingForm.test.tsx create mode 100644 lib/PortingEmbed/__tests__/sanitizeSubmitData.test.ts create mode 100644 lib/PortingEmbed/sanitizeSubmitData.ts diff --git a/lib/PortingEmbed/PortingForm.tsx b/lib/PortingEmbed/PortingForm.tsx index 3933be7..9b7e00c 100644 --- a/lib/PortingEmbed/PortingForm.tsx +++ b/lib/PortingEmbed/PortingForm.tsx @@ -2,11 +2,12 @@ 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: PortingForm) => unknown + onSubmit: (data: Partial) => unknown } type PortingForm = { @@ -21,7 +22,7 @@ export function PortingForm({ porting, onValidationChange, onSubmit }: Props) { const [portingForm, { Form, Field }] = useForm({ initialValues: { accountNumber: porting.accountNumber || '', - accountPin: porting.accountPinExists ? '[***]' : '', + accountPin: '', birthday: porting.birthday || '', firstName: porting.firstName || '', lastName: porting.lastName || '', @@ -35,7 +36,15 @@ export function PortingForm({ porting, onValidationChange, onSubmit }: Props) { }) return ( -
+ { + const sanitizedData = sanitizeSubmitData(data) + return onSubmit(sanitizedData) + }} + shouldDirty + > {(field, props) => (
@@ -51,11 +60,19 @@ export function PortingForm({ porting, onValidationChange, onSubmit }: Props) { )} - + {(field, props) => (
- + {field.error &&
{field.error}
}
)} diff --git a/lib/PortingEmbed/__tests__/PortingForm.test.tsx b/lib/PortingEmbed/__tests__/PortingForm.test.tsx new file mode 100644 index 0000000..b9f6b14 --- /dev/null +++ b/lib/PortingEmbed/__tests__/PortingForm.test.tsx @@ -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 ( +
+ {children} + +
+ ) +} + +it('can enter and submit', async () => { + const user = userEvent.setup() + const porting = portingFactory.build() + const submit = vi.fn() + render(, { 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(, { 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 cannot be prefilled, and instead indicates it exists 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(, { 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', + }) + }) +}) diff --git a/lib/PortingEmbed/__tests__/index.test.tsx b/lib/PortingEmbed/__tests__/index.test.tsx index bfc6a70..c979614 100644 --- a/lib/PortingEmbed/__tests__/index.test.tsx +++ b/lib/PortingEmbed/__tests__/index.test.tsx @@ -222,34 +222,3 @@ describe('updating a porting', () => { }) }) }) - -describe('validationChange event', () => { - it('fires initially always valid', async () => { - const event = vitest.fn() - - const csn = await createFixtures() - const embed = await PortingEmbed(csn, { project }) - embed.mount('#mount') - embed.on('validationChange', event) - - await waitFor(() => expect(event).toHaveBeenCalledWith({ isValid: true })) - }) - - it('fires with invalid field', async () => { - const event = vitest.fn() - const user = userEvent.setup() - - const csn = await createFixtures() - const embed = await PortingEmbed(csn, { project }) - embed.mount('#mount') - embed.on('validationChange', event) - - await user.type(screen.getByLabelText('Account Number'), '123') - await user.clear(screen.getByLabelText('Account Number')) - await user.click(screen.getByLabelText('Account PIN')) - - await waitFor(() => - expect(event).toHaveBeenLastCalledWith({ isValid: false }), - ) - }) -}) diff --git a/lib/PortingEmbed/__tests__/sanitizeSubmitData.test.ts b/lib/PortingEmbed/__tests__/sanitizeSubmitData.test.ts new file mode 100644 index 0000000..0178381 --- /dev/null +++ b/lib/PortingEmbed/__tests__/sanitizeSubmitData.test.ts @@ -0,0 +1,31 @@ +import { sanitizeSubmitData } from '../sanitizeSubmitData' + +it('removes any null values', () => { + expect(sanitizeSubmitData({ foo: 'bar', bar: null, baz: null })).toEqual({ + foo: 'bar', + }) +}) + +it('removes any empty string values', () => { + expect(sanitizeSubmitData({ foo: 'bar', bar: '', baz: '' })).toEqual({ + foo: 'bar', + }) +}) + +it('removes any undefined values', () => { + expect(sanitizeSubmitData({ foo: 'bar', bar: undefined })).toEqual({ + foo: 'bar', + }) +}) + +it('keeps any other values', () => { + expect( + sanitizeSubmitData({ foo: 'bar', bar: 1, baz: true, qux: {}, quux: false }), + ).toEqual({ + foo: 'bar', + bar: 1, + baz: true, + qux: {}, + quux: false, + }) +}) diff --git a/lib/PortingEmbed/sanitizeSubmitData.ts b/lib/PortingEmbed/sanitizeSubmitData.ts new file mode 100644 index 0000000..37b4b2d --- /dev/null +++ b/lib/PortingEmbed/sanitizeSubmitData.ts @@ -0,0 +1,14 @@ +/** + * Removes any empty, null or undefined values from submit data, to make sure + * we never submit any empty data to the API which resets already filled-out + * porting fields. + */ +export function sanitizeSubmitData>(data: T) { + const sanitizedData = Object.fromEntries( + Object.entries(data).filter( + ([_, v]) => v !== '' && v !== null && v !== undefined, + ), + ) + + return sanitizedData +} From 3b856ccaa30222eb2e683488ad1d31a5df3e55d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timo=20M=C3=A4mecke?= Date: Mon, 26 Feb 2024 15:54:25 +0100 Subject: [PATCH 05/11] Improve callbacks --- lib/PortingEmbed/__tests__/index.test.tsx | 44 ++++++++++++++++++++--- lib/PortingEmbed/index.tsx | 28 +++++++++------ testing/http.ts | 7 ++++ 3 files changed, 65 insertions(+), 14 deletions(-) diff --git a/lib/PortingEmbed/__tests__/index.test.tsx b/lib/PortingEmbed/__tests__/index.test.tsx index c979614..7981956 100644 --- a/lib/PortingEmbed/__tests__/index.test.tsx +++ b/lib/PortingEmbed/__tests__/index.test.tsx @@ -190,14 +190,14 @@ describe('initialization', () => { }) describe('updating a porting', () => { - it('sends the updated data', async () => { + it('saves the updated data', async () => { const user = userEvent.setup() - const updatedFn = vitest.fn() + const submitStatusEvent = vitest.fn() const csn = await createFixtures() const embed = await PortingEmbed(csn, { project }) embed.mount('#mount') - embed.on('portingUpdated', updatedFn) + embed.on('submitStatus', submitStatusEvent) await user.type(screen.getByLabelText('Account Number'), '11880') await user.type(screen.getByLabelText('Account PIN'), '1337') @@ -206,7 +206,13 @@ describe('updating a porting', () => { await user.type(screen.getByLabelText('Last Name'), 'Doe') await user.click(screen.getByRole('button', { name: 'Submit' })) - await waitFor(() => expect(updatedFn).toHaveBeenCalled()) + expect(submitStatusEvent).toHaveBeenCalledWith({ status: 'loading' }) + await waitFor(() => + expect(submitStatusEvent).toHaveBeenLastCalledWith({ + status: 'success', + porting: expect.anything(), + }), + ) const sub = db.subscriptions.find( (s) => s.id === csn.intent.completePorting.subscription, @@ -221,4 +227,34 @@ describe('updating a porting', () => { lastName: 'Doe', }) }) + + it('triggers an error event on error', async () => { + const user = userEvent.setup() + const submitStatusEvent = vitest.fn() + + const csn = await createFixtures() + const embed = await PortingEmbed(csn, { project }) + embed.mount('#mount') + embed.on('submitStatus', submitStatusEvent) + + // magic string in the mocked http handler + await user.type(screen.getByLabelText('Account Number'), 'MAGIC_FAIL') + + await user.type(screen.getByLabelText('Account PIN'), '1337') + await user.type(screen.getByLabelText('Birthday'), '01.01.1990') + await user.type(screen.getByLabelText('First Name'), 'Jane') + await user.type(screen.getByLabelText('Last Name'), 'Doe') + await user.click(screen.getByRole('button', { name: 'Submit' })) + + await waitFor(() => { + expect(submitStatusEvent).toHaveBeenCalledWith({ + status: 'error', + error: expect.anything(), + }) + }) + + expect(submitStatusEvent.mock.lastCall[0].error).toMatch( + /FETCH_FAILED: Simulated error/i, + ) + }) }) diff --git a/lib/PortingEmbed/index.tsx b/lib/PortingEmbed/index.tsx index dc79ccf..a4b9498 100644 --- a/lib/PortingEmbed/index.tsx +++ b/lib/PortingEmbed/index.tsx @@ -18,13 +18,14 @@ type PortingEmbedInit = { } export type PortingEmbedOptions = CustomizableEmbedProps -type PortingUpdatedEvent = { - porting: Porting -} +type SubmitStatusEvent = + | { status: 'loading' } + | { status: 'success'; porting: Porting } + | { status: 'error'; error: unknown } type Events = { validationChange: ValidationChangeEvent - portingUpdated: PortingUpdatedEvent + submitStatus: SubmitStatusEvent } /** @@ -90,12 +91,19 @@ export async function PortingEmbed( } const handlePortingUpdate = async (updatedFields: UpdatePortingBody) => { - porting = await patchPorting(porting.id, updatedFields, { - token, - project, - }) - emitter.emit('portingUpdated', { porting }) - renderWithCurrentOptions() + emitter.emit('submitStatus', { status: 'loading' }) + + try { + porting = await patchPorting(porting.id, updatedFields, { + token, + project, + }) + emitter.emit('submitStatus', { status: 'success', porting }) + } catch (error) { + emitter.emit('submitStatus', { status: 'error', error }) + } finally { + renderWithCurrentOptions() + } } const renderWithCurrentOptions = () => { diff --git a/testing/http.ts b/testing/http.ts index 2c65884..15bc7a5 100644 --- a/testing/http.ts +++ b/testing/http.ts @@ -39,6 +39,13 @@ export const handlers: HttpHandler[] = [ const body = await request.json() + if (body.accountNumber === 'MAGIC_FAIL') { + return HttpResponse.json( + { message: 'Simulated error' }, + { status: 422 }, + ) + } + const newPrt = { ...prt, ...body, From e6b3d90aebbb5b54ed85b7b441475acd06eb0f47 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timo=20M=C3=A4mecke?= Date: Mon, 26 Feb 2024 16:46:48 +0100 Subject: [PATCH 06/11] Add example callbacks --- src/App.tsx | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/App.tsx b/src/App.tsx index 3fa93e8..8729440 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -8,6 +8,7 @@ import { PortingEmbed } from '../lib' function App() { const $portingEmbedEl = useRef(null) const [loading, setLoading] = useState<'idle' | 'loading' | 'loaded'>('idle') + const [submitting, setSubmitting] = useState(false) async function handleSubmit( event: React.JSX.TargetedSubmitEvent, @@ -24,6 +25,14 @@ function App() { setLoading('loaded') embed.mount($portingEmbedEl.current!) + + embed.on('submitStatus', (evt) => { + console.log('submit status changed:', evt) + setSubmitting(evt.status === 'loading') + }) + embed.on('validationChange', (evt) => { + console.log('Porting Form is valid: ', evt) + }) } catch (error) { console.error(error) setLoading('idle') From d6322295af5bec54f80e7bc716c042c5d63a71ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timo=20M=C3=A4mecke?= Date: Mon, 26 Feb 2024 16:50:50 +0100 Subject: [PATCH 07/11] Add comments --- lib/PortingEmbed/PortingForm.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/PortingEmbed/PortingForm.tsx b/lib/PortingEmbed/PortingForm.tsx index 9b7e00c..b8bc782 100644 --- a/lib/PortingEmbed/PortingForm.tsx +++ b/lib/PortingEmbed/PortingForm.tsx @@ -37,13 +37,13 @@ export function PortingForm({ porting, onValidationChange, onSubmit }: Props) { return ( { const sanitizedData = sanitizeSubmitData(data) return onSubmit(sanitizedData) }} - shouldDirty > {(field, props) => ( From a93409a6f35d8cf36170b6d7ce742d6560810cf1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timo=20M=C3=A4mecke?= Date: Mon, 26 Feb 2024 16:52:00 +0100 Subject: [PATCH 08/11] Add comments --- lib/PortingEmbed/__tests__/PortingEmbed.test.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lib/PortingEmbed/__tests__/PortingEmbed.test.tsx b/lib/PortingEmbed/__tests__/PortingEmbed.test.tsx index b0e8557..9e8a40f 100644 --- a/lib/PortingEmbed/__tests__/PortingEmbed.test.tsx +++ b/lib/PortingEmbed/__tests__/PortingEmbed.test.tsx @@ -10,3 +10,5 @@ it('renders a form', () => { const form = screen.getByRole('form') expect(form).toBeInTheDocument() }) + +// TODO: different forms based on required fields From 553f637a622c6cb013c0ba6abd52bc093b347eb8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timo=20M=C3=A4mecke?= Date: Mon, 26 Feb 2024 16:53:17 +0100 Subject: [PATCH 09/11] Add missing button submitting state in example --- src/App.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 8729440..2acccc4 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -57,8 +57,8 @@ function App() { {loading === 'idle' &&
Fill out form first
} {loading === 'loading' &&
Loading...
}
- ) From e89bc087d013bc3b813a0ece1463933b869d8f89 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timo=20M=C3=A4mecke?= Date: Mon, 26 Feb 2024 17:33:26 +0100 Subject: [PATCH 10/11] Self-review --- testing/http.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/testing/http.ts b/testing/http.ts index 15bc7a5..2d61efb 100644 --- a/testing/http.ts +++ b/testing/http.ts @@ -39,6 +39,7 @@ export const handlers: HttpHandler[] = [ const body = await request.json() + // Simulate an API error by submitting accountNumber: 'MAGIC_FAIL' if (body.accountNumber === 'MAGIC_FAIL') { return HttpResponse.json( { message: 'Simulated error' }, @@ -46,11 +47,10 @@ export const handlers: HttpHandler[] = [ ) } - const newPrt = { - ...prt, - ...body, - } + const newPrt = { ...prt, ...body } + // The accountPin is not saved in the resource. When an accountPin is set, + // it sets the accountPinExists flag. if (newPrt.accountPin) { newPrt.accountPinExists = true delete newPrt.accountPin From 7962bcd717ada924653b1d536686b00baff2ab24 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timo=20M=C3=A4mecke?= Date: Mon, 26 Feb 2024 17:35:15 +0100 Subject: [PATCH 11/11] Improve comment --- lib/PortingEmbed/__tests__/PortingForm.test.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/PortingEmbed/__tests__/PortingForm.test.tsx b/lib/PortingEmbed/__tests__/PortingForm.test.tsx index b9f6b14..d366733 100644 --- a/lib/PortingEmbed/__tests__/PortingForm.test.tsx +++ b/lib/PortingEmbed/__tests__/PortingForm.test.tsx @@ -53,8 +53,8 @@ describe('with existing porting fields', () => { expect(screen.getByLabelText('First Name')).toHaveValue('Jerry') expect(screen.getByLabelText('Last Name')).toHaveValue('Seinfeld') - // the account pin cannot be prefilled, and instead indicates it exists with - // a placeholder + // 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',