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',
+ },
+});