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 }) => {