diff --git a/package.json b/package.json
index 33d51bc..b830ff9 100644
--- a/package.json
+++ b/package.json
@@ -18,6 +18,10 @@
"keywords": [],
"author": "",
"license": "ISC",
+ "peerDependencies": {
+ "react": ">=17",
+ "react-dom": ">=17"
+ },
"dependencies": {
"debug": "^4.3.4",
"fast-glob": "^3.2.11",
@@ -29,6 +33,7 @@
},
"devDependencies": {
"@testing-library/dom": "^8.17.1",
+ "@testing-library/react": "^13.4.0",
"@types/debug": "^4.1.7",
"@types/node": "^18.7.15",
"@types/node-fetch": "^2.6.2",
@@ -55,7 +60,11 @@
"typescript": "^4.8.2",
"vite": "^3.1.0",
"vitest": "^0.23.1",
- "watchlist": "^0.3.1"
+ "watchlist": "^0.3.1",
+ "react": "^18.2.0",
+ "react-dom": "18.2.0",
+ "@types/react": "17.0.49",
+ "@types/react-dom": "^17.0.11"
},
"lint-staged": {
"*.{js,ts,tsx}": "eslint --cache --fix",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index d903dd6..c5221da 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -2,9 +2,12 @@ lockfileVersion: 5.4
specifiers:
'@testing-library/dom': ^8.17.1
+ '@testing-library/react': ^13.4.0
'@types/debug': ^4.1.7
'@types/node': ^18.7.15
'@types/node-fetch': ^2.6.2
+ '@types/react': 17.0.49
+ '@types/react-dom': ^17.0.11
'@types/uuid': ^8.3.4
'@typescript-eslint/eslint-plugin': ^5.36.1
'@typescript-eslint/parser': ^5.36.1
@@ -27,6 +30,8 @@ specifiers:
jsdom: ^20.0.0
node-fetch: ^3.2.10
prettier: 2.7.1
+ react: ^18.2.0
+ react-dom: 18.2.0
release-it: ^15.4.1
retes: ^0.32.0
tsm: ^2.2.2
@@ -48,9 +53,12 @@ dependencies:
devDependencies:
'@testing-library/dom': 8.17.1
+ '@testing-library/react': 13.4.0_biqbaboplfbrettd7655fr4n2y
'@types/debug': 4.1.7
'@types/node': 18.7.15
'@types/node-fetch': 2.6.2
+ '@types/react': 17.0.49
+ '@types/react-dom': 17.0.17
'@types/uuid': 8.3.4
'@typescript-eslint/eslint-plugin': 5.36.1_lbwfnm54o3pmr3ypeqp3btnera
'@typescript-eslint/parser': 5.36.1_yqf6kl63nyoq5megxukfnom5rm
@@ -68,6 +76,8 @@ devDependencies:
husky: 8.0.1
jsdom: 20.0.0
prettier: 2.7.1
+ react: 18.2.0
+ react-dom: 18.2.0_react@18.2.0
release-it: 15.4.1
tsm: 2.2.2
tsup: 6.2.3_typescript@4.8.2
@@ -648,6 +658,20 @@ packages:
pretty-format: 27.5.1
dev: true
+ /@testing-library/react/13.4.0_biqbaboplfbrettd7655fr4n2y:
+ resolution: {integrity: sha512-sXOGON+WNTh3MLE9rve97ftaZukN3oNf2KjDy7YTx6hcTO2uuLHuCGynMDhFwGw/jYf4OJ2Qk0i4i79qMNNkyw==}
+ engines: {node: '>=12'}
+ peerDependencies:
+ react: ^18.0.0
+ react-dom: ^18.0.0
+ dependencies:
+ '@babel/runtime': 7.18.9
+ '@testing-library/dom': 8.17.1
+ '@types/react-dom': 18.0.6
+ react: 18.2.0
+ react-dom: 18.2.0_react@18.2.0
+ dev: true
+
/@tootallnate/once/1.1.2:
resolution: {integrity: sha512-RbzJvlNzmRq5c3O09UipeuXno4tA1FE6ikOjxZK0tuxVv3412l64l5t1W5pj4+rJq9vpkm/kwiR07aZXnsKPxw==}
engines: {node: '>= 6'}
@@ -724,12 +748,48 @@ packages:
resolution: {integrity: sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA==}
dev: true
+ /@types/prop-types/15.7.5:
+ resolution: {integrity: sha512-JCB8C6SnDoQf0cNycqd/35A7MjcnK+ZTqE7judS6o7utxUCg6imJg3QK2qzHKszlTjcj2cn+NwMB2i96ubpj7w==}
+ dev: true
+
+ /@types/react-dom/17.0.17:
+ resolution: {integrity: sha512-VjnqEmqGnasQKV0CWLevqMTXBYG9GbwuE6x3VetERLh0cq2LTptFE73MrQi2S7GkKXCf2GgwItB/melLnxfnsg==}
+ dependencies:
+ '@types/react': 17.0.49
+ dev: true
+
+ /@types/react-dom/18.0.6:
+ resolution: {integrity: sha512-/5OFZgfIPSwy+YuIBP/FgJnQnsxhZhjjrnxudMddeblOouIodEQ75X14Rr4wGSG/bknL+Omy9iWlLo1u/9GzAA==}
+ dependencies:
+ '@types/react': 18.0.18
+ dev: true
+
+ /@types/react/17.0.49:
+ resolution: {integrity: sha512-CCBPMZaPhcKkYUTqFs/hOWqKjPxhTEmnZWjlHHgIMop67DsXywf9B5Os9Hz8KSacjNOgIdnZVJamwl232uxoPg==}
+ dependencies:
+ '@types/prop-types': 15.7.5
+ '@types/scheduler': 0.16.2
+ csstype: 3.1.0
+ dev: true
+
+ /@types/react/18.0.18:
+ resolution: {integrity: sha512-6hI08umYs6NaiHFEEGioXnxJ+oEhY3eRz8VCUaudZmGdtvPviCJB8mgaMxaDWAdPSYd4eFavrPk2QIolwbLYrg==}
+ dependencies:
+ '@types/prop-types': 15.7.5
+ '@types/scheduler': 0.16.2
+ csstype: 3.1.0
+ dev: true
+
/@types/responselike/1.0.0:
resolution: {integrity: sha512-85Y2BjiufFzaMIlvJDvTTB8Fxl2xfLo4HgmHzVBz08w4wDePCTjYw66PdrolO0kzli3yam/YCgRufyo1DdQVTA==}
dependencies:
'@types/node': 18.7.15
dev: true
+ /@types/scheduler/0.16.2:
+ resolution: {integrity: sha512-hppQEBDmlwhFAXKJX2KnWLYu5yMfi91yazPb2l+lbJiwW+wdo1gNeRA+3RgNSO39WYX2euey41KEwnqesU2Jew==}
+ dev: true
+
/@types/uuid/8.3.4:
resolution: {integrity: sha512-c/I8ZRb51j+pYGAu5CrFMRxqZ2ke4y2grEBO5AUjgSkSk+qT2Ea+OdWElz/OiMf5MNpn2b17kuVBwZLQJXzihw==}
dev: true
@@ -1457,6 +1517,10 @@ packages:
cssom: 0.3.8
dev: true
+ /csstype/3.1.0:
+ resolution: {integrity: sha512-uX1KG+x9h5hIJsaKR9xHUeUraxf8IODOwq9JLNPq6BwB04a/xgpq3rcx47l5BZu5zBPlgD342tdke3Hom/nJRA==}
+ dev: true
+
/damerau-levenshtein/1.0.8:
resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==}
dev: true
@@ -4205,6 +4269,16 @@ packages:
strip-json-comments: 2.0.1
dev: true
+ /react-dom/18.2.0_react@18.2.0:
+ resolution: {integrity: sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==}
+ peerDependencies:
+ react: ^18.2.0
+ dependencies:
+ loose-envify: 1.4.0
+ react: 18.2.0
+ scheduler: 0.23.0
+ dev: true
+
/react-is/16.13.1:
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
dev: true
@@ -4218,6 +4292,13 @@ packages:
engines: {node: '>=0.10.0'}
dev: true
+ /react/18.2.0:
+ resolution: {integrity: sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==}
+ engines: {node: '>=0.10.0'}
+ dependencies:
+ loose-envify: 1.4.0
+ dev: true
+
/readable-stream/1.1.14:
resolution: {integrity: sha512-+MeVjFf4L44XUkhM1eYbD8fyEsxcV81pqMSR5gblfcLCHfZvbrqy4/qYHE+/R5HoBUT11WV5O08Cr1n3YXkWVQ==}
dependencies:
@@ -4442,6 +4523,12 @@ packages:
xmlchars: 2.2.0
dev: true
+ /scheduler/0.23.0:
+ resolution: {integrity: sha512-CtuThmgHNg7zIZWAXi3AsyIzA3n4xx7aNyjwC2VJldO2LMVDhFK+63xGqq6CsJH4rTAt6/M+N4GhZiDYPx9eUw==}
+ dependencies:
+ loose-envify: 1.4.0
+ dev: true
+
/semver-diff/4.0.0:
resolution: {integrity: sha512-0Ju4+6A8iOnpL/Thra7dZsSlOHYAHIeMxfhWQRI1/VLcT3WDBZKKtQt/QkBOsiIN9ZpuvHE6cGZ0x4glCMmfiA==}
engines: {node: '>=12'}
diff --git a/src/app-bridge/app-bridge-provider.test.tsx b/src/app-bridge/app-bridge-provider.test.tsx
new file mode 100644
index 0000000..fcb6470
--- /dev/null
+++ b/src/app-bridge/app-bridge-provider.test.tsx
@@ -0,0 +1,139 @@
+import { fireEvent, render, renderHook, waitFor } from "@testing-library/react";
+import * as React from "react";
+import { describe, expect, it, vi } from "vitest";
+
+import { AppBridge } from "./app-bridge";
+import { AppBridgeProvider, useAppBridge } from "./app-bridge-provider";
+import { DashboardEventFactory } from "./events";
+
+const origin = "http://example.com";
+const domain = "saleor.domain.host";
+
+Object.defineProperty(window.document, "referrer", {
+ value: origin,
+ writable: true,
+});
+
+Object.defineProperty(window, "location", {
+ value: {
+ href: `${origin}?domain=${domain}&id=appid`,
+ },
+ writable: true,
+});
+
+describe("AppBridgeProvider", () => {
+ it("Mounts provider in React DOM", () => {
+ const { container } = render(
+
+
+
+ );
+
+ expect(container).toBeDefined();
+ });
+
+ it("Mounts provider in React DOM with provided AppBridge instance", () => {
+ const { container } = render(
+
+
+
+ );
+
+ expect(container).toBeDefined();
+ });
+});
+
+describe("useAppBridge hook", () => {
+ it("App is defined when wrapped in AppBridgeProvider", async () => {
+ const { result } = renderHook(() => useAppBridge(), {
+ wrapper: AppBridgeProvider,
+ });
+
+ expect(result.current.appBridge).toBeDefined();
+ });
+
+ it("Throws if not wrapped inside AppBridgeProvider", () => {
+ expect.assertions(2);
+
+ const mockConsoleError = vi.spyOn(console, "error");
+ /**
+ * Silent errors
+ */
+ mockConsoleError.mockImplementation(() => {
+ // Silence
+ });
+
+ let appBridgeResult: AppBridge | undefined;
+
+ try {
+ const { result } = renderHook(() => useAppBridge(), {});
+ appBridgeResult = result.current.appBridge;
+
+ mockConsoleError.mockClear();
+ } catch (e) {
+ expect(mockConsoleError).toHaveBeenCalled();
+ expect(appBridgeResult).toBeUndefined();
+ }
+
+ mockConsoleError.mockClear();
+ });
+
+ it("Returned instance provided in Provider", () => {
+ const appBridge = new AppBridge({
+ targetDomain: "test-domain",
+ });
+
+ const { result } = renderHook(() => useAppBridge(), {
+ wrapper: (props: {}) => ,
+ });
+
+ expect(result.current.appBridge?.getState().domain).toBe("test-domain");
+ });
+
+ it("Stores active state in React State", () => {
+ const appBridge = new AppBridge({
+ targetDomain: origin,
+ });
+
+ const renderCallback = vi.fn();
+
+ function TestComponent() {
+ const { appBridgeState } = useAppBridge();
+
+ renderCallback(appBridgeState);
+
+ return null;
+ }
+
+ render(
+
+
+
+ );
+
+ fireEvent(
+ window,
+ new MessageEvent("message", {
+ data: DashboardEventFactory.createThemeChangeEvent("light"),
+ origin,
+ })
+ );
+
+ return waitFor(() => {
+ expect(renderCallback).toHaveBeenCalledTimes(2);
+ expect(renderCallback).toHaveBeenCalledWith({
+ domain: "http://example.com",
+ id: "appid",
+ path: "",
+ ready: false,
+ theme: "light",
+ });
+ });
+ });
+});
diff --git a/src/app-bridge/app-bridge-provider.tsx b/src/app-bridge/app-bridge-provider.tsx
new file mode 100644
index 0000000..e7fb2bb
--- /dev/null
+++ b/src/app-bridge/app-bridge-provider.tsx
@@ -0,0 +1,93 @@
+import debugPkg from "debug";
+import * as React from "react";
+import { useCallback, useContext, useEffect, useMemo, useState } from "react";
+
+import { AppBridge } from "./app-bridge";
+import { AppBridgeState } from "./app-bridge-state";
+
+const debug = debugPkg.debug("app-sdk:AppBridgeProvider");
+
+interface AppBridgeContext {
+ /**
+ * App can be undefined, because it gets initialized in Browser only
+ */
+ appBridge?: AppBridge;
+ mounted: boolean;
+}
+
+type Props = {
+ appBridgeInstance?: AppBridge;
+};
+
+export const AppContext = React.createContext({
+ appBridge: undefined,
+ mounted: false,
+});
+
+/**
+ * Experimental - try to use provider in app-sdk itself
+ * Consider monorepo with dedicated react package
+ */
+export function AppBridgeProvider({ appBridgeInstance, ...props }: React.PropsWithChildren) {
+ debug("Provider mounted");
+ const [appBridge, setAppBridge] = useState(appBridgeInstance);
+
+ useEffect(() => {
+ if (!appBridge) {
+ debug("AppBridge not defined, will create new instance");
+ setAppBridge(appBridgeInstance ?? new AppBridge());
+ } else {
+ debug("AppBridge provided in props, will use this one");
+ }
+ }, []);
+
+ const contextValue = useMemo(
+ (): AppBridgeContext => ({
+ appBridge,
+ mounted: true,
+ }),
+ [appBridge]
+ );
+
+ return ;
+}
+
+export const useAppBridge = () => {
+ const { appBridge, mounted } = useContext(AppContext);
+ const [appBridgeState, setAppBridgeState] = useState(() =>
+ appBridge ? appBridge.getState() : null
+ );
+
+ if (typeof window !== "undefined" && !mounted) {
+ throw new Error("useAppBridge used outside of AppBridgeProvider");
+ }
+
+ const updateState = useCallback(() => {
+ if (appBridge?.getState()) {
+ debug("Detected state change in AppBridge, will set new state");
+ setAppBridgeState(appBridge.getState());
+ }
+ }, [appBridge]);
+
+ useEffect(() => {
+ let unsubscribes: Array = [];
+
+ if (appBridge) {
+ debug("Provider mounted, will set up listeners");
+
+ unsubscribes = [
+ appBridge.subscribe("handshake", updateState),
+ appBridge.subscribe("theme", updateState),
+ appBridge.subscribe("response", updateState),
+ appBridge.subscribe("redirect", updateState),
+ ];
+ }
+
+ return () => {
+ debug("Provider unmounted, will clean up listeners");
+ unsubscribes.forEach((unsubscribe) => unsubscribe());
+ };
+ }, [appBridge, updateState]);
+
+ return { appBridge, appBridgeState };
+};
diff --git a/src/app-bridge/events.test.ts b/src/app-bridge/events.test.ts
new file mode 100644
index 0000000..317f05f
--- /dev/null
+++ b/src/app-bridge/events.test.ts
@@ -0,0 +1,40 @@
+import { describe, expect, it } from "vitest";
+
+import { DashboardEventFactory } from "./events";
+
+describe("DashboardEventFactory", () => {
+ it("Creates handshake event", () => {
+ expect(DashboardEventFactory.createHandshakeEvent("mock-token")).toEqual({
+ payload: {
+ token: "mock-token",
+ version: 1,
+ },
+ type: "handshake",
+ });
+ });
+ it("Creates redirect event", () => {
+ expect(DashboardEventFactory.createRedirectEvent("/new-path")).toEqual({
+ payload: {
+ path: "/new-path",
+ },
+ type: "redirect",
+ });
+ });
+ it("Creates dispatch response event", () => {
+ expect(DashboardEventFactory.createDispatchResponseEvent("123", true)).toEqual({
+ payload: {
+ actionId: "123",
+ ok: true,
+ },
+ type: "response",
+ });
+ });
+ it("Creates theme change event", () => {
+ expect(DashboardEventFactory.createThemeChangeEvent("light")).toEqual({
+ payload: {
+ theme: "light",
+ },
+ type: "theme",
+ });
+ });
+});
diff --git a/src/app-bridge/events.ts b/src/app-bridge/events.ts
index 259ad9d..889f1e6 100644
--- a/src/app-bridge/events.ts
+++ b/src/app-bridge/events.ts
@@ -67,3 +67,40 @@ export type PayloadOfEvent<
TEvent extends Events = Events
// @ts-ignore TODO - why this is not working with this tsconfig? Fixme
> = TEvent extends Event ? TEvent["payload"] : never;
+
+export const DashboardEventFactory = {
+ createThemeChangeEvent(theme: ThemeType): ThemeEvent {
+ return {
+ payload: {
+ theme,
+ },
+ type: "theme",
+ };
+ },
+ createRedirectEvent(path: string): RedirectEvent {
+ return {
+ type: "redirect",
+ payload: {
+ path,
+ },
+ };
+ },
+ createDispatchResponseEvent(actionId: string, ok: boolean): DispatchResponseEvent {
+ return {
+ type: "response",
+ payload: {
+ actionId,
+ ok,
+ },
+ };
+ },
+ createHandshakeEvent(token: string, version: Version = 1): HandshakeEvent {
+ return {
+ type: "handshake",
+ payload: {
+ token,
+ version,
+ },
+ };
+ },
+};
diff --git a/src/app-bridge/index.ts b/src/app-bridge/index.ts
index 29d16b9..55a9861 100644
--- a/src/app-bridge/index.ts
+++ b/src/app-bridge/index.ts
@@ -3,6 +3,7 @@ import { AppBridge } from "./app-bridge";
export { AppBridge };
export * from "./actions";
+export * from "./app-bridge-provider";
export * from "./events";
export * from "./types";