Add AppBridgeProvider and events factory (#51)
* Add events factory * Add AppBridgeProvider * Rewrite useAppBridge, add tests
This commit is contained in:
parent
ac6900f35f
commit
cc18c3deac
7 changed files with 407 additions and 1 deletions
11
package.json
11
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",
|
||||
|
|
|
@ -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'}
|
||||
|
|
139
src/app-bridge/app-bridge-provider.test.tsx
Normal file
139
src/app-bridge/app-bridge-provider.test.tsx
Normal file
|
@ -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(
|
||||
<AppBridgeProvider>
|
||||
<div />
|
||||
</AppBridgeProvider>
|
||||
);
|
||||
|
||||
expect(container).toBeDefined();
|
||||
});
|
||||
|
||||
it("Mounts provider in React DOM with provided AppBridge instance", () => {
|
||||
const { container } = render(
|
||||
<AppBridgeProvider
|
||||
appBridgeInstance={
|
||||
new AppBridge({
|
||||
targetDomain: "https://test-domain",
|
||||
})
|
||||
}
|
||||
>
|
||||
<div />
|
||||
</AppBridgeProvider>
|
||||
);
|
||||
|
||||
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: {}) => <AppBridgeProvider {...props} appBridgeInstance={appBridge} />,
|
||||
});
|
||||
|
||||
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(
|
||||
<AppBridgeProvider appBridgeInstance={appBridge}>
|
||||
<TestComponent />
|
||||
</AppBridgeProvider>
|
||||
);
|
||||
|
||||
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",
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
93
src/app-bridge/app-bridge-provider.tsx
Normal file
93
src/app-bridge/app-bridge-provider.tsx
Normal file
|
@ -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<AppBridgeContext>({
|
||||
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<Props>) {
|
||||
debug("Provider mounted");
|
||||
const [appBridge, setAppBridge] = useState<AppBridge | undefined>(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 <AppContext.Provider value={contextValue} {...props} />;
|
||||
}
|
||||
|
||||
export const useAppBridge = () => {
|
||||
const { appBridge, mounted } = useContext(AppContext);
|
||||
const [appBridgeState, setAppBridgeState] = useState<AppBridgeState | null>(() =>
|
||||
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<Function> = [];
|
||||
|
||||
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 };
|
||||
};
|
40
src/app-bridge/events.test.ts
Normal file
40
src/app-bridge/events.test.ts
Normal file
|
@ -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",
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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<TEventType, unknown> ? 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,
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
|
|
@ -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";
|
||||
|
||||
|
|
Loading…
Reference in a new issue