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:
Lukasz Ostrowski 2023-02-27 16:35:35 +01:00 committed by GitHub
parent 9097d4076e
commit b9acfe6214
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 219 additions and 109 deletions

View file

@ -1,16 +1,14 @@
import { useAppDashboardUpdates } from "@dashboard/apps/components/AppFrame/useAppDashboardUpdates";
import { import {
AppDetailsUrlQueryParams, AppDetailsUrlQueryParams,
getAppDeepPathFromDashboardUrl,
prepareFeatureFlagsList, prepareFeatureFlagsList,
resolveAppIframeUrl, resolveAppIframeUrl,
} from "@dashboard/apps/urls"; } from "@dashboard/apps/urls";
import { useAllFlags } from "@dashboard/hooks/useFlags"; import { useAllFlags } from "@dashboard/hooks/useFlags";
import useLocale from "@dashboard/hooks/useLocale"; import { CircularProgress } from "@material-ui/core";
import useShop from "@dashboard/hooks/useShop";
import { useTheme } from "@saleor/macaw-ui"; import { useTheme } from "@saleor/macaw-ui";
import clsx from "clsx"; import clsx from "clsx";
import React, { useEffect } from "react"; import React from "react";
import { useLocation } from "react-router";
import { useStyles } from "./styles"; import { useStyles } from "./styles";
import { useAppActions } from "./useAppActions"; import { useAppActions } from "./useAppActions";
@ -39,46 +37,36 @@ export const AppFrame: React.FC<Props> = ({
onError, onError,
refetch, refetch,
}) => { }) => {
const shop = useShop(); const frameRef = React.useRef<HTMLIFrameElement | null>(null);
const frameRef = React.useRef<HTMLIFrameElement>(null);
const { themeType } = useTheme(); const { themeType } = useTheme();
const classes = useStyles(); const classes = useStyles();
const appOrigin = getOrigin(src); const appOrigin = getOrigin(src);
const flags = useAllFlags(); const flags = useAllFlags();
const { postToExtension } = useAppActions(frameRef, appOrigin, appId);
const location = useLocation();
const { locale } = useLocale();
useEffect(() => { /**
postToExtension({ * React on messages from App
type: "localeChanged", */
payload: { const { postToExtension, handshakeDone, setHandshakeDone } = useAppActions(
locale, frameRef.current,
}, appOrigin,
}); appId,
}, [locale, postToExtension]); appToken,
);
useEffect(() => { /**
postToExtension({ * Listen to Dashboard context like theme or locale and inform app about it
type: "theme", */
payload: { useAppDashboardUpdates(frameRef.current, appOrigin, handshakeDone, appId);
theme: themeType,
},
});
}, [themeType, postToExtension]);
useEffect(() => {
postToExtension({
type: "redirect",
payload: {
path: getAppDeepPathFromDashboardUrl(location.pathname, appId),
},
});
}, [location.pathname]);
useTokenRefresh(appToken, refetch); useTokenRefresh(appToken, refetch);
const handleLoad = () => { 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({ postToExtension({
type: "handshake", type: "handshake",
payload: { payload: {
@ -86,34 +74,33 @@ export const AppFrame: React.FC<Props> = ({
version: 1, version: 1,
}, },
}); });
postToExtension({
type: "theme", setHandshakeDone(true);
payload: {
theme: themeType,
},
});
if (onLoad) { if (onLoad) {
onLoad(); onLoad();
} }
}; };
if (!shop?.domain.host) {
return null;
}
return ( return (
<iframe <>
ref={frameRef} <div className={classes.loader}>
src={resolveAppIframeUrl(appId, src, { <CircularProgress color="primary" />
...params, </div>
featureFlags: prepareFeatureFlagsList(flags), <iframe
theme: themeType, ref={frameRef}
})} src={resolveAppIframeUrl(appId, src, {
onError={onError} ...params,
onLoad={handleLoad} featureFlags: prepareFeatureFlagsList(flags),
className={clsx(classes.iframe, className)} theme: themeType,
sandbox="allow-same-origin allow-forms allow-scripts" })}
/> onError={onError}
onLoad={handleLoad}
className={clsx(classes.iframe, className, {
[classes.iframeHidden]: !handshakeDone,
})}
sandbox="allow-same-origin allow-forms allow-scripts"
/>
</>
); );
}; };

