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