diff --git a/packages/constants/package.json b/packages/constants/package.json index b293d006..96b2a14c 100644 --- a/packages/constants/package.json +++ b/packages/constants/package.json @@ -6,12 +6,23 @@ "access": "public", "registry": "https://registry.npmjs.org/" }, - "main": "lib/index.js", + "main": "./lib/index.js", + "module": "./lib/esm/index.mjs", + "types": "./lib/index.d.ts", + "exports": { + ".": { + "types": "./lib/index.d.ts", + "import": "./lib/esm/index.mjs", + "require": "./lib/index.js", + "default": "./lib/esm/index.mjs" + }, + "./package.json": "./package.json" + }, "directories": { "lib": "lib" }, "scripts": { - "build": "tsc --project tsconfig.build.json", + "build": "tsc --project tsconfig.build.json && vite build", "format": "eslint --ext .ts,.tsx src --fix", "lint": "eslint --ext .ts,.tsx src", "test": "jest", @@ -32,7 +43,8 @@ "eslint": "^8.12.0", "jest": "^29.7.0", "ts-jest": "^29.1.1", - "typescript": "^4.6.3" + "typescript": "^4.6.3", + "vite": "^6.4.2" }, "jest": { "cacheDirectory": "./.jest-cache", diff --git a/packages/constants/src/index.ts b/packages/constants/src/index.ts index d482c086..0745a099 100644 --- a/packages/constants/src/index.ts +++ b/packages/constants/src/index.ts @@ -1,7 +1,7 @@ export { default as CustomQuestionMessage } from './custom-question-message'; export { default as AnalyticsEventType } from './analytics-event-type'; export { default as WebEmbedMessage } from './web-embed-message'; -export { default as AnswerSemanticType } from './semantic-type'; +export type { default as AnswerSemanticType } from './semantic-type'; export * from './flow'; export * from './core'; export * from './embed-events'; diff --git a/packages/constants/vite.config.mts b/packages/constants/vite.config.mts new file mode 100644 index 00000000..b2e4db41 --- /dev/null +++ b/packages/constants/vite.config.mts @@ -0,0 +1,12 @@ +import { defineConfig } from 'vite'; + +export default defineConfig({ + build: { + lib: { + entry: 'src/index.ts', + formats: ['es'], + fileName: () => 'index.mjs', + }, + outDir: 'lib/esm', + }, +}); diff --git a/packages/embed-messaging-manager/package.json b/packages/embed-messaging-manager/package.json index 450bd395..b008f7c5 100644 --- a/packages/embed-messaging-manager/package.json +++ b/packages/embed-messaging-manager/package.json @@ -6,12 +6,22 @@ "access": "public", "registry": "https://registry.npmjs.org/" }, - "main": "lib/index.js", - "types": "lib/index.d.ts", + "main": "./lib/index.js", + "module": "./lib/esm/index.mjs", + "types": "./lib/index.d.ts", + "exports": { + ".": { + "types": "./lib/index.d.ts", + "import": "./lib/esm/index.mjs", + "require": "./lib/index.js", + "default": "./lib/esm/index.mjs" + }, + "./package.json": "./package.json" + }, "scripts": { "test": "jest", "coverage": "jest --coverage", - "build": "tsc --project tsconfig.build.json", + "build": "tsc --project tsconfig.build.json && vite build", "format": "eslint --ext .ts,.tsx src --fix", "lint": "eslint --ext .ts,.tsx src", "pack": "yarn pack", @@ -31,7 +41,8 @@ "eslint": "^8.12.0", "jest": "^29.7.0", "ts-jest": "^29.1.1", - "typescript": "^4.9.5" + "typescript": "^4.9.5", + "vite": "^6.4.2" }, "jest": { "cacheDirectory": "./.jest-cache", diff --git a/packages/embed-messaging-manager/vite.config.mts b/packages/embed-messaging-manager/vite.config.mts new file mode 100644 index 00000000..b2e4db41 --- /dev/null +++ b/packages/embed-messaging-manager/vite.config.mts @@ -0,0 +1,12 @@ +import { defineConfig } from 'vite'; + +export default defineConfig({ + build: { + lib: { + entry: 'src/index.ts', + formats: ['es'], + fileName: () => 'index.mjs', + }, + outDir: 'lib/esm', + }, +}); diff --git a/packages/react-embed/README.md b/packages/react-embed/README.md index 1ce10883..ac136786 100644 --- a/packages/react-embed/README.md +++ b/packages/react-embed/README.md @@ -3,6 +3,7 @@ Embed [Formsort](https://formsort.com) flows within react components. This is a handy wrapper around [@formsort/web-embed-api](https://github.com/formsort/oss/tree/master/packages/web-embed-api). +The package publishes both ESM and CommonJS entrypoints. **Important note:** This package is intended for use in React web applications. If you're looking to embed Formsort flows in React Native applications, please see [React native embed guide](./ReactNativeEmbed.md). @@ -10,15 +11,28 @@ This is a handy wrapper around [@formsort/web-embed-api](https://github.com/form Add `@formsort/react-embed` to your project by executing `yarn add @formsort/react-embed` or `npm install @formsort/react-embed`. +## Upgrading + +Documented root imports continue to work for both ESM and CommonJS consumers: + +```tsx +import EmbedFlow from '@formsort/react-embed'; +``` + +```js +const EmbedFlow = require('@formsort/react-embed').default; +``` + +This is a minor upgrade because the package output and React lifecycle behavior were modernized. `EmbedFlow` now reloads when load props such as `clientLabel`, `flowLabel`, `variantLabel`, `queryParams`, or `embedConfig` change. Deep imports from internal `lib/*` paths are not part of the public API and are not guaranteed. + ## Usage Here's an example of basic usage: ```tsx -import React from 'react'; import EmbedFlow from '@formsort/react-embed'; -const EmbedFlowExample: React.FunctionComponent = () => ( +const EmbedFlowExample = () => (
( ### Events -You can add event listeners to flows like `Flowloaded`, `redirect` etc. See [all event listeners](https://github.com/formsort/oss/tree/master/packages/web-embed-api#event-listeners) +You can add event listeners to flows like `FlowLoaded`, `redirect` etc. See [all event listeners](https://github.com/formsort/oss/tree/master/packages/web-embed-api#event-listeners) ### Props @@ -54,8 +68,8 @@ You can add event listeners to flows like `Flowloaded`, `redirect` etc. See [all | onFlowClosed | [event listener](https://github.com/formsort/oss/tree/master/packages/web-embed-api#flowclosed-answers--key-string-any---void) | no | `() => { console.log('flow closed') }` | | onFlowFinalized | [event listener](https://github.com/formsort/oss/tree/master/packages/web-embed-api#flowfinalized-answers--key-string-any---void) | no | `() => { console.log('flow finalized') }` | | onStepLoaded | [event listener](https://github.com/formsort/oss/tree/master/packages/web-embed-api#steploaded-answers--key-string-any---void) | no | `() => { console.log('step loaded') }` | -| onStepCompleted | [event listener](https://github.com/formsort/oss/tree/master/packages/web-embed-api#steploaded-answers--key-string-any---void) | no | `() => { console.log('step loaded') }` | -| onRedirect | [event listener](https://github.com/formsort/oss/tree/master/packages/web-embed-api#redirect--url-string-answers--key-string-any-----cancel-boolean---undefined) | no | `(url: string) => { console.log('redirecting to:', url) }` | +| onStepCompleted | [event listener](https://github.com/formsort/oss/tree/master/packages/web-embed-api#steploaded-answers--key-string-any---void) | no | `() => { console.log('step completed') }` | +| onRedirect | [event listener](https://github.com/formsort/oss/tree/master/packages/web-embed-api#redirect--url-string-answers--key-string-any-----cancel-boolean---undefined) | no | `({ url }) => { console.log('redirecting to:', url) }` | | onUnauthorized | [event listener](https://github.com/formsort/oss/tree/master/packages/web-embed-api#unauthorized---void) | no | `() => { console.log('ID token is missing or invalid.') }` | ### Loading a specific variant revision @@ -63,16 +77,15 @@ You can add event listeners to flows like `Flowloaded`, `redirect` etc. See [all You can use query parameters to load a specific variant revision. Don't use it if you want to show latest variant. ```tsx -import React from 'react'; import EmbedFlow from '@formsort/react-embed'; -const EmbedFlowExample: React.FunctionComponent = () => ( +const EmbedFlowExample = () => (
']} + queryParams={[['variantRevisionUuid', '']]} />
); diff --git a/packages/react-embed/package.json b/packages/react-embed/package.json index 1aeb63e8..6d6c6bbe 100644 --- a/packages/react-embed/package.json +++ b/packages/react-embed/package.json @@ -2,17 +2,29 @@ "name": "@formsort/react-embed", "version": "3.5.0", "description": "Embed formsort flows in react components", + "type": "module", "publishConfig": { "access": "public", "registry": "https://registry.npmjs.org/" }, - "main": "lib/index.js", - "types": "lib/index.d.ts", + "main": "./lib/cjs/index.cjs", + "module": "./lib/esm/index.js", + "types": "./lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "import": "./lib/esm/index.js", + "require": "./lib/cjs/index.cjs", + "default": "./lib/esm/index.js" + }, + "./package.json": "./package.json" + }, + "sideEffects": false, "directories": { "example": "example" }, "scripts": { - "build": "tsc --project tsconfig.build.json", + "build": "rm -rf lib && tsc --project tsconfig.build.esm.json && tsc --project tsconfig.build.cjs.json && mv lib/cjs/index.js lib/cjs/index.cjs && tsc --project tsconfig.build.types.json", "test": "jest", "format": "eslint --ext .ts,.tsx src --fix", "lint": "eslint --ext .ts,.tsx src", @@ -61,7 +73,7 @@ "@formsort/web-embed-api": "^2.10.0" }, "peerDependencies": { - "react": ">=16.13.0, <19.0.0" + "react": ">=16.13.0 <20.0.0" }, "jest": { "cacheDirectory": "./.jest-cache", diff --git a/packages/react-embed/src/__tests__/EmbedFlow.test.tsx b/packages/react-embed/src/__tests__/EmbedFlow.test.tsx index 800b4357..c05448a6 100644 --- a/packages/react-embed/src/__tests__/EmbedFlow.test.tsx +++ b/packages/react-embed/src/__tests__/EmbedFlow.test.tsx @@ -1,41 +1,74 @@ -import FormsortWebEmbed, { IFormsortWebEmbed } from '@formsort/web-embed-api'; -import { render } from '@testing-library/react'; -import React from 'react'; +import FormsortWebEmbed, { + SupportedAnalyticsEvent, + type IEventMap, + type IFormsortWebEmbed, +} from '@formsort/web-embed-api'; +import { act, render } from '@testing-library/react'; +import React, { StrictMode } from 'react'; import EmbedFlow from '..'; -jest.mock('@formsort/web-embed-api'); +jest.mock('@formsort/web-embed-api', () => ({ + __esModule: true, + default: jest.fn(), + SupportedAnalyticsEvent: { + FlowLoaded: 'FlowLoaded', + FlowClosed: 'FlowClosed', + FlowFinalized: 'FlowFinalized', + StepLoaded: 'StepLoaded', + StepCompleted: 'StepCompleted', + }, +})); const mockWebEmbedApi = FormsortWebEmbed as jest.MockedFunction< typeof FormsortWebEmbed >; +const getAddedListener = ( + addEventListenerMock: jest.Mock, + eventName: K +): IEventMap[K] => { + const listenerCall = addEventListenerMock.mock.calls.find( + ([addedEventName]) => addedEventName === eventName + ); + + if (!listenerCall) { + throw new Error(`Expected ${String(eventName)} listener to be registered`); + } + + return listenerCall[1] as IEventMap[K]; +}; + describe('EmbedFlow component', () => { let loadMock: jest.Mock; + let unloadMock: jest.Mock; let embedMock: IFormsortWebEmbed; let addEventListenerMock: jest.Mock; let removeEventListenerMock: jest.Mock; beforeEach(() => { loadMock = jest.fn(); + unloadMock = jest.fn(); addEventListenerMock = jest.fn(); removeEventListenerMock = jest.fn(); embedMock = { loadFlow: loadMock, - unloadFlow: jest.fn(), + unloadFlow: unloadMock, setSize: jest.fn(), addEventListener: addEventListenerMock, removeEventListener: removeEventListenerMock, }; - mockWebEmbedApi.mockReturnValueOnce(embedMock); + mockWebEmbedApi.mockReturnValue(embedMock); }); + afterEach(() => { - mockWebEmbedApi.mockClear(); + mockWebEmbedApi.mockReset(); }); - test('should load flows without variant label', () => { + test('loads flows without variant label', () => { render(); - expect(loadMock).toBeCalledWith( + + expect(loadMock).toHaveBeenCalledWith( 'test-client', 'test-flow', undefined, @@ -43,7 +76,7 @@ describe('EmbedFlow component', () => { ); }); - test('should load flows with variant label', () => { + test('loads flows with variant label', () => { render( { variantLabel="test-variant" /> ); - expect(loadMock).toBeCalledWith( + + expect(loadMock).toHaveBeenCalledWith( 'test-client', 'test-flow', 'test-variant', @@ -59,65 +93,263 @@ describe('EmbedFlow component', () => { ); }); - test('should pass down the event listeners given as props', () => { - const flowloadedMock = jest.fn(); + test('passes embed config to the web embed and applies style to the container', () => { + const embedConfig = { + iframeTitle: 'Example flow', + style: { + width: '80%', + height: '400px', + }, + }; + + const { container } = render( + + ); + + expect(mockWebEmbedApi).toHaveBeenCalledWith( + container.firstChild, + embedConfig + ); + expect((container.firstChild as HTMLDivElement).style.width).toBe('80%'); + expect((container.firstChild as HTMLDivElement).style.height).toBe('400px'); + }); + + test('does not reload when rerendered with an equivalent inline embed config', () => { + const { rerender } = render( + + ); + + rerender( + + ); + + expect(mockWebEmbedApi).toHaveBeenCalledTimes(1); + expect(loadMock).toHaveBeenCalledTimes(1); + expect(unloadMock).not.toHaveBeenCalled(); + }); + + test('bridges web embed events to the latest React callbacks', () => { + const flowLoadedMock = jest.fn(); + const updatedFlowLoadedMock = jest.fn(); const flowFinalizedMock = jest.fn(); - const redirectMock = jest.fn(); + const redirectMock = jest.fn().mockReturnValue({ cancel: true }); const unauthorizedMock = jest.fn(); - render( + const { rerender } = render( ); - expect(loadMock).toBeCalledWith( - 'test-client', - 'test-flow', - 'test-variant', - undefined + rerender( + ); - expect(embedMock.addEventListener).toHaveBeenCalledTimes(5); - expect(embedMock.addEventListener).toBeCalledWith( - 'FlowLoaded', - flowloadedMock + + const flowLoadedListener = getAddedListener( + addEventListenerMock, + SupportedAnalyticsEvent.FlowLoaded + ); + const flowFinalizedListener = getAddedListener( + addEventListenerMock, + SupportedAnalyticsEvent.FlowFinalized + ); + const redirectListener = getAddedListener( + addEventListenerMock, + 'redirect' + ); + const unauthorizedListener = getAddedListener( + addEventListenerMock, + 'unauthorized' + ); + + const eventPayload = { + answers: {}, + responder: { + responderUuid: 'responder-uuid', + sessionUuid: 'session-uuid', + }, + variantRevisionUuid: 'variant-revision-uuid', + stepId: 'step-id', + stepIndex: 0, + answerSources: {}, + }; + const redirectPayload = { + url: 'https://example.com', + answers: {}, + responder: { + responderUuid: 'responder-uuid', + sessionUuid: 'session-uuid', + }, + }; + + flowLoadedListener(eventPayload); + flowFinalizedListener(eventPayload); + const redirectResult = redirectListener(redirectPayload); + unauthorizedListener(); + + expect(flowLoadedMock).not.toHaveBeenCalled(); + expect(updatedFlowLoadedMock).toHaveBeenCalledWith(eventPayload); + expect(flowFinalizedMock).toHaveBeenCalledWith(eventPayload); + expect(redirectMock).toHaveBeenCalledWith(redirectPayload); + expect(redirectResult).toEqual({ cancel: true }); + expect(unauthorizedMock).toHaveBeenCalled(); + }); + + test('hides the container after FlowClosed and calls the React callback', () => { + const flowClosedMock = jest.fn(); + const { container } = render( + + ); + + const flowClosedListener = getAddedListener( + addEventListenerMock, + SupportedAnalyticsEvent.FlowClosed + ); + const eventPayload = { + answers: {}, + responder: { + responderUuid: 'responder-uuid', + sessionUuid: 'session-uuid', + }, + variantRevisionUuid: 'variant-revision-uuid', + stepId: 'step-id', + stepIndex: 0, + answerSources: {}, + }; + + act(() => { + flowClosedListener(eventPayload); + }); + + expect(flowClosedMock).toHaveBeenCalledWith(eventPayload); + expect(container.firstChild).toBeNull(); + }); + + test('loads a new flow after the previous flow has closed', () => { + const { container, rerender } = render( + ); - expect(embedMock.addEventListener).toBeCalledWith( - 'FlowFinalized', - flowFinalizedMock + const flowClosedListener = getAddedListener( + addEventListenerMock, + SupportedAnalyticsEvent.FlowClosed + ); + const eventPayload = { + answers: {}, + responder: { + responderUuid: 'responder-uuid', + sessionUuid: 'session-uuid', + }, + variantRevisionUuid: 'variant-revision-uuid', + stepId: 'step-id', + stepIndex: 0, + answerSources: {}, + }; + + act(() => { + flowClosedListener(eventPayload); + }); + + expect(container.firstChild).toBeNull(); + + rerender(); + + expect(container.firstChild).not.toBeNull(); + expect(loadMock).toHaveBeenLastCalledWith( + 'test-client', + 'next-flow', + undefined, + undefined ); - expect(embedMock.addEventListener).toBeCalledWith('redirect', redirectMock); - expect(embedMock.addEventListener).toBeCalledWith('unauthorized', unauthorizedMock); - // Check for FlowClosed event listener that removes the parent container of the embed - expect(embedMock.addEventListener).toBeCalledWith('FlowClosed', expect.any(Function)); }); - test('should load flows with URL params', () => { + test('loads flows with URL params without mutating props', () => { const uuid = 'b1c7d9c8-f4b0-4f3f-9fc3-abf32ae8a061'; + const queryParams: Array<[string, string]> = [['name', 'Olivia']]; + render( ); - expect(loadMock).toBeCalledWith( + + expect(loadMock).toHaveBeenCalledWith( 'test-client', 'test-flow', 'test-variant', [ + ['name', 'Olivia'], ['responderUuid', uuid], ['formsortEnv', 'staging'], ] ); + expect(queryParams).toEqual([['name', 'Olivia']]); + }); + + test('unloads the flow and removes event listeners on unmount', () => { + const { unmount } = render( + + ); + + unmount(); + + expect(removeEventListenerMock).toHaveBeenCalledTimes(7); + expect(unloadMock).toHaveBeenCalledTimes(1); + }); + + test('cleans up the development StrictMode remount', () => { + const { unmount } = render( + + + + ); + + unmount(); + + expect(mockWebEmbedApi).toHaveBeenCalledTimes(2); + expect(unloadMock).toHaveBeenCalledTimes(2); }); }); diff --git a/packages/react-embed/src/index.tsx b/packages/react-embed/src/index.tsx index c1195e60..45da15ed 100644 --- a/packages/react-embed/src/index.tsx +++ b/packages/react-embed/src/index.tsx @@ -1,25 +1,33 @@ -import { SupportedAnalyticsEvent } from '@formsort/embed-messaging-manager'; -import FormsortWebEmbed, { +import * as WebEmbedApi from '@formsort/web-embed-api'; +import type { IEventMap, IFormsortWebEmbed, IFormsortWebEmbedConfig, } from '@formsort/web-embed-api'; - -import React, { useEffect, useRef, useState } from 'react'; +import { + createElement, + type CSSProperties, + type MutableRefObject, + type ReactElement, + useEffect, + useRef, + useState, +} from 'react'; // Using this type to preserve auto-complete for default environments // while allowing any other string to be passed. // See https://github.com/microsoft/TypeScript/issues/29729 type LiteralUnion = T | (U & Record); -type FormsortEnv = LiteralUnion<'staging' | 'production'>; -interface ILoadProps { +export type FormsortEnv = LiteralUnion<'staging' | 'production'>; + +export interface IEmbedFlowLoadProps { clientLabel: string; flowLabel: string; variantLabel?: string; responderUuid?: string; formsortEnv?: FormsortEnv; - queryParams?: Array<[string, string]>; + queryParams?: ReadonlyArray; embedConfig?: IFormsortWebEmbedConfig; } @@ -33,7 +41,67 @@ export interface IReactEmbedEventMap { onStepCompleted?: IEventMap['StepCompleted']; } -export type EmbedFlowProps = ILoadProps & IReactEmbedEventMap; +export interface EmbedFlowProps + extends IEmbedFlowLoadProps, + IReactEmbedEventMap {} + +type FormsortWebEmbedFactory = ( + rootEl: HTMLElement, + config?: IFormsortWebEmbedConfig +) => IFormsortWebEmbed; + +const getFormsortWebEmbed = ( + moduleExport: typeof WebEmbedApi +): FormsortWebEmbedFactory => { + const defaultExport = moduleExport.default as unknown; + + if (typeof defaultExport === 'function') { + return defaultExport as FormsortWebEmbedFactory; + } + + const nestedDefault = + defaultExport && typeof defaultExport === 'object' + ? (defaultExport as unknown as { default?: FormsortWebEmbedFactory }) + .default + : undefined; + + if (typeof nestedDefault === 'function') { + return nestedDefault; + } + + throw new Error('Unable to load @formsort/web-embed-api default export'); +}; + +const getSupportedAnalyticsEvent = ( + moduleExport: typeof WebEmbedApi +): typeof WebEmbedApi.SupportedAnalyticsEvent => { + const directExport = ( + moduleExport as unknown as { + SupportedAnalyticsEvent?: typeof WebEmbedApi.SupportedAnalyticsEvent; + } + ).SupportedAnalyticsEvent; + + if (directExport) { + return directExport; + } + + const defaultExport = + moduleExport.default && + typeof (moduleExport.default as unknown) === 'object' + ? (moduleExport.default as unknown as { + SupportedAnalyticsEvent?: typeof WebEmbedApi.SupportedAnalyticsEvent; + }) + : undefined; + + if (defaultExport?.SupportedAnalyticsEvent) { + return defaultExport.SupportedAnalyticsEvent; + } + + throw new Error('Unable to load @formsort/web-embed-api event exports'); +}; + +const FormsortWebEmbed = getFormsortWebEmbed(WebEmbedApi); +const SupportedAnalyticsEvent = getSupportedAnalyticsEvent(WebEmbedApi); export const eventMapping: Record = { @@ -46,79 +114,186 @@ export const eventMapping: Record = onStepCompleted: SupportedAnalyticsEvent.StepCompleted, }; -const attachEventListenersToEmbed = ( - embed: IFormsortWebEmbed, - events: IReactEmbedEventMap -): void => { - for (const [reactEventName, listener] of Object.entries(events)) { - const embedEventName = - eventMapping[reactEventName as keyof IReactEmbedEventMap]; - embed.addEventListener(embedEventName, listener); +const buildFlowQueryParams = ({ + queryParams, + responderUuid, + formsortEnv, +}: Pick< + IEmbedFlowLoadProps, + 'queryParams' | 'responderUuid' | 'formsortEnv' +>): Array<[string, string]> | undefined => { + const flowQueryParams: Array<[string, string]> = + queryParams?.map(([key, value]) => [key, value]) ?? []; + + if (responderUuid) { + flowQueryParams.push(['responderUuid', responderUuid]); } -}; -const onMount = ( - containerRef: React.RefObject, - props: EmbedFlowProps -): IFormsortWebEmbed | undefined => { - const containerElement = containerRef.current; - if (!containerElement) { - return; + if (formsortEnv) { + flowQueryParams.push(['formsortEnv', formsortEnv]); } - const { - clientLabel, - flowLabel, - variantLabel, - embedConfig, - responderUuid, - formsortEnv, - queryParams = [], - ...eventListeners - } = props; + return flowQueryParams.length ? flowQueryParams : undefined; +}; - const embed = FormsortWebEmbed(containerElement, embedConfig); - attachEventListenersToEmbed(embed, eventListeners); +const getQueryParamsKey = ( + queryParams: Array<[string, string]> | undefined +): string => JSON.stringify(queryParams ?? []); - if (responderUuid) { - queryParams.push(['responderUuid', responderUuid]); - } - if (formsortEnv) { - queryParams.push(['formsortEnv', formsortEnv]); +const getEmbedConfigKey = ( + embedConfig: IFormsortWebEmbedConfig | undefined +): string => { + try { + return JSON.stringify(embedConfig ?? {}); + } catch { + return String(embedConfig); } +}; - embed.loadFlow( +const getFlowLoadKey = ({ + clientLabel, + embedConfigKey, + flowLabel, + queryParamsKey, + variantLabel, +}: { + clientLabel: string; + embedConfigKey: string; + flowLabel: string; + queryParamsKey: string; + variantLabel?: string; +}): string => + JSON.stringify([ clientLabel, + embedConfigKey, flowLabel, + queryParamsKey, variantLabel, - queryParams.length ? queryParams : undefined - ); + ]); - return embed; +const attachEventListener = ( + embed: IFormsortWebEmbed, + eventName: K, + eventListener: IEventMap[K] +): (() => void) => { + embed.addEventListener(eventName, eventListener); + + return () => { + embed.removeEventListener(eventName, eventListener); + }; }; -const EmbedFlow: React.FunctionComponent = (props) => { +const attachEventListeners = ( + embed: IFormsortWebEmbed, + eventListenersRef: MutableRefObject, + onFlowClosed: () => void +): Array<() => void> => [ + attachEventListener(embed, 'unauthorized', () => { + eventListenersRef.current.onUnauthorized?.(); + }), + attachEventListener(embed, 'redirect', (event) => + eventListenersRef.current.onRedirect?.(event) + ), + attachEventListener(embed, SupportedAnalyticsEvent.FlowLoaded, (event) => { + eventListenersRef.current.onFlowLoaded?.(event); + }), + attachEventListener(embed, SupportedAnalyticsEvent.FlowClosed, (event) => { + onFlowClosed(); + eventListenersRef.current.onFlowClosed?.(event); + }), + attachEventListener(embed, SupportedAnalyticsEvent.FlowFinalized, (event) => { + eventListenersRef.current.onFlowFinalized?.(event); + }), + attachEventListener(embed, SupportedAnalyticsEvent.StepLoaded, (event) => { + eventListenersRef.current.onStepLoaded?.(event); + }), + attachEventListener(embed, SupportedAnalyticsEvent.StepCompleted, (event) => { + eventListenersRef.current.onStepCompleted?.(event); + }), +]; + +export const EmbedFlow = ({ + clientLabel, + flowLabel, + variantLabel, + responderUuid, + formsortEnv, + queryParams, + embedConfig, + onUnauthorized, + onRedirect, + onFlowLoaded, + onFlowClosed, + onFlowFinalized, + onStepLoaded, + onStepCompleted, +}: EmbedFlowProps): ReactElement | null => { const containerRef = useRef(null); - const style = props.embedConfig?.style; - const [flowClosed, setFlowClosed] = useState(false); + const eventListenersRef = useRef({}); + const [closedFlowKey, setClosedFlowKey] = useState(); + + eventListenersRef.current = { + onUnauthorized, + onRedirect, + onFlowLoaded, + onFlowClosed, + onFlowFinalized, + onStepLoaded, + onStepCompleted, + }; + + const flowQueryParams = buildFlowQueryParams({ + queryParams, + responderUuid, + formsortEnv, + }); + const queryParamsKey = getQueryParamsKey(flowQueryParams); + const embedConfigKey = getEmbedConfigKey(embedConfig); + const flowLoadKey = getFlowLoadKey({ + clientLabel, + embedConfigKey, + flowLabel, + queryParamsKey, + variantLabel, + }); + const flowClosed = closedFlowKey === flowLoadKey; useEffect(() => { - const embed = onMount(containerRef, props); + const containerElement = containerRef.current; + + if (!containerElement) { + return undefined; + } + + setClosedFlowKey(undefined); + + const embed = FormsortWebEmbed(containerElement, embedConfig); + const removeEventListeners = attachEventListeners( + embed, + eventListenersRef, + () => { + setClosedFlowKey(flowLoadKey); + } + ); - embed?.addEventListener(SupportedAnalyticsEvent.FlowClosed, () => { - setFlowClosed(true); - }) + embed.loadFlow(clientLabel, flowLabel, variantLabel, flowQueryParams); return () => { - embed?.unloadFlow(); + removeEventListeners.forEach((removeEventListener) => { + removeEventListener(); + }); + embed.unloadFlow(); }; - }, []); + }, [flowLoadKey]); if (flowClosed) { return null; } - return
; + return createElement('div', { + ref: containerRef, + style: embedConfig?.style as CSSProperties | undefined, + }); }; export default EmbedFlow; diff --git a/packages/react-embed/tsconfig.build.cjs.json b/packages/react-embed/tsconfig.build.cjs.json new file mode 100644 index 00000000..650113dd --- /dev/null +++ b/packages/react-embed/tsconfig.build.cjs.json @@ -0,0 +1,12 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "composite": false, + "declaration": false, + "module": "CommonJS", + "outDir": "./lib/cjs", + "rootDir": "src" + }, + "include": ["src"], + "exclude": ["lib", "src/__tests__/**/*", "node_modules"] +} diff --git a/packages/react-embed/tsconfig.build.esm.json b/packages/react-embed/tsconfig.build.esm.json new file mode 100644 index 00000000..d7ff5668 --- /dev/null +++ b/packages/react-embed/tsconfig.build.esm.json @@ -0,0 +1,12 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "composite": false, + "declaration": false, + "module": "ES2020", + "outDir": "./lib/esm", + "rootDir": "src" + }, + "include": ["src"], + "exclude": ["lib", "src/__tests__/**/*", "node_modules"] +} diff --git a/packages/react-embed/tsconfig.build.json b/packages/react-embed/tsconfig.build.json index 01b31932..caa3a167 100644 --- a/packages/react-embed/tsconfig.build.json +++ b/packages/react-embed/tsconfig.build.json @@ -1,7 +1,10 @@ { "extends": "./tsconfig.json", "compilerOptions": { - "rootDir": "src" + "composite": false, + "rootDir": "src", + "outDir": "./lib/types", + "emitDeclarationOnly": true }, "include": ["src"], "exclude": ["lib", "src/__tests__/**/*", "node_modules"] diff --git a/packages/react-embed/tsconfig.build.types.json b/packages/react-embed/tsconfig.build.types.json new file mode 100644 index 00000000..d8faaf50 --- /dev/null +++ b/packages/react-embed/tsconfig.build.types.json @@ -0,0 +1,3 @@ +{ + "extends": "./tsconfig.build.json" +} diff --git a/packages/react-embed/tsconfig.json b/packages/react-embed/tsconfig.json index b9946087..3b9accc5 100644 --- a/packages/react-embed/tsconfig.json +++ b/packages/react-embed/tsconfig.json @@ -2,6 +2,7 @@ "extends": "@formsort/tsconfig", "compilerOptions": { "declaration": true, + "importHelpers": false, "outDir": "./lib", "strict": true, "types": ["jest"] diff --git a/packages/web-embed-api/package.json b/packages/web-embed-api/package.json index 841f1353..95c98be3 100644 --- a/packages/web-embed-api/package.json +++ b/packages/web-embed-api/package.json @@ -6,12 +6,22 @@ "access": "public", "registry": "https://registry.npmjs.org/" }, - "main": "lib/index.js", - "types": "lib/index.d.ts", + "main": "./lib/index.js", + "module": "./lib/esm/index.mjs", + "types": "./lib/index.d.ts", + "exports": { + ".": { + "types": "./lib/index.d.ts", + "import": "./lib/esm/index.mjs", + "require": "./lib/index.js", + "default": "./lib/esm/index.mjs" + }, + "./package.json": "./package.json" + }, "scripts": { "test": "jest", "coverage": "jest --coverage", - "build": "tsc --project tsconfig.build.json", + "build": "tsc --project tsconfig.build.json && vite build", "format": "eslint --ext .ts,.tsx src --fix", "lint": "eslint --ext .ts,.tsx src", "pack": "yarn pack", @@ -43,7 +53,8 @@ "jest": "^29.7.0", "prettier": "^2.2.1", "ts-jest": "^29.1.1", - "typescript": "^4.9.5" + "typescript": "^4.9.5", + "vite": "^6.4.2" }, "dependencies": { "@formsort/embed-messaging-manager": "^0.3.0" diff --git a/packages/web-embed-api/src/index.ts b/packages/web-embed-api/src/index.ts index d2e9c5c3..e9566fe7 100644 --- a/packages/web-embed-api/src/index.ts +++ b/packages/web-embed-api/src/index.ts @@ -1,7 +1,8 @@ -import EmbedMessagingManager, { - type IFormsortEmbedConfig, - type IEventMap, - SupportedAnalyticsEvent, +import * as EmbedMessagingManagerModule from '@formsort/embed-messaging-manager'; +import type { + IFormsortEmbedConfig, + IEventMap, + SupportedAnalyticsEvent as SupportedAnalyticsEventType, } from '@formsort/embed-messaging-manager'; import { getMessageSender } from './iframe-utils'; import { isLocalOrLegacyFlowOrigin } from './utils'; @@ -44,6 +45,70 @@ const DEFAULT_CONFIG: IFormsortWebEmbedConfig = { const DEFAULT_ALLOW = 'camera;'; +type SupportedAnalyticsEvent = SupportedAnalyticsEventType; + +const getEmbedMessagingManager = ( + moduleExport: typeof EmbedMessagingManagerModule +): typeof EmbedMessagingManagerModule.default => { + const defaultExport = moduleExport.default as unknown; + + if (typeof defaultExport === 'function') { + return defaultExport as typeof EmbedMessagingManagerModule.default; + } + + const nestedDefault = + defaultExport && typeof defaultExport === 'object' + ? (defaultExport as unknown as { + default?: typeof EmbedMessagingManagerModule.default; + }).default + : undefined; + + if (typeof nestedDefault === 'function') { + return nestedDefault; + } + + throw new Error( + 'Unable to load @formsort/embed-messaging-manager default export' + ); +}; + +const getSupportedAnalyticsEvent = ( + moduleExport: typeof EmbedMessagingManagerModule +): typeof EmbedMessagingManagerModule.SupportedAnalyticsEvent => { + const directExport = ( + moduleExport as unknown as { + SupportedAnalyticsEvent?: typeof EmbedMessagingManagerModule.SupportedAnalyticsEvent; + } + ).SupportedAnalyticsEvent; + + if (directExport) { + return directExport; + } + + const defaultExport = + moduleExport.default && + typeof (moduleExport.default as unknown) === 'object' + ? (moduleExport.default as unknown as { + SupportedAnalyticsEvent?: typeof EmbedMessagingManagerModule.SupportedAnalyticsEvent; + }) + : undefined; + + if (defaultExport?.SupportedAnalyticsEvent) { + return defaultExport.SupportedAnalyticsEvent; + } + + throw new Error( + 'Unable to load @formsort/embed-messaging-manager event exports' + ); +}; + +const EmbedMessagingManager = getEmbedMessagingManager( + EmbedMessagingManagerModule +); +const SupportedAnalyticsEvent = getSupportedAnalyticsEvent( + EmbedMessagingManagerModule +); + const FormsortWebEmbed = ( rootEl: HTMLElement, config: IFormsortWebEmbedConfig = DEFAULT_CONFIG @@ -179,6 +244,6 @@ const FormsortWebEmbed = ( export { IFormsortWebEmbed, IFormsortWebEmbedConfig, IEventMap }; -export { SupportedAnalyticsEvent } from '@formsort/embed-messaging-manager'; +export { SupportedAnalyticsEvent }; export default FormsortWebEmbed; diff --git a/packages/web-embed-api/vite.config.mts b/packages/web-embed-api/vite.config.mts new file mode 100644 index 00000000..5e23c903 --- /dev/null +++ b/packages/web-embed-api/vite.config.mts @@ -0,0 +1,15 @@ +import { defineConfig } from 'vite'; + +export default defineConfig({ + build: { + lib: { + entry: 'src/index.ts', + formats: ['es'], + fileName: () => 'index.mjs', + }, + rollupOptions: { + external: ['@formsort/embed-messaging-manager'], + }, + outDir: 'lib/esm', + }, +});