Skip to content

Commit

Permalink
Do all the initialization
Browse files Browse the repository at this point in the history
  • Loading branch information
timomeh committed Feb 19, 2024
1 parent 50e5444 commit b517864
Show file tree
Hide file tree
Showing 26 changed files with 802 additions and 1 deletion.
10 changes: 10 additions & 0 deletions .eslintrc.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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',
},
],
},
}
19 changes: 19 additions & 0 deletions lib/PortingEmbed/PortingEmbed.tsx
Original file line number Diff line number Diff line change
@@ -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 <div className="__gigsPortingEmbed">Hello {initialPorting.id}!</div>
}
19 changes: 19 additions & 0 deletions lib/PortingEmbed/__stories__/WelcomeEmbed.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { WelcomeEmbed } from '../WelcomeEmbed'

Check failure on line 1 in lib/PortingEmbed/__stories__/WelcomeEmbed.stories.tsx

View workflow job for this annotation

GitHub Actions / lint

Cannot find module '../WelcomeEmbed' or its corresponding type declarations.

Check failure on line 1 in lib/PortingEmbed/__stories__/WelcomeEmbed.stories.tsx

View workflow job for this annotation

GitHub Actions / build

Cannot find module '../WelcomeEmbed' or its corresponding type declarations.

export default {
title: 'Example/WelcomeEmbed',
component: WelcomeEmbed,
tags: ['autodocs'],
argTypes: {
token: { control: 'text' },
name: { control: 'text' },
onCounterChange: { action: 'counterChange' },
},
}

export const Primary = {
args: {
token: 'abc:123',
name: 'Jerry',
},
}
12 changes: 12 additions & 0 deletions lib/PortingEmbed/__tests__/PortingEmbed.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<PortingEmbed initialPorting={porting} token="abc:123" />)
const greeting = screen.getByText(/Hello prt_123/i)
expect(greeting).toBeInTheDocument()
})
173 changes: 173 additions & 0 deletions lib/PortingEmbed/__tests__/index.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
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(<div id="mount" />)
})

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 a JS-developer forgets the project
const init = PortingEmbed(csn, {})
expect(init).rejects.toThrow(/NO_PROJECT/)
})

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(/WRONG_INTENT/)
})

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/)
})
})
})
118 changes: 118 additions & 0 deletions lib/PortingEmbed/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import mitt from 'mitt'
import { render } from 'preact'

import { fetchSubscription } from '../core/subscription'
import { exchangeSessionWithToken } from '../core/token'
import { ConnectSession, PortingStatus } from '../types'
import {
CustomizableEmbedProps,
PortingEmbed as PortingEmbedComponent,
} from './PortingEmbed'

type PortingEmbedInit = { project: string }
export type PortingEmbedOptions = CustomizableEmbedProps

type Events = never

export async function PortingEmbed(
session: ConnectSession,
{
options: initialOptions,
project,
}: { options?: PortingEmbedOptions } & PortingEmbedInit,
) {
// Ensure embed was initialized with proper options
const { intent } = session
assert(
project,
'NO_PROJECT: Cannot initialize PortingEmbed without a project.',
)
assert(
intent.type === 'completePorting',
`WRONG_INTENT: PortingEmbed must be initialized with the "completePorting" intent, but got "${intent.type}" instead.`,
)

// Get a user token and ensure that the ConnectSession is valid.
const token = await exchangeSessionWithToken(session)

let element: Element | null = null
let options = initialOptions
const emitter = mitt<Events>()

// 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(
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.`,
)

/**
* Mount the embed into a container.
* @param container The HTML Element or selector in which the embed should be
* mounted to.
*/
const 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()
}

/**
* Unmount the mounted embed.
*/
const unmount = () => {
assert(element, 'Cannot call unmount() on an unmounted embed.')

render(null, element)
element = null
}

/**
* Update the mounted embed with new options.
* @param newOptions New options for the embed
*/
const update = (newOptions: PortingEmbedOptions) => {
assert(element, 'Cannot call update() on an unmounted embed.')

options = newOptions
renderWithCurrentOptions()
}

const renderWithCurrentOptions = () => {
assert(element, 'No element present to render embed into.')

render(
<PortingEmbedComponent
{...options}
token={token}
initialPorting={porting}
/>,
element,
)
}

return {
mount,
update,
unmount,
on: emitter.on.bind(emitter),
off: emitter.off.bind(emitter),
}
}
16 changes: 16 additions & 0 deletions lib/core/__tests__/assert.test.ts
Original file line number Diff line number Diff line change
@@ -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')
})
18 changes: 18 additions & 0 deletions lib/core/__tests__/subscription.test.ts
Original file line number Diff line number Diff line change
@@ -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/)
})
Loading

0 comments on commit b517864

Please sign in to comment.