Skip to content
Open
Show file tree
Hide file tree
Changes from 18 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
12 changes: 12 additions & 0 deletions locale/defaultMessages.json
Original file line number Diff line number Diff line change
Expand Up @@ -7427,6 +7427,10 @@
"context": "fulfillment status refunded",
"string": "Refunded"
},
"cxUBO1": {
"context": "empty home page description",
"string": "Install an app that registers a HOMEPAGE_WIDGETS extension to see it here."
},
"cy8sV7": {
"context": "volume units types",
"string": "Volume"
Expand Down Expand Up @@ -7756,6 +7760,10 @@
"context": "tooltip for taxes amount",
"string": "Tax amount"
},
"fTLvHX": {
"context": "empty home page title",
"string": "Welcome"
},
"fU+a9k": {
"context": "date attribute type",
"string": "Date"
Expand Down Expand Up @@ -9799,6 +9807,10 @@
"qkUBLC": {
"string": "See logs"
},
"qkaF5G": {
"context": "Label of the home page tab grouping non-fullscreen widget extensions",
"string": "Widgets"
},
"qkt/Km": {
"context": "bulk delete label",
"string": "Delete"
Expand Down
2 changes: 1 addition & 1 deletion src/components/DevModePanel/DevModePanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ export const DevModePanel = () => {

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