Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
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
2 changes: 2 additions & 0 deletions src/components/Sidebar/menu/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -275,6 +275,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 +329,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
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
83 changes: 83 additions & 0 deletions src/extensions/components/IframePost/IframePost.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
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;
}

/**
* 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,
}: 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}>
<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;
84 changes: 84 additions & 0 deletions src/extensions/domain/app-extension-manifest-options.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,90 @@ describe("App Extension Manifest Options Schema", () => {
});
});

describe("homeWidget", () => {
it("should accept valid homeWidget with explicit fields", () => {
// Arrange
const validData = {
homeWidget: {
method: "GET",
fullscreen: true,
},
};

// Act
const result = appExtensionManifestOptionsSchema.safeParse(validData);

// Assert
expect(result.success).toBe(true);

if (result.success) {
expect(result.data).toEqual(validData);
}
});

it("should apply defaults (method=POST, fullscreen=true) when fields omitted", () => {
// Arrange
const validData = {
homeWidget: {},
};

// Act
const result = appExtensionManifestOptionsSchema.safeParse(validData);

// Assert
expect(result.success).toBe(true);

if (result.success) {
expect(result.data.homeWidget).toEqual({
method: "POST",
fullscreen: true,
});
}
});

it("should reject homeWidget with invalid method", () => {
// Arrange
const invalidData = {
homeWidget: {
method: "PATCH",
},
};

// Act
const result = appExtensionManifestOptionsSchema.safeParse(invalidData);

// Assert
expect(result.success).toBe(false);

if (!result.success) {
expect(result.error.issues[0].message).toBe("Method must be either GET or POST");
}
});

it("should allow homeWidget to coexist with widgetTarget", () => {
// Arrange
const validData = {
widgetTarget: {
method: "POST",
},
homeWidget: {
method: "GET",
fullscreen: false,
},
};

// Act
const result = appExtensionManifestOptionsSchema.safeParse(validData);

// Assert
expect(result.success).toBe(true);

if (result.success) {
expect(result.data).toEqual(validData);
}
});
});

describe("Edge cases", () => {
it("should reject null values", () => {
// Arrange
Expand Down
6 changes: 6 additions & 0 deletions src/extensions/domain/app-extension-manifest-options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,16 @@ const widgetTargetOptionsSchema = z.object({
method: httpMethodSchema.optional().nullable(),
});

const homeWidgetOptionsSchema = z.object({
method: z.enum(["GET", "POST"], { message: "Method must be either GET or POST" }).default("POST"),
fullscreen: z.boolean().default(true),
});

export const appExtensionManifestOptionsSchema = z
.object({
newTabTarget: newTabTargetOptionsSchema.optional().nullable(),
widgetTarget: widgetTargetOptionsSchema.optional().nullable(),
homeWidget: homeWidgetOptionsSchema.optional().nullable(),
})
.refine(
data => {
Expand Down
Loading
Loading