diff --git a/.eslintrc.cjs b/.eslintrc.cjs index a5c295f..376a3d7 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -19,5 +19,15 @@ module.exports = { }, ], 'simple-import-sort/imports': 'error', + '@typescript-eslint/no-unused-vars': [ + 'error', + { + args: 'all', + argsIgnorePattern: '^_', + ignoreRestSiblings: true, + varsIgnorePattern: '^_', + caughtErrors: 'all', + }, + ], }, } diff --git a/lib/PortingEmbed/PortingEmbed.tsx b/lib/PortingEmbed/PortingEmbed.tsx new file mode 100644 index 0000000..6b46dca --- /dev/null +++ b/lib/PortingEmbed/PortingEmbed.tsx @@ -0,0 +1,19 @@ +import { Porting } from '../types' + +export type CustomizableEmbedProps = { + // TODO: add styling options + styleConfig?: { + foo?: string + } +} + +type CoreEmbedProps = { + token: string + initialPorting: Porting +} + +type PortingEmbedProps = CoreEmbedProps & CustomizableEmbedProps + +export function PortingEmbed({ token: _, initialPorting }: PortingEmbedProps) { + return
Hello {initialPorting.id}!
+} diff --git a/lib/PortingEmbed/README.md b/lib/PortingEmbed/README.md new file mode 100644 index 0000000..b2827be --- /dev/null +++ b/lib/PortingEmbed/README.md @@ -0,0 +1,22 @@ +# Porting Embed + +## Usage + +```ts +import { PortingEmbed } from '@gigs/gigs-embeds-js' + +// Obtain a ConnectSession from your own backend +const connectSession = await fetchConnectSession() + +const embed = await PortingEmbed(connectSession, { project: 'your-project' }) +embed.mount(document.getElementById('gigsEmbedMount')) + +// can also use a selector: +// embed.mount('#gigsEmbedMount') + +// Here be more dragons + +// --- +// index.html +
+``` diff --git a/lib/PortingEmbed/__stories__/PortingEmbed.stories.tsx b/lib/PortingEmbed/__stories__/PortingEmbed.stories.tsx new file mode 100644 index 0000000..b4ce8ca --- /dev/null +++ b/lib/PortingEmbed/__stories__/PortingEmbed.stories.tsx @@ -0,0 +1,20 @@ +import { portingFactory } from '@/testing/factories/porting' + +import { PortingEmbed } from '../PortingEmbed' + +export default { + title: 'Porting Embed/Base', + component: PortingEmbed, + tags: ['autodocs'], + argTypes: { + token: { control: 'text' }, + initialPorting: { control: 'object' }, + }, +} + +export const Primary = { + args: { + token: 'abc:123', + initialPorting: portingFactory.build(), + }, +} diff --git a/lib/PortingEmbed/__tests__/PortingEmbed.test.tsx b/lib/PortingEmbed/__tests__/PortingEmbed.test.tsx new file mode 100644 index 0000000..e4e113e --- /dev/null +++ b/lib/PortingEmbed/__tests__/PortingEmbed.test.tsx @@ -0,0 +1,12 @@ +import { render, screen } from '@testing-library/preact' + +import { portingFactory } from '@/testing/factories/porting' + +import { PortingEmbed } from '../PortingEmbed' + +it('gets the porting', () => { + const porting = portingFactory.params({ id: 'prt_123' }).build() + render() + const greeting = screen.getByText(/Hello prt_123/i) + expect(greeting).toBeInTheDocument() +}) diff --git a/lib/PortingEmbed/__tests__/index.test.tsx b/lib/PortingEmbed/__tests__/index.test.tsx new file mode 100644 index 0000000..36c54f5 --- /dev/null +++ b/lib/PortingEmbed/__tests__/index.test.tsx @@ -0,0 +1,181 @@ +import { render } from '@testing-library/preact' + +import { connectSessionFactory } from '@/testing/factories/connectSession' +import { portingFactory } from '@/testing/factories/porting' +import { subscriptionFactory } from '@/testing/factories/subscription' + +import { PortingStatus } from '../../types' +import { PortingEmbed } from '../' + +const project = 'test_project' + +async function createFixtures() { + const subscription = await subscriptionFactory.create() + const connectSession = connectSessionFactory + .completePorting(subscription.id) + .build() + return connectSession +} + +beforeEach(() => { + render(
) +}) + +describe('mounting', () => { + it('mounts into a DOM selector', async () => { + const csn = await createFixtures() + const embed = await PortingEmbed(csn, { project }) + + embed.mount('#mount') + expect(document.querySelector('.__gigsPortingEmbed')).toBeInTheDocument() + }) + + it('mounts into a DOM element', async () => { + const csn = await createFixtures() + const embed = await PortingEmbed(csn, { project }) + + embed.mount(document.getElementById('mount')!) + expect(document.querySelector('.__gigsPortingEmbed')).toBeInTheDocument() + }) +}) + +describe('updating', () => { + it('updates the embed', async () => { + const csn = await createFixtures() + const embed = await PortingEmbed(csn, { project }) + + embed.mount('#mount') + embed.update({}) + expect(document.querySelector('.__gigsPortingEmbed')).toBeInTheDocument() + }) + + it('fails to update an unmounted embed', async () => { + const csn = await createFixtures() + const embed = await PortingEmbed(csn, { project }) + + expect(() => embed.update({})).toThrow(/an unmounted embed/i) + }) +}) + +describe('unmounting', () => { + it('unmounts the embed', async () => { + const csn = await createFixtures() + const embed = await PortingEmbed(csn, { project }) + + embed.mount('#mount') + expect(document.getElementById('mount')).not.toBeEmptyDOMElement() + embed.unmount() + expect(document.getElementById('mount')).toBeEmptyDOMElement() + }) + + it('fails to unmount an unmounted embed', async () => { + const csn = await createFixtures() + const embed = await PortingEmbed(csn, { project }) + expect(() => embed.unmount()).toThrow(/an unmounted embed/i) + }) +}) + +describe('initialization', () => { + it('initializes with valid data', async () => { + const csn = await createFixtures() + const embed = await PortingEmbed(csn, { project }) + + expect(embed.mount).toBeDefined() + expect(embed.update).toBeDefined() + expect(embed.unmount).toBeDefined() + expect(embed.on).toBeDefined() + expect(embed.off).toBeDefined() + }) + + it('throws without a project', async () => { + const csn = await createFixtures() + // @ts-expect-error Assume the project is missing in a non-typechecked usage + const init = PortingEmbed(csn, {}) + expect(init).rejects.toThrow(/NO_PROJECT/) + }) + + it('throws with the wrong ConnectSession', async () => { + expect(PortingEmbed(null, { project })).rejects.toThrow(/INVALID_SESSION/) + expect(PortingEmbed({}, { project })).rejects.toThrow(/INVALID_SESSION/) + expect(PortingEmbed({ secret: 'foo' }, { project })).rejects.toThrow( + /INVALID_SESSION/, + ) + }) + + it('throws with a wrong intent', async () => { + const csn = connectSessionFactory + // @ts-expect-error Unsupported intent type + .params({ intent: { type: 'foo' } }) + .build() + const init = PortingEmbed(csn, { project }) + expect(init).rejects.toThrow(/INVALID_SESSION/) + }) + + it('throws with a non-existing subscription', async () => { + const csn = connectSessionFactory.completePorting('sub_404').build() + const init = PortingEmbed(csn, { project }) + expect(init).rejects.toThrow(/NOT_FOUND/) + }) + + it('throws without a porting', async () => { + const sub = await subscriptionFactory.withoutPorting().create() + const csn = connectSessionFactory.completePorting(sub.id).build() + const init = PortingEmbed(csn, { project }) + expect(init).rejects.toThrow(/NOT_FOUND/) + }) + + describe('with porting status', () => { + async function createWithStatus(status: PortingStatus) { + const porting = portingFactory.params({ status }).build() + const subscription = await subscriptionFactory + .associations({ porting }) + .create() + const connectSession = connectSessionFactory + .completePorting(subscription.id) + .build() + return connectSession + } + + it('initializes with informationRequired', async () => { + const csn = await createWithStatus('informationRequired') + const init = PortingEmbed(csn, { project }) + expect(init).resolves.toBeDefined() + }) + + it('initializes with declined', async () => { + const csn = await createWithStatus('declined') + const init = PortingEmbed(csn, { project }) + expect(init).resolves.toBeDefined() + }) + + it('throws with draft', async () => { + const csn = await createWithStatus('draft') + const init = PortingEmbed(csn, { project }) + expect(init).rejects.toThrow(/UNSUPPORTED/) + }) + + it('throws with requested', async () => { + const csn = await createWithStatus('requested') + const init = PortingEmbed(csn, { project }) + expect(init).rejects.toThrow(/UNSUPPORTED/) + }) + + it('throws with completed', async () => { + const csn = await createWithStatus('completed') + const init = PortingEmbed(csn, { project }) + expect(init).rejects.toThrow(/UNSUPPORTED/) + }) + + it('throws with canceled', async () => { + const csn = await createWithStatus('canceled') + const init = PortingEmbed(csn, { project }) + expect(init).rejects.toThrow(/UNSUPPORTED/) + }) + + it('throws with expired', async () => { + const csn = await createWithStatus('expired') + const init = PortingEmbed(csn, { project }) + expect(init).rejects.toThrow(/UNSUPPORTED/) + }) + }) +}) diff --git a/lib/PortingEmbed/index.tsx b/lib/PortingEmbed/index.tsx new file mode 100644 index 0000000..a472a9c --- /dev/null +++ b/lib/PortingEmbed/index.tsx @@ -0,0 +1,151 @@ +import mitt from 'mitt' +import { render } from 'preact' + +import { assert } from '../core/assert' +import { fetchSubscription } from '../core/subscription' +import { exchangeSessionWithToken } from '../core/token' +import { PortingStatus } from '../types' +import { + CustomizableEmbedProps, + PortingEmbed as PortingEmbedComponent, +} from './PortingEmbed' + +type PortingEmbedInit = { + /** The ID of your Gigs project. */ + project: string +} +export type PortingEmbedOptions = CustomizableEmbedProps + +type Events = never + +/** + * Initializes an embed to complete a porting (port-in a number). Requires an + * authenticated ConnectSession. After initialization, the embed can be mounted + * into your document. + * + * @example + * const embed = await PortingEmbed(connectSession, { project: 'my-project' }) + * // hide your loading states + * embed.mount('#embed') + * + * @param connectSession An authenticated ConnectSession with an intent type of + * "completePorting". + * @param options Initialization options. + */ +export async function PortingEmbed( + initConnectSession: unknown, + { + options: initialOptions, + project, + }: { + /** Additional options to configure the behavior of the embed. */ + options?: PortingEmbedOptions + } & PortingEmbedInit, +) { + // Ensure embed was initialized with proper options. + assert( + project, + 'NO_PROJECT: Cannot initialize PortingEmbed without a project.', + ) + + // Ensure that the ConnectSession is valid and obtain a token. + const { connectSession, token } = await exchangeSessionWithToken( + initConnectSession, + 'completePorting', + ) + + let element: Element | null = null + let options = initialOptions + const emitter = mitt() + + // Fetch the necessary data before the embed can be mounted. While the embed + // is loading, the embedder can show their own loading state. + const subscription = await fetchSubscription( + connectSession.intent.completePorting.subscription, + { project, token }, + ) + const { porting } = subscription + assert(porting, 'NOT_FOUND: The given subscription has no porting.') + + const supportedPortingStatus: PortingStatus[] = [ + 'informationRequired', + 'declined', + ] + assert( + supportedPortingStatus.includes(porting.status), + `UNSUPPORTED: Porting status "${porting.status}" is not supported by the embed.`, + ) + + const renderWithCurrentOptions = () => { + assert(element, 'No element present to render embed into.') + + render( + , + element, + ) + } + + return { + /** + * Mount the embed into a container. + * + * @example + * embed.mount('#embed') + * + * @example + * embed.mount(document.getElementById('embed')) + * + * @param container The HTML Element or selector in which the embed should be + * mounted to. + */ + mount(container: Element | string) { + assert(container, 'Cannot call mount() without specifying a container.') + + element = + typeof container === 'string' + ? document.querySelector(container) + : container + assert(element, 'Element to mount to could not be found.') + + renderWithCurrentOptions() + }, + + /** + * Update the mounted embed with new options. + * + * @example + * embed.update({ styleOptions: { ... }}) + * + * @param newOptions New options for the embed + */ + update(newOptions: PortingEmbedOptions) { + assert(element, 'Cannot call update() on an unmounted embed.') + + options = newOptions + renderWithCurrentOptions() + }, + + /** + * Unmount the mounted embed. + * + * @example + * embed.unmount() + */ + unmount() { + assert(element, 'Cannot call unmount() on an unmounted embed.') + + render(null, element) + element = null + }, + + /** Add an event listener. */ + on: emitter.on.bind(emitter), + + /** Remove event listener. */ + off: emitter.off.bind(emitter), + } +} diff --git a/lib/core/__tests__/assert.test.ts b/lib/core/__tests__/assert.test.ts new file mode 100644 index 0000000..5b12a32 --- /dev/null +++ b/lib/core/__tests__/assert.test.ts @@ -0,0 +1,16 @@ +import { assert } from '../assert' + +it('throws when it evaluates to false', () => { + expect(() => assert(false, 'is false')).toThrowError('is false') + expect(() => assert(null, 'is null')).toThrowError('is null') + expect(() => assert(0, 'is zero')).toThrowError('is zero') + expect(() => assert('', 'is empty')).toThrowError('is empty') +}) + +it('does not throw when it evaluates to true', () => { + expect(() => assert(true, 'is true')).not.toThrowError('is true') + expect(() => assert({}, 'is object')).not.toThrowError('is object') + expect(() => assert('string', 'is string')).not.toThrowError('is string') + expect(() => assert(1, 'is 1')).not.toThrowError('is 1') + expect(() => assert([], 'is array')).not.toThrowError('is array') +}) diff --git a/lib/core/__tests__/subscription.test.ts b/lib/core/__tests__/subscription.test.ts new file mode 100644 index 0000000..ef0b084 --- /dev/null +++ b/lib/core/__tests__/subscription.test.ts @@ -0,0 +1,18 @@ +import { subscriptionFactory } from '@/testing/factories/subscription' + +import { fetchSubscription } from '../subscription' + +const project = 'test_project' +const token = 'test_token' + +it('returns an existing subscription', async () => { + const sub = await subscriptionFactory.create() + const result = await fetchSubscription(sub.id, { project, token }) + expect(result).toEqual(sub) +}) + +it('throws if the subscription does not exist', async () => { + expect( + fetchSubscription('sub_not_found', { project, token }), + ).rejects.toThrow(/SUB_NOT_FOUND/) +}) diff --git a/lib/core/__tests__/token.test.ts b/lib/core/__tests__/token.test.ts new file mode 100644 index 0000000..5e50049 --- /dev/null +++ b/lib/core/__tests__/token.test.ts @@ -0,0 +1,77 @@ +import { connectSessionFactory } from '@/testing/factories/connectSession' + +import { exchangeSessionWithToken } from '../token' + +it('exchanges an authenticated session with a user token', async () => { + const csn = connectSessionFactory + .params({ intent: { type: 'completePorting' } }) + .transient({ token: 'secret_sauce' }) + .build() + const { token } = await exchangeSessionWithToken(csn, 'completePorting') + expect(token).toBe('exchanged:secret_sauce') +}) + +it('returns the validated connectSession object', async () => { + const csn = connectSessionFactory + .params({ intent: { type: 'completePorting' } }) + .build() + const { connectSession } = await exchangeSessionWithToken( + csn, + 'completePorting', + ) + expect(connectSession).toEqual(csn) +}) + +it('throws with an invalid connectSession object', () => { + expect(exchangeSessionWithToken({}, 'completePorting')).rejects.toThrow( + /INVALID_SESSION: The object you passed in/, + ) + expect(exchangeSessionWithToken(null, 'completePorting')).rejects.toThrow( + /INVALID_SESSION: The object you passed in/, + ) + expect( + exchangeSessionWithToken(undefined, 'completePorting'), + ).rejects.toThrow(/INVALID_SESSION: The object you passed in/) + expect(exchangeSessionWithToken('secret', 'completePorting')).rejects.toThrow( + /INVALID_SESSION: The object you passed in/, + ) +}) + +it('throws with the wrong intent type', () => { + const csn = connectSessionFactory + // @ts-expect-error Assume the intent type is not supported + .params({ intent: { type: 'somethingElse' } }) + .build() + expect(exchangeSessionWithToken(csn, 'completePorting')).rejects.toThrow( + /INVALID_SESSION: PortingEmbed must be initialized with the "completePorting" intent/, + ) +}) + +it('throws with an unauthenticated session', async () => { + const csn = connectSessionFactory + .params({ intent: { type: 'completePorting' } }) + .unauthenticated() + .build() + expect(exchangeSessionWithToken(csn, 'completePorting')).rejects.toThrow( + /INVALID_SESSION: Session has no token./, + ) +}) + +it('throws without a url in the session', async () => { + const csn = connectSessionFactory + .params({ url: null, intent: { type: 'completePorting' } }) + .build() + expect(exchangeSessionWithToken(csn, 'completePorting')).rejects.toThrow( + /INVALID_SESSION: Session has no URL/, + ) +}) + +it('throws with an expired session', async () => { + const csn = connectSessionFactory + .params({ intent: { type: 'completePorting' } }) + .transient({ token: 'expired' }) + .build() + expect(exchangeSessionWithToken(csn, 'completePorting')).rejects.toThrow( + /INVALID_SESSION: Session is expired/, + ) +}) diff --git a/lib/core/assert.ts b/lib/core/assert.ts new file mode 100644 index 0000000..3410093 --- /dev/null +++ b/lib/core/assert.ts @@ -0,0 +1,9 @@ +export function assert( + condition: unknown, + message: string, + cause?: Error, +): asserts condition { + if (!condition) { + throw new Error(message, { cause }) + } +} diff --git a/lib/core/subscription.ts b/lib/core/subscription.ts new file mode 100644 index 0000000..ab29cb9 --- /dev/null +++ b/lib/core/subscription.ts @@ -0,0 +1,30 @@ +import { Subscription } from '../types' +import { assert } from './assert' + +type FetchSubOptions = { + /** Project id of the subscription. */ + project: string + /** User token. */ + token: string +} + +/** + * Fetch the user's subscription. + * @param sub The subscription id. + * @param opts Additional options. + */ +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} ` } }, + ) + const body = await res.json().catch(() => res.text()) + assert( + res.status !== 404, + 'SUB_NOT_FOUND: Subscription could not be fetched.', + ) + assert(res.ok, `FETCH_FAILED: ${body?.message || body?.toString()}`) + const subscription = body as Subscription + + return subscription +} diff --git a/lib/core/token.ts b/lib/core/token.ts new file mode 100644 index 0000000..a21273b --- /dev/null +++ b/lib/core/token.ts @@ -0,0 +1,76 @@ +import { ConnectSession, ConnectSessionIntent } from '../types' +import { assert } from './assert' + +type Tokens = { + access_token: string +} + +/** + * Exchange an authenticated ConnectSession with a user token. + * Returns the token, together with the validated ConnectSession for the sake + * of easier type-safety. + */ +export async function exchangeSessionWithToken( + session: unknown, + type: ConnectSessionIntent['type'], +) { + const { connectSession, token } = parseConnectSession(session, type) + + const res = await fetch(`https://connect.gigs.com/api/embeds/auth`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ secret: token }), + }) + const body: { token?: Tokens; error?: string } = await res + .json() + .catch(async () => ({ error: await res.text() })) + assert(res.status !== 422, 'INVALID_SESSION: Session is expired.') + assert(res.ok, `FETCH_FAILED: ${body.error}`) + assert( + body.token?.access_token, + 'Expected user token to be returned in response of token exchange, but was not found.', + ) + + return { connectSession, token: body.token.access_token } +} + +function parseConnectSession( + session: unknown, + type: ConnectSessionIntent['type'], +) { + // The session which is used to initilialize an embed is intentionally typed + // as unknown. + // 1. For non-typescript usage, we need to ensure that the passed in object is + // correct anyways. + // 2. To not require the caller to cast the response as a ConnectSession. + // Especially since fetch returns any. Makes it a little easier to use. + + assert( + session && + typeof session === 'object' && + 'object' in session && + 'intent' in session && + 'url' in session && + session.object === 'connectSession', + 'INVALID_SESSION: The object you passed in is not a ConnectSession resoure. Make sure to pass in the complete resource.', + ) + const connectSession = session as ConnectSession + + assert( + connectSession.intent.type === type, + `INVALID_SESSION: PortingEmbed must be initialized with the "${type}" intent, but got "${connectSession.intent.type}" instead.`, + ) + assert( + connectSession.url, + 'INVALID_SESSION: Session has no URL. Did you pass in the created session?', + ) + + const url = new URL(connectSession.url) + const token = url.searchParams.get('token') + assert( + token, + 'INVALID_SESSION: Session has no token. Is it an authenticated session?', + ) + + return { connectSession, token } +} diff --git a/lib/main.ts b/lib/index.ts similarity index 50% rename from lib/main.ts rename to lib/index.ts index d1b8cf0..d9b3287 100644 --- a/lib/main.ts +++ b/lib/index.ts @@ -1 +1,2 @@ export { WelcomeEmbed } from './WelcomeEmbed' +export { PortingEmbed } from './PortingEmbed' diff --git a/lib/types/connectSession.ts b/lib/types/connectSession.ts new file mode 100644 index 0000000..069ec11 --- /dev/null +++ b/lib/types/connectSession.ts @@ -0,0 +1,17 @@ +export type ConnectSession = { + object: 'connectSession' + id: string + callbackUrl: string | null + intent: ConnectSessionIntent + url: string | null + user: string | null +} + +export type ConnectSessionIntent = ConnectSessionCompletePortingIntent + +export type ConnectSessionCompletePortingIntent = { + type: 'completePorting' + completePorting: { + subscription: string + } +} diff --git a/lib/types/index.ts b/lib/types/index.ts new file mode 100644 index 0000000..71119f6 --- /dev/null +++ b/lib/types/index.ts @@ -0,0 +1,3 @@ +export * from './connectSession' +export * from './porting' +export * from './subscription' diff --git a/lib/types/porting.ts b/lib/types/porting.ts new file mode 100644 index 0000000..5330131 --- /dev/null +++ b/lib/types/porting.ts @@ -0,0 +1,64 @@ +export type Porting = { + object: 'porting' + id: string + accountNumber: string | null + accountPinExists: boolean + address: PortingAddress | null + birthday: string | null + declinedAttempts: number + declinedCode: string | null + declinedMessage: string | null + donorProvider: ServiceProvider | null + donorProviderApproval: boolean | null + firstName: string | null + lastName: string | null + phoneNumber: string + provider: string + recipientProvider: ServiceProvider + required: PortingRequiredField[] + status: PortingStatus + subscription: string | null + user: string + canceledAt: string | null + completedAt: string | null + createdAt: string + expiredAt: string | null + lastDeclinedAt: string | null + lastRequestedAt: string | null +} + +export type PortingAddress = { + city: string + country: string + line1: string + line2: string + postalCode: string + state: string | null +} + +export type ServiceProvider = { + object: 'serviceProvider' + id: string + name: string + recipientProviders: string[] +} + +export type PortingRequiredField = + | 'accountNumber' + | 'accountPin' + | 'address' + | 'birthday' + | 'donorProvider' + | 'donorProviderApproval' + | 'firstName' + | 'lastName' + +export type PortingStatus = + | 'draft' + | 'pending' + | 'informationRequired' + | 'requested' + | 'declined' + | 'completed' + | 'canceled' + | 'expired' diff --git a/lib/types/subscription.ts b/lib/types/subscription.ts new file mode 100644 index 0000000..dda709c --- /dev/null +++ b/lib/types/subscription.ts @@ -0,0 +1,8 @@ +import { Porting } from '.' + +// Reduced version of a subscription because we only care about the porting +export type Subscription = { + object: 'subscription' + id: string + porting: Porting | null +} diff --git a/package-lock.json b/package-lock.json index 2f9dfaf..b5a319a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -29,6 +29,7 @@ "eslint-plugin-react-refresh": "^0.4.5", "eslint-plugin-simple-import-sort": "^10.0.0", "eslint-plugin-storybook": "^0.6.15", + "fishery": "^2.2.2", "jsdom": "^24.0.0", "mitt": "^3.0.1", "msw": "^2.2.0", @@ -11560,6 +11561,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/fishery": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/fishery/-/fishery-2.2.2.tgz", + "integrity": "sha512-jeU0nDhPHJkupmjX+r9niKgVMTBDB8X+U/pktoGHAiWOSyNlMd0HhmqnjrpjUOCDPJYaSSu4Ze16h6dZOKSp2w==", + "dev": true, + "dependencies": { + "lodash.mergewith": "^4.6.2" + } + }, "node_modules/flat-cache": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz", @@ -13957,6 +13967,12 @@ "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", "dev": true }, + "node_modules/lodash.mergewith": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.mergewith/-/lodash.mergewith-4.6.2.tgz", + "integrity": "sha512-GK3g5RPZWTRSeLSpgP8Xhra+pnjBC56q9FZYe1d5RN3TJ35dbkGy3YqBSMbyCrlbi+CM9Z3Jk5yTL7RCsqboyQ==", + "dev": true + }, "node_modules/lodash.uniqby": { "version": "4.7.0", "resolved": "https://registry.npmjs.org/lodash.uniqby/-/lodash.uniqby-4.7.0.tgz", diff --git a/package.json b/package.json index 6ea0d48..e1ec15a 100644 --- a/package.json +++ b/package.json @@ -46,6 +46,7 @@ "eslint-plugin-react-refresh": "^0.4.5", "eslint-plugin-simple-import-sort": "^10.0.0", "eslint-plugin-storybook": "^0.6.15", + "fishery": "^2.2.2", "jsdom": "^24.0.0", "mitt": "^3.0.1", "msw": "^2.2.0", diff --git a/scripts/setupTests.ts b/scripts/setupTests.ts index c6e5fd6..80b9f8e 100644 --- a/scripts/setupTests.ts +++ b/scripts/setupTests.ts @@ -4,6 +4,8 @@ import '@testing-library/jest-dom/vitest' import { cleanup } from '@testing-library/preact' +import { clearDb } from '@/testing/db' + import { server } from '../testing/http' afterEach(() => cleanup()) @@ -12,3 +14,5 @@ afterEach(() => cleanup()) beforeAll(() => server.listen({ onUnhandledRequest: 'error' })) afterAll(() => server.close()) afterEach(() => server.resetHandlers()) + +afterEach(() => clearDb()) diff --git a/src/App.css b/src/App.css index b9d355d..74b17e3 100644 --- a/src/App.css +++ b/src/App.css @@ -2,7 +2,6 @@ max-width: 1280px; margin: 0 auto; padding: 2rem; - text-align: center; } .logo { diff --git a/src/App.tsx b/src/App.tsx index bf7e0c8..2882945 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,33 +1,51 @@ import './App.css' -import { useEffect, useRef } from 'preact/hooks' +import { useRef, useState } from 'preact/hooks' +import * as React from 'react' -import { WelcomeEmbed } from '../lib/main' +import { PortingEmbed } from '../lib' function App() { - const $welcomeEmbedEl = useRef(null) - - useEffect(() => { - async function main() { - const embed = await WelcomeEmbed('abc', { name: 'Jerry' }) - embed.mount($welcomeEmbedEl.current!) - - embed.on('count', (count) => { - if (count > 5) { - embed.update({ name: `Elaine ${count}` }) - } - }) + const $portingEmbedEl = useRef(null) + const [loading, setLoading] = useState<'idle' | 'loading' | 'loaded'>('idle') + + async function handleSubmit( + event: React.JSX.TargetedSubmitEvent, + ) { + event.preventDefault() + + try { + setLoading('loading') + const formData = new FormData(event.currentTarget) + const csn = formData.get('csn')!.toString() + const project = formData.get('project')!.toString() + + const embed = await PortingEmbed(JSON.parse(csn), { project }) + embed.mount($portingEmbedEl.current!) + } catch (error) { + console.error(error) + setLoading('idle') } - - main() - }, []) + } return ( <> -
-

WelcomeEmbed

-
-
+
+ +