Skip to content
Open
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .env.template
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ API_URL=http://localhost:8000/graphql/
APP_MOUNT_URI=/
# Provides source for App Store / explore feature. Only for Saleor Cloud
# EXTENSIONS_API_URL=https://apps.saleor.io/api/v1/extensions
# Domain that hosts first-party Saleor Cloud apps. Extensions whose URL is not under this domain are considered external.
SALEOR_CLOUD_APP_DOMAIN=saleor.app
LOCALE_CODE="EN"

# Multi-schema support (optional)
Expand Down
8 changes: 1 addition & 7 deletions src/components/DevModePanel/DevModePanel.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
// @ts-strict-ignore
import { useDashboardTheme } from "@dashboard/components/GraphiQL/styles";
import { DashboardModal } from "@dashboard/components/Modal";
import { useOnboarding } from "@dashboard/welcomePage/WelcomePageOnboarding/onboardingContext";
import { type FetcherOpts, type FetcherParams } from "@graphiql/toolkit";
import { useIntl } from "react-intl";

Expand All @@ -16,16 +15,11 @@ export const DevModePanel = () => {
const intl = useIntl();
const subtitle = useContextualLink("dev_panel");
const { rootStyle } = useDashboardTheme();
const { markOnboardingStepAsCompleted } = useOnboarding();
const { isDevModeVisible, variables, devModeContent, setDevModeVisibility } = useDevModeContext();
const fetcher = async (graphQLParams: FetcherParams, opts: FetcherOpts) => {
if (graphQLParams.operationName !== "IntrospectionQuery") {
markOnboardingStepAsCompleted("graphql-playground");
}

const baseFetcher = getFetcher(opts);

const result = await baseFetcher(graphQLParams, opts); // Call the base fetcher
const result = await baseFetcher(graphQLParams, opts);

return result;
};
Expand Down
4 changes: 4 additions & 0 deletions src/components/Sidebar/menu/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ describe("mapToExtensionsItems", () => {
mountName: "NAVIGATION_CATALOG",
targetName: "APP_PAGE",
settings: {},
isSaleorOfficial: false,
};

const mockHeader: SidebarMenuItem = {
Expand Down Expand Up @@ -214,6 +215,7 @@ describe("getMenuItemExtension", () => {
mountName: "NAVIGATION_CATALOG",
settings: {},
targetName: "POPUP",
isSaleorOfficial: false,
};

const mockExtension: Extension = {
Expand Down Expand Up @@ -275,6 +277,7 @@ describe("getMenuItemExtension", () => {
DRAFT_ORDER_DETAILS_WIDGETS: [],
GIFT_CARD_DETAILS_WIDGETS: [],
TRANSLATIONS_MORE_ACTIONS: [],
HOMEPAGE_WIDGETS: [],
};

const emptyExtensionsRecord: Record<AllAppExtensionMounts, Extension[]> = {
Expand Down Expand Up @@ -328,6 +331,7 @@ describe("getMenuItemExtension", () => {
DRAFT_ORDER_DETAILS_WIDGETS: [],
GIFT_CARD_DETAILS_WIDGETS: [],
TRANSLATIONS_MORE_ACTIONS: [],
HOMEPAGE_WIDGETS: [],
};

it("should return the corresponding Extension object when a menu item ID represents a registered extension", () => {
Expand Down
3 changes: 3 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@ export const getAbsoluteApiUrl = () => new URL(getApiUrl(), window.location.orig
export const SW_INTERVAL = parseInt(process.env.SW_INTERVAL ?? "300", 10);
export const IS_CLOUD_INSTANCE = window.__SALEOR_CONFIG__.IS_CLOUD_INSTANCE === "true";

export const getSaleorCloudAppDomain = (): string | null =>
window?.__SALEOR_CONFIG__?.SALEOR_CLOUD_APP_DOMAIN || null;

export const getExtensionsConfig = () => ({
extensionsApiUri: window.__SALEOR_CONFIG__.EXTENSIONS_API_URL,
});
Expand Down
94 changes: 4 additions & 90 deletions src/extensions/components/AppWidgets/AppWidgets.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { DashboardCard } from "@dashboard/components/Card";
import Link from "@dashboard/components/Link";
import { APP_VERSION, getAbsoluteApiUrl } from "@dashboard/config";
import { APP_VERSION } from "@dashboard/config";
import { AppAvatar } from "@dashboard/extensions/components/AppAvatar/AppAvatar";
import { IframePost } from "@dashboard/extensions/components/IframePost/IframePost";
import { appExtensionManifestOptionsSchema } from "@dashboard/extensions/domain/app-extension-manifest-options";
import { isUrlAbsolute } from "@dashboard/extensions/isUrlAbsolute";
import { extensionActions } from "@dashboard/extensions/messages";
Expand All @@ -10,106 +11,19 @@ import { type AppDetailsUrlMountQueryParams, ExtensionsUrls } from "@dashboard/e
import { AppFrame } from "@dashboard/extensions/views/ViewManifestExtension/components/AppFrame/AppFrame";
import useNavigator from "@dashboard/hooks/useNavigator";
import { type ThemeType } from "@saleor/app-sdk/app-bridge";
import { Box, Skeleton, Text } from "@saleor/macaw-ui-next";
import { Box, Text } from "@saleor/macaw-ui-next";
import { ExternalLink } from "lucide-react";
import { useEffect, useRef } from "react";
import { useRef } from "react";
import { useIntl } from "react-intl";

type AppWidgetsProps = {
extensions: ExtensionWithParams[];
params: AppDetailsUrlMountQueryParams;
};

const hiddenStyle = { visibility: "hidden" } as const;

// TODO We will add size negotiations after render
const defaultIframeSize = 200;

/**
* Renders a form and iframe, the form is automatically submitted with POST action and <iframe> content is replaced
*/
const IframePost = ({
extensionId,
extensionUrl,
appId,
accessToken,
params,
}: {
extensionUrl: string;
extensionId: string;
accessToken: string;
appId: string;
params?: AppDetailsUrlMountQueryParams;
}) => {
const formRef = useRef<HTMLFormElement | null>(null);
const iframeRef = useRef<HTMLFormElement | null>(null);
const loadingRef = useRef<HTMLDivElement | null>(null);

useEffect(() => {
if (formRef.current) {
formRef.current.submit();
}

if (iframeRef.current && loadingRef.current) {
const iframe = iframeRef.current;
const loading = loadingRef.current;

const onload = () => {
if (loading) {
loading.style.display = "none";
}

if (iframe) {
iframe.style.visibility = "visible";
}
};

iframe.addEventListener("load", onload);

return () => {
if (iframe) {
iframe.removeEventListener("load", onload);
}
};
}
}, []);

/**
* This form is rendered locally, but somewhere above there is another form. Since this is hidden, it is not visible to the user,
* but under the hood browser is changing the DOM
*
* TODO: We should either render form in JS directly in <body> directly or change the tree
*/
return (
<Box>
<form ref={formRef} action={extensionUrl} method="POST" target={`ext-frame-${extensionId}`}>
<input type="hidden" name="saleorApiUrl" value={getAbsoluteApiUrl()} />
<input type="hidden" name="accessToken" value={accessToken} />
<input type="hidden" name="appId" value={appId} />
<>
{params &&
Object.entries(params).map(([key, value]) => (
<input type="hidden" key={key} name={key} value={value} />
))}
</>
</form>
<Box ref={loadingRef} width={"100%"} __height={defaultIframeSize}>
<Skeleton __height={defaultIframeSize} />
</Box>
<Box
style={hiddenStyle}
ref={iframeRef}
as="iframe"
borderWidth={0}
__height={defaultIframeSize}
sandbox="allow-same-origin allow-forms allow-scripts allow-downloads"
name={`ext-frame-${extensionId}`}
width={"100%"}
/>
</Box>
);
};

export const AppWidgets = ({ extensions, params }: AppWidgetsProps) => {
const navigate = useNavigator();
const themeRef = useRef<ThemeType>();
Expand Down
97 changes: 97 additions & 0 deletions src/extensions/components/IframePost/IframePost.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { SaleorThrobber } from "@dashboard/components/Throbber";
import { getAbsoluteApiUrl } from "@dashboard/config";
import { type AppDetailsUrlMountQueryParams } from "@dashboard/extensions/urls";
import { Box, Skeleton } from "@saleor/macaw-ui-next";
import { type CSSProperties, useEffect, useRef } from "react";

const hiddenStyle: CSSProperties = { visibility: "hidden" };

interface IframePostProps {
extensionId: string;
extensionUrl: string;
appId: string;
accessToken: string;
params?: AppDetailsUrlMountQueryParams;
height?: number | string;
loaderType?: "skeleton" | "throbber";
}

/**
* Renders a hidden form which auto-submits on mount with POST so the iframe
* receives credentials in the body instead of the URL.
*/
export const IframePost = ({
extensionId,
extensionUrl,
appId,
accessToken,
params,
height = 200,
loaderType = "skeleton",
}: IframePostProps) => {
const formRef = useRef<HTMLFormElement | null>(null);
const iframeRef = useRef<HTMLIFrameElement | null>(null);
const loadingRef = useRef<HTMLDivElement | null>(null);

useEffect(() => {
if (formRef.current) {
formRef.current.submit();
}
Comment thread
lkostrowski marked this conversation as resolved.

const iframe = iframeRef.current;
const loading = loadingRef.current;

if (!iframe || !loading) {
return;
}

const onload = () => {
loading.style.display = "none";
iframe.style.visibility = "visible";
};

iframe.addEventListener("load", onload);

return () => {
iframe.removeEventListener("load", onload);
};
}, []);
Comment thread
lkostrowski marked this conversation as resolved.
Comment thread
lkostrowski marked this conversation as resolved.

return (
<Box width="100%" __height={height as number | string}>
<form ref={formRef} action={extensionUrl} method="POST" target={`ext-frame-${extensionId}`}>
<input type="hidden" name="saleorApiUrl" value={getAbsoluteApiUrl()} />
<input type="hidden" name="accessToken" value={accessToken} />
<input type="hidden" name="appId" value={appId} />
{params &&
Comment thread
lkostrowski marked this conversation as resolved.
Object.entries(params).map(([key, value]) => (
<input type="hidden" key={key} name={key} value={value} />
))}
</form>
<Box
ref={loadingRef}
width="100%"
__height={height as number | string}
display={loaderType === "throbber" ? "flex" : "block"}
alignItems={loaderType === "throbber" ? "center" : undefined}
justifyContent={loaderType === "throbber" ? "center" : undefined}
>
{loaderType === "throbber" ? (
<SaleorThrobber />
) : (
<Skeleton __height={height as number | string} />
)}
</Box>
<Box
style={hiddenStyle}
ref={iframeRef}
as="iframe"
borderWidth={0}
__height={height as number | string}
sandbox="allow-same-origin allow-forms allow-scripts allow-downloads"
name={`ext-frame-${extensionId}`}
width="100%"
/>
Comment thread
lkostrowski marked this conversation as resolved.
Comment thread
lkostrowski marked this conversation as resolved.
</Box>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -87,13 +87,16 @@ const PAGE_TYPE_MOUNTS = [

const TRANSLATIONS_MOUNTS = ["TRANSLATIONS_MORE_ACTIONS"] as const;

const HOMEPAGE_MOUNTS = ["HOMEPAGE_WIDGETS"] as const;

// Create a const array with all mounts to preserve literal types
const ALL_MOUNTS_ARRAY = [
...CATEGORY_MOUNTS,
...COLLECTION_MOUNTS,
...CUSTOMER_MOUNTS,
...DISCOUNT_MOUNTS,
...GIFT_CARD_MOUNTS,
...HOMEPAGE_MOUNTS,
...MENU_MOUNTS,
...NAVIGATION_MOUNTS,
...ORDER_MOUNTS,
Expand All @@ -118,4 +121,5 @@ export const WIDGET_AVAILABLE_MOUNTS = [
"GIFT_CARD_DETAILS_WIDGETS",
"CUSTOMER_DETAILS_WIDGETS",
"COLLECTION_DETAILS_WIDGETS",
"HOMEPAGE_WIDGETS",
] as const;
Loading
Loading