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";