Add docs and test

This commit is contained in:
Lukasz Ostrowski 2022-08-22 15:47:40 +02:00
parent c4d672683c
commit 683f19b5c0
5 changed files with 158 additions and 26 deletions

View file

@ -21,6 +21,10 @@ Feel free to play with SDK and to move its code to your app directly
npm i @saleor/app-sdk npm i @saleor/app-sdk
``` ```
## Docs
- [AppBridge](./docs/app-bridge.md) - communication layer between Saleor App and Dashboard
## Development ## Development
### How to link development version to your project ### How to link development version to your project

109
docs/app-bridge.md Normal file
View file

@ -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 | |

View file

@ -16,7 +16,7 @@ Object.defineProperty(window, "location", {
}); });
// eslint-disable-next-line // eslint-disable-next-line
import { actions, DispatchResponseEvent, createApp, HandshakeEvent } from "."; import { actions, DispatchResponseEvent, HandshakeEvent, AppBridge } from ".";
const handshakeEvent: HandshakeEvent = { const handshakeEvent: HandshakeEvent = {
payload: { payload: {
@ -26,20 +26,20 @@ const handshakeEvent: HandshakeEvent = {
type: "handshake", type: "handshake",
}; };
describe("createApp", () => { describe("AppBridge", () => {
const domain = "saleor.domain.host"; const domain = "saleor.domain.host";
let app = createApp(domain); let appBridge = new AppBridge();
beforeEach(() => { beforeEach(() => {
app = createApp(domain); appBridge = new AppBridge();
}); });
it("correctly sets the domain", () => { it("correctly sets the default domain, if not set in constructor", () => {
expect(app.getState().domain).toEqual(domain); expect(appBridge.getState().domain).toEqual(domain);
}); });
it("authenticates", () => { it("authenticates", () => {
expect(app.getState().ready).toBe(false); expect(appBridge.getState().ready).toBe(false);
const token = "test-token"; const token = "test-token";
fireEvent( fireEvent(
@ -50,13 +50,13 @@ describe("createApp", () => {
}) })
); );
expect(app.getState().ready).toBe(true); expect(appBridge.getState().ready).toBe(true);
expect(app.getState().token).toEqual(token); expect(appBridge.getState().token).toEqual(token);
}); });
it("subscribes to an event and returns unsubscribe function", () => { it("subscribes to an event and returns unsubscribe function", () => {
const callback = vi.fn(); const callback = vi.fn();
const unsubscribe = app.subscribe("handshake", callback); const unsubscribe = appBridge.subscribe("handshake", callback);
expect(callback).not.toHaveBeenCalled(); expect(callback).not.toHaveBeenCalled();
@ -91,8 +91,8 @@ describe("createApp", () => {
expect(callback).toHaveBeenCalledTimes(1); expect(callback).toHaveBeenCalledTimes(1);
expect(callback).toHaveBeenCalledWith(handshakeEvent.payload); expect(callback).toHaveBeenCalledWith(handshakeEvent.payload);
expect(app.getState().token).toEqual(handshakeEvent.payload.token); expect(appBridge.getState().token).toEqual(handshakeEvent.payload.token);
expect(app.getState().id).toEqual("appid"); expect(appBridge.getState().id).toEqual("appid");
unsubscribe(); unsubscribe();
@ -105,11 +105,11 @@ describe("createApp", () => {
); );
expect(callback).toHaveBeenCalledTimes(1); expect(callback).toHaveBeenCalledTimes(1);
expect(app.getState().token).toEqual("123"); expect(appBridge.getState().token).toEqual("123");
}); });
it("persists domain", () => { it("persists domain", () => {
expect(app.getState().domain).toEqual(domain); expect(appBridge.getState().domain).toEqual(domain);
}); });
it("dispatches valid action", () => { 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", () => 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", () => { it("unsubscribes from all listeners", () => {
const cb1 = vi.fn(); const cb1 = vi.fn();
const cb2 = vi.fn(); const cb2 = vi.fn();
app.subscribe("handshake", cb1); appBridge.subscribe("handshake", cb1);
app.subscribe("handshake", cb2); appBridge.subscribe("handshake", cb2);
fireEvent( fireEvent(
window, window,
@ -155,7 +155,7 @@ describe("createApp", () => {
expect(cb1).toHaveBeenCalledTimes(1); expect(cb1).toHaveBeenCalledTimes(1);
expect(cb2).toHaveBeenCalledTimes(1); expect(cb2).toHaveBeenCalledTimes(1);
app.unsubscribeAll(); appBridge.unsubscribeAll();
fireEvent( fireEvent(
window, window,
@ -168,4 +168,12 @@ describe("createApp", () => {
expect(cb1).toHaveBeenCalledTimes(1); expect(cb1).toHaveBeenCalledTimes(1);
expect(cb2).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");
});
}); });

View file

@ -51,6 +51,14 @@ const createEmptySubscribeMap = (): SubscribeMap => ({
theme: {}, theme: {},
}); });
export type AppBridgeOptions = {
targetDomain?: string;
};
const getDefaultOptions = (): AppBridgeOptions => ({
targetDomain: new URL(window.location.href).searchParams.get("domain") || "",
});
export class AppBridge { export class AppBridge {
private state = new AppBridgeStateContainer(); private state = new AppBridgeStateContainer();
@ -58,16 +66,19 @@ export class AppBridge {
private subscribeMap = createEmptySubscribeMap(); private subscribeMap = createEmptySubscribeMap();
constructor(private targetDomain?: string) { private combinedOptions = getDefaultOptions();
constructor(options: AppBridgeOptions = {}) {
if (SSR) { if (SSR) {
throw new Error( throw new Error(
"AppBridge detected you're running this app in SSR mode. Make sure to call `new AppBridge()` when window object exists." "AppBridge detected you're running this app in SSR mode. Make sure to call `new AppBridge()` when window object exists."
); );
} }
if (!targetDomain) { this.combinedOptions = {
this.targetDomain = new URL(window.location.href).searchParams.get("domain") || ""; ...this.combinedOptions,
} ...options,
};
if (!this.refererOrigin) { if (!this.refererOrigin) {
// TODO probably throw // TODO probably throw
@ -168,7 +179,7 @@ export class AppBridge {
const path = window.location.pathname || ""; const path = window.location.pathname || "";
const theme: ThemeType = url.searchParams.get("theme") === "light" ? "light" : "dark"; 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() { private listenOnMessages() {

View file

@ -7,7 +7,7 @@ export * from "./events";
export * from "./types"; 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; export default createApp;