Add AppBridgeProvider and events factory (#51)

* Add events factory

* Add AppBridgeProvider

* Rewrite useAppBridge, add tests
This commit is contained in:
Lukasz Ostrowski 2022-09-13 12:00:02 +02:00 committed by GitHub
parent ac6900f35f
commit cc18c3deac
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 407 additions and 1 deletions

View file

@ -18,6 +18,10 @@
"keywords": [], "keywords": [],
"author": "", "author": "",
"license": "ISC", "license": "ISC",
"peerDependencies": {
"react": ">=17",
"react-dom": ">=17"
},
"dependencies": { "dependencies": {
"debug": "^4.3.4", "debug": "^4.3.4",
"fast-glob": "^3.2.11", "fast-glob": "^3.2.11",
@ -29,6 +33,7 @@
}, },
"devDependencies": { "devDependencies": {
"@testing-library/dom": "^8.17.1", "@testing-library/dom": "^8.17.1",
"@testing-library/react": "^13.4.0",
"@types/debug": "^4.1.7", "@types/debug": "^4.1.7",
"@types/node": "^18.7.15", "@types/node": "^18.7.15",
"@types/node-fetch": "^2.6.2", "@types/node-fetch": "^2.6.2",
@ -55,7 +60,11 @@
"typescript": "^4.8.2", "typescript": "^4.8.2",
"vite": "^3.1.0", "vite": "^3.1.0",
"vitest": "^0.23.1", "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": { "lint-staged": {
"*.{js,ts,tsx}": "eslint --cache --fix", "*.{js,ts,tsx}": "eslint --cache --fix",

View file

@ -2,9 +2,12 @@ lockfileVersion: 5.4
specifiers: specifiers:
'@testing-library/dom': ^8.17.1 '@testing-library/dom': ^8.17.1
'@testing-library/react': ^13.4.0
'@types/debug': ^4.1.7 '@types/debug': ^4.1.7
'@types/node': ^18.7.15 '@types/node': ^18.7.15
'@types/node-fetch': ^2.6.2 '@types/node-fetch': ^2.6.2
'@types/react': 17.0.49
'@types/react-dom': ^17.0.11
'@types/uuid': ^8.3.4 '@types/uuid': ^8.3.4
'@typescript-eslint/eslint-plugin': ^5.36.1 '@typescript-eslint/eslint-plugin': ^5.36.1
'@typescript-eslint/parser': ^5.36.1 '@typescript-eslint/parser': ^5.36.1
@ -27,6 +30,8 @@ specifiers:
jsdom: ^20.0.0 jsdom: ^20.0.0
node-fetch: ^3.2.10 node-fetch: ^3.2.10
prettier: 2.7.1 prettier: 2.7.1
react: ^18.2.0
react-dom: 18.2.0
release-it: ^15.4.1 release-it: ^15.4.1
retes: ^0.32.0 retes: ^0.32.0
tsm: ^2.2.2 tsm: ^2.2.2
@ -48,9 +53,12 @@ dependencies:
devDependencies: devDependencies:
'@testing-library/dom': 8.17.1 '@testing-library/dom': 8.17.1
'@testing-library/react': 13.4.0_biqbaboplfbrettd7655fr4n2y
'@types/debug': 4.1.7 '@types/debug': 4.1.7
'@types/node': 18.7.15 '@types/node': 18.7.15
'@types/node-fetch': 2.6.2 '@types/node-fetch': 2.6.2
'@types/react': 17.0.49
'@types/react-dom': 17.0.17
'@types/uuid': 8.3.4 '@types/uuid': 8.3.4
'@typescript-eslint/eslint-plugin': 5.36.1_lbwfnm54o3pmr3ypeqp3btnera '@typescript-eslint/eslint-plugin': 5.36.1_lbwfnm54o3pmr3ypeqp3btnera
'@typescript-eslint/parser': 5.36.1_yqf6kl63nyoq5megxukfnom5rm '@typescript-eslint/parser': 5.36.1_yqf6kl63nyoq5megxukfnom5rm
@ -68,6 +76,8 @@ devDependencies:
husky: 8.0.1 husky: 8.0.1
jsdom: 20.0.0 jsdom: 20.0.0
prettier: 2.7.1 prettier: 2.7.1
react: 18.2.0
react-dom: 18.2.0_react@18.2.0
release-it: 15.4.1 release-it: 15.4.1
tsm: 2.2.2 tsm: 2.2.2
tsup: 6.2.3_typescript@4.8.2 tsup: 6.2.3_typescript@4.8.2
@ -648,6 +658,20 @@ packages:
pretty-format: 27.5.1 pretty-format: 27.5.1
dev: true 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: /@tootallnate/once/1.1.2:
resolution: {integrity: sha512-RbzJvlNzmRq5c3O09UipeuXno4tA1FE6ikOjxZK0tuxVv3412l64l5t1W5pj4+rJq9vpkm/kwiR07aZXnsKPxw==} resolution: {integrity: sha512-RbzJvlNzmRq5c3O09UipeuXno4tA1FE6ikOjxZK0tuxVv3412l64l5t1W5pj4+rJq9vpkm/kwiR07aZXnsKPxw==}
engines: {node: '>= 6'} engines: {node: '>= 6'}
@ -724,12 +748,48 @@ packages:
resolution: {integrity: sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA==} resolution: {integrity: sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA==}
dev: true 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: /@types/responselike/1.0.0:
resolution: {integrity: sha512-85Y2BjiufFzaMIlvJDvTTB8Fxl2xfLo4HgmHzVBz08w4wDePCTjYw66PdrolO0kzli3yam/YCgRufyo1DdQVTA==} resolution: {integrity: sha512-85Y2BjiufFzaMIlvJDvTTB8Fxl2xfLo4HgmHzVBz08w4wDePCTjYw66PdrolO0kzli3yam/YCgRufyo1DdQVTA==}
dependencies: dependencies:
'@types/node': 18.7.15 '@types/node': 18.7.15
dev: true dev: true
/@types/scheduler/0.16.2:
resolution: {integrity: sha512-hppQEBDmlwhFAXKJX2KnWLYu5yMfi91yazPb2l+lbJiwW+wdo1gNeRA+3RgNSO39WYX2euey41KEwnqesU2Jew==}
dev: true
/@types/uuid/8.3.4: /@types/uuid/8.3.4:
resolution: {integrity: sha512-c/I8ZRb51j+pYGAu5CrFMRxqZ2ke4y2grEBO5AUjgSkSk+qT2Ea+OdWElz/OiMf5MNpn2b17kuVBwZLQJXzihw==} resolution: {integrity: sha512-c/I8ZRb51j+pYGAu5CrFMRxqZ2ke4y2grEBO5AUjgSkSk+qT2Ea+OdWElz/OiMf5MNpn2b17kuVBwZLQJXzihw==}
dev: true dev: true
@ -1457,6 +1517,10 @@ packages:
cssom: 0.3.8 cssom: 0.3.8
dev: true dev: true
/csstype/3.1.0:
resolution: {integrity: sha512-uX1KG+x9h5hIJsaKR9xHUeUraxf8IODOwq9JLNPq6BwB04a/xgpq3rcx47l5BZu5zBPlgD342tdke3Hom/nJRA==}
dev: true
/damerau-levenshtein/1.0.8: /damerau-levenshtein/1.0.8:
resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==} resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==}
dev: true dev: true
@ -4205,6 +4269,16 @@ packages:
strip-json-comments: 2.0.1 strip-json-comments: 2.0.1
dev: true 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: /react-is/16.13.1:
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
dev: true dev: true
@ -4218,6 +4292,13 @@ packages:
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
dev: true 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: /readable-stream/1.1.14:
resolution: {integrity: sha512-+MeVjFf4L44XUkhM1eYbD8fyEsxcV81pqMSR5gblfcLCHfZvbrqy4/qYHE+/R5HoBUT11WV5O08Cr1n3YXkWVQ==} resolution: {integrity: sha512-+MeVjFf4L44XUkhM1eYbD8fyEsxcV81pqMSR5gblfcLCHfZvbrqy4/qYHE+/R5HoBUT11WV5O08Cr1n3YXkWVQ==}
dependencies: dependencies:
@ -4442,6 +4523,12 @@ packages:
xmlchars: 2.2.0 xmlchars: 2.2.0
dev: true 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: /semver-diff/4.0.0:
resolution: {integrity: sha512-0Ju4+6A8iOnpL/Thra7dZsSlOHYAHIeMxfhWQRI1/VLcT3WDBZKKtQt/QkBOsiIN9ZpuvHE6cGZ0x4glCMmfiA==} resolution: {integrity: sha512-0Ju4+6A8iOnpL/Thra7dZsSlOHYAHIeMxfhWQRI1/VLcT3WDBZKKtQt/QkBOsiIN9ZpuvHE6cGZ0x4glCMmfiA==}
engines: {node: '>=12'} engines: {node: '>=12'}

View 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",
});
});
});
});

View 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 };
};

View 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",
});
});
});

View file

@ -67,3 +67,40 @@ export type PayloadOfEvent<
TEvent extends Events = Events TEvent extends Events = Events
// @ts-ignore TODO - why this is not working with this tsconfig? Fixme // @ts-ignore TODO - why this is not working with this tsconfig? Fixme
> = TEvent extends Event<TEventType, unknown> ? TEvent["payload"] : never; > = 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,
},
};
},
};

View file

@ -3,6 +3,7 @@ import { AppBridge } from "./app-bridge";
export { AppBridge }; export { AppBridge };
export * from "./actions"; export * from "./actions";
export * from "./app-bridge-provider";
export * from "./events"; export * from "./events";
export * from "./types"; export * from "./types";