Improve AppPage (#3216)
* WIP - reduce rerenders and change how dashboard sends events to app * Cleanup * Refactor * Add loader behind the iframe * Fix linter
This commit is contained in:
parent
9097d4076e
commit
b9acfe6214
15 changed files with 219 additions and 109 deletions
|
@ -1,16 +1,14 @@
|
|||
import { useAppDashboardUpdates } from "@dashboard/apps/components/AppFrame/useAppDashboardUpdates";
|
||||
import {
|
||||
AppDetailsUrlQueryParams,
|
||||
getAppDeepPathFromDashboardUrl,
|
||||
prepareFeatureFlagsList,
|
||||
resolveAppIframeUrl,
|
||||
} from "@dashboard/apps/urls";
|
||||
import { useAllFlags } from "@dashboard/hooks/useFlags";
|
||||
import useLocale from "@dashboard/hooks/useLocale";
|
||||
import useShop from "@dashboard/hooks/useShop";
|
||||
import { CircularProgress } from "@material-ui/core";
|
||||
import { useTheme } from "@saleor/macaw-ui";
|
||||
import clsx from "clsx";
|
||||
import React, { useEffect } from "react";
|
||||
import { useLocation } from "react-router";
|
||||
import React from "react";
|
||||
|
||||
import { useStyles } from "./styles";
|
||||
import { useAppActions } from "./useAppActions";
|
||||
|
@ -39,46 +37,36 @@ export const AppFrame: React.FC<Props> = ({
|
|||
onError,
|
||||
refetch,
|
||||
}) => {
|
||||
const shop = useShop();
|
||||
const frameRef = React.useRef<HTMLIFrameElement>(null);
|
||||
const frameRef = React.useRef<HTMLIFrameElement | null>(null);
|
||||
const { themeType } = useTheme();
|
||||
const classes = useStyles();
|
||||
const appOrigin = getOrigin(src);
|
||||
const flags = useAllFlags();
|
||||
const { postToExtension } = useAppActions(frameRef, appOrigin, appId);
|
||||
const location = useLocation();
|
||||
const { locale } = useLocale();
|
||||
|
||||
useEffect(() => {
|
||||
postToExtension({
|
||||
type: "localeChanged",
|
||||
payload: {
|
||||
locale,
|
||||
},
|
||||
});
|
||||
}, [locale, postToExtension]);
|
||||
/**
|
||||
* React on messages from App
|
||||
*/
|
||||
const { postToExtension, handshakeDone, setHandshakeDone } = useAppActions(
|
||||
frameRef.current,
|
||||
appOrigin,
|
||||
appId,
|
||||
appToken,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
postToExtension({
|
||||
type: "theme",
|
||||
payload: {
|
||||
theme: themeType,
|
||||
},
|
||||
});
|
||||
}, [themeType, postToExtension]);
|
||||
|
||||
useEffect(() => {
|
||||
postToExtension({
|
||||
type: "redirect",
|
||||
payload: {
|
||||
path: getAppDeepPathFromDashboardUrl(location.pathname, appId),
|
||||
},
|
||||
});
|
||||
}, [location.pathname]);
|
||||
/**
|
||||
* Listen to Dashboard context like theme or locale and inform app about it
|
||||
*/
|
||||
useAppDashboardUpdates(frameRef.current, appOrigin, handshakeDone, appId);
|
||||
|
||||
useTokenRefresh(appToken, refetch);
|
||||
|
||||
const handleLoad = () => {
|
||||
/**
|
||||
* @deprecated
|
||||
*
|
||||
* Move handshake to notifyReady, so app is requesting token after it's ready to receive it
|
||||
* Currently handshake it 2 times, for compatibility
|
||||
*/
|
||||
postToExtension({
|
||||
type: "handshake",
|
||||
payload: {
|
||||
|
@ -86,34 +74,33 @@ export const AppFrame: React.FC<Props> = ({
|
|||
version: 1,
|
||||
},
|
||||
});
|
||||
postToExtension({
|
||||
type: "theme",
|
||||
payload: {
|
||||
theme: themeType,
|
||||
},
|
||||
});
|
||||
|
||||
setHandshakeDone(true);
|
||||
|
||||
if (onLoad) {
|
||||
onLoad();
|
||||
}
|
||||
};
|
||||
|
||||
if (!shop?.domain.host) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<iframe
|
||||
ref={frameRef}
|
||||
src={resolveAppIframeUrl(appId, src, {
|
||||
...params,
|
||||
featureFlags: prepareFeatureFlagsList(flags),
|
||||
theme: themeType,
|
||||
})}
|
||||
onError={onError}
|
||||
onLoad={handleLoad}
|
||||
className={clsx(classes.iframe, className)}
|
||||
sandbox="allow-same-origin allow-forms allow-scripts"
|
||||
/>
|
||||
<>
|
||||
<div className={classes.loader}>
|
||||
<CircularProgress color="primary" />
|
||||
</div>
|
||||
<iframe
|
||||
ref={frameRef}
|
||||
src={resolveAppIframeUrl(appId, src, {
|
||||
...params,
|
||||
featureFlags: prepareFeatureFlagsList(flags),
|
||||
theme: themeType,
|
||||
})}
|
||||
onError={onError}
|
||||
onLoad={handleLoad}
|
||||
className={clsx(classes.iframe, className, {
|
||||
[classes.iframeHidden]: !handshakeDone,
|
||||
})}
|
||||
sandbox="allow-same-origin allow-forms allow-scripts"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import { usePostToExtension } from "@dashboard/apps/components/AppFrame/usePostToExtension";
|
||||
import { useExternalApp } from "@dashboard/apps/components/ExternalAppContext/ExternalAppContext";
|
||||
import { getAppMountUri } from "@dashboard/config";
|
||||
import useNavigator from "@dashboard/hooks/useNavigator";
|
||||
|
@ -169,19 +170,34 @@ const useHandleUpdateRoutingAction = (appId: string) => ({
|
|||
},
|
||||
});
|
||||
|
||||
const useNotifyReadyAction = () => ({
|
||||
handle(action: NotifyReady) {
|
||||
console.debug(
|
||||
`Handling NotifyReady action with ID: ${action.payload.actionId}`,
|
||||
);
|
||||
console.warn("Not implemented");
|
||||
return createResponseStatus(action.payload.actionId, true);
|
||||
},
|
||||
});
|
||||
/**
|
||||
* TODO Remove prop drilling, consume context
|
||||
*/
|
||||
const useNotifyReadyAction = (
|
||||
frameEl: HTMLIFrameElement | null,
|
||||
appOrigin: string,
|
||||
appToken: string,
|
||||
) => {
|
||||
const postToExtension = usePostToExtension(frameEl, appOrigin);
|
||||
|
||||
return {
|
||||
handle(action: NotifyReady) {
|
||||
postToExtension({
|
||||
type: "handshake",
|
||||
payload: {
|
||||
token: appToken,
|
||||
version: 1,
|
||||
},
|
||||
});
|
||||
return createResponseStatus(action.payload.actionId, true);
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export const AppActionsHandler = {
|
||||
useHandleNotificationAction,
|
||||
useHandleUpdateRoutingAction,
|
||||
useHandleRedirectAction,
|
||||
useNotifyReadyAction,
|
||||
createResponseStatus,
|
||||
};
|
||||
|
|
|
@ -7,6 +7,16 @@ export const useStyles = makeStyles(
|
|||
height: "100%",
|
||||
border: "none",
|
||||
},
|
||||
iframeHidden: {
|
||||
visibility: "hidden",
|
||||
},
|
||||
loader: {
|
||||
position: "absolute",
|
||||
top: "50%",
|
||||
left: "50%",
|
||||
transform: "translate(-50%, -50%)",
|
||||
zIndex: -1,
|
||||
},
|
||||
}),
|
||||
{ name: "AppFrame" },
|
||||
);
|
||||
|
|
|
@ -1,22 +1,32 @@
|
|||
import { AppActionsHandler } from "@dashboard/apps/components/AppFrame/appActionsHandler";
|
||||
import {
|
||||
Actions,
|
||||
DispatchResponseEvent,
|
||||
Events,
|
||||
} from "@saleor/app-sdk/app-bridge";
|
||||
import React from "react";
|
||||
import { usePostToExtension } from "@dashboard/apps/components/AppFrame/usePostToExtension";
|
||||
import { Actions, DispatchResponseEvent } from "@saleor/app-sdk/app-bridge";
|
||||
import React, { useState } from "react";
|
||||
|
||||
export const useAppActions = (
|
||||
frameEl: React.MutableRefObject<HTMLIFrameElement | null>,
|
||||
frameEl: HTMLIFrameElement | null,
|
||||
appOrigin: string,
|
||||
appId: string,
|
||||
appToken: string,
|
||||
) => {
|
||||
const postToExtension = usePostToExtension(frameEl, appOrigin);
|
||||
|
||||
const { handle: handleNotification } =
|
||||
AppActionsHandler.useHandleNotificationAction();
|
||||
const { handle: handleUpdateRouting } =
|
||||
AppActionsHandler.useHandleUpdateRoutingAction(appId);
|
||||
const { handle: handleRedirect } =
|
||||
AppActionsHandler.useHandleRedirectAction(appId);
|
||||
const { handle: handleNotifyReady } = AppActionsHandler.useNotifyReadyAction(
|
||||
frameEl,
|
||||
appOrigin,
|
||||
appToken,
|
||||
);
|
||||
|
||||
/**
|
||||
* Store if app has performed a handshake with Dashboard, to avoid sending events before that
|
||||
*/
|
||||
const [handshakeDone, setHandshakeDone] = useState(false);
|
||||
|
||||
const handleAction = (action: Actions | undefined): DispatchResponseEvent => {
|
||||
switch (action?.type) {
|
||||
|
@ -29,8 +39,15 @@ export const useAppActions = (
|
|||
case "updateRouting": {
|
||||
return handleUpdateRouting(action);
|
||||
}
|
||||
/**
|
||||
* Send handshake after app informs its ready and mounted
|
||||
*/
|
||||
case "notifyReady": {
|
||||
console.warn("Not implemented");
|
||||
const response = handleNotifyReady(action);
|
||||
|
||||
setHandshakeDone(true);
|
||||
|
||||
return response;
|
||||
}
|
||||
default: {
|
||||
throw new Error("Unknown action type");
|
||||
|
@ -38,12 +55,6 @@ export const useAppActions = (
|
|||
}
|
||||
};
|
||||
|
||||
const postToExtension = (event: Events) => {
|
||||
if (frameEl?.current?.contentWindow) {
|
||||
frameEl.current.contentWindow.postMessage(event, appOrigin);
|
||||
}
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
const handler = (event: MessageEvent<Actions>) => {
|
||||
if (event.origin === appOrigin) {
|
||||
|
@ -58,9 +69,11 @@ export const useAppActions = (
|
|||
return () => {
|
||||
window.removeEventListener("message", handler);
|
||||
};
|
||||
}, []);
|
||||
}, [appOrigin, handleAction, postToExtension]);
|
||||
|
||||
return {
|
||||
handshakeDone,
|
||||
postToExtension,
|
||||
setHandshakeDone,
|
||||
};
|
||||
};
|
||||
|
|
58
src/apps/components/AppFrame/useAppDashboardUpdates.ts
Normal file
58
src/apps/components/AppFrame/useAppDashboardUpdates.ts
Normal file
|
@ -0,0 +1,58 @@
|
|||
import { usePostToExtension } from "@dashboard/apps/components/AppFrame/usePostToExtension";
|
||||
import { getAppDeepPathFromDashboardUrl } from "@dashboard/apps/urls";
|
||||
import useLocale from "@dashboard/hooks/useLocale";
|
||||
import { useTheme } from "@saleor/macaw-ui";
|
||||
import { useEffect } from "react";
|
||||
|
||||
/**
|
||||
* TODO: Refactor prop-drilling, use context or some atomic state
|
||||
*/
|
||||
export const useAppDashboardUpdates = (
|
||||
frameEl: HTMLIFrameElement | null,
|
||||
appOrigin: string,
|
||||
enabled: boolean,
|
||||
appId: string,
|
||||
) => {
|
||||
const postToExtension = usePostToExtension(frameEl, appOrigin);
|
||||
|
||||
const { locale } = useLocale();
|
||||
const { themeType } = useTheme();
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
postToExtension({
|
||||
type: "localeChanged",
|
||||
payload: {
|
||||
locale,
|
||||
},
|
||||
});
|
||||
}, [enabled, locale, postToExtension]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
postToExtension({
|
||||
type: "theme",
|
||||
payload: {
|
||||
theme: themeType,
|
||||
},
|
||||
});
|
||||
}, [themeType, postToExtension, enabled]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
return;
|
||||
}
|
||||
postToExtension({
|
||||
type: "redirect",
|
||||
payload: {
|
||||
path: getAppDeepPathFromDashboardUrl(location.pathname, appId),
|
||||
},
|
||||
});
|
||||
}, [appId, enabled, postToExtension]);
|
||||
};
|
22
src/apps/components/AppFrame/usePostToExtension.ts
Normal file
22
src/apps/components/AppFrame/usePostToExtension.ts
Normal file
|
@ -0,0 +1,22 @@
|
|||
import { Events } from "@saleor/app-sdk/app-bridge";
|
||||
import { useCallback } from "react";
|
||||
|
||||
export const usePostToExtension = (
|
||||
iframeElement: HTMLIFrameElement | null,
|
||||
appOrigin: string,
|
||||
) => {
|
||||
const postToExtension = useCallback(
|
||||
(event: Events) => {
|
||||
if (iframeElement?.contentWindow) {
|
||||
try {
|
||||
iframeElement.contentWindow.postMessage(event, appOrigin);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
},
|
||||
[appOrigin, iframeElement],
|
||||
);
|
||||
|
||||
return postToExtension;
|
||||
};
|
|
@ -3,7 +3,7 @@ import { storiesOf } from "@storybook/react";
|
|||
import React from "react";
|
||||
|
||||
import { appDetails } from "../../fixtures";
|
||||
import AppPage, { AppPageProps } from "./AppPage";
|
||||
import { AppPage, AppPageProps } from "./AppPage";
|
||||
|
||||
const props: AppPageProps = {
|
||||
data: appDetails,
|
||||
|
|
|
@ -21,12 +21,12 @@ export const AppPage: React.FC<AppPageProps> = ({
|
|||
|
||||
return (
|
||||
<div className={classes.iframeContainer}>
|
||||
{url && (
|
||||
{url && data.id && data.accessToken && (
|
||||
<AppFrame
|
||||
src={url}
|
||||
appToken={data?.accessToken ?? ""}
|
||||
appToken={data?.accessToken}
|
||||
onError={onError}
|
||||
appId={data?.id ?? ""}
|
||||
appId={data?.id}
|
||||
refetch={refetch}
|
||||
/>
|
||||
)}
|
||||
|
@ -35,4 +35,3 @@ export const AppPage: React.FC<AppPageProps> = ({
|
|||
};
|
||||
|
||||
AppPage.displayName = "AppPage";
|
||||
export default AppPage;
|
||||
|
|
|
@ -1,2 +1 @@
|
|||
export * from "./AppPage";
|
||||
export { default } from "./AppPage";
|
||||
|
|
|
@ -6,6 +6,7 @@ export const useStyles = makeStyles(
|
|||
height: "100%",
|
||||
},
|
||||
iframeContainer: {
|
||||
position: "relative",
|
||||
lineHeight: 0, // It removes extra space between iframe and container
|
||||
height: "100%",
|
||||
"& > iframe": {
|
||||
|
|
|
@ -15,7 +15,7 @@ import {
|
|||
appPath,
|
||||
appsListPath,
|
||||
} from "./urls";
|
||||
import AppView from "./views/App";
|
||||
import { AppView } from "./views/App";
|
||||
import AppDetailsView from "./views/AppDetails";
|
||||
import AppInstallView from "./views/AppInstall";
|
||||
import AppsListView from "./views/AppsList";
|
||||
|
|
|
@ -3,18 +3,18 @@ import NotFoundPage from "@dashboard/components/NotFoundPage";
|
|||
import { useAppQuery } from "@dashboard/graphql";
|
||||
import useNavigator from "@dashboard/hooks/useNavigator";
|
||||
import useNotifier from "@dashboard/hooks/useNotifier";
|
||||
import React from "react";
|
||||
import React, { useCallback } from "react";
|
||||
import { useIntl } from "react-intl";
|
||||
import { useLocation } from "react-router";
|
||||
|
||||
import AppPage from "../../components/AppPage";
|
||||
import { AppPage } from "../../components/AppPage";
|
||||
import { appsListPath, getAppCompleteUrlFromDashboardUrl } from "../../urls";
|
||||
|
||||
interface AppProps {
|
||||
id: string;
|
||||
}
|
||||
|
||||
export const App: React.FC<AppProps> = ({ id }) => {
|
||||
export const AppView: React.FC<AppProps> = ({ id }) => {
|
||||
const location = useLocation();
|
||||
const { data, refetch } = useAppQuery({
|
||||
displayLoader: true,
|
||||
|
@ -27,6 +27,15 @@ export const App: React.FC<AppProps> = ({ id }) => {
|
|||
const notify = useNotifier();
|
||||
const intl = useIntl();
|
||||
|
||||
const handleError = useCallback(
|
||||
() =>
|
||||
notify({
|
||||
status: "error",
|
||||
text: intl.formatMessage(appMessages.failedToFetchAppSettings),
|
||||
}),
|
||||
[intl, notify],
|
||||
);
|
||||
|
||||
if (!appExists) {
|
||||
return <NotFoundPage onBack={() => navigate(appsListPath)} />;
|
||||
}
|
||||
|
@ -37,19 +46,16 @@ export const App: React.FC<AppProps> = ({ id }) => {
|
|||
id,
|
||||
);
|
||||
|
||||
if (!data || !appCompleteUrl) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<AppPage
|
||||
data={data?.app || null}
|
||||
url={appCompleteUrl || ""}
|
||||
data={data.app}
|
||||
url={appCompleteUrl}
|
||||
refetch={refetch}
|
||||
onError={() =>
|
||||
notify({
|
||||
status: "error",
|
||||
text: intl.formatMessage(appMessages.failedToFetchAppSettings),
|
||||
})
|
||||
}
|
||||
onError={handleError}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default App;
|
|
@ -1,2 +1 @@
|
|||
export * from "./App";
|
||||
export { default } from "./App";
|
||||
export * from "./AppView";
|
||||
|
|
|
@ -5,7 +5,7 @@ import useNotifier from "@dashboard/hooks/useNotifier";
|
|||
import React from "react";
|
||||
import { useIntl } from "react-intl";
|
||||
|
||||
import AppPage from "../../components/AppPage";
|
||||
import { AppPage } from "../../components/AppPage";
|
||||
import { appsListPath } from "../../urls";
|
||||
|
||||
interface AppSettingsProps {
|
||||
|
|
|
@ -2,7 +2,7 @@ import {
|
|||
AppDetailsUrlQueryParams,
|
||||
AppInstallUrlQueryParams,
|
||||
} from "@dashboard/apps/urls";
|
||||
import AppView from "@dashboard/apps/views/App";
|
||||
import { AppView } from "@dashboard/apps/views/App";
|
||||
import AppDetailsView from "@dashboard/apps/views/AppDetails";
|
||||
import AppInstallView from "@dashboard/apps/views/AppInstall";
|
||||
import { sectionNames } from "@dashboard/intl";
|
||||
|
@ -26,9 +26,9 @@ const AppDetails: React.FC<RouteComponentProps<{ id: string }>> = ({
|
|||
);
|
||||
};
|
||||
|
||||
const App: React.FC<RouteComponentProps<{ id: string }>> = ({ match }) => (
|
||||
<AppView id={decodeURIComponent(match.params.id)} />
|
||||
);
|
||||
const AppViewRoute: React.FC<RouteComponentProps<{ id: string }>> = ({
|
||||
match,
|
||||
}) => <AppView id={decodeURIComponent(match.params.id)} />;
|
||||
|
||||
const AppInstall: React.FC<RouteComponentProps> = props => {
|
||||
const qs = parseQs(location.search.substr(1));
|
||||
|
@ -58,7 +58,7 @@ const Apps = () => {
|
|||
path={AppPaths.resolveAppDetailsPath(":id")}
|
||||
component={AppDetails}
|
||||
/>
|
||||
<Route path={AppPaths.resolveAppPath(":id")} component={App} />
|
||||
<Route path={AppPaths.resolveAppPath(":id")} component={AppViewRoute} />
|
||||
</Switch>
|
||||
</>
|
||||
);
|
||||
|
|
Loading…
Reference in a new issue