From 683f19b5c08ab15844c417482a977875a54bb77a Mon Sep 17 00:00:00 2001 From: Lukasz Ostrowski Date: Mon, 22 Aug 2022 15:47:40 +0200 Subject: [PATCH] Add docs and test --- README.md | 4 ++ docs/app-bridge.md | 109 ++++++++++++++++++++++++++++++ src/app-bridge/app-bridge.test.ts | 46 +++++++------ src/app-bridge/app-bridge.ts | 21 ++++-- src/app-bridge/index.ts | 4 +- 5 files changed, 158 insertions(+), 26 deletions(-) create mode 100644 docs/app-bridge.md diff --git a/README.md b/README.md index 52c4c34..a2c84a5 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,10 @@ Feel free to play with SDK and to move its code to your app directly npm i @saleor/app-sdk ``` +## Docs + +- [AppBridge](./docs/app-bridge.md) - communication layer between Saleor App and Dashboard + ## Development ### How to link development version to your project diff --git a/docs/app-bridge.md b/docs/app-bridge.md new file mode 100644 index 0000000..06eb436 --- /dev/null +++ b/docs/app-bridge.md @@ -0,0 +1,109 @@ +# AppBridge + +App bridge is an interface that connects App (running inside Dashboard) with Dashboard itself. + +## Setup + +Create instance of AppBridge by running following code + +```js +import { AppBridge } from "@saleor/app-sdk/app-bridge"; + +const appBridge = new AppBridge(options); +``` + +## Access app state: + +```js +const { token, domain, ready, id } = appBridge.getState(); +``` + +Available state represents `AppBridgeState`: + +```typescript +type AppBridgeState = { + token?: string; + id: string; + ready: boolean; + domain: string; + path: string; + theme: ThemeType; +}; +``` + +## Events + +Events are messages that originate in Saleor Dashboard. AppBridge can subscribe on events and app can react on them + +### Subscribing to events + +`subscribe(eventType, callback)` - can be used to listen to particular [event type](#available-event-types). It returns an unsubscribe function, which unregisters the callback. + +Example: + +```typescript +const unsubscribe = appBridge.subscribe("handshake", (payload) => { + setToken(payload.token); // do something with event payload + + const { token } = appState.getState(); // you can also get app's current state here +}); + +// unsubscribe when callback is no longer needed +unsubscribe(); +``` + +### Unsubscribing multiple listeners + +`unsubscribeAll(eventType?)` - unregisters all callbacks of provided type. If no type was provided, it will remove all event callbacks. + +Example: + +```js +// unsubscribe from all handshake events +appBridge.unsubscribeAll("handshake"); + +// unsubscribe from all events +appBridge.unsubscribeAll(); +``` + +### Available event types + +| Event type | Description | +| :---------- | :--------------------------------------------------------------------------- | +| `handshake` | Fired when iFrame containing the App is initialized or new token is assigned | +| `response` | Fired when Dashboard responds to an Action | +| `redirect` | Fired when Dashboard changes a subpath within the app path | +| `theme` | Fired when Dashboard changes the theme | + +## Actions + +Actions expose a high-level API to communicate with Saleor Dashboard. They're exported under an `actions` namespace. + +### Available methods + +**`dispatch(action)`** - dispatches an Action. Returns a promise which resolves when action is successfully completed. + +Example: + +```js +import { actions } from "@saleor/app-sdk/app-bridge"; + +const handleRedirect = async () => { + await appBridge.dispatch(actions.Redirect({ to: "/orders" })); + + console.log("Redirect complete!"); +}; + +handleRedirect(); +``` + +### Available actions + +| Action | Arguments | Description | +| :------------- | :--------------------------------------------------------------- | :---------- | +| `Redirect` | `to` (string) - relative (inside Dashboard) or absolute URL path | | +| | `newContext` (boolean) - should open in a new browsing context | | +| `Notification` | `status` (`info` / `success` / `warning` / `error` / undefined) | | +| | `title` (string / undefined) - title of the notification | | +| | `text` (string / undefined) - content of the notification | | +| | `apiMessage` (string / undefined) - error log from api | | diff --git a/src/app-bridge/app-bridge.test.ts b/src/app-bridge/app-bridge.test.ts index 295d5f6..e4d01b3 100644 --- a/src/app-bridge/app-bridge.test.ts +++ b/src/app-bridge/app-bridge.test.ts @@ -16,7 +16,7 @@ Object.defineProperty(window, "location", { }); // eslint-disable-next-line -import { actions, DispatchResponseEvent, createApp, HandshakeEvent } from "."; +import { actions, DispatchResponseEvent, HandshakeEvent, AppBridge } from "."; const handshakeEvent: HandshakeEvent = { payload: { @@ -26,20 +26,20 @@ const handshakeEvent: HandshakeEvent = { type: "handshake", }; -describe("createApp", () => { +describe("AppBridge", () => { const domain = "saleor.domain.host"; - let app = createApp(domain); + let appBridge = new AppBridge(); beforeEach(() => { - app = createApp(domain); + appBridge = new AppBridge(); }); - it("correctly sets the domain", () => { - expect(app.getState().domain).toEqual(domain); + it("correctly sets the default domain, if not set in constructor", () => { + expect(appBridge.getState().domain).toEqual(domain); }); it("authenticates", () => { - expect(app.getState().ready).toBe(false); + expect(appBridge.getState().ready).toBe(false); const token = "test-token"; fireEvent( @@ -50,13 +50,13 @@ describe("createApp", () => { }) ); - expect(app.getState().ready).toBe(true); - expect(app.getState().token).toEqual(token); + expect(appBridge.getState().ready).toBe(true); + expect(appBridge.getState().token).toEqual(token); }); it("subscribes to an event and returns unsubscribe function", () => { const callback = vi.fn(); - const unsubscribe = app.subscribe("handshake", callback); + const unsubscribe = appBridge.subscribe("handshake", callback); expect(callback).not.toHaveBeenCalled(); @@ -91,8 +91,8 @@ describe("createApp", () => { expect(callback).toHaveBeenCalledTimes(1); expect(callback).toHaveBeenCalledWith(handshakeEvent.payload); - expect(app.getState().token).toEqual(handshakeEvent.payload.token); - expect(app.getState().id).toEqual("appid"); + expect(appBridge.getState().token).toEqual(handshakeEvent.payload.token); + expect(appBridge.getState().id).toEqual("appid"); unsubscribe(); @@ -105,11 +105,11 @@ describe("createApp", () => { ); expect(callback).toHaveBeenCalledTimes(1); - expect(app.getState().token).toEqual("123"); + expect(appBridge.getState().token).toEqual("123"); }); it("persists domain", () => { - expect(app.getState().domain).toEqual(domain); + expect(appBridge.getState().domain).toEqual(domain); }); it("dispatches valid action", () => { @@ -131,18 +131,18 @@ describe("createApp", () => { } }); - return expect(app.dispatch(action)).resolves.toBeUndefined(); + return expect(appBridge.dispatch(action)).resolves.toBeUndefined(); }); it("times out after action response has not been registered", () => - expect(app.dispatch(actions.Redirect({ to: "/test" }))).rejects.toBeInstanceOf(Error)); + expect(appBridge.dispatch(actions.Redirect({ to: "/test" }))).rejects.toBeInstanceOf(Error)); it("unsubscribes from all listeners", () => { const cb1 = vi.fn(); const cb2 = vi.fn(); - app.subscribe("handshake", cb1); - app.subscribe("handshake", cb2); + appBridge.subscribe("handshake", cb1); + appBridge.subscribe("handshake", cb2); fireEvent( window, @@ -155,7 +155,7 @@ describe("createApp", () => { expect(cb1).toHaveBeenCalledTimes(1); expect(cb2).toHaveBeenCalledTimes(1); - app.unsubscribeAll(); + appBridge.unsubscribeAll(); fireEvent( window, @@ -168,4 +168,12 @@ describe("createApp", () => { expect(cb1).toHaveBeenCalledTimes(1); expect(cb2).toHaveBeenCalledTimes(1); }); + + it("attaches domain from options in constructor", () => { + appBridge = new AppBridge({ + targetDomain: "https://foo.bar", + }); + + expect(appBridge.getState().domain).toEqual("https://foo.bar"); + }); }); diff --git a/src/app-bridge/app-bridge.ts b/src/app-bridge/app-bridge.ts index 36b6287..087f0f0 100644 --- a/src/app-bridge/app-bridge.ts +++ b/src/app-bridge/app-bridge.ts @@ -51,6 +51,14 @@ const createEmptySubscribeMap = (): SubscribeMap => ({ theme: {}, }); +export type AppBridgeOptions = { + targetDomain?: string; +}; + +const getDefaultOptions = (): AppBridgeOptions => ({ + targetDomain: new URL(window.location.href).searchParams.get("domain") || "", +}); + export class AppBridge { private state = new AppBridgeStateContainer(); @@ -58,16 +66,19 @@ export class AppBridge { private subscribeMap = createEmptySubscribeMap(); - constructor(private targetDomain?: string) { + private combinedOptions = getDefaultOptions(); + + constructor(options: AppBridgeOptions = {}) { if (SSR) { throw new Error( "AppBridge detected you're running this app in SSR mode. Make sure to call `new AppBridge()` when window object exists." ); } - if (!targetDomain) { - this.targetDomain = new URL(window.location.href).searchParams.get("domain") || ""; - } + this.combinedOptions = { + ...this.combinedOptions, + ...options, + }; if (!this.refererOrigin) { // TODO probably throw @@ -168,7 +179,7 @@ export class AppBridge { const path = window.location.pathname || ""; const theme: ThemeType = url.searchParams.get("theme") === "light" ? "light" : "dark"; - this.state.setState({ domain: this.targetDomain, id, path, theme }); + this.state.setState({ domain: this.combinedOptions.targetDomain, id, path, theme }); } private listenOnMessages() { diff --git a/src/app-bridge/index.ts b/src/app-bridge/index.ts index 128b0ab..654bd60 100644 --- a/src/app-bridge/index.ts +++ b/src/app-bridge/index.ts @@ -7,7 +7,7 @@ export * from "./events"; export * from "./types"; /** - * @deprecated use new AppBridge() + * @deprecated use new AppBridge(), createApp will be removed */ -export const createApp = (targetDomain?: string) => new AppBridge(targetDomain); +export const createApp = (targetDomain?: string) => new AppBridge({ targetDomain }); export default createApp;