View file

@ -1,3 +1,4 @@
import { usePostToExtension } from "@dashboard/apps/components/AppFrame/usePostToExtension";
import { useExternalApp } from "@dashboard/apps/components/ExternalAppContext/ExternalAppContext"; import { useExternalApp } from "@dashboard/apps/components/ExternalAppContext/ExternalAppContext";
import { getAppMountUri } from "@dashboard/config"; import { getAppMountUri } from "@dashboard/config";
import useNavigator from "@dashboard/hooks/useNavigator"; import useNavigator from "@dashboard/hooks/useNavigator";
@ -169,19 +170,34 @@ const useHandleUpdateRoutingAction = (appId: string) => ({
}, },
}); });
const useNotifyReadyAction = () => ({ /**
handle(action: NotifyReady) { * TODO Remove prop drilling, consume context
console.debug( */
`Handling NotifyReady action with ID: ${action.payload.actionId}`, const useNotifyReadyAction = (
); frameEl: HTMLIFrameElement | null,
console.warn("Not implemented"); appOrigin: string,
return createResponseStatus(action.payload.actionId, true); 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 = { export const AppActionsHandler = {
useHandleNotificationAction, useHandleNotificationAction,
useHandleUpdateRoutingAction, useHandleUpdateRoutingAction,
useHandleRedirectAction, useHandleRedirectAction,
useNotifyReadyAction, useNotifyReadyAction,
createResponseStatus,
}; };

View file

@ -7,6 +7,16 @@ export const useStyles = makeStyles(
height: "100%", height: "100%",
border: "none", border: "none",
}, },
iframeHidden: {
visibility: "hidden",
},
loader: {
position: "absolute",
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
zIndex: -1,
},
}), }),
{ name: "AppFrame" }, { name: "AppFrame" },
); );

View file

