Skip to content
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { z } from "zod/index";

const PRODUCT_MOUNTS = [
"PRODUCT_OVERVIEW_CREATE",
"PRODUCT_OVERVIEW_MORE_ACTIONS",
"PRODUCT_DETAILS_MORE_ACTIONS",
"PRODUCT_DETAILS_WIDGETS",
] as const;

const NAVIGATION_MOUNTS = [
"NAVIGATION_CATALOG",
"NAVIGATION_ORDERS",
"NAVIGATION_CUSTOMERS",
"NAVIGATION_DISCOUNTS",
"NAVIGATION_TRANSLATIONS",
"NAVIGATION_PAGES",
] as const;

const ORDER_MOUNTS = [
"ORDER_DETAILS_MORE_ACTIONS",
"ORDER_OVERVIEW_CREATE",
"ORDER_DETAILS_WIDGETS",
"DRAFT_ORDER_DETAILS_WIDGETS",
"ORDER_OVERVIEW_MORE_ACTIONS",
] as const;

const CUSTOMER_MOUNTS = [
"CUSTOMER_OVERVIEW_CREATE",
"CUSTOMER_OVERVIEW_MORE_ACTIONS",
"CUSTOMER_DETAILS_MORE_ACTIONS",
"CUSTOMER_DETAILS_WIDGETS",
] as const;

const COLLECTION_MOUNTS = ["COLLECTION_DETAILS_WIDGETS"] as const;

const GIFT_CARD_MOUNTS = ["GIFT_CARD_DETAILS_WIDGETS"] as const;

const VOUCHER_MOUNTS = ["VOUCHER_DETAILS_WIDGETS"] as const;

export const ALL_APP_EXTENSION_MOUNTS = z.enum([
...PRODUCT_MOUNTS,
...NAVIGATION_MOUNTS,
...ORDER_MOUNTS,
...COLLECTION_MOUNTS,
...GIFT_CARD_MOUNTS,
...VOUCHER_MOUNTS,
...CUSTOMER_MOUNTS,
] as const);

// Subset of mounts available for WIDGET target
export const WIDGET_AVAILABLE_MOUNTS = [
"ORDER_DETAILS_WIDGETS",
"PRODUCT_DETAILS_WIDGETS",
"VOUCHER_DETAILS_WIDGETS",
"DRAFT_ORDER_DETAILS_WIDGETS",
"GIFT_CARD_DETAILS_WIDGETS",
"CUSTOMER_DETAILS_WIDGETS",
"COLLECTION_DETAILS_WIDGETS",
] as const;
31 changes: 31 additions & 0 deletions src/extensions/domain/app-extension-manifest-options.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { z } from "zod/index";

const httpMethodSchema = z.enum(["GET", "POST"], {
// todo errorMap or message?
Comment thread
lkostrowski marked this conversation as resolved.
Outdated
errorMap: () => ({ message: "Method must be either GET or POST" }),
});

const newTabTargetOptionsSchema = z.object({
method: httpMethodSchema,
});

const widgetTargetOptionsSchema = z.object({
method: httpMethodSchema,
});

export const appExtensionManifestOptionsSchema = z
.object({
newTabTarget: newTabTargetOptionsSchema.optional(),
widgetTarget: widgetTargetOptionsSchema.optional(),
})
.refine(
data => {
// Only one of newTabTarget or widgetTarget can be set
return !(data.newTabTarget && data.widgetTarget);
},
{
message: "Only one of 'newTabTarget' or 'widgetTarget' can be set.",
},
);

export type AppExtensionManifestOptions = z.infer<typeof appExtensionManifestOptionsSchema>;
4 changes: 4 additions & 0 deletions src/extensions/domain/app-extension-manifest-target.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import { z } from "zod/index";

