Refactor app bridge state container
This commit is contained in:
parent
af68bedfb3
commit
983dba1126
6 changed files with 218 additions and 176 deletions
|
@ -7,6 +7,7 @@ export const ActionType = {
|
||||||
redirect: "redirect",
|
redirect: "redirect",
|
||||||
notification: "notification",
|
notification: "notification",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export type ActionType = Values<typeof ActionType>;
|
export type ActionType = Values<typeof ActionType>;
|
||||||
|
|
||||||
type Action<Name extends ActionType, Payload extends {}> = {
|
type Action<Name extends ActionType, Payload extends {}> = {
|
||||||
|
@ -44,7 +45,8 @@ export type RedirectPayload = {
|
||||||
* Redirects Dashboard user.
|
* Redirects Dashboard user.
|
||||||
*/
|
*/
|
||||||
export type RedirectAction = ActionWithId<"redirect", RedirectPayload>;
|
export type RedirectAction = ActionWithId<"redirect", RedirectPayload>;
|
||||||
function Redirect(payload: RedirectPayload): RedirectAction {
|
|
||||||
|
function createRedirectAction(payload: RedirectPayload): RedirectAction {
|
||||||
return withActionId({
|
return withActionId({
|
||||||
payload,
|
payload,
|
||||||
type: "redirect",
|
type: "redirect",
|
||||||
|
@ -65,7 +67,7 @@ export type NotificationAction = ActionWithId<"notification", NotificationPayloa
|
||||||
/**
|
/**
|
||||||
* Shows a notification using Dashboard's notification system.
|
* Shows a notification using Dashboard's notification system.
|
||||||
*/
|
*/
|
||||||
function Notification(payload: NotificationPayload): NotificationAction {
|
function createNotificationAction(payload: NotificationPayload): NotificationAction {
|
||||||
return withActionId({
|
return withActionId({
|
||||||
type: "notification",
|
type: "notification",
|
||||||
payload,
|
payload,
|
||||||
|
@ -75,6 +77,6 @@ function Notification(payload: NotificationPayload): NotificationAction {
|
||||||
export type Actions = RedirectAction | NotificationAction;
|
export type Actions = RedirectAction | NotificationAction;
|
||||||
|
|
||||||
export const actions = {
|
export const actions = {
|
||||||
Redirect,
|
Redirect: createRedirectAction,
|
||||||
Notification,
|
Notification: createNotificationAction,
|
||||||
};
|
};
|
||||||
|
|
32
src/app-bridge/app-bridge-state.test.ts
Normal file
32
src/app-bridge/app-bridge-state.test.ts
Normal file
|
@ -0,0 +1,32 @@
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { AppBridgeState, AppBridgeStateContainer } from "./app-bridge-state";
|
||||||
|
|
||||||
|
describe("app-bridge-state.ts", () => {
|
||||||
|
it("Creates with default state", () => {
|
||||||
|
const instance = new AppBridgeStateContainer();
|
||||||
|
|
||||||
|
expect(instance.getState()).toEqual({
|
||||||
|
id: "",
|
||||||
|
domain: "",
|
||||||
|
ready: false,
|
||||||
|
path: "/",
|
||||||
|
theme: "light",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Can update state", () => {
|
||||||
|
const instance = new AppBridgeStateContainer();
|
||||||
|
|
||||||
|
const newState: Partial<AppBridgeState> = {
|
||||||
|
domain: "https://my-saleor-instance.cloud",
|
||||||
|
id: "foo-bar",
|
||||||
|
path: "/",
|
||||||
|
theme: "light",
|
||||||
|
};
|
||||||
|
|
||||||
|
instance.setState(newState);
|
||||||
|
|
||||||
|
expect(instance.getState()).toEqual(expect.objectContaining(newState));
|
||||||
|
});
|
||||||
|
});
|
160
src/app-bridge/app-bridge-state.ts
Normal file
160
src/app-bridge/app-bridge-state.ts
Normal file
|
@ -0,0 +1,160 @@
|
||||||
|
import { Events, EventType, PayloadOfEvent, ThemeType } from "./events";
|
||||||
|
|
||||||
|
export type AppBridgeState = {
|
||||||
|
token?: string;
|
||||||
|
id: string;
|
||||||
|
ready: boolean;
|
||||||
|
domain: string;
|
||||||
|
path: string;
|
||||||
|
theme: ThemeType;
|
||||||
|
};
|
||||||
|
type EventCallback<TPayload extends {} = {}> = (data: TPayload) => void;
|
||||||
|
type SubscribeMap = {
|
||||||
|
[type in EventType]: Record<symbol, EventCallback<PayloadOfEvent<type>>>;
|
||||||
|
};
|
||||||
|
|
||||||
|
function reducer(state: AppBridgeState, event: Events) {
|
||||||
|
switch (event.type) {
|
||||||
|
case EventType.handshake: {
|
||||||
|
const newState = {
|
||||||
|
...state,
|
||||||
|
ready: true,
|
||||||
|
token: event.payload.token,
|
||||||
|
};
|
||||||
|
|
||||||
|
return newState;
|
||||||
|
}
|
||||||
|
case EventType.redirect: {
|
||||||
|
const newState = {
|
||||||
|
...state,
|
||||||
|
path: event.payload.path,
|
||||||
|
};
|
||||||
|
|
||||||
|
return newState;
|
||||||
|
}
|
||||||
|
case EventType.theme: {
|
||||||
|
const newState = {
|
||||||
|
...state,
|
||||||
|
theme: event.payload.theme,
|
||||||
|
};
|
||||||
|
|
||||||
|
return newState;
|
||||||
|
}
|
||||||
|
default: {
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AppBridgeStateContainer {
|
||||||
|
private state: AppBridgeState = {
|
||||||
|
id: "",
|
||||||
|
domain: "",
|
||||||
|
ready: false,
|
||||||
|
path: "/",
|
||||||
|
theme: "light",
|
||||||
|
};
|
||||||
|
|
||||||
|
private subscribeMap: SubscribeMap = {
|
||||||
|
handshake: {},
|
||||||
|
response: {},
|
||||||
|
redirect: {},
|
||||||
|
theme: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
private refererOrigin: string | null = null;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
try {
|
||||||
|
this.refererOrigin = new URL(document.referrer).origin;
|
||||||
|
} catch (e) {
|
||||||
|
// TODO probably throw
|
||||||
|
console.warn("document.referrer is empty");
|
||||||
|
}
|
||||||
|
|
||||||
|
this.listenOnMessages();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TODO Move to higher instance
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
private listenOnMessages() {
|
||||||
|
window.addEventListener(
|
||||||
|
"message",
|
||||||
|
({ origin, data }: Omit<MessageEvent, "data"> & { data: Events }) => {
|
||||||
|
// check if event origin matches the document referer
|
||||||
|
if (origin !== this.refererOrigin) {
|
||||||
|
// TODO what should happen here - be explicit
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.state = reducer(this.state, data);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TODO Validate and warn/throw
|
||||||
|
*/
|
||||||
|
const { type, payload } = data;
|
||||||
|
|
||||||
|
if (EventType[type]) {
|
||||||
|
Object.getOwnPropertySymbols(this.subscribeMap[type]).forEach((key) =>
|
||||||
|
// @ts-ignore fixme
|
||||||
|
this.subscribeMap[type][key](payload)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscribes to an Event.
|
||||||
|
*
|
||||||
|
* @param eventType - Event type.
|
||||||
|
* @param cb - Callback that executes when Event is registered. Called with Event payload object.
|
||||||
|
* @returns Unsubscribe function. Call to unregister the callback.
|
||||||
|
*/
|
||||||
|
subscribe<TEventType extends EventType, TPayload extends PayloadOfEvent<TEventType>>(
|
||||||
|
eventType: TEventType,
|
||||||
|
cb: EventCallback<TPayload>
|
||||||
|
) {
|
||||||
|
const key = Symbol("Callback token");
|
||||||
|
// @ts-ignore fixme
|
||||||
|
this.subscribeMap[eventType][key] = cb;
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
delete this.subscribeMap[eventType][key];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unsubscribe to all Events of type.
|
||||||
|
* If type not provider, unsubscribe all
|
||||||
|
*
|
||||||
|
* @param eventType - (optional) Event type. If empty, all callbacks will be unsubscribed.
|
||||||
|
*/
|
||||||
|
unsubscribeAll(eventType?: EventType) {
|
||||||
|
if (eventType) {
|
||||||
|
this.subscribeMap[eventType] = {};
|
||||||
|
} else {
|
||||||
|
this.subscribeMap = {
|
||||||
|
handshake: {},
|
||||||
|
response: {},
|
||||||
|
redirect: {},
|
||||||
|
theme: {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getState() {
|
||||||
|
return this.state;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(newState: Partial<AppBridgeState>) {
|
||||||
|
this.state = {
|
||||||
|
...this.state,
|
||||||
|
...newState,
|
||||||
|
};
|
||||||
|
|
||||||
|
return this.state;
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,165 +0,0 @@
|
||||||
import { SSR } from "./constants";
|
|
||||||
import { Events, EventType, PayloadOfEvent, ThemeType } from "./events";
|
|
||||||
|
|
||||||
export type AppBridgeState = {
|
|
||||||
token?: string;
|
|
||||||
id: string;
|
|
||||||
ready: boolean;
|
|
||||||
domain: string;
|
|
||||||
path: string;
|
|
||||||
theme: ThemeType;
|
|
||||||
};
|
|
||||||
type EventCallback<TPayload extends {} = {}> = (data: TPayload) => void;
|
|
||||||
type SubscribeMap = {
|
|
||||||
[type in EventType]: Record<any, EventCallback<PayloadOfEvent<type>>>;
|
|
||||||
};
|
|
||||||
|
|
||||||
function reducer(state: AppBridgeState, event: Events) {
|
|
||||||
switch (event.type) {
|
|
||||||
case EventType.handshake: {
|
|
||||||
const newState = {
|
|
||||||
...state,
|
|
||||||
ready: true,
|
|
||||||
token: event.payload.token,
|
|
||||||
};
|
|
||||||
|
|
||||||
return newState;
|
|
||||||
}
|
|
||||||
case EventType.redirect: {
|
|
||||||
const newState = {
|
|
||||||
...state,
|
|
||||||
path: event.payload.path,
|
|
||||||
};
|
|
||||||
|
|
||||||
return newState;
|
|
||||||
}
|
|
||||||
case EventType.theme: {
|
|
||||||
const newState = {
|
|
||||||
...state,
|
|
||||||
theme: event.payload.theme,
|
|
||||||
};
|
|
||||||
|
|
||||||
return newState;
|
|
||||||
}
|
|
||||||
default: {
|
|
||||||
return state;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const app = (() => {
|
|
||||||
if (SSR) {
|
|
||||||
console.warn(
|
|
||||||
"@saleor/app-bridge detected you're running this app in SSR mode. Make sure to call `createApp` when window object exists."
|
|
||||||
);
|
|
||||||
return null as never;
|
|
||||||
}
|
|
||||||
|
|
||||||
let state: AppBridgeState = {
|
|
||||||
id: "",
|
|
||||||
domain: "",
|
|
||||||
ready: false,
|
|
||||||
path: "/",
|
|
||||||
theme: "light",
|
|
||||||
};
|
|
||||||
let subscribeMap: SubscribeMap = {
|
|
||||||
handshake: {},
|
|
||||||
response: {},
|
|
||||||
redirect: {},
|
|
||||||
theme: {},
|
|
||||||
};
|
|
||||||
let refererOrigin: string;
|
|
||||||
|
|
||||||
try {
|
|
||||||
refererOrigin = new URL(document.referrer).origin;
|
|
||||||
} catch (e) {
|
|
||||||
console.warn("document.referrer is empty");
|
|
||||||
}
|
|
||||||
|
|
||||||
window.addEventListener(
|
|
||||||
"message",
|
|
||||||
// Generic MessageEvent is not supported by tsdx's TS version
|
|
||||||
({ origin, data }: Omit<MessageEvent, "data"> & { data: Events }) => {
|
|
||||||
// check if event origin matches the document referer
|
|
||||||
if (origin !== refererOrigin) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// compute new state
|
|
||||||
state = reducer(state, data);
|
|
||||||
|
|
||||||
// run callbacks
|
|
||||||
const { type, payload } = data;
|
|
||||||
// We know this is valid object, su supress
|
|
||||||
// eslint-disable-next-line no-prototype-builtins
|
|
||||||
if (EventType.hasOwnProperty(type)) {
|
|
||||||
Object.getOwnPropertySymbols(subscribeMap[type]).forEach((key) =>
|
|
||||||
// @ts-ignore
|
|
||||||
subscribeMap[type][key](payload)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Subscribes to an Event.
|
|
||||||
*
|
|
||||||
* @param eventType - Event type.
|
|
||||||
* @param cb - Callback that executes when Event is registered. Called with Event payload object.
|
|
||||||
* @returns Unsubscribe function. Call to unregister the callback.
|
|
||||||
*/
|
|
||||||
function subscribe<TEventType extends EventType, TPayload extends PayloadOfEvent<TEventType>>(
|
|
||||||
eventType: TEventType,
|
|
||||||
cb: EventCallback<TPayload>
|
|
||||||
) {
|
|
||||||
const key = Symbol("Callback token") as unknown as string; // https://github.com/Microsoft/TypeScript/issues/24587
|
|
||||||
// @ts-ignore
|
|
||||||
subscribeMap[eventType][key] = cb;
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
delete subscribeMap[eventType][key];
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Unsubscribe to all Events of type.
|
|
||||||
*
|
|
||||||
* @param eventType - (optional) Event type. If empty, all callbacks will be unsubscribed.
|
|
||||||
*/
|
|
||||||
function unsubscribeAll(eventType?: EventType) {
|
|
||||||
if (eventType) {
|
|
||||||
subscribeMap[eventType] = {};
|
|
||||||
} else {
|
|
||||||
subscribeMap = {
|
|
||||||
handshake: {},
|
|
||||||
response: {},
|
|
||||||
redirect: {},
|
|
||||||
theme: {},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Gets current state.
|
|
||||||
*
|
|
||||||
* @returns State object.
|
|
||||||
*/
|
|
||||||
function getState() {
|
|
||||||
return state;
|
|
||||||
}
|
|
||||||
|
|
||||||
function setState(newState: Partial<AppBridgeState>) {
|
|
||||||
state = {
|
|
||||||
...state,
|
|
||||||
...newState,
|
|
||||||
};
|
|
||||||
|
|
||||||
return state;
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
subscribe,
|
|
||||||
unsubscribeAll,
|
|
||||||
getState,
|
|
||||||
setState,
|
|
||||||
};
|
|
||||||
})();
|
|
|
@ -1,8 +1,17 @@
|
||||||
import { Actions } from "./actions";
|
import { Actions } from "./actions";
|
||||||
import { app } from "./app";
|
import { AppBridgeStateContainer } from "./app-bridge-state";
|
||||||
|
import { SSR } from "./constants";
|
||||||
import { EventType, ThemeType } from "./events";
|
import { EventType, ThemeType } from "./events";
|
||||||
|
|
||||||
export function createApp(targetDomain?: string) {
|
export function createApp(targetDomain?: string) {
|
||||||
|
const appBridgeState = new AppBridgeStateContainer();
|
||||||
|
|
||||||
|
if (SSR) {
|
||||||
|
throw new Error(
|
||||||
|
"AppBridge detected you're running this app in SSR mode. Make sure to call `createApp` when window object exists."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
let domain: string;
|
let domain: string;
|
||||||
const url = new URL(window.location.href);
|
const url = new URL(window.location.href);
|
||||||
const id = url.searchParams.get("id") || "";
|
const id = url.searchParams.get("id") || "";
|
||||||
|
@ -15,7 +24,7 @@ export function createApp(targetDomain?: string) {
|
||||||
domain = url.searchParams.get("domain") || "";
|
domain = url.searchParams.get("domain") || "";
|
||||||
}
|
}
|
||||||
|
|
||||||
app.setState({ domain, id, path, theme });
|
appBridgeState.setState({ domain, id, path, theme });
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Dispatches Action to Saleor Dashboard.
|
* Dispatches Action to Saleor Dashboard.
|
||||||
|
@ -36,7 +45,7 @@ export function createApp(targetDomain?: string) {
|
||||||
|
|
||||||
let intervalId: NodeJS.Timer;
|
let intervalId: NodeJS.Timer;
|
||||||
|
|
||||||
const unsubscribe = app.subscribe(EventType.response, ({ actionId, ok }) => {
|
const unsubscribe = appBridgeState.subscribe(EventType.response, ({ actionId, ok }) => {
|
||||||
if (action.payload.actionId === actionId) {
|
if (action.payload.actionId === actionId) {
|
||||||
unsubscribe();
|
unsubscribe();
|
||||||
clearInterval(intervalId);
|
clearInterval(intervalId);
|
||||||
|
@ -66,13 +75,17 @@ export function createApp(targetDomain?: string) {
|
||||||
|
|
||||||
return {
|
return {
|
||||||
dispatch,
|
dispatch,
|
||||||
subscribe: app.subscribe,
|
subscribe: appBridgeState.subscribe.bind(appBridgeState),
|
||||||
unsubscribeAll: app.unsubscribeAll,
|
unsubscribeAll: appBridgeState.unsubscribeAll.bind(appBridgeState),
|
||||||
getState: app.getState,
|
getState: appBridgeState.getState.bind(appBridgeState),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export * from "./actions";
|
export * from "./actions";
|
||||||
export * from "./events";
|
export * from "./events";
|
||||||
export * from "./types";
|
export * from "./types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated avoid default functions in SDKs
|
||||||
|
*/
|
||||||
export default createApp;
|
export default createApp;
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import { createApp } from ".";
|
import { createApp } from ".";
|
||||||
import { AppBridgeState } from "./app";
|
import { AppBridgeState } from "./app-bridge-state";
|
||||||
|
|
||||||
export type App = ReturnType<typeof createApp>;
|
export type App = ReturnType<typeof createApp>;
|
||||||
export { AppBridgeState };
|
export { AppBridgeState };
|
||||||
|
|
Loading…
Reference in a new issue