@ -1,22 +1,32 @@
import { AppActionsHandler } from "@dashboard/apps/components/AppFrame/appActionsHandler"; import { AppActionsHandler } from "@dashboard/apps/components/AppFrame/appActionsHandler";
import { import { usePostToExtension } from "@dashboard/apps/components/AppFrame/usePostToExtension";
Actions, import { Actions, DispatchResponseEvent } from "@saleor/app-sdk/app-bridge";
DispatchResponseEvent, import React, { useState } from "react";
Events,
} from "@saleor/app-sdk/app-bridge";
import React from "react";
export const useAppActions = ( export const useAppActions = (
frameEl: React.MutableRefObject<HTMLIFrameElement | null>, frameEl: HTMLIFrameElement | null,
appOrigin: string, appOrigin: string,
appId: string, appId: string,
appToken: string,
) => { ) => {
const postToExtension = usePostToExtension(frameEl, appOrigin);
const { handle: handleNotification } = const { handle: handleNotification } =
AppActionsHandler.useHandleNotificationAction(); AppActionsHandler.useHandleNotificationAction();
const { handle: handleUpdateRouting } = const { handle: handleUpdateRouting } =
AppActionsHandler.useHandleUpdateRoutingAction(appId); AppActionsHandler.useHandleUpdateRoutingAction(appId);
const { handle: handleRedirect } = const { handle: handleRedirect } =
AppActionsHandler.useHandleRedirectAction(appId); 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 => { const handleAction = (action: Actions | undefined): DispatchResponseEvent => {
switch (action?.type) { switch (action?.type) {
@ -29,8 +39,15 @@ export const useAppActions = (
case "updateRouting": { case "updateRouting": {
return handleUpdateRouting(action); return handleUpdateRouting(action);
} }
/**
* Send handshake after app informs its ready and mounted
*/
case "notifyReady": { case "notifyReady": {
console.warn("Not implemented"); const response = handleNotifyReady(action);
setHandshakeDone(true);
return response;
} }
default: { default: {
throw new Error("Unknown action type"); 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(() => { React.useEffect(() => {
const handler = (event: MessageEvent<Actions>) => { const handler = (event: MessageEvent<Actions>) => {
if (event.origin === appOrigin) { if (event.origin === appOrigin) {
@ -58,9 +69,11 @@ export const useAppActions = (
return () => { return () => {
window.removeEventListener("message", handler); window.removeEventListener("message", handler);
}; };
}, []); }, [appOrigin, handleAction, postToExtension]);
return { return {
handshakeDone,
postToExtension, postToExtension,
setHandshakeDone,
}; };
}; };

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

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

View file

@ -3,7 +3,7 @@ import { storiesOf } from "@storybook/react";
import React from "react"; import React from "react";
import { appDetails } from "../../fixtures"; import { appDetails } from "../../fixtures";
import AppPage, { AppPageProps } from "./AppPage"; import { AppPage, AppPageProps } from "./AppPage";
const props: AppPageProps = { const props: AppPageProps = {
data: appDetails, data: appDetails,

View file

@ -21,12 +21,12 @@ export const AppPage: React.FC<AppPageProps> = ({
return ( return (
<div className={classes.iframeContainer}> <div className={classes.iframeContainer}>
{url && ( {url && data.id && data.accessToken && (
<AppFrame <AppFrame
src={url} src={url}
appToken={data?.accessToken ?? ""} appToken={data?.accessToken}
onError={onError} onError={onError}
appId={data?.id ?? ""} appId={data?.id}
refetch={refetch} refetch={refetch}
/> />
)} )}
@ -35,4 +35,3 @@ export const AppPage: React.FC<AppPageProps> = ({
}; };
AppPage.displayName = "AppPage"; AppPage.displayName = "AppPage";
export default AppPage;

View file

@ -1,2 +1 @@
export * from "./AppPage"; export * from "./AppPage";
export { default } from "./AppPage";

View file

@ -6,6 +6,7 @@ export const useStyles = makeStyles(
height: "100%", height: "100%",
}, },
iframeContainer: { iframeContainer: {
position: "relative",
lineHeight: 0, // It removes extra space between iframe and container lineHeight: 0, // It removes extra space between iframe and container
height: "100%", height: "100%",
"& > iframe": { "& > iframe": {

View file

@ -15,7 +15,7 @@ import {
appPath, appPath,
appsListPath, appsListPath,
} from "./urls"; } from "./urls";
import AppView from "./views/App"; import { AppView } from "./views/App";
import AppDetailsView from "./views/AppDetails"; import AppDetailsView from "./views/AppDetails";
import AppInstallView from "./views/AppInstall"; import AppInstallView from "./views/AppInstall";
import AppsListView from "./views/AppsList"; import AppsListView from "./views/AppsList";

View file

@ -3,18 +3,18 @@ import NotFoundPage from "@dashboard/components/NotFoundPage";
import { useAppQuery } from "@dashboard/graphql"; import { useAppQuery } from "@dashboard/graphql";
import useNavigator from "@dashboard/hooks/useNavigator"; import useNavigator from "@dashboard/hooks/useNavigator";
import useNotifier from "@dashboard/hooks/useNotifier"; import useNotifier from "@dashboard/hooks/useNotifier";
import React from "react"; import React, { useCallback } from "react";
import { useIntl } from "react-intl"; import { useIntl } from "react-intl";
import { useLocation } from "react-router"; import { useLocation } from "react-router";
import AppPage from "../../components/AppPage"; import { AppPage } from "../../components/AppPage";
import { appsListPath, getAppCompleteUrlFromDashboardUrl } from "../../urls"; import { appsListPath, getAppCompleteUrlFromDashboardUrl } from "../../urls";
interface AppProps { interface AppProps {
id: string; id: string;
} }
export const App: React.FC<AppProps> = ({ id }) => { export const AppView: React.FC<AppProps> = ({ id }) => {
const location = useLocation(); const location = useLocation();
const { data, refetch } = useAppQuery({ const { data, refetch } = useAppQuery({
displayLoader: true, displayLoader: true,
@ -27,6 +27,15 @@ export const App: React.FC<AppProps> = ({ id }) => {
const notify = useNotifier(); const notify = useNotifier();
const intl = useIntl(); const intl = useIntl();
const handleError = useCallback(
() =>
notify({
status: "error",
text: intl.formatMessage(appMessages.failedToFetchAppSettings),
}),
[intl, notify],
);
if (!appExists) { if (!appExists) {
return <NotFoundPage onBack={() => navigate(appsListPath)} />; return <NotFoundPage onBack={() => navigate(appsListPath)} />;
} }
@ -37,19 +46,16 @@ export const App: React.FC<AppProps> = ({ id }) => {
id, id,
); );
if (!data || !appCompleteUrl) {
return null;
}
return ( return (
<AppPage <AppPage
data={data?.app || null} data={data.app}
url={appCompleteUrl || ""} url={appCompleteUrl}
refetch={refetch} refetch={refetch}
onError={() => onError={handleError}
notify({
status: "error",
text: intl.formatMessage(appMessages.failedToFetchAppSettings),
})
}
/> />
); );
}; };
export default App;

View file

@ -1,2 +1 @@
export * from "./App"; export * from "./AppView";
export { default } from "./App";

View file

@ -5,7 +5,7 @@ import useNotifier from "@dashboard/hooks/useNotifier";
import React from "react"; import React from "react";
import { useIntl } from "react-intl"; import { useIntl } from "react-intl";
import AppPage from "../../components/AppPage"; import { AppPage } from "../../components/AppPage";
import { appsListPath } from "../../urls"; import { appsListPath } from "../../urls";
interface AppSettingsProps { interface AppSettingsProps {

View file

@ -2,7 +2,7 @@ import {
AppDetailsUrlQueryParams, AppDetailsUrlQueryParams,
AppInstallUrlQueryParams, AppInstallUrlQueryParams,
} from "@dashboard/apps/urls"; } 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 AppDetailsView from "@dashboard/apps/views/AppDetails";
import AppInstallView from "@dashboard/apps/views/AppInstall"; import AppInstallView from "@dashboard/apps/views/AppInstall";
import { sectionNames } from "@dashboard/intl"; import { sectionNames } from "@dashboard/intl";
@ -26,9 +26,9 @@ const AppDetails: React.FC<RouteComponentProps<{ id: string }>> = ({
); );
}; };
const App: React.FC<RouteComponentProps<{ id: string }>> = ({ match }) => ( const AppViewRoute: React.FC<RouteComponentProps<{ id: string }>> = ({
<AppView id={decodeURIComponent(match.params.id)} /> match,
); }) => <AppView id={decodeURIComponent(match.params.id)} />;
const AppInstall: React.FC<RouteComponentProps> = props => { const AppInstall: React.FC<RouteComponentProps> = props => {
const qs = parseQs(location.search.substr(1)); const qs = parseQs(location.search.substr(1));
@ -58,7 +58,7 @@ const Apps = () => {
path={AppPaths.resolveAppDetailsPath(":id")} path={AppPaths.resolveAppDetailsPath(":id")}
component={AppDetails} component={AppDetails}
/> />
<Route path={AppPaths.resolveAppPath(":id")} component={App} /> <Route path={AppPaths.resolveAppPath(":id")} component={AppViewRoute} />
</Switch> </Switch>
</> </>
); );