export const AppExtensionManifestTarget = z.enum(["POPUP", "APP_PAGE", "NEW_TAB", "WIDGET"]);
export type AppExtensionManifestTarget = z.infer<typeof AppExtensionManifestTarget>;
81 changes: 81 additions & 0 deletions src/extensions/domain/app-extension-manifest.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import {
ALL_APP_EXTENSION_MOUNTS,
WIDGET_AVAILABLE_MOUNTS,
} from "@dashboard/extensions/domain/app-extension-manifest-available-mounts";
import { appExtensionManifestOptionsSchema } from "@dashboard/extensions/domain/app-extension-manifest-options";
import { AppExtensionManifestTarget } from "@dashboard/extensions/domain/app-extension-manifest-target";
import { z } from "zod/index";

export const appExtensionManifest = z
.object({
label: z.string().min(1),
url: z.string().min(1),
mount: ALL_APP_EXTENSION_MOUNTS,
target: AppExtensionManifestTarget.default("POPUP"),
permissions: z.array(z.string()).optional().default([]),
options: appExtensionManifestOptionsSchema.optional(),
})
.refine(
data => {
// Validate that WIDGET target only uses widget-compatible mounts
if (data.target === "WIDGET") {
return WIDGET_AVAILABLE_MOUNTS.includes(data.mount as any);
Comment thread
lkostrowski marked this conversation as resolved.
}

return true;
},
{
message: "Mount is not available for WIDGET target.",
},
)
.refine(
data => {
// Validate widgetTarget options only on WIDGET target
if (data.options?.widgetTarget && data.target !== "WIDGET") {
return false;
}

return true;
},
{
message: "widgetTarget options must be set only on WIDGET target",
},
)
.refine(
data => {
// Validate newTabTarget options only on NEW_TAB target
if (data.options?.newTabTarget && data.target !== "NEW_TAB") {
return false;
}

return true;
},
{
message: "newTabTarget options must be set only on NEW_TAB target",
},
)
.refine(
data => {
// URL validation based on target
const url = data.url;
const target = data.target;

// Relative URL validation
if (url.startsWith("/")) {
// APP_PAGE can use relative URLs
return target === "APP_PAGE";
}

// APP_PAGE cannot use absolute URLs
if (target === "APP_PAGE") {
return false;
}

return true;
},
{
message: "Incorrect relation between extension target and URL fields.",
},
);

export type AppExtensionManifest = z.infer<typeof appExtensionManifest>;
39 changes: 39 additions & 0 deletions src/extensions/domain/app-manifest.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { appExtensionManifest } from "@dashboard/extensions/domain/app-extension-manifest";
import { z } from "zod/index";

// For now contains only partial fields, because Saleor is validating manifest anyway.
// Subset here serves only fields needed for dashboard extensions.
export const appManifestSchema = z
.object({
appUrl: z.string().optional(),
permissions: z.array(z.string()).optional().default([]),
extensions: z.array(appExtensionManifest).optional().default([]),
})
.refine(
data => {
// Validate extension URLs require appUrl for certain cases
return data.extensions.every(ext => {
if (ext.url.startsWith("/") && ext.target === "NEW_TAB") {
return !!data.appUrl;
}

return true;
});
},
{
message: "To use relative URL, you must specify appUrl.",
},
)
.refine(
data => {
// Validate extension permissions are subset of app permissions
return data.extensions.every(ext => {
return ext.permissions.every(perm => data.permissions.includes(perm));
});
},
{
message: "Extension permission must be listed in App's permissions.",
},
);

export type AppManifest = z.infer<typeof appManifestSchema>;
17 changes: 17 additions & 0 deletions src/extensions/extension-manifest-validator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { AppManifest, appManifestSchema } from "@dashboard/extensions/domain/app-manifest";
import { ZodIssue } from "zod";

//todo test
Comment thread
lkostrowski marked this conversation as resolved.
Outdated
export class ExtensionManifestValidator {
validateAppManifest(manifestJson: unknown): AppManifest | { issues: ZodIssue[] } {
const result = appManifestSchema.safeParse(manifestJson);

if (result.success) {
return result.data;
}

return {
issues: result.error.issues,
};
}
}
Loading