feat(dashboard,admin-vite-plugin,admin-bundler,admin-sdk): Rework admin extensions and introduce custom fields API (#9338)
This commit is contained in:
committed by
GitHub
parent
35e69d32f2
commit
d71343d6ab
@@ -1,9 +1,15 @@
|
||||
import App from "@medusajs/dashboard"
|
||||
import React from "react"
|
||||
import { createRoot } from "react-dom/client"
|
||||
import App from "@medusajs/dashboard";
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import "./index.css";
|
||||
|
||||
import "./index.css"
|
||||
ReactDOM.createRoot(document.getElementById("medusa")!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
)
|
||||
|
||||
const container = document.getElementById("root")
|
||||
const root = createRoot(container!)
|
||||
root.render(<App />)
|
||||
|
||||
if (import.meta.hot) {
|
||||
import.meta.hot.accept()
|
||||
}
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
<!doctype html>
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1, user-scalable=no"
|
||||
/>
|
||||
<link rel="icon" href="data:," data-placeholder-favicon />
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<div id="medusa"></div>
|
||||
<script type="module" src="./entry.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
</html>
|
||||
|
||||
@@ -28,8 +28,8 @@ export async function getViteConfig(
|
||||
outDir: path.resolve(process.cwd(), options.outDir),
|
||||
},
|
||||
optimizeDeps: {
|
||||
include: ["@medusajs/dashboard", "react-dom/client"],
|
||||
exclude: VIRTUAL_MODULES,
|
||||
include: ["react-dom/client", "@medusajs/ui", "@medusajs/dashboard"],
|
||||
exclude: [...VIRTUAL_MODULES],
|
||||
},
|
||||
define: {
|
||||
__BASE__: JSON.stringify(options.path),
|
||||
@@ -43,7 +43,6 @@ export async function getViteConfig(
|
||||
hmr: {
|
||||
port: hmrPort,
|
||||
},
|
||||
middlewareMode: true,
|
||||
},
|
||||
css: {
|
||||
postcss: {
|
||||
@@ -64,7 +63,6 @@ export async function getViteConfig(
|
||||
|
||||
if (options.vite) {
|
||||
const customConfig = options.vite(baseConfig)
|
||||
|
||||
return mergeConfig(baseConfig, customConfig)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,72 @@
|
||||
import express from "express"
|
||||
import type { InlineConfig } from "vite"
|
||||
import express, { RequestHandler } from "express"
|
||||
import fs from "fs"
|
||||
import path from "path"
|
||||
import type { InlineConfig, ViteDevServer } from "vite"
|
||||
|
||||
import { BundlerOptions } from "../types"
|
||||
import { getViteConfig } from "./config"
|
||||
|
||||
const router = express.Router()
|
||||
|
||||
function findTemplateFilePath(
|
||||
reqPath: string,
|
||||
root: string
|
||||
): string | undefined {
|
||||
if (reqPath.endsWith(".html")) {
|
||||
const pathToTest = path.join(root, reqPath)
|
||||
if (fs.existsSync(pathToTest)) {
|
||||
return pathToTest
|
||||
}
|
||||
}
|
||||
|
||||
const basePath = reqPath.slice(0, reqPath.lastIndexOf("/"))
|
||||
const dirs = basePath.split("/")
|
||||
|
||||
while (dirs.length > 0) {
|
||||
const pathToTest = path.join(root, ...dirs, "index.html")
|
||||
if (fs.existsSync(pathToTest)) {
|
||||
return pathToTest
|
||||
}
|
||||
dirs.pop()
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
async function injectViteMiddleware(
|
||||
router: express.Router,
|
||||
middleware: RequestHandler
|
||||
) {
|
||||
router.use((req, res, next) => {
|
||||
req.path.endsWith(".html") ? next() : middleware(req, res, next)
|
||||
})
|
||||
}
|
||||
|
||||
async function injectHtmlMiddleware(
|
||||
router: express.Router,
|
||||
server: ViteDevServer
|
||||
) {
|
||||
router.use(async (req, res, next) => {
|
||||
if (req.method !== "GET") {
|
||||
return next()
|
||||
}
|
||||
|
||||
const templateFilePath = findTemplateFilePath(req.path, server.config.root)
|
||||
if (!templateFilePath) {
|
||||
return next()
|
||||
}
|
||||
|
||||
const template = fs.readFileSync(templateFilePath, "utf8")
|
||||
const html = await server.transformIndexHtml(
|
||||
templateFilePath,
|
||||
template,
|
||||
req.originalUrl
|
||||
)
|
||||
|
||||
res.send(html)
|
||||
})
|
||||
}
|
||||
|
||||
export async function develop(options: BundlerOptions) {
|
||||
const vite = await import("vite")
|
||||
|
||||
@@ -14,14 +75,18 @@ export async function develop(options: BundlerOptions) {
|
||||
|
||||
const developConfig: InlineConfig = {
|
||||
mode: "development",
|
||||
logLevel: "warn",
|
||||
logLevel: "error",
|
||||
appType: "spa",
|
||||
server: {
|
||||
middlewareMode: true,
|
||||
},
|
||||
}
|
||||
|
||||
const server = await vite.createServer(
|
||||
vite.mergeConfig(viteConfig, developConfig)
|
||||
)
|
||||
const mergedConfig = vite.mergeConfig(viteConfig, developConfig)
|
||||
const server = await vite.createServer(mergedConfig)
|
||||
|
||||
router.use(server.middlewares)
|
||||
await injectViteMiddleware(router, server.middlewares)
|
||||
await injectHtmlMiddleware(router, server)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
throw new Error(
|
||||
|
||||
@@ -21,10 +21,14 @@
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.2",
|
||||
"tsup": "^8.0.1",
|
||||
"typescript": "^5.3.3"
|
||||
"typescript": "^5.3.3",
|
||||
"zod": "^3.22"
|
||||
},
|
||||
"dependencies": {
|
||||
"@medusajs/admin-shared": "^0.0.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"zod": "^3.22"
|
||||
},
|
||||
"packageManager": "yarn@3.2.1"
|
||||
}
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from "./types"
|
||||
export * from "./utils"
|
||||
// We don't export anything related to CustomFields for the time being
|
||||
export type { RouteConfig, WidgetConfig } from "./types"
|
||||
export { defineRouteConfig, defineWidgetConfig } from "./utils"
|
||||
|
||||
@@ -1,11 +1,144 @@
|
||||
import type { InjectionZone } from "@medusajs/admin-shared"
|
||||
import type {
|
||||
CustomFieldFormKeys,
|
||||
CustomFieldModel,
|
||||
CustomFieldModelContainerMap,
|
||||
CustomFieldModelFormTabsMap,
|
||||
InjectionZone,
|
||||
} from "@medusajs/admin-shared"
|
||||
import type { ComponentType } from "react"
|
||||
import { ZodFirstPartySchemaTypes } from "zod"
|
||||
|
||||
export type WidgetConfig = {
|
||||
export interface WidgetConfig {
|
||||
/**
|
||||
* The injection zone or zones that the widget should be injected into.
|
||||
*/
|
||||
zone: InjectionZone | InjectionZone[]
|
||||
}
|
||||
|
||||
export type RouteConfig = {
|
||||
export interface RouteConfig {
|
||||
/**
|
||||
* An optional label to display in the sidebar. If not provided, the route will not be displayed in the sidebar.
|
||||
*/
|
||||
label?: string
|
||||
/**
|
||||
* An optional icon to display in the sidebar together with the label. If no label is provided, the icon will be ignored.
|
||||
*/
|
||||
icon?: ComponentType
|
||||
}
|
||||
|
||||
export type CustomFormField<
|
||||
TData = unknown,
|
||||
TValidation extends ZodFirstPartySchemaTypes = ZodFirstPartySchemaTypes
|
||||
> = {
|
||||
/**
|
||||
* The rules that the field should be validated against.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* rules: z.string().email() // The field must be a valid email
|
||||
* ```
|
||||
*/
|
||||
validation: TValidation
|
||||
/**
|
||||
* The default value of the field.
|
||||
*/
|
||||
defaultValue: ((data: TData) => any) | any
|
||||
/**
|
||||
* The label of the field. If not provided, the label will be inferred from the field name.
|
||||
*/
|
||||
label?: string
|
||||
/**
|
||||
* The description of the field.
|
||||
*/
|
||||
description?: string
|
||||
/**
|
||||
* The placeholder of the field.
|
||||
*/
|
||||
placeholder?: string
|
||||
/**
|
||||
* Custom component to render the field. If not provided, the field will be rendered using the
|
||||
* default component for the field type, which is determined by the field's validation schema.
|
||||
*/
|
||||
component?: ComponentType
|
||||
}
|
||||
|
||||
// Define the main configuration type
|
||||
export interface CustomFieldConfig<TModel extends CustomFieldModel> {
|
||||
/**
|
||||
* The name of the model that the custom models are linked to.
|
||||
* This should be the name of one of the built-in models, such as `product` or `customer`.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* model: "product"
|
||||
* ```
|
||||
*/
|
||||
model: TModel
|
||||
/**
|
||||
* The name of the custom model(s) that the custom fields belong to.
|
||||
* This is used to ensure that the custom fields are fetched when
|
||||
* querying the entrypoint model.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* export default unstable_defineCustomFieldsConfig({
|
||||
* model: "product",
|
||||
* link: "brand"
|
||||
* // ...
|
||||
* })
|
||||
* ```
|
||||
* or
|
||||
* ```ts
|
||||
* export default unstable_defineCustomFieldsConfig({
|
||||
* model: "product",
|
||||
* link: ["brand", "seller"]
|
||||
* // ...
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
link: string | string[]
|
||||
forms: Array<
|
||||
{
|
||||
[K in CustomFieldFormKeys<TModel> &
|
||||
keyof CustomFieldModelFormTabsMap[TModel]]: {
|
||||
/**
|
||||
* The form to extend.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* export default unstable_defineCustomFieldsConfig({
|
||||
* model: "product",
|
||||
* link: "brand",
|
||||
* forms: [
|
||||
* {
|
||||
* zone: "create",
|
||||
* // ...
|
||||
* }
|
||||
* ],
|
||||
* // ...
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
zone: K
|
||||
fields: Record<string, CustomFormField<any, any>>
|
||||
} & (CustomFieldModelFormTabsMap[TModel][K] extends never
|
||||
? {}
|
||||
: { tab: CustomFieldModelFormTabsMap[TModel][K] })
|
||||
}[CustomFieldFormKeys<TModel> & keyof CustomFieldModelFormTabsMap[TModel]]
|
||||
>
|
||||
/**
|
||||
* Optionally define how to display the custom fields, in an existing container on the entity details page.
|
||||
* Alternatively, you can create a new widget to display the custom fields.
|
||||
*/
|
||||
displays?: Array<{
|
||||
/**
|
||||
* The identifier of the container that the custom fields should be injected into.
|
||||
*/
|
||||
zone: CustomFieldModelContainerMap[TModel]
|
||||
/**
|
||||
* The component that should be rendered to display the custom fields.
|
||||
* This component will receive the entity data as a prop.
|
||||
*/
|
||||
component: ComponentType
|
||||
}>
|
||||
}
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import { RouteConfig, WidgetConfig } from "./types"
|
||||
import type { CustomFieldModelFormMap } from "@medusajs/admin-shared"
|
||||
import { z, ZodFirstPartySchemaTypes } from "zod"
|
||||
import {
|
||||
CustomFieldConfig,
|
||||
CustomFormField,
|
||||
RouteConfig,
|
||||
WidgetConfig,
|
||||
} from "./types"
|
||||
|
||||
function createConfigHelper<TConfig extends Record<string, unknown>>(
|
||||
config: TConfig
|
||||
): TConfig {
|
||||
function createConfigHelper<TConfig>(config: TConfig): TConfig {
|
||||
return {
|
||||
...config,
|
||||
/**
|
||||
@@ -35,3 +40,77 @@ export function defineWidgetConfig(config: WidgetConfig) {
|
||||
export function defineRouteConfig(config: RouteConfig) {
|
||||
return createConfigHelper(config)
|
||||
}
|
||||
|
||||
/**
|
||||
* Define a custom fields configuration.
|
||||
*
|
||||
* @param config The custom fields configuration.
|
||||
* @returns The custom fields configuration.
|
||||
*
|
||||
* @experimental This API is experimental and may change in the future.
|
||||
*/
|
||||
export function unstable_defineCustomFieldsConfig<
|
||||
TModel extends keyof CustomFieldModelFormMap
|
||||
>(config: CustomFieldConfig<TModel>) {
|
||||
return createConfigHelper(config)
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a type-safe form builder.
|
||||
*
|
||||
* @returns The form helper.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* import { unstable_createFormHelper, unstable_defineCustomFieldsConfig } from "@medusajs/admin-sdk"
|
||||
* import type { HttpTypes } from "@medusajs/types"
|
||||
* import type { Brand } from "../../types/brand"
|
||||
*
|
||||
* type ExtendedProduct = HttpTypes.Product & {
|
||||
* brand: Brand | null
|
||||
* }
|
||||
*
|
||||
* const form = unstable_createFormHelper<ExtendedProduct>()
|
||||
*
|
||||
* export default unstable_defineCustomFieldsConfig({
|
||||
* entryPoint: "product",
|
||||
* link: "brand",
|
||||
* forms: [{
|
||||
* form: "create",
|
||||
* fields: {
|
||||
* brand_id: form.define({
|
||||
* rules: form.string().nullish(),
|
||||
* defaultValue: "",
|
||||
* }),
|
||||
* }
|
||||
* }]
|
||||
* })
|
||||
* ```
|
||||
*
|
||||
* @experimental This API is experimental and may change in the future.
|
||||
*/
|
||||
export function unstable_createFormHelper<TData>() {
|
||||
return {
|
||||
/**
|
||||
* Define a custom form field.
|
||||
*
|
||||
* @param field The field to define.
|
||||
* @returns The field.
|
||||
*/
|
||||
define: <T extends ZodFirstPartySchemaTypes>(
|
||||
field: Omit<CustomFormField<TData, T>, "validation"> & { validation: T }
|
||||
): CustomFormField<TData, T> => {
|
||||
return field as CustomFormField<TData, T>
|
||||
},
|
||||
string: () => z.string(),
|
||||
number: () => z.number(),
|
||||
boolean: () => z.boolean(),
|
||||
date: () => z.date(),
|
||||
array: z.array,
|
||||
object: z.object,
|
||||
null: () => z.null(),
|
||||
nullable: z.nullable,
|
||||
undefined: () => z.undefined(),
|
||||
coerce: z.coerce,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import {
|
||||
PRODUCT_CUSTOM_FIELD_DISPLAY_PATHS,
|
||||
PRODUCT_CUSTOM_FIELD_DISPLAY_ZONES,
|
||||
PRODUCT_CUSTOM_FIELD_FORM_CONFIG_PATHS,
|
||||
PRODUCT_CUSTOM_FIELD_FORM_FIELD_PATHS,
|
||||
PRODUCT_CUSTOM_FIELD_FORM_TABS,
|
||||
PRODUCT_CUSTOM_FIELD_FORM_ZONES,
|
||||
PRODUCT_CUSTOM_FIELD_LINK_PATHS,
|
||||
PRODUCT_CUSTOM_FIELD_MODEL,
|
||||
} from "./product"
|
||||
|
||||
export const CUSTOM_FIELD_MODELS = [PRODUCT_CUSTOM_FIELD_MODEL] as const
|
||||
|
||||
export const CUSTOM_FIELD_CONTAINER_ZONES = [
|
||||
...PRODUCT_CUSTOM_FIELD_DISPLAY_ZONES,
|
||||
] as const
|
||||
|
||||
export const CUSTOM_FIELD_FORM_ZONES = [
|
||||
...PRODUCT_CUSTOM_FIELD_FORM_ZONES,
|
||||
] as const
|
||||
|
||||
export const CUSTOM_FIELD_FORM_TABS = [
|
||||
...PRODUCT_CUSTOM_FIELD_FORM_TABS,
|
||||
] as const
|
||||
|
||||
export const CUSTOM_FIELD_FORM_CONFIG_PATHS = [
|
||||
...PRODUCT_CUSTOM_FIELD_FORM_CONFIG_PATHS,
|
||||
] as const
|
||||
|
||||
export const CUSTOM_FIELD_FORM_FIELD_PATHS = [
|
||||
...PRODUCT_CUSTOM_FIELD_FORM_FIELD_PATHS,
|
||||
] as const
|
||||
|
||||
export const CUSTOM_FIELD_DISPLAY_PATHS = [
|
||||
...PRODUCT_CUSTOM_FIELD_DISPLAY_PATHS,
|
||||
] as const
|
||||
|
||||
export const CUSTOM_FIELD_LINK_PATHS = [
|
||||
...PRODUCT_CUSTOM_FIELD_LINK_PATHS,
|
||||
] as const
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from "./product"
|
||||
export * from "./types"
|
||||
export * from "./utils"
|
||||
@@ -0,0 +1,48 @@
|
||||
export const PRODUCT_CUSTOM_FIELD_MODEL = "product" as const
|
||||
|
||||
export const PRODUCT_CUSTOM_FIELD_FORM_ZONES = [
|
||||
"create",
|
||||
"edit",
|
||||
"organize",
|
||||
"attributes",
|
||||
] as const
|
||||
|
||||
export const PRODUCT_CUSTOM_FIELD_CREATE_FORM_TABS = [
|
||||
"general",
|
||||
"organize",
|
||||
] as const
|
||||
export const PRODUCT_CUSTOM_FIELD_FORM_TABS = [
|
||||
...PRODUCT_CUSTOM_FIELD_CREATE_FORM_TABS,
|
||||
] as const
|
||||
|
||||
export const PRODUCT_CUSTOM_FIELD_DISPLAY_ZONES = [
|
||||
"general",
|
||||
"organize",
|
||||
"attributes",
|
||||
] as const
|
||||
|
||||
export const PRODUCT_CUSTOM_FIELD_LINK_PATHS = [
|
||||
`${PRODUCT_CUSTOM_FIELD_MODEL}.$link`,
|
||||
] as const
|
||||
|
||||
export const PRODUCT_CUSTOM_FIELD_FORM_CONFIG_PATHS = [
|
||||
...PRODUCT_CUSTOM_FIELD_FORM_ZONES.map(
|
||||
(form) => `${PRODUCT_CUSTOM_FIELD_MODEL}.${form}.$config`
|
||||
),
|
||||
] as const
|
||||
|
||||
export const PRODUCT_CUSTOM_FIELD_FORM_FIELD_PATHS = [
|
||||
...PRODUCT_CUSTOM_FIELD_FORM_ZONES.flatMap((form) => {
|
||||
return form === "create"
|
||||
? PRODUCT_CUSTOM_FIELD_CREATE_FORM_TABS.map(
|
||||
(tab) => `${PRODUCT_CUSTOM_FIELD_MODEL}.${form}.${tab}.$field`
|
||||
)
|
||||
: [`${PRODUCT_CUSTOM_FIELD_MODEL}.${form}.$field`]
|
||||
}),
|
||||
] as const
|
||||
|
||||
export const PRODUCT_CUSTOM_FIELD_DISPLAY_PATHS = [
|
||||
...PRODUCT_CUSTOM_FIELD_DISPLAY_ZONES.map(
|
||||
(id) => `${PRODUCT_CUSTOM_FIELD_MODEL}.${id}.$display`
|
||||
),
|
||||
] as const
|
||||
@@ -0,0 +1,10 @@
|
||||
import {
|
||||
PRODUCT_CUSTOM_FIELD_DISPLAY_ZONES,
|
||||
PRODUCT_CUSTOM_FIELD_FORM_TABS,
|
||||
PRODUCT_CUSTOM_FIELD_FORM_ZONES,
|
||||
} from "./constants"
|
||||
|
||||
export type ProductFormZone = (typeof PRODUCT_CUSTOM_FIELD_FORM_ZONES)[number]
|
||||
export type ProductFormTab = (typeof PRODUCT_CUSTOM_FIELD_FORM_TABS)[number]
|
||||
export type ProductDisplayZone =
|
||||
(typeof PRODUCT_CUSTOM_FIELD_DISPLAY_ZONES)[number]
|
||||
@@ -0,0 +1,48 @@
|
||||
import {
|
||||
CUSTOM_FIELD_CONTAINER_ZONES,
|
||||
CUSTOM_FIELD_FORM_TABS,
|
||||
CUSTOM_FIELD_FORM_ZONES,
|
||||
CUSTOM_FIELD_MODELS,
|
||||
} from "./constants"
|
||||
import type {
|
||||
ProductDisplayZone,
|
||||
ProductFormTab,
|
||||
ProductFormZone,
|
||||
} from "./product"
|
||||
|
||||
export type CustomFieldModel = (typeof CUSTOM_FIELD_MODELS)[number]
|
||||
|
||||
export type CustomFieldFormZone = (typeof CUSTOM_FIELD_FORM_ZONES)[number]
|
||||
|
||||
export type CustomFieldFormTab = (typeof CUSTOM_FIELD_FORM_TABS)[number]
|
||||
|
||||
export type CustomFieldContainerZone =
|
||||
(typeof CUSTOM_FIELD_CONTAINER_ZONES)[number]
|
||||
|
||||
export type CustomFieldZone = CustomFieldFormZone | CustomFieldContainerZone
|
||||
|
||||
export type CustomFieldImportType = "display" | "field" | "link" | "config"
|
||||
|
||||
export interface CustomFieldModelFormMap {
|
||||
product: ProductFormZone
|
||||
}
|
||||
|
||||
export interface CustomFieldModelContainerMap {
|
||||
product: ProductDisplayZone
|
||||
}
|
||||
|
||||
export type CustomFieldModelFormTabsMap = {
|
||||
product: {
|
||||
create: ProductFormTab
|
||||
edit: never
|
||||
organize: never
|
||||
attributes: never
|
||||
}
|
||||
customer: {
|
||||
create: never
|
||||
edit: never
|
||||
}
|
||||
}
|
||||
|
||||
export type CustomFieldFormKeys<T extends CustomFieldModel> =
|
||||
CustomFieldModelFormMap[T]
|
||||
@@ -0,0 +1,54 @@
|
||||
import {
|
||||
CUSTOM_FIELD_CONTAINER_ZONES,
|
||||
CUSTOM_FIELD_DISPLAY_PATHS,
|
||||
CUSTOM_FIELD_FORM_CONFIG_PATHS,
|
||||
CUSTOM_FIELD_FORM_FIELD_PATHS,
|
||||
CUSTOM_FIELD_FORM_TABS,
|
||||
CUSTOM_FIELD_FORM_ZONES,
|
||||
CUSTOM_FIELD_LINK_PATHS,
|
||||
CUSTOM_FIELD_MODELS,
|
||||
} from "./constants"
|
||||
import {
|
||||
CustomFieldContainerZone,
|
||||
CustomFieldFormTab,
|
||||
CustomFieldFormZone,
|
||||
CustomFieldModel,
|
||||
} from "./types"
|
||||
|
||||
// Validators for individual segments of the custom field extension system
|
||||
|
||||
export function isValidCustomFieldModel(id: any): id is CustomFieldModel {
|
||||
return CUSTOM_FIELD_MODELS.includes(id)
|
||||
}
|
||||
|
||||
export function isValidCustomFieldFormZone(id: any): id is CustomFieldFormZone {
|
||||
return CUSTOM_FIELD_FORM_ZONES.includes(id)
|
||||
}
|
||||
|
||||
export function isValidCustomFieldFormTab(id: any): id is CustomFieldFormTab {
|
||||
return CUSTOM_FIELD_FORM_TABS.includes(id)
|
||||
}
|
||||
|
||||
export function isValidCustomFieldDisplayZone(
|
||||
id: any
|
||||
): id is CustomFieldContainerZone {
|
||||
return CUSTOM_FIELD_CONTAINER_ZONES.includes(id)
|
||||
}
|
||||
|
||||
// Validators for full paths of custom field extensions
|
||||
|
||||
export function isValidCustomFieldDisplayPath(id: any): id is string {
|
||||
return CUSTOM_FIELD_DISPLAY_PATHS.includes(id)
|
||||
}
|
||||
|
||||
export function isValidCustomFieldFormConfigPath(id: any): id is string {
|
||||
return CUSTOM_FIELD_FORM_CONFIG_PATHS.includes(id)
|
||||
}
|
||||
|
||||
export function isValidCustomFieldFormFieldPath(id: any): id is string {
|
||||
return CUSTOM_FIELD_FORM_FIELD_PATHS.includes(id)
|
||||
}
|
||||
|
||||
export function isValidCustomFieldLinkPath(id: any): id is string {
|
||||
return CUSTOM_FIELD_LINK_PATHS.includes(id)
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export const ROUTE_IMPORTS = ["routes/pages", "routes/links"] as const
|
||||
@@ -1,3 +0,0 @@
|
||||
import { ROUTE_IMPORTS } from "./constants"
|
||||
|
||||
export type RouteImport = (typeof ROUTE_IMPORTS)[number]
|
||||
@@ -1,40 +0,0 @@
|
||||
import { ROUTE_IMPORTS } from "../routes"
|
||||
import { INJECTION_ZONES } from "../widgets"
|
||||
import { getVirtualId, getWidgetImport, resolveVirtualId } from "./utils"
|
||||
|
||||
const VIRTUAL_WIDGET_MODULES = INJECTION_ZONES.map((zone) => {
|
||||
return getVirtualId(getWidgetImport(zone))
|
||||
})
|
||||
|
||||
const VIRTUAL_ROUTE_MODULES = ROUTE_IMPORTS.map((route) => {
|
||||
return getVirtualId(route)
|
||||
})
|
||||
|
||||
/**
|
||||
* All virtual modules that are used in the admin panel. Virtual modules are used
|
||||
* to inject custom widgets, routes and settings. A virtual module is imported using
|
||||
* a string that corresponds to the id of the virtual module.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* import ProductDetailsBefore from "virtual:medusa/widgets/product/details/before"
|
||||
* ```
|
||||
*/
|
||||
export const VIRTUAL_MODULES = [
|
||||
...VIRTUAL_WIDGET_MODULES,
|
||||
...VIRTUAL_ROUTE_MODULES,
|
||||
]
|
||||
|
||||
/**
|
||||
* Reolved paths to all virtual widget modules.
|
||||
*/
|
||||
export const RESOLVED_WIDGET_MODULES = VIRTUAL_WIDGET_MODULES.map((id) => {
|
||||
return resolveVirtualId(id)
|
||||
})
|
||||
|
||||
/**
|
||||
* Reolved paths to all virtual route modules.
|
||||
*/
|
||||
export const RESOLVED_ROUTE_MODULES = VIRTUAL_ROUTE_MODULES.map((id) => {
|
||||
return resolveVirtualId(id)
|
||||
})
|
||||
@@ -1,25 +0,0 @@
|
||||
import { InjectionZone } from "../widgets"
|
||||
|
||||
const PREFIX = "virtual:medusa/"
|
||||
|
||||
export const getVirtualId = (name: string) => {
|
||||
return `${PREFIX}${name}`
|
||||
}
|
||||
|
||||
export const resolveVirtualId = (id: string) => {
|
||||
return `\0${id}`
|
||||
}
|
||||
|
||||
export const getWidgetImport = (zone: InjectionZone) => {
|
||||
return `widgets/${zone.replace(/\./g, "/")}`
|
||||
}
|
||||
|
||||
export const getWidgetZone = (resolvedId: string): InjectionZone => {
|
||||
const virtualPrefix = `\0${PREFIX}widgets/`
|
||||
|
||||
const zone = resolvedId
|
||||
.replace(virtualPrefix, "")
|
||||
.replace(/\//g, ".") as InjectionZone
|
||||
|
||||
return zone as InjectionZone
|
||||
}
|
||||
@@ -1,3 +1,3 @@
|
||||
export * from "./extensions/virtual"
|
||||
export * from "./extensions/custom-fields"
|
||||
export * from "./extensions/widgets"
|
||||
|
||||
export * from "./virtual-modules"
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
export const LINK_VIRTUAL_MODULE = `virtual:medusa/links`
|
||||
export const FORM_VIRTUAL_MODULE = `virtual:medusa/forms`
|
||||
export const DISPLAY_VIRTUAL_MODULE = `virtual:medusa/displays`
|
||||
export const ROUTE_VIRTUAL_MODULE = `virtual:medusa/routes`
|
||||
export const MENU_ITEM_VIRTUAL_MODULE = `virtual:medusa/menu-items`
|
||||
export const WIDGET_VIRTUAL_MODULE = `virtual:medusa/widgets`
|
||||
|
||||
export const VIRTUAL_MODULES = [
|
||||
LINK_VIRTUAL_MODULE,
|
||||
FORM_VIRTUAL_MODULE,
|
||||
DISPLAY_VIRTUAL_MODULE,
|
||||
ROUTE_VIRTUAL_MODULE,
|
||||
MENU_ITEM_VIRTUAL_MODULE,
|
||||
WIDGET_VIRTUAL_MODULE,
|
||||
] as const
|
||||
-1
@@ -1,2 +1 @@
|
||||
export * from "./constants"
|
||||
export * from "./utils"
|
||||
@@ -25,8 +25,7 @@
|
||||
"watch": "tsup --watch"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/types": "7.22.5",
|
||||
"@types/babel__traverse": "7.20.5",
|
||||
"@babel/types": "7.25.6",
|
||||
"@types/node": "^20.10.4",
|
||||
"tsup": "8.0.1",
|
||||
"typescript": "5.3.3",
|
||||
@@ -36,12 +35,14 @@
|
||||
"vite": "^5.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@babel/parser": "7.23.5",
|
||||
"@babel/traverse": "7.23.5",
|
||||
"@babel/parser": "7.25.6",
|
||||
"@babel/traverse": "7.25.6",
|
||||
"@medusajs/admin-shared": "0.0.1",
|
||||
"chokidar": "3.5.3",
|
||||
"fdir": "6.1.1",
|
||||
"magic-string": "0.30.5"
|
||||
"magic-string": "0.30.5",
|
||||
"outdent": "^0.8.0",
|
||||
"picocolors": "^1.1.0"
|
||||
},
|
||||
"packageManager": "yarn@3.2.1"
|
||||
}
|
||||
|
||||
@@ -4,7 +4,24 @@ import {
|
||||
ExportDefaultDeclaration,
|
||||
ExportNamedDeclaration,
|
||||
File,
|
||||
isArrayExpression,
|
||||
isCallExpression,
|
||||
isFunctionDeclaration,
|
||||
isIdentifier,
|
||||
isJSXElement,
|
||||
isJSXFragment,
|
||||
isMemberExpression,
|
||||
isObjectExpression,
|
||||
isObjectProperty,
|
||||
isStringLiteral,
|
||||
isTemplateLiteral,
|
||||
isVariableDeclaration,
|
||||
isVariableDeclarator,
|
||||
ObjectExpression,
|
||||
ObjectMethod,
|
||||
ObjectProperty,
|
||||
SpreadElement,
|
||||
StringLiteral,
|
||||
} from "@babel/types"
|
||||
|
||||
/**
|
||||
@@ -20,13 +37,33 @@ if (typeof _traverse === "function") {
|
||||
traverse = (_traverse as any).default
|
||||
}
|
||||
|
||||
export { parse, traverse }
|
||||
export {
|
||||
isArrayExpression,
|
||||
isCallExpression,
|
||||
isFunctionDeclaration,
|
||||
isIdentifier,
|
||||
isJSXElement,
|
||||
isJSXFragment,
|
||||
isMemberExpression,
|
||||
isObjectExpression,
|
||||
isObjectProperty,
|
||||
isStringLiteral,
|
||||
isTemplateLiteral,
|
||||
isVariableDeclaration,
|
||||
isVariableDeclarator,
|
||||
parse,
|
||||
traverse,
|
||||
}
|
||||
export type {
|
||||
ExportDefaultDeclaration,
|
||||
ExportNamedDeclaration,
|
||||
File,
|
||||
NodePath,
|
||||
ObjectExpression,
|
||||
ObjectMethod,
|
||||
ObjectProperty,
|
||||
ParseResult,
|
||||
ParserOptions,
|
||||
SpreadElement,
|
||||
StringLiteral,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,292 @@
|
||||
import {
|
||||
isValidCustomFieldDisplayPath,
|
||||
isValidCustomFieldDisplayZone,
|
||||
type CustomFieldContainerZone,
|
||||
type CustomFieldModel,
|
||||
} from "@medusajs/admin-shared"
|
||||
import fs from "fs/promises"
|
||||
import {
|
||||
ExportDefaultDeclaration,
|
||||
File,
|
||||
isArrayExpression,
|
||||
isIdentifier,
|
||||
isObjectExpression,
|
||||
isObjectProperty,
|
||||
isStringLiteral,
|
||||
NodePath,
|
||||
ObjectProperty,
|
||||
parse,
|
||||
ParseResult,
|
||||
traverse,
|
||||
} from "../babel"
|
||||
import { logger } from "../logger"
|
||||
import { crawl, getParserOptions } from "../utils"
|
||||
import { getConfigArgument, getModel, validateLink } from "./helpers"
|
||||
|
||||
type CustomFieldDisplay = {
|
||||
zone: CustomFieldContainerZone
|
||||
Component: string
|
||||
}
|
||||
|
||||
type ParsedCustomFieldDisplayConfig = {
|
||||
import: string
|
||||
model: CustomFieldModel
|
||||
displays: CustomFieldDisplay[] | null
|
||||
}
|
||||
|
||||
export async function generateCustomFieldDisplays(sources: Set<string>) {
|
||||
const files = await getFilesFromSources(sources)
|
||||
const results = await getCustomFieldDisplayResults(files)
|
||||
|
||||
const imports = results.map((result) => result.import).flat()
|
||||
const code = generateDisplayCode(results)
|
||||
|
||||
return {
|
||||
imports,
|
||||
code,
|
||||
}
|
||||
}
|
||||
|
||||
async function getFilesFromSources(sources: Set<string>): Promise<string[]> {
|
||||
const files = (
|
||||
await Promise.all(
|
||||
Array.from(sources).map(async (source) =>
|
||||
crawl(`${source}/custom-fields`)
|
||||
)
|
||||
)
|
||||
).flat()
|
||||
return files
|
||||
}
|
||||
|
||||
function generateDisplayCode(
|
||||
results: ParsedCustomFieldDisplayConfig[]
|
||||
): string {
|
||||
const groupedByModel = new Map<
|
||||
CustomFieldModel,
|
||||
ParsedCustomFieldDisplayConfig[]
|
||||
>()
|
||||
|
||||
results.forEach((result) => {
|
||||
const model = result.model
|
||||
if (!groupedByModel.has(model)) {
|
||||
groupedByModel.set(model, [])
|
||||
}
|
||||
groupedByModel.get(model)!.push(result)
|
||||
})
|
||||
|
||||
const segments: string[] = []
|
||||
|
||||
groupedByModel.forEach((results, model) => {
|
||||
const displays = results
|
||||
.map((result) => formatDisplays(result.displays))
|
||||
.filter((display) => display !== "")
|
||||
.join(",\n")
|
||||
|
||||
segments.push(`
|
||||
${model}: [
|
||||
${displays}
|
||||
],
|
||||
`)
|
||||
})
|
||||
|
||||
return `
|
||||
displays: {
|
||||
${segments.join("\n")}
|
||||
}
|
||||
`
|
||||
}
|
||||
|
||||
function formatDisplays(displays: CustomFieldDisplay[] | null): string {
|
||||
if (!displays || displays.length === 0) {
|
||||
return ""
|
||||
}
|
||||
|
||||
return displays
|
||||
.map(
|
||||
(display) => `
|
||||
{
|
||||
zone: "${display.zone}",
|
||||
Component: ${display.Component},
|
||||
}
|
||||
`
|
||||
)
|
||||
.join(",\n")
|
||||
}
|
||||
|
||||
async function getCustomFieldDisplayResults(
|
||||
files: string[]
|
||||
): Promise<ParsedCustomFieldDisplayConfig[]> {
|
||||
return (
|
||||
await Promise.all(
|
||||
files.map(async (file, index) => parseDisplayFile(file, index))
|
||||
)
|
||||
).filter(Boolean) as ParsedCustomFieldDisplayConfig[]
|
||||
}
|
||||
|
||||
async function parseDisplayFile(
|
||||
file: string,
|
||||
index: number
|
||||
): Promise<ParsedCustomFieldDisplayConfig | null> {
|
||||
const content = await fs.readFile(file, "utf8")
|
||||
let ast: ParseResult<File>
|
||||
|
||||
try {
|
||||
ast = parse(content, getParserOptions(file))
|
||||
} catch (e) {
|
||||
logger.error(`An error occurred while parsing the file`, { file, error: e })
|
||||
return null
|
||||
}
|
||||
|
||||
const import_ = generateImport(file, index)
|
||||
|
||||
let displays: CustomFieldDisplay[] | null = null
|
||||
let model: CustomFieldModel | null = null
|
||||
let hasLink = false
|
||||
try {
|
||||
traverse(ast, {
|
||||
ExportDefaultDeclaration(path) {
|
||||
const _model = getModel(path, file)
|
||||
|
||||
if (!_model) {
|
||||
return
|
||||
}
|
||||
|
||||
model = _model
|
||||
displays = getDisplays(path, model, index, file)
|
||||
hasLink = validateLink(path, file)
|
||||
},
|
||||
})
|
||||
} catch (err) {
|
||||
logger.error(`An error occurred while traversing the file.`, {
|
||||
file,
|
||||
error: err,
|
||||
})
|
||||
return null
|
||||
}
|
||||
|
||||
if (!model) {
|
||||
logger.warn(`'model' property is missing.`, { file })
|
||||
return null
|
||||
}
|
||||
|
||||
if (!hasLink) {
|
||||
logger.warn(`'link' property is missing.`, { file })
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
import: import_,
|
||||
model,
|
||||
displays,
|
||||
}
|
||||
}
|
||||
|
||||
function getDisplays(
|
||||
path: NodePath<ExportDefaultDeclaration>,
|
||||
model: CustomFieldModel,
|
||||
index: number,
|
||||
file: string
|
||||
): CustomFieldDisplay[] | null {
|
||||
const configArgument = getConfigArgument(path)
|
||||
|
||||
if (!configArgument) {
|
||||
return null
|
||||
}
|
||||
|
||||
const displayProperty = configArgument.properties.find(
|
||||
(p) => isObjectProperty(p) && isIdentifier(p.key, { name: "displays" })
|
||||
) as ObjectProperty | undefined
|
||||
|
||||
if (!displayProperty) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (!isArrayExpression(displayProperty.value)) {
|
||||
logger.warn(
|
||||
`'displays' is not an array. The 'displays' property must be an array of objects.`,
|
||||
{ file }
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
const displays: CustomFieldDisplay[] = []
|
||||
|
||||
displayProperty.value.elements.forEach((element, j) => {
|
||||
if (!isObjectExpression(element)) {
|
||||
return
|
||||
}
|
||||
|
||||
const zoneProperty = element.properties.find(
|
||||
(p) => isObjectProperty(p) && isIdentifier(p.key, { name: "zone" })
|
||||
) as ObjectProperty | undefined
|
||||
|
||||
if (!zoneProperty) {
|
||||
logger.warn(
|
||||
`'zone' property is missing at the ${j} index of the 'displays' property.`,
|
||||
{ file }
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (!isStringLiteral(zoneProperty.value)) {
|
||||
logger.warn(
|
||||
`'zone' property at index ${j} in the 'displays' property is not a string literal. 'zone' must be a string literal, e.g. 'general' or 'attributes'.`,
|
||||
{ file }
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const zone = zoneProperty.value.value
|
||||
const fullPath = getDisplayEntryPath(model, zone)
|
||||
|
||||
if (
|
||||
!isValidCustomFieldDisplayZone(zone) ||
|
||||
!isValidCustomFieldDisplayPath(fullPath)
|
||||
) {
|
||||
logger.warn(
|
||||
`'zone' is invalid at index ${j} in the 'displays' property. Received: ${zone}.`,
|
||||
{ file }
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const componentProperty = element.properties.find(
|
||||
(p) => isObjectProperty(p) && isIdentifier(p.key, { name: "component" })
|
||||
) as ObjectProperty | undefined
|
||||
|
||||
if (!componentProperty) {
|
||||
logger.warn(
|
||||
`'component' property is missing at index ${j} in the 'displays' property.`,
|
||||
{ file }
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
displays.push({
|
||||
zone: zone,
|
||||
Component: getDisplayComponent(index, j),
|
||||
})
|
||||
})
|
||||
|
||||
return displays.length > 0 ? displays : null
|
||||
}
|
||||
|
||||
function getDisplayEntryPath(model: CustomFieldModel, zone: string): string {
|
||||
return `${model}.${zone}.$display`
|
||||
}
|
||||
|
||||
function getDisplayComponent(
|
||||
fileIndex: number,
|
||||
displayEntryIndex: number
|
||||
): string {
|
||||
const import_ = generateCustomFieldConfigName(fileIndex)
|
||||
return `${import_}.displays[${displayEntryIndex}].component`
|
||||
}
|
||||
|
||||
function generateCustomFieldConfigName(index: number): string {
|
||||
return `CustomFieldConfig${index}`
|
||||
}
|
||||
|
||||
function generateImport(file: string, index: number): string {
|
||||
return `import ${generateCustomFieldConfigName(index)} from "${file}"`
|
||||
}
|
||||
@@ -0,0 +1,707 @@
|
||||
import { ArrayExpression } from "@babel/types"
|
||||
import {
|
||||
isValidCustomFieldFormConfigPath,
|
||||
isValidCustomFieldFormFieldPath,
|
||||
isValidCustomFieldFormTab,
|
||||
isValidCustomFieldFormZone,
|
||||
type CustomFieldFormTab,
|
||||
type CustomFieldFormZone,
|
||||
type CustomFieldModel,
|
||||
} from "@medusajs/admin-shared"
|
||||
import fs from "fs/promises"
|
||||
import { outdent } from "outdent"
|
||||
import {
|
||||
ExportDefaultDeclaration,
|
||||
File,
|
||||
isArrayExpression,
|
||||
isCallExpression,
|
||||
isIdentifier,
|
||||
isMemberExpression,
|
||||
isObjectExpression,
|
||||
isObjectProperty,
|
||||
isStringLiteral,
|
||||
isTemplateLiteral,
|
||||
NodePath,
|
||||
ObjectExpression,
|
||||
ObjectProperty,
|
||||
parse,
|
||||
ParseResult,
|
||||
traverse,
|
||||
} from "../babel"
|
||||
import { logger } from "../logger"
|
||||
import { crawl, getParserOptions } from "../utils"
|
||||
import { getConfigArgument, getModel, validateLink } from "./helpers"
|
||||
|
||||
type CustomFieldConfigField = {
|
||||
name: string
|
||||
defaultValue: string
|
||||
validation: string
|
||||
}
|
||||
|
||||
type CustomFieldConfig = {
|
||||
zone: CustomFieldFormZone
|
||||
fields: CustomFieldConfigField[]
|
||||
}
|
||||
|
||||
type CustomFieldFormField = {
|
||||
name: string
|
||||
label: string
|
||||
description: string
|
||||
placeholder: string
|
||||
Component: string
|
||||
validation: string
|
||||
}
|
||||
|
||||
type CustomFieldFormSection = {
|
||||
zone: CustomFieldFormZone
|
||||
tab?: CustomFieldFormTab
|
||||
fields: CustomFieldFormField[]
|
||||
}
|
||||
|
||||
type ParsedCustomFieldConfig = {
|
||||
import: string
|
||||
model: CustomFieldModel
|
||||
configs: CustomFieldConfig[] | null
|
||||
forms: CustomFieldFormSection[] | null
|
||||
}
|
||||
|
||||
export async function generateCustomFieldForms(sources: Set<string>) {
|
||||
const files = await getFilesFromSources(sources)
|
||||
const results = await getCustomFieldResults(files)
|
||||
|
||||
const imports = results.map((result) => result.import).flat()
|
||||
const code = generateCode(results)
|
||||
|
||||
return {
|
||||
imports,
|
||||
code,
|
||||
}
|
||||
}
|
||||
|
||||
async function getFilesFromSources(sources: Set<string>): Promise<string[]> {
|
||||
const files = (
|
||||
await Promise.all(
|
||||
Array.from(sources).map(async (source) =>
|
||||
crawl(`${source}/custom-fields`)
|
||||
)
|
||||
)
|
||||
).flat()
|
||||
return files
|
||||
}
|
||||
|
||||
function generateCode(results: ParsedCustomFieldConfig[]): string {
|
||||
const groupedByModel = new Map<CustomFieldModel, ParsedCustomFieldConfig[]>()
|
||||
|
||||
results.forEach((result) => {
|
||||
const model = result.model
|
||||
if (!groupedByModel.has(model)) {
|
||||
groupedByModel.set(model, [])
|
||||
}
|
||||
groupedByModel.get(model)!.push(result)
|
||||
})
|
||||
|
||||
const segments: string[] = []
|
||||
|
||||
groupedByModel.forEach((results, model) => {
|
||||
const configs = results
|
||||
.map((result) => formatConfig(result.configs))
|
||||
.filter((config) => config !== "")
|
||||
.join(",\n")
|
||||
const forms = results
|
||||
.map((result) => formatForms(result.forms))
|
||||
.filter((form) => form !== "")
|
||||
.join(",\n")
|
||||
|
||||
segments.push(outdent`
|
||||
${model}: {
|
||||
configs: [
|
||||
${configs}
|
||||
],
|
||||
forms: [
|
||||
${forms}
|
||||
],
|
||||
}
|
||||
`)
|
||||
})
|
||||
|
||||
return outdent`
|
||||
customFields: {
|
||||
${segments.join("\n")}
|
||||
}
|
||||
`
|
||||
}
|
||||
|
||||
function formatConfig(configs: CustomFieldConfig[] | null): string {
|
||||
if (!configs || configs.length === 0) {
|
||||
return ""
|
||||
}
|
||||
|
||||
return outdent`
|
||||
${configs
|
||||
.map(
|
||||
(config) => outdent`
|
||||
{
|
||||
zone: "${config.zone}",
|
||||
fields: {
|
||||
${config.fields
|
||||
.map(
|
||||
(field) => `${field.name}: {
|
||||
defaultValue: ${field.defaultValue},
|
||||
validation: ${field.validation},
|
||||
}`
|
||||
)
|
||||
.join(",\n")}
|
||||
},
|
||||
}
|
||||
`
|
||||
)
|
||||
.join(",\n")}
|
||||
`
|
||||
}
|
||||
|
||||
function formatForms(forms: CustomFieldFormSection[] | null): string {
|
||||
if (!forms || forms.length === 0) {
|
||||
return ""
|
||||
}
|
||||
|
||||
return forms
|
||||
.map(
|
||||
(form) => outdent`
|
||||
{
|
||||
zone: "${form.zone}",
|
||||
tab: ${form.tab === undefined ? undefined : `"${form.tab}"`},
|
||||
fields: {
|
||||
${form.fields
|
||||
.map(
|
||||
(field) => `${field.name}: {
|
||||
validation: ${field.validation},
|
||||
Component: ${field.Component},
|
||||
label: ${field.label},
|
||||
description: ${field.description},
|
||||
placeholder: ${field.placeholder},
|
||||
}`
|
||||
)
|
||||
.join(",\n")}
|
||||
},
|
||||
}
|
||||
`
|
||||
)
|
||||
.join(",\n")
|
||||
}
|
||||
|
||||
async function getCustomFieldResults(
|
||||
files: string[]
|
||||
): Promise<ParsedCustomFieldConfig[]> {
|
||||
return (
|
||||
await Promise.all(files.map(async (file, index) => parseFile(file, index)))
|
||||
).filter(Boolean) as ParsedCustomFieldConfig[]
|
||||
}
|
||||
|
||||
async function parseFile(
|
||||
file: string,
|
||||
index: number
|
||||
): Promise<ParsedCustomFieldConfig | null> {
|
||||
const content = await fs.readFile(file, "utf8")
|
||||
let ast: ParseResult<File>
|
||||
|
||||
try {
|
||||
ast = parse(content, getParserOptions(file))
|
||||
} catch (e) {
|
||||
logger.error(`An error occurred while parsing the file`, { file, error: e })
|
||||
return null
|
||||
}
|
||||
|
||||
const import_ = generateImport(file, index)
|
||||
|
||||
let configs: CustomFieldConfig[] | null = []
|
||||
let forms: CustomFieldFormSection[] | null = []
|
||||
let model: CustomFieldModel | null = null
|
||||
let hasLink = false
|
||||
try {
|
||||
traverse(ast, {
|
||||
ExportDefaultDeclaration(path) {
|
||||
const _model = getModel(path, file)
|
||||
|
||||
if (!_model) {
|
||||
return
|
||||
}
|
||||
|
||||
model = _model
|
||||
hasLink = validateLink(path, file) // Add this line to validate link
|
||||
configs = getConfigs(path, model, index, file)
|
||||
forms = getForms(path, model, index, file)
|
||||
},
|
||||
})
|
||||
} catch (err) {
|
||||
logger.error(`An error occurred while traversing the file.`, {
|
||||
file,
|
||||
error: err,
|
||||
})
|
||||
return null
|
||||
}
|
||||
|
||||
if (!model) {
|
||||
logger.warn(`'model' property is missing.`, { file })
|
||||
return null
|
||||
}
|
||||
|
||||
if (!hasLink) {
|
||||
logger.warn(`'link' property is missing.`, { file })
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
import: import_,
|
||||
model,
|
||||
configs,
|
||||
forms,
|
||||
}
|
||||
}
|
||||
|
||||
function generateCustomFieldConfigName(index: number): string {
|
||||
return `CustomFieldConfig${index}`
|
||||
}
|
||||
|
||||
function generateImport(file: string, index: number): string {
|
||||
return `import ${generateCustomFieldConfigName(index)} from "${file}"`
|
||||
}
|
||||
|
||||
function getForms(
|
||||
path: NodePath<ExportDefaultDeclaration>,
|
||||
model: CustomFieldModel,
|
||||
index: number,
|
||||
file: string
|
||||
): CustomFieldFormSection[] | null {
|
||||
const formArray = getFormsArgument(path, file)
|
||||
|
||||
if (!formArray) {
|
||||
return null
|
||||
}
|
||||
|
||||
const forms: CustomFieldFormSection[] = []
|
||||
|
||||
formArray.elements.forEach((element, j) => {
|
||||
if (!isObjectExpression(element)) {
|
||||
return
|
||||
}
|
||||
|
||||
const zoneProperty = element.properties.find(
|
||||
(p) => isObjectProperty(p) && isIdentifier(p.key, { name: "zone" })
|
||||
) as ObjectProperty | undefined
|
||||
|
||||
if (!zoneProperty) {
|
||||
logger.warn(
|
||||
`'zone' property is missing from the ${j} index of the 'forms' property. The 'zone' property is required to load a custom field form.`,
|
||||
{ file }
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (!isStringLiteral(zoneProperty.value)) {
|
||||
logger.warn(
|
||||
`'zone' property at the ${j} index of the 'forms' property is not a string literal. The 'zone' property must be a string literal, e.g. 'general' or 'attributes'.`,
|
||||
{ file }
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const tabProperty = element.properties.find(
|
||||
(p) => isObjectProperty(p) && isIdentifier(p.key, { name: "tab" })
|
||||
) as ObjectProperty | undefined
|
||||
|
||||
let tab: string | undefined
|
||||
|
||||
if (tabProperty) {
|
||||
if (!isStringLiteral(tabProperty.value)) {
|
||||
logger.warn(
|
||||
`'tab' property at the ${j} index of the 'forms' property is not a string literal. The 'tab' property must be a string literal, e.g. 'general' or 'attributes'.`,
|
||||
{ file }
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
tab = tabProperty.value.value
|
||||
}
|
||||
|
||||
if (tab && !isValidCustomFieldFormTab(tab)) {
|
||||
logger.warn(
|
||||
`'tab' property at the ${j} index of the 'forms' property is not a valid custom field form tab for the '${model}' model. Received: ${tab}.`,
|
||||
{ file }
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const zone = zoneProperty.value.value
|
||||
const fullPath = getFormEntryFieldPath(model, zone, tab)
|
||||
|
||||
if (
|
||||
!isValidCustomFieldFormZone(zone) ||
|
||||
!isValidCustomFieldFormFieldPath(fullPath)
|
||||
) {
|
||||
logger.warn(
|
||||
`'zone' and 'tab' properties at the ${j} index of the 'forms' property are not a valid for the '${model}' model. Received: { zone: ${zone}, tab: ${tab} }.`,
|
||||
{ file }
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const fieldsObject = element.properties.find(
|
||||
(p) => isObjectProperty(p) && isIdentifier(p.key, { name: "fields" })
|
||||
) as ObjectProperty | undefined
|
||||
|
||||
if (!fieldsObject) {
|
||||
logger.warn(
|
||||
`The 'fields' property is missing at the ${j} index of the 'forms' property. The 'fields' property is required to load a custom field form.`,
|
||||
{ file }
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const fields: CustomFieldFormField[] = []
|
||||
|
||||
if (!isObjectExpression(fieldsObject.value)) {
|
||||
logger.warn(
|
||||
`The 'fields' property at the ${j} index of the 'forms' property is malformed. The 'fields' property must be an object.`,
|
||||
{ file }
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
fieldsObject.value.properties.forEach((field) => {
|
||||
if (!isObjectProperty(field) || !isIdentifier(field.key)) {
|
||||
return
|
||||
}
|
||||
|
||||
const name = field.key.name
|
||||
|
||||
if (
|
||||
!isObjectExpression(field.value) &&
|
||||
!(
|
||||
isCallExpression(field.value) &&
|
||||
isMemberExpression(field.value.callee) &&
|
||||
isIdentifier(field.value.callee.object) &&
|
||||
isIdentifier(field.value.callee.property) &&
|
||||
field.value.callee.object.name === "form" &&
|
||||
field.value.callee.property.name === "define" &&
|
||||
field.value.arguments.length === 1 &&
|
||||
isObjectExpression(field.value.arguments[0])
|
||||
)
|
||||
) {
|
||||
logger.warn(
|
||||
`'${name}' property in the 'fields' property at the ${j} index of the 'forms' property in ${file} is malformed. The property must be an object or a call to form.define().`,
|
||||
{ file }
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const fieldObject = isObjectExpression(field.value)
|
||||
? field.value
|
||||
: (field.value.arguments[0] as ObjectExpression)
|
||||
|
||||
const labelProperty = fieldObject.properties.find(
|
||||
(p) => isObjectProperty(p) && isIdentifier(p.key, { name: "label" })
|
||||
) as ObjectProperty | undefined
|
||||
|
||||
const descriptionProperty = fieldObject.properties.find(
|
||||
(p) =>
|
||||
isObjectProperty(p) && isIdentifier(p.key, { name: "description" })
|
||||
) as ObjectProperty | undefined
|
||||
|
||||
const componentProperty = fieldObject.properties.find(
|
||||
(p) => isObjectProperty(p) && isIdentifier(p.key, { name: "component" })
|
||||
) as ObjectProperty | undefined
|
||||
|
||||
const validationProperty = fieldObject.properties.find(
|
||||
(p) =>
|
||||
isObjectProperty(p) && isIdentifier(p.key, { name: "validation" })
|
||||
) as ObjectProperty | undefined
|
||||
|
||||
const placeholderProperty = fieldObject.properties.find(
|
||||
(p) =>
|
||||
isObjectProperty(p) && isIdentifier(p.key, { name: "placeholder" })
|
||||
) as ObjectProperty | undefined
|
||||
|
||||
const label = getFormFieldSectionValue(
|
||||
!!labelProperty,
|
||||
index,
|
||||
j,
|
||||
name,
|
||||
"label"
|
||||
)
|
||||
const description = getFormFieldSectionValue(
|
||||
!!descriptionProperty,
|
||||
index,
|
||||
j,
|
||||
name,
|
||||
"description"
|
||||
)
|
||||
const placeholder = getFormFieldSectionValue(
|
||||
!!placeholderProperty,
|
||||
index,
|
||||
j,
|
||||
name,
|
||||
"placeholder"
|
||||
)
|
||||
const component = getFormFieldSectionValue(
|
||||
!!componentProperty,
|
||||
index,
|
||||
j,
|
||||
name,
|
||||
"component"
|
||||
)
|
||||
const validation = getFormFieldSectionValue(
|
||||
!!validationProperty,
|
||||
index,
|
||||
j,
|
||||
name,
|
||||
"validation"
|
||||
)
|
||||
|
||||
fields.push({
|
||||
name,
|
||||
label,
|
||||
description,
|
||||
Component: component,
|
||||
validation,
|
||||
placeholder,
|
||||
})
|
||||
})
|
||||
|
||||
forms.push({
|
||||
zone,
|
||||
tab: tab as CustomFieldFormTab | undefined,
|
||||
fields,
|
||||
})
|
||||
})
|
||||
|
||||
return forms.length > 0 ? forms : null
|
||||
}
|
||||
|
||||
function getFormFieldSectionValue(
|
||||
exists: boolean,
|
||||
fileIndex: number,
|
||||
formIndex: number,
|
||||
fieldKey: string,
|
||||
value: string
|
||||
): string {
|
||||
if (!exists) {
|
||||
return "undefined"
|
||||
}
|
||||
|
||||
const import_ = generateCustomFieldConfigName(fileIndex)
|
||||
return `${import_}.forms[${formIndex}].fields.${fieldKey}.${value}`
|
||||
}
|
||||
|
||||
function getFormEntryFieldPath(
|
||||
model: CustomFieldModel,
|
||||
zone: string,
|
||||
tab?: string
|
||||
): string {
|
||||
return `${model}.${zone}.${tab ? `${tab}.` : ""}$field`
|
||||
}
|
||||
|
||||
function getConfigs(
|
||||
path: NodePath<ExportDefaultDeclaration>,
|
||||
model: CustomFieldModel,
|
||||
index: number,
|
||||
file: string
|
||||
): CustomFieldConfig[] | null {
|
||||
const formArray = getFormsArgument(path, file)
|
||||
|
||||
if (!formArray) {
|
||||
logger.warn(`'forms' property is missing.`, { file })
|
||||
return null
|
||||
}
|
||||
|
||||
const configs: CustomFieldConfig[] = []
|
||||
|
||||
formArray.elements.forEach((element, j) => {
|
||||
if (!isObjectExpression(element)) {
|
||||
logger.warn(
|
||||
`'forms' property at the ${j} index is malformed. The 'forms' property must be an object.`,
|
||||
{ file }
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const zoneProperty = element.properties.find(
|
||||
(p) => isObjectProperty(p) && isIdentifier(p.key, { name: "zone" })
|
||||
) as ObjectProperty | undefined
|
||||
|
||||
if (!zoneProperty) {
|
||||
logger.warn(
|
||||
`'zone' property is missing from the ${j} index of the 'forms' property.`,
|
||||
{ file }
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (isTemplateLiteral(zoneProperty.value)) {
|
||||
logger.warn(
|
||||
`'zone' property at the ${j} index of the 'forms' property cannot be a template literal (e.g. \`general\`).`,
|
||||
{ file }
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (!isStringLiteral(zoneProperty.value)) {
|
||||
logger.warn(
|
||||
`'zone' property at the ${j} index of the 'forms' property is not a string literal (e.g. 'general' or 'attributes').`,
|
||||
{ file }
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const zone = zoneProperty.value.value
|
||||
const fullPath = getFormEntryConfigPath(model, zone)
|
||||
|
||||
if (
|
||||
!isValidCustomFieldFormZone(zone) ||
|
||||
!isValidCustomFieldFormConfigPath(fullPath)
|
||||
) {
|
||||
logger.warn(
|
||||
`'zone' property at the ${j} index of the 'forms' property is not a valid custom field form zone for the '${model}' model. Received: ${zone}.`
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const fieldsObject = element.properties.find(
|
||||
(p) => isObjectProperty(p) && isIdentifier(p.key, { name: "fields" })
|
||||
) as ObjectProperty | undefined
|
||||
|
||||
if (!fieldsObject) {
|
||||
logger.warn(
|
||||
`'fields' property is missing from the ${j} entry in the 'forms' property in ${file}.`,
|
||||
{ file }
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const fields: CustomFieldConfigField[] = []
|
||||
|
||||
if (!isObjectExpression(fieldsObject.value)) {
|
||||
logger.warn(
|
||||
`'fields' property at the ${j} index of the 'forms' property is malformed. The 'fields' property must be an object.`,
|
||||
{ file }
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
fieldsObject.value.properties.forEach((field) => {
|
||||
if (!isObjectProperty(field) || !isIdentifier(field.key)) {
|
||||
return
|
||||
}
|
||||
|
||||
const name = field.key.name
|
||||
|
||||
if (
|
||||
!isObjectExpression(field.value) &&
|
||||
!(
|
||||
isCallExpression(field.value) &&
|
||||
isMemberExpression(field.value.callee) &&
|
||||
isIdentifier(field.value.callee.object) &&
|
||||
isIdentifier(field.value.callee.property) &&
|
||||
field.value.callee.object.name === "form" &&
|
||||
field.value.callee.property.name === "define" &&
|
||||
field.value.arguments.length === 1 &&
|
||||
isObjectExpression(field.value.arguments[0])
|
||||
)
|
||||
) {
|
||||
logger.warn(
|
||||
`'${name}' property in the 'fields' property at the ${j} index of the 'forms' property in ${file} is malformed. The property must be an object or a call to form.define().`,
|
||||
{ file }
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const fieldObject = isObjectExpression(field.value)
|
||||
? field.value
|
||||
: (field.value.arguments[0] as ObjectExpression)
|
||||
|
||||
const defaultValueProperty = fieldObject.properties.find(
|
||||
(p) =>
|
||||
isObjectProperty(p) && isIdentifier(p.key, { name: "defaultValue" })
|
||||
) as ObjectProperty | undefined
|
||||
|
||||
if (!defaultValueProperty) {
|
||||
logger.warn(
|
||||
`'defaultValue' property is missing at the ${j} index of the 'forms' property in ${file}.`,
|
||||
{ file }
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const validationProperty = fieldObject.properties.find(
|
||||
(p) =>
|
||||
isObjectProperty(p) && isIdentifier(p.key, { name: "validation" })
|
||||
) as ObjectProperty | undefined
|
||||
|
||||
if (!validationProperty) {
|
||||
logger.warn(
|
||||
`'validation' property is missing at the ${j} index of the 'forms' property in ${file}.`,
|
||||
{ file }
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const defaultValue = getFormFieldValue(index, j, name, "defaultValue")
|
||||
const validation = getFormFieldValue(index, j, name, "validation")
|
||||
|
||||
fields.push({
|
||||
name,
|
||||
defaultValue,
|
||||
validation,
|
||||
})
|
||||
})
|
||||
|
||||
configs.push({
|
||||
zone: zone,
|
||||
fields,
|
||||
})
|
||||
})
|
||||
|
||||
return configs.length > 0 ? configs : null
|
||||
}
|
||||
|
||||
function getFormFieldValue(
|
||||
fileIndex: number,
|
||||
formIndex: number,
|
||||
fieldKey: string,
|
||||
value: string
|
||||
): string {
|
||||
const import_ = generateCustomFieldConfigName(fileIndex)
|
||||
return `${import_}.forms[${formIndex}].fields.${fieldKey}.${value}`
|
||||
}
|
||||
|
||||
function getFormEntryConfigPath(model: CustomFieldModel, zone: string): string {
|
||||
return `${model}.${zone}.$config`
|
||||
}
|
||||
|
||||
function getFormsArgument(
|
||||
path: NodePath<ExportDefaultDeclaration>,
|
||||
file: string
|
||||
): ArrayExpression | null {
|
||||
const configArgument = getConfigArgument(path)
|
||||
|
||||
if (!configArgument) {
|
||||
return null
|
||||
}
|
||||
|
||||
const formProperty = configArgument.properties.find(
|
||||
(p) => isObjectProperty(p) && isIdentifier(p.key, { name: "forms" })
|
||||
) as ObjectProperty | undefined
|
||||
|
||||
if (!formProperty) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (!isArrayExpression(formProperty.value)) {
|
||||
logger.warn(
|
||||
`The 'forms' property is malformed. The 'forms' property must be an array of objects.`,
|
||||
{ file }
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
return formProperty.value
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import fs from "fs/promises"
|
||||
import { isIdentifier, isObjectProperty, parse, traverse } from "../babel"
|
||||
import { logger } from "../logger"
|
||||
import { crawl, generateHash, getParserOptions } from "../utils"
|
||||
import { getConfigArgument } from "./helpers"
|
||||
|
||||
export async function generateCustomFieldHashes(
|
||||
sources: Set<string>
|
||||
): Promise<{ linkHash: string; formHash: string; displayHash: string }> {
|
||||
const files = await getFilesFromSources(sources)
|
||||
const contents = await Promise.all(files.map(getCustomFieldContents))
|
||||
|
||||
const linkContents = contents.map((c) => c.link).filter(Boolean)
|
||||
const formContents = contents.map((c) => c.form).filter(Boolean)
|
||||
const displayContents = contents.map((c) => c.display).filter(Boolean)
|
||||
|
||||
const totalLinkContent = linkContents.join("")
|
||||
const totalFormContent = formContents.join("")
|
||||
const totalDisplayContent = displayContents.join("")
|
||||
|
||||
return {
|
||||
linkHash: generateHash(totalLinkContent),
|
||||
formHash: generateHash(totalFormContent),
|
||||
displayHash: generateHash(totalDisplayContent),
|
||||
}
|
||||
}
|
||||
|
||||
async function getFilesFromSources(sources: Set<string>): Promise<string[]> {
|
||||
return (
|
||||
await Promise.all(
|
||||
Array.from(sources).map(async (source) =>
|
||||
crawl(`${source}/custom-fields`)
|
||||
)
|
||||
)
|
||||
).flat()
|
||||
}
|
||||
|
||||
async function getCustomFieldContents(file: string): Promise<{
|
||||
link: string | null
|
||||
form: string | null
|
||||
display: string | null
|
||||
}> {
|
||||
const code = await fs.readFile(file, "utf-8")
|
||||
const ast = parse(code, getParserOptions(file))
|
||||
|
||||
let linkContent: string | null = null
|
||||
let formContent: string | null = null
|
||||
let displayContent: string | null = null
|
||||
|
||||
try {
|
||||
traverse(ast, {
|
||||
ExportDefaultDeclaration(path) {
|
||||
const configArgument = getConfigArgument(path)
|
||||
if (!configArgument) {
|
||||
return
|
||||
}
|
||||
|
||||
configArgument.properties.forEach((prop) => {
|
||||
if (!isObjectProperty(prop) || !prop.key || !isIdentifier(prop.key)) {
|
||||
return
|
||||
}
|
||||
|
||||
switch (prop.key.name) {
|
||||
case "link":
|
||||
linkContent = code.slice(prop.start!, prop.end!)
|
||||
break
|
||||
case "forms":
|
||||
formContent = code.slice(prop.start!, prop.end!)
|
||||
break
|
||||
case "display":
|
||||
displayContent = code.slice(prop.start!, prop.end!)
|
||||
break
|
||||
}
|
||||
})
|
||||
},
|
||||
})
|
||||
} catch (e) {
|
||||
logger.error(
|
||||
`An error occurred while processing ${file}. See the below error for more details:\n${e}`,
|
||||
{ file, error: e }
|
||||
)
|
||||
return { link: null, form: null, display: null }
|
||||
}
|
||||
|
||||
return { link: linkContent, form: formContent, display: displayContent }
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
import { CustomFieldModel } from "@medusajs/admin-shared"
|
||||
import fs from "fs/promises"
|
||||
import {
|
||||
ExportDefaultDeclaration,
|
||||
File,
|
||||
isIdentifier,
|
||||
isObjectProperty,
|
||||
NodePath,
|
||||
ObjectProperty,
|
||||
parse,
|
||||
ParseResult,
|
||||
traverse,
|
||||
} from "../babel"
|
||||
import { logger } from "../logger"
|
||||
import { crawl, getParserOptions } from "../utils"
|
||||
import { getConfigArgument, getModel } from "./helpers"
|
||||
|
||||
type ParsedCustomFieldLink = {
|
||||
import: string
|
||||
model: CustomFieldModel
|
||||
link: string
|
||||
}
|
||||
|
||||
export async function generateCustomFieldLinks(sources: Set<string>) {
|
||||
const files = await getFilesFromSources(sources)
|
||||
const results = await getCustomFieldLinkResults(files)
|
||||
|
||||
const imports = results.map((result) => result.import)
|
||||
const code = generateCode(results)
|
||||
|
||||
return {
|
||||
imports,
|
||||
code,
|
||||
}
|
||||
}
|
||||
|
||||
async function getFilesFromSources(sources: Set<string>): Promise<string[]> {
|
||||
const files = (
|
||||
await Promise.all(
|
||||
Array.from(sources).map(async (source) =>
|
||||
crawl(`${source}/custom-fields`)
|
||||
)
|
||||
)
|
||||
).flat()
|
||||
return files
|
||||
}
|
||||
|
||||
function generateCode(results: ParsedCustomFieldLink[]): string {
|
||||
const groupedByModel = new Map<CustomFieldModel, ParsedCustomFieldLink[]>()
|
||||
|
||||
results.forEach((result) => {
|
||||
const model = result.model
|
||||
if (!groupedByModel.has(model)) {
|
||||
groupedByModel.set(model, [])
|
||||
}
|
||||
groupedByModel.get(model)!.push(result)
|
||||
})
|
||||
|
||||
const segments: string[] = []
|
||||
|
||||
groupedByModel.forEach((results, model) => {
|
||||
const links = results.map((result) => result.link).join(",\n")
|
||||
|
||||
segments.push(`
|
||||
${model}: [
|
||||
${links}
|
||||
],
|
||||
`)
|
||||
})
|
||||
|
||||
return `
|
||||
links: {
|
||||
${segments.join("\n")}
|
||||
}
|
||||
`
|
||||
}
|
||||
|
||||
async function getCustomFieldLinkResults(
|
||||
files: string[]
|
||||
): Promise<ParsedCustomFieldLink[]> {
|
||||
return (
|
||||
await Promise.all(files.map(async (file, index) => parseFile(file, index)))
|
||||
).filter(Boolean) as ParsedCustomFieldLink[]
|
||||
}
|
||||
|
||||
async function parseFile(
|
||||
file: string,
|
||||
index: number
|
||||
): Promise<ParsedCustomFieldLink | null> {
|
||||
const content = await fs.readFile(file, "utf8")
|
||||
let ast: ParseResult<File>
|
||||
|
||||
try {
|
||||
ast = parse(content, getParserOptions(file))
|
||||
} catch (e) {
|
||||
logger.error(`An error occurred while parsing the file`, { file, error: e })
|
||||
return null
|
||||
}
|
||||
|
||||
const import_ = generateImport(file, index)
|
||||
|
||||
let link: string | null = null
|
||||
let model: CustomFieldModel | null = null
|
||||
try {
|
||||
traverse(ast, {
|
||||
ExportDefaultDeclaration(path) {
|
||||
const _model = getModel(path, file)
|
||||
|
||||
if (!_model) {
|
||||
return
|
||||
}
|
||||
|
||||
model = _model
|
||||
link = getLink(path, index, file)
|
||||
},
|
||||
})
|
||||
} catch (err) {
|
||||
logger.error(`An error occurred while traversing the file.`, {
|
||||
file,
|
||||
error: err,
|
||||
})
|
||||
return null
|
||||
}
|
||||
|
||||
if (!link || !model) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
import: import_,
|
||||
model,
|
||||
link,
|
||||
}
|
||||
}
|
||||
|
||||
function generateCustomFieldConfigName(index: number): string {
|
||||
return `CustomFieldConfig${index}`
|
||||
}
|
||||
|
||||
function generateImport(file: string, index: number): string {
|
||||
return `import ${generateCustomFieldConfigName(index)} from "${file}"`
|
||||
}
|
||||
|
||||
function getLink(
|
||||
path: NodePath<ExportDefaultDeclaration>,
|
||||
index: number,
|
||||
file: string
|
||||
): string | null {
|
||||
const configArgument = getConfigArgument(path)
|
||||
|
||||
if (!configArgument) {
|
||||
return null
|
||||
}
|
||||
|
||||
const linkProperty = configArgument.properties.find(
|
||||
(p) => isObjectProperty(p) && isIdentifier(p.key, { name: "link" })
|
||||
) as ObjectProperty | undefined
|
||||
|
||||
if (!linkProperty) {
|
||||
logger.warn(`'link' is missing.`, { file })
|
||||
return null
|
||||
}
|
||||
|
||||
const import_ = generateCustomFieldConfigName(index)
|
||||
|
||||
return `${import_}.link`
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import {
|
||||
CustomFieldModel,
|
||||
isValidCustomFieldModel,
|
||||
} from "@medusajs/admin-shared"
|
||||
import {
|
||||
ExportDefaultDeclaration,
|
||||
isCallExpression,
|
||||
isIdentifier,
|
||||
isObjectExpression,
|
||||
isObjectProperty,
|
||||
isStringLiteral,
|
||||
isTemplateLiteral,
|
||||
NodePath,
|
||||
ObjectExpression,
|
||||
ObjectProperty,
|
||||
} from "../babel"
|
||||
import { logger } from "../logger"
|
||||
|
||||
export function getModel(
|
||||
path: NodePath<ExportDefaultDeclaration>,
|
||||
file: string
|
||||
): CustomFieldModel | null {
|
||||
const configArgument = getConfigArgument(path)
|
||||
|
||||
if (!configArgument) {
|
||||
return null
|
||||
}
|
||||
|
||||
const modelProperty = configArgument.properties.find(
|
||||
(p) => isObjectProperty(p) && isIdentifier(p.key, { name: "model" })
|
||||
) as ObjectProperty | undefined
|
||||
|
||||
if (!modelProperty) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (isTemplateLiteral(modelProperty.value)) {
|
||||
logger.warn(
|
||||
`'model' property cannot be a template literal (e.g. \`product\`).`,
|
||||
{ file }
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
if (!isStringLiteral(modelProperty.value)) {
|
||||
logger.warn(
|
||||
`'model' is invalid. The 'model' property must be a string literal, e.g. 'product' or 'customer'.`,
|
||||
{ file }
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
const model = modelProperty.value.value.trim()
|
||||
|
||||
if (!isValidCustomFieldModel(model)) {
|
||||
logger.warn(
|
||||
`'model' is invalid, received: ${model}. The 'model' property must be set to a valid model, e.g. 'product' or 'customer'.`,
|
||||
{ file }
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
return model
|
||||
}
|
||||
|
||||
export function getConfigArgument(
|
||||
path: NodePath<ExportDefaultDeclaration>
|
||||
): ObjectExpression | null {
|
||||
if (!isCallExpression(path.node.declaration)) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (
|
||||
!isIdentifier(path.node.declaration.callee, {
|
||||
name: "unstable_defineCustomFieldsConfig",
|
||||
})
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
const configArgument = path.node.declaration.arguments[0]
|
||||
|
||||
if (!isObjectExpression(configArgument)) {
|
||||
return null
|
||||
}
|
||||
|
||||
return configArgument
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that the 'link' property is present in the custom field config.
|
||||
* @param path - The NodePath to the export default declaration.
|
||||
* @param file - The file path.
|
||||
* @returns - True if the 'link' property is present, false otherwise.
|
||||
*/
|
||||
export function validateLink(
|
||||
path: NodePath<ExportDefaultDeclaration>,
|
||||
file: string
|
||||
): boolean {
|
||||
const configArgument = getConfigArgument(path)
|
||||
|
||||
if (!configArgument) {
|
||||
return false
|
||||
}
|
||||
|
||||
const linkProperty = configArgument.properties.find(
|
||||
(p) => isObjectProperty(p) && isIdentifier(p.key, { name: "link" })
|
||||
) as ObjectProperty | undefined
|
||||
|
||||
if (!linkProperty) {
|
||||
logger.warn(`'link' property is missing.`, { file })
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from "./generate-custom-field-displays"
|
||||
export * from "./generate-custom-field-forms"
|
||||
export * from "./generate-custom-field-hashes"
|
||||
export * from "./generate-custom-field-links"
|
||||
@@ -1,4 +1,5 @@
|
||||
import { medusaVitePlugin, type MedusaVitePlugin } from "./plugin"
|
||||
import { medusaVitePlugin } from "./plugin"
|
||||
import type { MedusaVitePlugin } from "./types"
|
||||
|
||||
export default medusaVitePlugin
|
||||
export type { MedusaVitePlugin }
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import colors from "picocolors"
|
||||
|
||||
type LoggerOptions = {
|
||||
file?: string | string[]
|
||||
error?: any
|
||||
}
|
||||
|
||||
function getTimestamp(): string {
|
||||
const now = new Date()
|
||||
return now.toLocaleTimeString("en-US", { hour12: true })
|
||||
}
|
||||
|
||||
function getPrefix(type: "warn" | "info" | "error") {
|
||||
const timestamp = colors.dim(getTimestamp())
|
||||
const typeColor =
|
||||
type === "warn"
|
||||
? colors.yellow
|
||||
: type === "info"
|
||||
? colors.green
|
||||
: colors.red
|
||||
|
||||
const prefix = typeColor("[@medusajs/admin-vite-plugin]")
|
||||
|
||||
return `${timestamp} ${prefix}`
|
||||
}
|
||||
|
||||
function getFile(options: LoggerOptions): string {
|
||||
if (!options.file) {
|
||||
return ""
|
||||
}
|
||||
|
||||
const value = Array.isArray(options.file)
|
||||
? options.file.map((f) => f).join(", ")
|
||||
: options.file
|
||||
|
||||
return colors.dim(`${value}`)
|
||||
}
|
||||
|
||||
function formatError(error: any): string {
|
||||
if (error instanceof Error) {
|
||||
return colors.red(`${error.name}: ${error.message}\n${error.stack}`)
|
||||
} else if (typeof error === "object") {
|
||||
return colors.red(JSON.stringify(error, null, 2))
|
||||
} else {
|
||||
return colors.red(String(error))
|
||||
}
|
||||
}
|
||||
|
||||
const logger = {
|
||||
warn(msg: string, options: LoggerOptions = {}) {
|
||||
console.warn(`${getPrefix("warn")} ${msg} ${getFile(options)}`)
|
||||
},
|
||||
info(msg: string, options: LoggerOptions = {}) {
|
||||
console.info(`${getPrefix("info")} ${msg} ${getFile(options)}`)
|
||||
},
|
||||
error(msg: string, options: LoggerOptions = {}) {
|
||||
console.error(`${getPrefix("error")} ${msg} ${getFile(options)}`)
|
||||
if (options.error) {
|
||||
console.error(formatError(options.error))
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
export { logger }
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,157 @@
|
||||
import fs from "fs/promises"
|
||||
import { outdent } from "outdent"
|
||||
import { isIdentifier, isObjectProperty, parse, traverse } from "../babel"
|
||||
import { logger } from "../logger"
|
||||
import { crawl, getConfigObjectProperties, getParserOptions } from "../utils"
|
||||
import { getRoute } from "./helpers"
|
||||
|
||||
type MenuItem = {
|
||||
icon?: string
|
||||
label: string
|
||||
path: string
|
||||
}
|
||||
|
||||
type MenuItemResult = {
|
||||
import: string
|
||||
menuItem: MenuItem
|
||||
}
|
||||
|
||||
export async function generateMenuItems(sources: Set<string>) {
|
||||
const files = await getFilesFromSources(sources)
|
||||
const results = await getMenuItemResults(files)
|
||||
|
||||
const imports = results.map((result) => result.import).flat()
|
||||
const code = generateCode(results)
|
||||
|
||||
return {
|
||||
imports,
|
||||
code,
|
||||
}
|
||||
}
|
||||
|
||||
function generateCode(results: MenuItemResult[]): string {
|
||||
return outdent`
|
||||
menuItems: [
|
||||
${results
|
||||
.map((result) => formatMenuItem(result.menuItem))
|
||||
.join(",\n")}
|
||||
]
|
||||
}
|
||||
`
|
||||
}
|
||||
|
||||
function formatMenuItem(route: MenuItem): string {
|
||||
return `{
|
||||
label: ${route.label},
|
||||
icon: ${route.icon ? route.icon : "undefined"},
|
||||
path: "${route.path}",
|
||||
}`
|
||||
}
|
||||
|
||||
async function getFilesFromSources(sources: Set<string>): Promise<string[]> {
|
||||
const files = (
|
||||
await Promise.all(
|
||||
Array.from(sources).map(async (source) =>
|
||||
crawl(`${source}/routes`, "page", { min: 1 })
|
||||
)
|
||||
)
|
||||
).flat()
|
||||
return files
|
||||
}
|
||||
|
||||
async function getMenuItemResults(files: string[]): Promise<MenuItemResult[]> {
|
||||
const results = await Promise.all(files.map(parseFile))
|
||||
return results.filter((item): item is MenuItemResult => item !== null)
|
||||
}
|
||||
|
||||
async function parseFile(
|
||||
file: string,
|
||||
index: number
|
||||
): Promise<MenuItemResult | null> {
|
||||
const config = await getRouteConfig(file)
|
||||
|
||||
if (!config) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (!config.label) {
|
||||
logger.warn(`Config is missing a label.`, {
|
||||
file,
|
||||
})
|
||||
}
|
||||
|
||||
const import_ = generateImport(file, index)
|
||||
const menuItem = generateMenuItem(config, file, index)
|
||||
|
||||
return {
|
||||
import: import_,
|
||||
menuItem,
|
||||
}
|
||||
}
|
||||
|
||||
function generateImport(file: string, index: number): string {
|
||||
return `import { config as ${generateRouteConfigName(index)} } from "${file}"`
|
||||
}
|
||||
|
||||
function generateMenuItem(
|
||||
config: { label: boolean; icon: boolean },
|
||||
file: string,
|
||||
index: number
|
||||
): MenuItem {
|
||||
const configName = generateRouteConfigName(index)
|
||||
const routePath = getRoute(file)
|
||||
|
||||
return {
|
||||
label: `${configName}.label`,
|
||||
icon: config.icon ? `${configName}.icon` : undefined,
|
||||
path: routePath,
|
||||
}
|
||||
}
|
||||
|
||||
async function getRouteConfig(
|
||||
file: string
|
||||
): Promise<{ label: boolean; icon: boolean } | null> {
|
||||
const code = await fs.readFile(file, "utf-8")
|
||||
const ast = parse(code, getParserOptions(file))
|
||||
|
||||
let config: { label: boolean; icon: boolean } | null = null
|
||||
|
||||
try {
|
||||
traverse(ast, {
|
||||
ExportNamedDeclaration(path) {
|
||||
const properties = getConfigObjectProperties(path)
|
||||
|
||||
if (!properties) {
|
||||
return
|
||||
}
|
||||
|
||||
const hasLabel = properties.some(
|
||||
(prop) =>
|
||||
isObjectProperty(prop) && isIdentifier(prop.key, { name: "label" })
|
||||
)
|
||||
|
||||
if (!hasLabel) {
|
||||
return
|
||||
}
|
||||
|
||||
const hasIcon = properties.some(
|
||||
(prop) =>
|
||||
isObjectProperty(prop) && isIdentifier(prop.key, { name: "icon" })
|
||||
)
|
||||
|
||||
config = { label: hasLabel, icon: hasIcon }
|
||||
},
|
||||
})
|
||||
} catch (e) {
|
||||
logger.error(`An error occurred while traversing the file.`, {
|
||||
file,
|
||||
error: e,
|
||||
})
|
||||
}
|
||||
|
||||
return config
|
||||
}
|
||||
|
||||
function generateRouteConfigName(index: number): string {
|
||||
return `RouteConfig${index}`
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import fs from "fs/promises"
|
||||
import { parse, traverse } from "../babel"
|
||||
import { logger } from "../logger"
|
||||
import {
|
||||
crawl,
|
||||
generateHash,
|
||||
getConfigObjectProperties,
|
||||
getParserOptions,
|
||||
} from "../utils"
|
||||
|
||||
export async function generateRouteHashes(
|
||||
sources: Set<string>
|
||||
): Promise<{ defaultExportHash: string; configHash: string }> {
|
||||
const files = await getFilesFromSources(sources)
|
||||
const contents = await Promise.all(files.map(getRouteContents))
|
||||
|
||||
const defaultExportContents = contents
|
||||
.map((c) => c.defaultExport)
|
||||
.filter(Boolean)
|
||||
const configContents = contents.map((c) => c.config).filter(Boolean)
|
||||
|
||||
const totalDefaultExportContent = defaultExportContents.join("")
|
||||
const totalConfigContent = configContents.join("")
|
||||
|
||||
return {
|
||||
defaultExportHash: generateHash(totalDefaultExportContent),
|
||||
configHash: generateHash(totalConfigContent),
|
||||
}
|
||||
}
|
||||
|
||||
async function getFilesFromSources(sources: Set<string>): Promise<string[]> {
|
||||
return (
|
||||
await Promise.all(
|
||||
Array.from(sources).map(async (source) =>
|
||||
crawl(`${source}/routes`, "page", { min: 1 })
|
||||
)
|
||||
)
|
||||
).flat()
|
||||
}
|
||||
|
||||
async function getRouteContents(
|
||||
file: string
|
||||
): Promise<{ defaultExport: string | null; config: string | null }> {
|
||||
const code = await fs.readFile(file, "utf-8")
|
||||
const ast = parse(code, getParserOptions(file))
|
||||
|
||||
let defaultExportContent: string | null = null
|
||||
let configContent: string | null = null
|
||||
|
||||
try {
|
||||
traverse(ast, {
|
||||
ExportDefaultDeclaration(path) {
|
||||
defaultExportContent = code.slice(path.node.start!, path.node.end!)
|
||||
},
|
||||
ExportNamedDeclaration(path) {
|
||||
const properties = getConfigObjectProperties(path)
|
||||
if (properties) {
|
||||
configContent = code.slice(path.node.start!, path.node.end!)
|
||||
}
|
||||
},
|
||||
})
|
||||
} catch (e) {
|
||||
logger.error(
|
||||
`An error occurred while processing ${file}. See the below error for more details:\n${e}`,
|
||||
{ file, error: e }
|
||||
)
|
||||
return { defaultExport: null, config: null }
|
||||
}
|
||||
|
||||
return { defaultExport: defaultExportContent, config: configContent }
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import fs from "fs/promises"
|
||||
import { outdent } from "outdent"
|
||||
import { parse } from "../babel"
|
||||
import { logger } from "../logger"
|
||||
import {
|
||||
crawl,
|
||||
getParserOptions,
|
||||
hasDefaultExport,
|
||||
normalizePath,
|
||||
} from "../utils"
|
||||
import { getRoute } from "./helpers"
|
||||
|
||||
type Route = {
|
||||
Component: string
|
||||
loader?: string
|
||||
path: string
|
||||
}
|
||||
|
||||
type RouteResult = {
|
||||
imports: string[]
|
||||
route: Route
|
||||
}
|
||||
|
||||
export async function generateRoutes(sources: Set<string>) {
|
||||
const files = await getFilesFromSources(sources)
|
||||
const results = await getRouteResults(files)
|
||||
|
||||
const imports = results.map((result) => result.imports).flat()
|
||||
const code = generateCode(results)
|
||||
|
||||
return {
|
||||
imports,
|
||||
code,
|
||||
}
|
||||
}
|
||||
|
||||
function generateCode(results: RouteResult[]): string {
|
||||
return outdent`
|
||||
routes: [
|
||||
${results.map((result) => formatRoute(result.route)).join(",\n")}
|
||||
]
|
||||
}
|
||||
`
|
||||
}
|
||||
|
||||
function formatRoute(route: Route): string {
|
||||
return `{
|
||||
Component: ${route.Component},
|
||||
loader: ${route.loader ? route.loader : "undefined"},
|
||||
path: "${route.path}",
|
||||
}`
|
||||
}
|
||||
|
||||
async function getFilesFromSources(sources: Set<string>): Promise<string[]> {
|
||||
const files = (
|
||||
await Promise.all(
|
||||
Array.from(sources).map(async (source) =>
|
||||
crawl(`${source}/routes`, "page", { min: 1 })
|
||||
)
|
||||
)
|
||||
).flat()
|
||||
return files
|
||||
}
|
||||
|
||||
async function getRouteResults(files: string[]): Promise<RouteResult[]> {
|
||||
const results = (await Promise.all(files.map(parseFile))).filter(
|
||||
(result): result is RouteResult => result !== null
|
||||
)
|
||||
return results
|
||||
}
|
||||
|
||||
async function parseFile(
|
||||
file: string,
|
||||
index: number
|
||||
): Promise<RouteResult | null> {
|
||||
if (!(await isValidRouteFile(file))) {
|
||||
return null
|
||||
}
|
||||
|
||||
const loaderPath = await getLoader(file)
|
||||
const routePath = getRoute(file)
|
||||
|
||||
const imports = generateImports(file, loaderPath, index)
|
||||
const route = generateRoute(routePath, loaderPath, index)
|
||||
|
||||
return {
|
||||
imports,
|
||||
route,
|
||||
}
|
||||
}
|
||||
|
||||
async function isValidRouteFile(file: string): Promise<boolean> {
|
||||
const code = await fs.readFile(file, "utf-8")
|
||||
const ast = parse(code, getParserOptions(file))
|
||||
|
||||
try {
|
||||
return await hasDefaultExport(ast)
|
||||
} catch (e) {
|
||||
logger.error(
|
||||
`An error occurred while checking for a default export in ${file}. The file will be ignored. See the below error for more details:\n${e}`
|
||||
)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function getLoader(file: string): Promise<string | null> {
|
||||
const loaderExtensions = ["ts", "js", "tsx", "jsx"]
|
||||
for (const ext of loaderExtensions) {
|
||||
const loaderPath = file.replace(/\/page\.(tsx|jsx)/, `/loader.${ext}`)
|
||||
const exists = await fs.stat(loaderPath).catch(() => null)
|
||||
if (exists) {
|
||||
return loaderPath
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function generateImports(
|
||||
file: string,
|
||||
loader: string | null,
|
||||
index: number
|
||||
): string[] {
|
||||
const imports: string[] = []
|
||||
const route = generateRouteComponentName(index)
|
||||
const importPath = normalizePath(file)
|
||||
|
||||
imports.push(`import ${route} from "${importPath}"`)
|
||||
|
||||
if (loader) {
|
||||
const loaderName = generateRouteLoaderName(index)
|
||||
imports.push(`import ${loaderName} from "${normalizePath(loader)}"`)
|
||||
}
|
||||
|
||||
return imports
|
||||
}
|
||||
|
||||
function generateRoute(
|
||||
route: string,
|
||||
loader: string | null,
|
||||
index: number
|
||||
): Route {
|
||||
return {
|
||||
Component: generateRouteComponentName(index),
|
||||
loader: loader ? generateRouteLoaderName(index) : undefined,
|
||||
path: route,
|
||||
}
|
||||
}
|
||||
|
||||
function generateRouteComponentName(index: number): string {
|
||||
return `RouteComponent${index}`
|
||||
}
|
||||
|
||||
function generateRouteLoaderName(index: number): string {
|
||||
return `RouteLoader${index}`
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { normalizePath } from "../utils"
|
||||
|
||||
export function getRoute(file: string): string {
|
||||
const importPath = normalizePath(file)
|
||||
return importPath
|
||||
.replace(/.*\/admin\/(routes)/, "")
|
||||
.replace(/\[([^\]]+)\]/g, ":$1")
|
||||
.replace(/\/page\.(tsx|jsx)/, "")
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from "./generate-menu-items"
|
||||
export * from "./generate-route-hashes"
|
||||
export * from "./generate-routes"
|
||||
@@ -0,0 +1,61 @@
|
||||
import type {
|
||||
CustomFieldFormTab,
|
||||
CustomFieldModel,
|
||||
CustomFieldZone,
|
||||
InjectionZone,
|
||||
} from "@medusajs/admin-shared"
|
||||
import type * as Vite from "vite"
|
||||
|
||||
export type ExtensionGraph = Map<string, Set<string>>
|
||||
|
||||
export type CustomFieldLinkPath = {
|
||||
model: CustomFieldModel
|
||||
}
|
||||
|
||||
export type CustomFieldDisplayPath = {
|
||||
model: CustomFieldModel
|
||||
zone: CustomFieldZone
|
||||
}
|
||||
|
||||
export type CustomFieldConfigPath = {
|
||||
model: CustomFieldModel
|
||||
zone: CustomFieldZone
|
||||
}
|
||||
|
||||
export type CustomFieldFieldPath = {
|
||||
model: CustomFieldModel
|
||||
zone: CustomFieldZone
|
||||
tab?: CustomFieldFormTab
|
||||
}
|
||||
|
||||
export type LoadModuleOptions =
|
||||
| {
|
||||
type: "widget"
|
||||
get: InjectionZone
|
||||
}
|
||||
| {
|
||||
type: "route"
|
||||
get: "page" | "link"
|
||||
}
|
||||
| {
|
||||
type: "link"
|
||||
get: CustomFieldLinkPath
|
||||
}
|
||||
| {
|
||||
type: "field"
|
||||
get: CustomFieldFieldPath
|
||||
}
|
||||
| {
|
||||
type: "config"
|
||||
get: CustomFieldConfigPath
|
||||
}
|
||||
| {
|
||||
type: "display"
|
||||
get: CustomFieldDisplayPath
|
||||
}
|
||||
|
||||
export interface MedusaVitePluginOptions {
|
||||
sources?: string[]
|
||||
}
|
||||
|
||||
export type MedusaVitePlugin = (config?: MedusaVitePluginOptions) => Vite.Plugin
|
||||
@@ -0,0 +1,147 @@
|
||||
import { fdir } from "fdir"
|
||||
import MagicString from "magic-string"
|
||||
import crypto from "node:crypto"
|
||||
import path from "path"
|
||||
import {
|
||||
File,
|
||||
isCallExpression,
|
||||
isIdentifier,
|
||||
isObjectExpression,
|
||||
isVariableDeclaration,
|
||||
isVariableDeclarator,
|
||||
ParseResult,
|
||||
traverse,
|
||||
type ExportNamedDeclaration,
|
||||
type NodePath,
|
||||
type ParserOptions,
|
||||
} from "./babel"
|
||||
|
||||
export function normalizePath(file: string) {
|
||||
return path.normalize(file).split(path.sep).join("/")
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the parser options for a given file.
|
||||
*/
|
||||
export function getParserOptions(file: string): ParserOptions {
|
||||
const options: ParserOptions = {
|
||||
sourceType: "module",
|
||||
plugins: ["jsx"],
|
||||
}
|
||||
|
||||
if (file.endsWith(".tsx")) {
|
||||
options.plugins?.push("typescript")
|
||||
}
|
||||
|
||||
return options
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a module with a source map from a code string
|
||||
*/
|
||||
export function generateModule(code: string) {
|
||||
const magicString = new MagicString(code)
|
||||
|
||||
return {
|
||||
code: magicString.toString(),
|
||||
map: magicString.generateMap({ hires: true }),
|
||||
}
|
||||
}
|
||||
|
||||
const VALID_FILE_EXTENSIONS = [".tsx", ".jsx"]
|
||||
|
||||
/**
|
||||
* Crawls a directory and returns all files that match the criteria.
|
||||
*/
|
||||
export async function crawl(
|
||||
dir: string,
|
||||
file?: string,
|
||||
depth?: { min: number; max?: number }
|
||||
) {
|
||||
const dirDepth = dir.split(path.sep).length
|
||||
|
||||
const crawler = new fdir()
|
||||
.withBasePath()
|
||||
.exclude((dirName) => dirName.startsWith("_"))
|
||||
.filter((path) => {
|
||||
return VALID_FILE_EXTENSIONS.some((ext) => path.endsWith(ext))
|
||||
})
|
||||
|
||||
if (file) {
|
||||
crawler.filter((path) => {
|
||||
return VALID_FILE_EXTENSIONS.some((ext) => path.endsWith(file + ext))
|
||||
})
|
||||
}
|
||||
|
||||
if (depth) {
|
||||
crawler.filter((file) => {
|
||||
const pathDepth = file.split(path.sep).length - 1
|
||||
|
||||
if (depth.max && pathDepth > dirDepth + depth.max) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (pathDepth < dirDepth + depth.min) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
return crawler.crawl(dir).withPromise()
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts and returns the properties of a `config` object from a named export declaration.
|
||||
*/
|
||||
export function getConfigObjectProperties(
|
||||
path: NodePath<ExportNamedDeclaration>
|
||||
) {
|
||||
const declaration = path.node.declaration
|
||||
|
||||
if (isVariableDeclaration(declaration)) {
|
||||
const configDeclaration = declaration.declarations.find(
|
||||
(d) => isVariableDeclarator(d) && isIdentifier(d.id, { name: "config" })
|
||||
)
|
||||
|
||||
if (
|
||||
configDeclaration &&
|
||||
isCallExpression(configDeclaration.init) &&
|
||||
configDeclaration.init.arguments.length > 0 &&
|
||||
isObjectExpression(configDeclaration.init.arguments[0])
|
||||
) {
|
||||
return configDeclaration.init.arguments[0].properties
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export async function hasDefaultExport(
|
||||
ast: ParseResult<File>
|
||||
): Promise<boolean> {
|
||||
let hasDefaultExport = false
|
||||
traverse(ast, {
|
||||
ExportDefaultDeclaration() {
|
||||
hasDefaultExport = true
|
||||
},
|
||||
})
|
||||
return hasDefaultExport
|
||||
}
|
||||
|
||||
export function generateHash(content: string) {
|
||||
return crypto.createHash("md5").update(content).digest("hex")
|
||||
}
|
||||
|
||||
const ADMIN_SUBDIRECTORIES = ["routes", "custom-fields", "widgets"] as const
|
||||
|
||||
export type AdminSubdirectory = (typeof ADMIN_SUBDIRECTORIES)[number]
|
||||
|
||||
export function isFileInAdminSubdirectory(
|
||||
file: string,
|
||||
subdirectory: AdminSubdirectory
|
||||
): boolean {
|
||||
const normalizedPath = normalizePath(file)
|
||||
return normalizedPath.includes(`/src/admin/${subdirectory}/`)
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
import { outdent } from "outdent"
|
||||
import { generateCustomFieldDisplays } from "../custom-fields"
|
||||
import { generateModule } from "../utils"
|
||||
|
||||
export async function generateVirtualDisplayModule(sources: Set<string>) {
|
||||
const displays = await generateCustomFieldDisplays(sources)
|
||||
|
||||
const code = outdent`
|
||||
${displays.imports.join("\n")}
|
||||
|
||||
export default {
|
||||
${displays.code}
|
||||
}
|
||||
`
|
||||
|
||||
return generateModule(code)
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import outdent from "outdent"
|
||||
import { generateCustomFieldForms } from "../custom-fields"
|
||||
import { generateMenuItems } from "../routes"
|
||||
import { generateModule } from "../utils"
|
||||
import { generateWidgets } from "../widgets"
|
||||
|
||||
export async function generateVirtualFormModule(sources: Set<string>) {
|
||||
const menuItems = await generateMenuItems(sources)
|
||||
const widgets = await generateWidgets(sources)
|
||||
const customFields = await generateCustomFieldForms(sources)
|
||||
|
||||
const imports = [
|
||||
...menuItems.imports,
|
||||
...widgets.imports,
|
||||
...customFields.imports,
|
||||
]
|
||||
|
||||
const code = outdent`
|
||||
${imports.join("\n")}
|
||||
|
||||
export default {
|
||||
${menuItems.code},
|
||||
${widgets.code},
|
||||
${customFields.code},
|
||||
}
|
||||
`
|
||||
|
||||
return generateModule(code)
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { outdent } from "outdent"
|
||||
import { generateCustomFieldLinks } from "../custom-fields"
|
||||
import { generateModule } from "../utils"
|
||||
|
||||
export async function generateVirtualLinkModule(sources: Set<string>) {
|
||||
const links = await generateCustomFieldLinks(sources)
|
||||
|
||||
const code = outdent`
|
||||
${links.imports.join("\n")}
|
||||
|
||||
export default {
|
||||
${links.code}
|
||||
}
|
||||
`
|
||||
|
||||
return generateModule(code)
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
import outdent from "outdent"
|
||||
|
||||
import { generateMenuItems } from "../routes"
|
||||
import { generateModule } from "../utils"
|
||||
|
||||
export async function generateVirtualMenuItemModule(sources: Set<string>) {
|
||||
const menuItems = await generateMenuItems(sources)
|
||||
|
||||
const code = outdent`
|
||||
${menuItems.imports.join("\n")}
|
||||
|
||||
export default {
|
||||
${menuItems.code},
|
||||
}
|
||||
`
|
||||
|
||||
return generateModule(code)
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { outdent } from "outdent"
|
||||
import { generateRoutes } from "../routes"
|
||||
import { generateModule } from "../utils"
|
||||
|
||||
export async function generateVirtualRouteModule(sources: Set<string>) {
|
||||
const routes = await generateRoutes(sources)
|
||||
|
||||
const imports = [...routes.imports]
|
||||
|
||||
const code = outdent`
|
||||
${imports.join("\n")}
|
||||
|
||||
export default {
|
||||
${routes.code}
|
||||
}
|
||||
`
|
||||
|
||||
return generateModule(code)
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
import outdent from "outdent"
|
||||
import { generateModule } from "../utils"
|
||||
import { generateWidgets } from "../widgets"
|
||||
|
||||
export async function generateVirtualWidgetModule(sources: Set<string>) {
|
||||
const widgets = await generateWidgets(sources)
|
||||
|
||||
const imports = [...widgets.imports]
|
||||
|
||||
const code = outdent`
|
||||
${imports.join("\n")}
|
||||
|
||||
export default {
|
||||
${widgets.code},
|
||||
}
|
||||
`
|
||||
|
||||
return generateModule(code)
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export * from "./generate-virtual-display-module"
|
||||
export * from "./generate-virtual-form-module"
|
||||
export * from "./generate-virtual-link-module"
|
||||
export * from "./generate-virtual-menu-item-module"
|
||||
export * from "./generate-virtual-route-module"
|
||||
export * from "./generate-virtual-widget-module"
|
||||
@@ -0,0 +1,80 @@
|
||||
import {
|
||||
DISPLAY_VIRTUAL_MODULE,
|
||||
FORM_VIRTUAL_MODULE,
|
||||
LINK_VIRTUAL_MODULE,
|
||||
MENU_ITEM_VIRTUAL_MODULE,
|
||||
ROUTE_VIRTUAL_MODULE,
|
||||
WIDGET_VIRTUAL_MODULE,
|
||||
} from "@medusajs/admin-shared"
|
||||
|
||||
const RESOLVED_LINK_VIRTUAL_MODULE = `\0${LINK_VIRTUAL_MODULE}`
|
||||
const RESOLVED_FORM_VIRTUAL_MODULE = `\0${FORM_VIRTUAL_MODULE}`
|
||||
const RESOLVED_DISPLAY_VIRTUAL_MODULE = `\0${DISPLAY_VIRTUAL_MODULE}`
|
||||
const RESOLVED_ROUTE_VIRTUAL_MODULE = `\0${ROUTE_VIRTUAL_MODULE}`
|
||||
const RESOLVED_MENU_ITEM_VIRTUAL_MODULE = `\0${MENU_ITEM_VIRTUAL_MODULE}`
|
||||
const RESOLVED_WIDGET_VIRTUAL_MODULE = `\0${WIDGET_VIRTUAL_MODULE}`
|
||||
|
||||
const VIRTUAL_MODULES = [
|
||||
LINK_VIRTUAL_MODULE,
|
||||
FORM_VIRTUAL_MODULE,
|
||||
DISPLAY_VIRTUAL_MODULE,
|
||||
ROUTE_VIRTUAL_MODULE,
|
||||
MENU_ITEM_VIRTUAL_MODULE,
|
||||
WIDGET_VIRTUAL_MODULE,
|
||||
] as const
|
||||
|
||||
const RESOLVED_VIRTUAL_MODULES = [
|
||||
RESOLVED_LINK_VIRTUAL_MODULE,
|
||||
RESOLVED_FORM_VIRTUAL_MODULE,
|
||||
RESOLVED_DISPLAY_VIRTUAL_MODULE,
|
||||
RESOLVED_ROUTE_VIRTUAL_MODULE,
|
||||
RESOLVED_MENU_ITEM_VIRTUAL_MODULE,
|
||||
RESOLVED_WIDGET_VIRTUAL_MODULE,
|
||||
] as const
|
||||
|
||||
export function resolveVirtualId(id: string) {
|
||||
return `\0${id}`
|
||||
}
|
||||
|
||||
export function isVirtualModuleId(id: string): id is VirtualModule {
|
||||
return VIRTUAL_MODULES.includes(id as VirtualModule)
|
||||
}
|
||||
|
||||
export function isResolvedVirtualModuleId(
|
||||
id: string
|
||||
): id is (typeof RESOLVED_VIRTUAL_MODULES)[number] {
|
||||
return RESOLVED_VIRTUAL_MODULES.includes(
|
||||
id as (typeof RESOLVED_VIRTUAL_MODULES)[number]
|
||||
)
|
||||
}
|
||||
|
||||
export type VirtualModule =
|
||||
| typeof LINK_VIRTUAL_MODULE
|
||||
| typeof FORM_VIRTUAL_MODULE
|
||||
| typeof DISPLAY_VIRTUAL_MODULE
|
||||
| typeof ROUTE_VIRTUAL_MODULE
|
||||
| typeof MENU_ITEM_VIRTUAL_MODULE
|
||||
| typeof WIDGET_VIRTUAL_MODULE
|
||||
|
||||
const resolvedVirtualModuleIds = {
|
||||
link: RESOLVED_LINK_VIRTUAL_MODULE,
|
||||
form: RESOLVED_FORM_VIRTUAL_MODULE,
|
||||
display: RESOLVED_DISPLAY_VIRTUAL_MODULE,
|
||||
route: RESOLVED_ROUTE_VIRTUAL_MODULE,
|
||||
menuItem: RESOLVED_MENU_ITEM_VIRTUAL_MODULE,
|
||||
widget: RESOLVED_WIDGET_VIRTUAL_MODULE,
|
||||
} as const
|
||||
|
||||
const virtualModuleIds = {
|
||||
link: LINK_VIRTUAL_MODULE,
|
||||
form: FORM_VIRTUAL_MODULE,
|
||||
display: DISPLAY_VIRTUAL_MODULE,
|
||||
route: ROUTE_VIRTUAL_MODULE,
|
||||
menuItem: MENU_ITEM_VIRTUAL_MODULE,
|
||||
widget: WIDGET_VIRTUAL_MODULE,
|
||||
} as const
|
||||
|
||||
export const vmod = {
|
||||
resolved: resolvedVirtualModuleIds,
|
||||
virtual: virtualModuleIds,
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import fs from "fs/promises"
|
||||
import { File, parse, ParseResult, traverse } from "../babel"
|
||||
import { logger } from "../logger"
|
||||
import {
|
||||
generateHash,
|
||||
getConfigObjectProperties,
|
||||
getParserOptions,
|
||||
} from "../utils"
|
||||
import { getWidgetFilesFromSources } from "./helpers"
|
||||
|
||||
export async function generateWidgetHash(
|
||||
sources: Set<string>
|
||||
): Promise<string> {
|
||||
const files = await getWidgetFilesFromSources(sources)
|
||||
const contents = await Promise.all(files.map(getWidgetContents))
|
||||
const totalContent = contents
|
||||
.flatMap(({ config, defaultExport }) => [config, defaultExport])
|
||||
.filter(Boolean)
|
||||
.join("")
|
||||
|
||||
return generateHash(totalContent)
|
||||
}
|
||||
|
||||
async function getWidgetContents(
|
||||
file: string
|
||||
): Promise<{ config: string | null; defaultExport: string | null }> {
|
||||
const code = await fs.readFile(file, "utf-8")
|
||||
let ast: ParseResult<File>
|
||||
|
||||
try {
|
||||
ast = parse(code, getParserOptions(file))
|
||||
} catch (e) {
|
||||
logger.error(
|
||||
`An error occurred while parsing the file. Due to the error we cannot validate whether the widget has changed. If your changes aren't correctly reflected try restarting the dev server.`,
|
||||
{
|
||||
file,
|
||||
error: e,
|
||||
}
|
||||
)
|
||||
return { config: null, defaultExport: null }
|
||||
}
|
||||
|
||||
let configContent: string | null = null
|
||||
let defaultExportContent: string | null = null
|
||||
|
||||
traverse(ast, {
|
||||
ExportNamedDeclaration(path) {
|
||||
const properties = getConfigObjectProperties(path)
|
||||
if (properties) {
|
||||
configContent = code.slice(path.node.start!, path.node.end!)
|
||||
}
|
||||
},
|
||||
ExportDefaultDeclaration(path) {
|
||||
defaultExportContent = code.slice(path.node.start!, path.node.end!)
|
||||
},
|
||||
})
|
||||
|
||||
return { config: configContent, defaultExport: defaultExportContent }
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
import { InjectionZone, isValidInjectionZone } from "@medusajs/admin-shared"
|
||||
import fs from "fs/promises"
|
||||
import outdent from "outdent"
|
||||
import {
|
||||
File,
|
||||
isArrayExpression,
|
||||
isStringLiteral,
|
||||
isTemplateLiteral,
|
||||
ObjectProperty,
|
||||
parse,
|
||||
ParseResult,
|
||||
traverse,
|
||||
} from "../babel"
|
||||
import { logger } from "../logger"
|
||||
import {
|
||||
getConfigObjectProperties,
|
||||
getParserOptions,
|
||||
hasDefaultExport,
|
||||
} from "../utils"
|
||||
import { getWidgetFilesFromSources } from "./helpers"
|
||||
|
||||
type WidgetConfig = {
|
||||
Component: string
|
||||
zone: InjectionZone[]
|
||||
}
|
||||
|
||||
type ParsedWidgetConfig = {
|
||||
import: string
|
||||
widget: WidgetConfig
|
||||
}
|
||||
|
||||
export async function generateWidgets(sources: Set<string>) {
|
||||
const files = await getWidgetFilesFromSources(sources)
|
||||
const results = await getWidgetResults(files)
|
||||
|
||||
const imports = results.map((r) => r.import)
|
||||
const code = generateCode(results)
|
||||
|
||||
return {
|
||||
imports,
|
||||
code,
|
||||
}
|
||||
}
|
||||
|
||||
async function getWidgetResults(
|
||||
files: string[]
|
||||
): Promise<ParsedWidgetConfig[]> {
|
||||
return (await Promise.all(files.map(parseFile))).filter(
|
||||
(r) => r !== null
|
||||
) as ParsedWidgetConfig[]
|
||||
}
|
||||
|
||||
function generateCode(results: ParsedWidgetConfig[]): string {
|
||||
return outdent`
|
||||
widgets: [
|
||||
${results.map((r) => formatWidget(r.widget)).join(",\n")}
|
||||
]
|
||||
`
|
||||
}
|
||||
|
||||
function formatWidget(widget: WidgetConfig): string {
|
||||
return outdent`
|
||||
{
|
||||
Component: ${widget.Component},
|
||||
zone: [${widget.zone.map((z) => `"${z}"`).join(", ")}]
|
||||
}
|
||||
`
|
||||
}
|
||||
|
||||
async function parseFile(
|
||||
file: string,
|
||||
index: number
|
||||
): Promise<ParsedWidgetConfig | null> {
|
||||
const code = await fs.readFile(file, "utf-8")
|
||||
let ast: ParseResult<File>
|
||||
|
||||
try {
|
||||
ast = parse(code, getParserOptions(file))
|
||||
} catch (e) {
|
||||
logger.error(`An error occurred while parsing the file.`, {
|
||||
file,
|
||||
error: e,
|
||||
})
|
||||
return null
|
||||
}
|
||||
|
||||
let fileHasDefaultExport = false
|
||||
|
||||
try {
|
||||
fileHasDefaultExport = await hasDefaultExport(ast)
|
||||
} catch (e) {
|
||||
logger.error(`An error occurred while checking for a default export.`, {
|
||||
file,
|
||||
error: e,
|
||||
})
|
||||
return null
|
||||
}
|
||||
|
||||
if (!fileHasDefaultExport) {
|
||||
return null
|
||||
}
|
||||
|
||||
let zone: InjectionZone[] | null
|
||||
|
||||
try {
|
||||
zone = await getWidgetZone(ast, file)
|
||||
} catch (e) {
|
||||
logger.error(`An error occurred while traversing the file.`, {
|
||||
file,
|
||||
error: e,
|
||||
})
|
||||
return null
|
||||
}
|
||||
|
||||
if (!zone) {
|
||||
logger.warn(`'zone' property is missing from the widget config.`, { file })
|
||||
return null
|
||||
}
|
||||
|
||||
const import_ = generateImport(file, index)
|
||||
const widget = generateWidget(zone, index)
|
||||
|
||||
return {
|
||||
widget,
|
||||
import: import_,
|
||||
}
|
||||
}
|
||||
|
||||
function generateWidgetComponentName(index: number): string {
|
||||
return `WidgetComponent${index}`
|
||||
}
|
||||
|
||||
function generateWidgetConfigName(index: number): string {
|
||||
return `WidgetConfig${index}`
|
||||
}
|
||||
|
||||
function generateImport(file: string, index: number): string {
|
||||
return `import ${generateWidgetComponentName(
|
||||
index
|
||||
)}, { config as ${generateWidgetConfigName(index)} } from "${file}"`
|
||||
}
|
||||
|
||||
function generateWidget(zone: InjectionZone[], index: number): WidgetConfig {
|
||||
return {
|
||||
Component: generateWidgetComponentName(index),
|
||||
zone: zone,
|
||||
}
|
||||
}
|
||||
|
||||
async function getWidgetZone(
|
||||
ast: ParseResult<File>,
|
||||
file: string
|
||||
): Promise<InjectionZone[] | null> {
|
||||
const zones: string[] = []
|
||||
|
||||
traverse(ast, {
|
||||
ExportNamedDeclaration(path) {
|
||||
const properties = getConfigObjectProperties(path)
|
||||
if (!properties) {
|
||||
return
|
||||
}
|
||||
|
||||
const zoneProperty = properties.find(
|
||||
(p) =>
|
||||
p.type === "ObjectProperty" &&
|
||||
p.key.type === "Identifier" &&
|
||||
p.key.name === "zone"
|
||||
) as ObjectProperty | undefined
|
||||
|
||||
if (!zoneProperty) {
|
||||
logger.warn(`'zone' property is missing from the widget config.`, {
|
||||
file,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (isTemplateLiteral(zoneProperty.value)) {
|
||||
logger.warn(
|
||||
`'zone' property cannot be a template literal (e.g. \`product.details.after\`).`,
|
||||
{ file }
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (isStringLiteral(zoneProperty.value)) {
|
||||
zones.push(zoneProperty.value.value)
|
||||
} else if (isArrayExpression(zoneProperty.value)) {
|
||||
const values: string[] = []
|
||||
|
||||
for (const element of zoneProperty.value.elements) {
|
||||
if (isStringLiteral(element)) {
|
||||
values.push(element.value)
|
||||
}
|
||||
}
|
||||
|
||||
zones.push(...values)
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const validatedZones = zones.filter(isValidInjectionZone)
|
||||
return validatedZones.length > 0 ? validatedZones : null
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { crawl } from "../utils"
|
||||
|
||||
export async function getWidgetFilesFromSources(
|
||||
sources: Set<string>
|
||||
): Promise<string[]> {
|
||||
return (
|
||||
await Promise.all(
|
||||
Array.from(sources).map(async (source) => crawl(`${source}/widgets`))
|
||||
)
|
||||
).flat()
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from "./generate-widget-hash"
|
||||
export * from "./generate-widgets"
|
||||
@@ -21,7 +21,9 @@
|
||||
"./css": {
|
||||
"import": "./dist/app.css",
|
||||
"require": "./dist/app.css"
|
||||
}
|
||||
},
|
||||
"./root": "./",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -29,8 +31,10 @@
|
||||
"directory": "packages/admin/dashboard"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"package.json"
|
||||
"package.json",
|
||||
"src",
|
||||
"index.html",
|
||||
"dist"
|
||||
],
|
||||
"dependencies": {
|
||||
"@ariakit/react": "^0.4.1",
|
||||
@@ -38,6 +42,7 @@
|
||||
"@dnd-kit/sortable": "^8.0.0",
|
||||
"@hookform/error-message": "^2.0.1",
|
||||
"@hookform/resolvers": "3.4.2",
|
||||
"@medusajs/admin-shared": "0.0.1",
|
||||
"@medusajs/icons": "1.2.1",
|
||||
"@medusajs/js-sdk": "0.0.1",
|
||||
"@medusajs/ui": "3.0.0",
|
||||
@@ -81,7 +86,8 @@
|
||||
"tailwindcss": "^3.4.1",
|
||||
"tsup": "^8.0.2",
|
||||
"typescript": "5.2.2",
|
||||
"vite": "^5.2.11"
|
||||
"vite": "^5.2.11",
|
||||
"vite-plugin-inspect": "^0.8.7"
|
||||
},
|
||||
"packageManager": "yarn@3.2.1"
|
||||
}
|
||||
|
||||
@@ -1,30 +1,26 @@
|
||||
import { Toaster, TooltipProvider } from "@medusajs/ui"
|
||||
import { QueryClientProvider } from "@tanstack/react-query"
|
||||
import { HelmetProvider } from "react-helmet-async"
|
||||
|
||||
import { I18n } from "./components/utilities/i18n"
|
||||
import { queryClient } from "./lib/query-client"
|
||||
import { I18nProvider } from "./providers/i18n-provider"
|
||||
import { DashboardExtensionManager } from "./extensions"
|
||||
import { Providers } from "./providers/providers"
|
||||
import { RouterProvider } from "./providers/router-provider"
|
||||
import { ThemeProvider } from "./providers/theme-provider"
|
||||
|
||||
import displayModule from "virtual:medusa/displays"
|
||||
import formModule from "virtual:medusa/forms"
|
||||
import menuItemModule from "virtual:medusa/menu-items"
|
||||
import widgetModule from "virtual:medusa/widgets"
|
||||
|
||||
import "./index.css"
|
||||
|
||||
function App() {
|
||||
const manager = new DashboardExtensionManager({
|
||||
displayModule,
|
||||
formModule,
|
||||
menuItemModule,
|
||||
widgetModule,
|
||||
})
|
||||
|
||||
return (
|
||||
<HelmetProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ThemeProvider>
|
||||
<I18n />
|
||||
<TooltipProvider>
|
||||
<I18nProvider>
|
||||
<RouterProvider />
|
||||
</I18nProvider>
|
||||
</TooltipProvider>
|
||||
<Toaster />
|
||||
</ThemeProvider>
|
||||
</QueryClientProvider>
|
||||
</HelmetProvider>
|
||||
<Providers api={manager.api}>
|
||||
<RouterProvider />
|
||||
</Providers>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -19,14 +19,13 @@ import * as Collapsible from "@radix-ui/react-collapsible"
|
||||
import { useTranslation } from "react-i18next"
|
||||
|
||||
import { useStore } from "../../../hooks/api/store"
|
||||
import { settingsRouteRegex } from "../../../lib/extension-helpers"
|
||||
import { Divider } from "../../common/divider"
|
||||
import { Skeleton } from "../../common/skeleton"
|
||||
import { NavItem, NavItemProps } from "../../layout/nav-item"
|
||||
import { INavItem, NavItem } from "../../layout/nav-item"
|
||||
import { Shell } from "../../layout/shell"
|
||||
|
||||
import { Link, useLocation, useNavigate } from "react-router-dom"
|
||||
import routes from "virtual:medusa/routes/links"
|
||||
import { useDashboardExtension } from "../../../extensions"
|
||||
import { useLogout } from "../../../hooks/api"
|
||||
import { queryClient } from "../../../lib/query-client"
|
||||
import { useSearch } from "../../../providers/search-provider"
|
||||
@@ -177,7 +176,7 @@ const Header = () => {
|
||||
)
|
||||
}
|
||||
|
||||
const useCoreRoutes = (): Omit<NavItemProps, "pathname">[] => {
|
||||
const useCoreRoutes = (): Omit<INavItem, "pathname">[] => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return [
|
||||
@@ -297,14 +296,11 @@ const CoreRouteSection = () => {
|
||||
|
||||
const ExtensionRouteSection = () => {
|
||||
const { t } = useTranslation()
|
||||
const { getMenu } = useDashboardExtension()
|
||||
|
||||
const links = routes.links
|
||||
const menuItems = getMenu("coreExtensions")
|
||||
|
||||
const extensionLinks = links
|
||||
.filter((link) => !settingsRouteRegex.test(link.path))
|
||||
.sort((a, b) => a.label.localeCompare(b.label))
|
||||
|
||||
if (!extensionLinks.length) {
|
||||
if (!menuItems.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -330,13 +326,14 @@ const ExtensionRouteSection = () => {
|
||||
</div>
|
||||
<Collapsible.Content>
|
||||
<nav className="flex flex-col gap-y-0.5 py-1 pb-4">
|
||||
{extensionLinks.map((link) => {
|
||||
{menuItems.map((item, i) => {
|
||||
return (
|
||||
<NavItem
|
||||
key={link.path}
|
||||
to={link.path}
|
||||
label={link.label}
|
||||
icon={link.icon ? <link.icon /> : <SquaresPlus />}
|
||||
key={i}
|
||||
to={item.to}
|
||||
label={item.label}
|
||||
icon={item.icon ? item.icon : <SquaresPlus />}
|
||||
items={item.items}
|
||||
type="extension"
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -19,7 +19,7 @@ type NestedItemProps = {
|
||||
to: string
|
||||
}
|
||||
|
||||
export type NavItemProps = {
|
||||
export type INavItem = {
|
||||
icon?: ReactNode
|
||||
label: string
|
||||
to: string
|
||||
@@ -29,11 +29,11 @@ export type NavItemProps = {
|
||||
}
|
||||
|
||||
const BASE_NAV_LINK_CLASSES =
|
||||
"text-ui-fg-subtle transition-fg hover:bg-ui-bg-subtle-hover flex items-center gap-x-2 rounded-md py-1 pl-0.5 pr-2 outline-none [&>svg]:text-ui-fg-subtle focus-visible:shadow-borders-focus"
|
||||
"text-ui-fg-subtle transition-fg hover:bg-ui-bg-subtle-hover flex items-center gap-x-2 rounded-md py-0.5 pl-0.5 pr-2 outline-none [&>svg]:text-ui-fg-subtle focus-visible:shadow-borders-focus"
|
||||
const ACTIVE_NAV_LINK_CLASSES =
|
||||
"bg-ui-bg-base shadow-elevation-card-rest text-ui-fg-base hover:bg-ui-bg-base"
|
||||
const NESTED_NAV_LINK_CLASSES = "pl-[34px] pr-2 w-full text-ui-fg-muted"
|
||||
const SETTING_NAV_LINK_CLASSES = "pl-2"
|
||||
const NESTED_NAV_LINK_CLASSES = "pl-[34px] pr-2 py-1 w-full text-ui-fg-muted"
|
||||
const SETTING_NAV_LINK_CLASSES = "pl-2 py-1"
|
||||
|
||||
const getIsOpen = (
|
||||
to: string,
|
||||
@@ -89,7 +89,7 @@ export const NavItem = ({
|
||||
items,
|
||||
type = "core",
|
||||
from,
|
||||
}: NavItemProps) => {
|
||||
}: INavItem) => {
|
||||
const { pathname } = useLocation()
|
||||
const [open, setOpen] = useState(getIsOpen(to, items, pathname))
|
||||
|
||||
@@ -149,7 +149,7 @@ export const NavItem = ({
|
||||
<Collapsible.Root open={open} onOpenChange={setOpen}>
|
||||
<Collapsible.Trigger
|
||||
className={clx(
|
||||
"text-ui-fg-subtle hover:text-ui-fg-base transition-fg hover:bg-ui-bg-subtle-hover flex w-full items-center gap-x-2 rounded-md py-1 pl-0.5 pr-2 outline-none lg:hidden",
|
||||
"text-ui-fg-subtle hover:text-ui-fg-base transition-fg hover:bg-ui-bg-subtle-hover flex w-full items-center gap-x-2 rounded-md py-0.5 pl-0.5 pr-2 outline-none lg:hidden",
|
||||
{ "pl-2": isSetting }
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -126,17 +126,17 @@ const Notification = ({
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-start justify-center gap-3 border-b p-6 relative">
|
||||
<div className="relative flex items-start justify-center gap-3 border-b p-6">
|
||||
<div className="text-ui-fg-muted flex size-5 items-center justify-center">
|
||||
<InformationCircleSolid />
|
||||
</div>
|
||||
<div className="flex w-full flex-col gap-y-3">
|
||||
<div className="flex flex-col">
|
||||
<div className="items-center flex justify-between">
|
||||
<div className="flex items-center justify-between">
|
||||
<Text size="small" leading="compact" weight="plus">
|
||||
{data.title}
|
||||
</Text>
|
||||
<div className="items-center flex justify-center align-center gap-2">
|
||||
<div className="align-center flex items-center justify-center gap-2">
|
||||
<Text
|
||||
as={"span"}
|
||||
className={clx("text-ui-fg-subtle", {
|
||||
@@ -152,7 +152,7 @@ const Notification = ({
|
||||
</Text>
|
||||
{unread && (
|
||||
<div
|
||||
className="h-2 w-2 rounded bg-ui-bg-interactive"
|
||||
className="bg-ui-bg-interactive h-2 w-2 rounded"
|
||||
role="status"
|
||||
/>
|
||||
)}
|
||||
@@ -201,7 +201,7 @@ const useUnreadNotifications = () => {
|
||||
const [hasUnread, setHasUnread] = useState(false)
|
||||
const { notifications } = useNotifications(
|
||||
{ limit: 1, offset: 0, fields: "created_at" },
|
||||
{ refetchInterval: 3000 }
|
||||
{ refetchInterval: 60_000 }
|
||||
)
|
||||
const lastNotification = notifications?.[0]
|
||||
|
||||
|
||||
+4
-4
@@ -48,12 +48,12 @@ export const SingleColumnPage = <TData,>({
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-y-3">
|
||||
{before.widgets.map((w, i) => {
|
||||
return <w.Component {...widgetProps} key={i} />
|
||||
{before.map((Component, i) => {
|
||||
return <Component {...widgetProps} key={i} />
|
||||
})}
|
||||
{children}
|
||||
{after.widgets.map((w, i) => {
|
||||
return <w.Component {...widgetProps} key={i} />
|
||||
{after.map((Component, i) => {
|
||||
return <Component {...widgetProps} key={i} />
|
||||
})}
|
||||
{showMetadata && <MetadataSection data={data!} />}
|
||||
{showJSON && <JsonViewSection data={data!} />}
|
||||
|
||||
+12
-12
@@ -1,13 +1,13 @@
|
||||
import { clx } from "@medusajs/ui"
|
||||
import { Children, ComponentPropsWithoutRef } from "react"
|
||||
import { Children, ComponentPropsWithoutRef, ComponentType } from "react"
|
||||
import { Outlet } from "react-router-dom"
|
||||
import { JsonViewSection } from "../../../common/json-view-section"
|
||||
import { MetadataSection } from "../../../common/metadata-section"
|
||||
import { PageProps, WidgetImport, WidgetProps } from "../types"
|
||||
import { PageProps, WidgetProps } from "../types"
|
||||
|
||||
interface TwoColumnWidgetProps extends WidgetProps {
|
||||
sideBefore: WidgetImport
|
||||
sideAfter: WidgetImport
|
||||
sideBefore: ComponentType<any>[]
|
||||
sideAfter: ComponentType<any>[]
|
||||
}
|
||||
|
||||
interface TwoColumnPageProps<TData> extends PageProps<TData> {
|
||||
@@ -71,14 +71,14 @@ const Root = <TData,>({
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-y-3">
|
||||
{before.widgets.map((w, i) => {
|
||||
return <w.Component {...widgetProps} key={i} />
|
||||
{before.map((Component, i) => {
|
||||
return <Component {...widgetProps} key={i} />
|
||||
})}
|
||||
<div className="flex flex-col gap-x-4 gap-y-3 xl:flex-row xl:items-start">
|
||||
<div className="flex w-full flex-col gap-y-3">
|
||||
{main}
|
||||
{after.widgets.map((w, i) => {
|
||||
return <w.Component {...widgetProps} key={i} />
|
||||
{after.map((Component, i) => {
|
||||
return <Component {...widgetProps} key={i} />
|
||||
})}
|
||||
{showExtraData && (
|
||||
<div className="hidden flex-col gap-y-3 xl:flex">
|
||||
@@ -88,12 +88,12 @@ const Root = <TData,>({
|
||||
)}
|
||||
</div>
|
||||
<div className="flex w-full max-w-[100%] flex-col gap-y-3 xl:mt-0 xl:max-w-[440px]">
|
||||
{sideBefore.widgets.map((w, i) => {
|
||||
return <w.Component {...widgetProps} key={i} />
|
||||
{sideBefore.map((Component, i) => {
|
||||
return <Component {...widgetProps} key={i} />
|
||||
})}
|
||||
{sidebar}
|
||||
{sideAfter.widgets.map((w, i) => {
|
||||
return <w.Component {...widgetProps} key={i} />
|
||||
{sideAfter.map((Component, i) => {
|
||||
return <Component {...widgetProps} key={i} />
|
||||
})}
|
||||
{showExtraData && (
|
||||
<div className="flex flex-col gap-y-3 xl:hidden">
|
||||
|
||||
@@ -1,16 +1,8 @@
|
||||
import { ReactNode } from "react"
|
||||
|
||||
export type Widget = {
|
||||
Component: React.ComponentType<any>
|
||||
}
|
||||
|
||||
export type WidgetImport = {
|
||||
widgets: Widget[]
|
||||
}
|
||||
import { ComponentType, ReactNode } from "react"
|
||||
|
||||
export interface WidgetProps {
|
||||
before: WidgetImport
|
||||
after: WidgetImport
|
||||
before: ComponentType<any>[]
|
||||
after: ComponentType<any>[]
|
||||
}
|
||||
|
||||
export interface PageProps<TData> {
|
||||
|
||||
@@ -1,14 +1,5 @@
|
||||
import { Outlet } from "react-router-dom";
|
||||
import { Outlet } from "react-router-dom"
|
||||
|
||||
export const PublicLayout = () => {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center px-4 py-6">
|
||||
<div className="bg-ui-bg-base text-ui-fg-subtle w-[520px] px-16 py-20 rounded-[32px] shadow-elevation-modal flex flex-col gap-y-12 items-center">
|
||||
<div className="w-24 h-24 rounded-3xl bg-ui-bg-subtle shadow-elevation-card-hover"></div>
|
||||
<div className="w-full">
|
||||
<Outlet />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
return <Outlet />
|
||||
}
|
||||
|
||||
@@ -5,12 +5,11 @@ import { Fragment, useEffect, useMemo, useState } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { Link, useLocation } from "react-router-dom"
|
||||
|
||||
import { settingsRouteRegex } from "../../../lib/extension-helpers"
|
||||
import { Divider } from "../../common/divider"
|
||||
import { NavItem, NavItemProps } from "../nav-item"
|
||||
import { INavItem, NavItem } from "../nav-item"
|
||||
import { Shell } from "../shell"
|
||||
|
||||
import routes from "virtual:medusa/routes/links"
|
||||
import { useDashboardExtension } from "../../../extensions"
|
||||
import { UserMenu } from "../user-menu"
|
||||
|
||||
export const SettingsLayout = () => {
|
||||
@@ -21,7 +20,7 @@ export const SettingsLayout = () => {
|
||||
)
|
||||
}
|
||||
|
||||
const useSettingRoutes = (): NavItemProps[] => {
|
||||
const useSettingRoutes = (): INavItem[] => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return useMemo(
|
||||
@@ -67,7 +66,7 @@ const useSettingRoutes = (): NavItemProps[] => {
|
||||
)
|
||||
}
|
||||
|
||||
const useDeveloperRoutes = (): NavItemProps[] => {
|
||||
const useDeveloperRoutes = (): INavItem[] => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return useMemo(
|
||||
@@ -89,7 +88,7 @@ const useDeveloperRoutes = (): NavItemProps[] => {
|
||||
)
|
||||
}
|
||||
|
||||
const useMyAccountRoutes = (): NavItemProps[] => {
|
||||
const useMyAccountRoutes = (): INavItem[] => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return useMemo(
|
||||
@@ -103,21 +102,6 @@ const useMyAccountRoutes = (): NavItemProps[] => {
|
||||
)
|
||||
}
|
||||
|
||||
const useExtensionRoutes = (): NavItemProps[] => {
|
||||
const links = routes.links
|
||||
|
||||
return useMemo(() => {
|
||||
const settingsLinks = links.filter((link) =>
|
||||
settingsRouteRegex.test(link.path)
|
||||
)
|
||||
|
||||
return settingsLinks.map((link) => ({
|
||||
label: link.label,
|
||||
to: link.path,
|
||||
}))
|
||||
}, [links])
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure that the `from` prop is not another settings route, to avoid
|
||||
* the user getting stuck in a navigation loop.
|
||||
@@ -131,10 +115,12 @@ const getSafeFromValue = (from: string) => {
|
||||
}
|
||||
|
||||
const SettingsSidebar = () => {
|
||||
const { getMenu } = useDashboardExtension()
|
||||
|
||||
const routes = useSettingRoutes()
|
||||
const developerRoutes = useDeveloperRoutes()
|
||||
const extensionRoutes = useExtensionRoutes()
|
||||
const myAccountRoutes = useMyAccountRoutes()
|
||||
const extensionRoutes = getMenu("settingsExtensions")
|
||||
|
||||
const { t } = useTranslation()
|
||||
|
||||
@@ -227,7 +213,7 @@ const CollapsibleSection = ({
|
||||
items,
|
||||
}: {
|
||||
label: string
|
||||
items: NavItemProps[]
|
||||
items: INavItem[]
|
||||
}) => {
|
||||
return (
|
||||
<Collapsible.Root defaultOpen className="py-3">
|
||||
|
||||
@@ -42,6 +42,7 @@ const Root = ({ prev = "..", children }: RouteDrawerProps) => {
|
||||
<RouteModalProvider prev={prev}>
|
||||
<StackedModalProvider onOpenChange={onStackedModalOpen}>
|
||||
<Drawer.Content
|
||||
aria-describedby={undefined}
|
||||
className={clx({
|
||||
"!bg-ui-bg-disabled !inset-y-5 !right-5": stackedModalOpen,
|
||||
})}
|
||||
|
||||
@@ -20,6 +20,16 @@ export const ErrorBoundary = () => {
|
||||
code = error.status ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* Log error in development mode.
|
||||
*
|
||||
* react-router-dom will sometimes swallow the error,
|
||||
* so this ensures that we always log it.
|
||||
*/
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
console.error(error)
|
||||
}
|
||||
|
||||
let title: string
|
||||
let message: string
|
||||
|
||||
|
||||
+319
@@ -0,0 +1,319 @@
|
||||
import {
|
||||
CustomFieldContainerZone,
|
||||
CustomFieldFormTab,
|
||||
CustomFieldFormZone,
|
||||
CustomFieldModel,
|
||||
InjectionZone,
|
||||
} from "@medusajs/admin-shared"
|
||||
import * as React from "react"
|
||||
import { INavItem } from "../../components/layout/nav-item"
|
||||
import {
|
||||
ConfigExtension,
|
||||
ConfigField,
|
||||
ConfigFieldMap,
|
||||
DisplayExtension,
|
||||
DisplayMap,
|
||||
DisplayModule,
|
||||
FormExtension,
|
||||
FormField,
|
||||
FormFieldExtension,
|
||||
FormFieldMap,
|
||||
FormModule,
|
||||
FormZoneMap,
|
||||
MenuItemExtension,
|
||||
MenuItemKey,
|
||||
MenuItemModule,
|
||||
WidgetExtension,
|
||||
WidgetModule,
|
||||
ZoneStructure,
|
||||
} from "../types"
|
||||
|
||||
export type DashboardExtensionManagerProps = {
|
||||
formModule: FormModule
|
||||
displayModule: DisplayModule
|
||||
menuItemModule: MenuItemModule
|
||||
widgetModule: WidgetModule
|
||||
}
|
||||
|
||||
export class DashboardExtensionManager {
|
||||
private widgets: Map<InjectionZone, React.ComponentType[]>
|
||||
private menus: Map<MenuItemKey, INavItem[]>
|
||||
private fields: FormFieldMap
|
||||
private configs: ConfigFieldMap
|
||||
private displays: DisplayMap
|
||||
|
||||
constructor({
|
||||
widgetModule,
|
||||
menuItemModule,
|
||||
displayModule,
|
||||
formModule,
|
||||
}: DashboardExtensionManagerProps) {
|
||||
this.widgets = this.populateWidgets(widgetModule.widgets)
|
||||
this.menus = this.populateMenus(menuItemModule.menuItems)
|
||||
|
||||
const { fields, configs } = this.populateForm(formModule)
|
||||
this.fields = fields
|
||||
this.configs = configs
|
||||
this.displays = this.populateDisplays(displayModule)
|
||||
}
|
||||
|
||||
private populateWidgets(widgets: WidgetExtension[] | undefined) {
|
||||
const registry = new Map<InjectionZone, React.ComponentType[]>()
|
||||
|
||||
if (!widgets) {
|
||||
return registry
|
||||
}
|
||||
|
||||
widgets.forEach((widget) => {
|
||||
widget.zone.forEach((zone) => {
|
||||
if (!registry.has(zone)) {
|
||||
registry.set(zone, [])
|
||||
}
|
||||
registry.get(zone)!.push(widget.Component)
|
||||
})
|
||||
})
|
||||
|
||||
return registry
|
||||
}
|
||||
|
||||
private populateMenus(menuItems: MenuItemExtension[] | undefined) {
|
||||
const registry = new Map<MenuItemKey, INavItem[]>()
|
||||
const tempRegistry: Record<string, INavItem> = {}
|
||||
|
||||
if (!menuItems) {
|
||||
return registry
|
||||
}
|
||||
|
||||
menuItems.sort((a, b) => a.path.length - b.path.length)
|
||||
|
||||
menuItems.forEach((item) => {
|
||||
if (item.path.includes("/:")) {
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
console.warn(
|
||||
`Menu item for path "${item.path}" can't be added to the sidebar as it contains a parameter.`
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const isSettingsPath = item.path.startsWith("/settings")
|
||||
const key = isSettingsPath ? "settingsExtensions" : "coreExtensions"
|
||||
|
||||
const navItem: INavItem = {
|
||||
label: item.label,
|
||||
to: item.path,
|
||||
icon: item.icon ? <item.icon /> : undefined,
|
||||
items: [],
|
||||
}
|
||||
|
||||
const pathParts = item.path.split("/").filter(Boolean)
|
||||
const parentPath = "/" + pathParts.slice(0, -1).join("/")
|
||||
|
||||
if (parentPath !== "/" && tempRegistry[parentPath]) {
|
||||
if (!tempRegistry[parentPath].items) {
|
||||
tempRegistry[parentPath].items = []
|
||||
}
|
||||
tempRegistry[parentPath].items!.push(navItem)
|
||||
} else {
|
||||
if (!registry.has(key)) {
|
||||
registry.set(key, [])
|
||||
}
|
||||
registry.get(key)!.push(navItem)
|
||||
}
|
||||
|
||||
tempRegistry[item.path] = navItem
|
||||
})
|
||||
|
||||
return registry
|
||||
}
|
||||
|
||||
private populateForm(formModule: FormModule): {
|
||||
fields: FormFieldMap
|
||||
configs: ConfigFieldMap
|
||||
} {
|
||||
const fields: FormFieldMap = new Map()
|
||||
const configs: ConfigFieldMap = new Map()
|
||||
|
||||
Object.entries(formModule.customFields).forEach(
|
||||
([model, customization]) => {
|
||||
fields.set(
|
||||
model as CustomFieldModel,
|
||||
this.processFields(customization.forms)
|
||||
)
|
||||
configs.set(
|
||||
model as CustomFieldModel,
|
||||
this.processConfigs(customization.configs)
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
return { fields, configs }
|
||||
}
|
||||
|
||||
private processFields(forms: FormExtension[]): FormZoneMap {
|
||||
const formZoneMap: FormZoneMap = new Map()
|
||||
|
||||
forms.forEach((fieldDef) =>
|
||||
this.processFieldDefinition(formZoneMap, fieldDef)
|
||||
)
|
||||
|
||||
return formZoneMap
|
||||
}
|
||||
|
||||
private processConfigs(
|
||||
configs: ConfigExtension[]
|
||||
): Map<CustomFieldFormZone, ConfigField[]> {
|
||||
const modelConfigMap = new Map<CustomFieldFormZone, ConfigField[]>()
|
||||
|
||||
configs.forEach((configDef) => {
|
||||
const { zone, fields } = configDef
|
||||
const zoneConfigs: ConfigField[] = []
|
||||
|
||||
Object.entries(fields).forEach(([name, config]) => {
|
||||
zoneConfigs.push({
|
||||
name,
|
||||
defaultValue: config.defaultValue,
|
||||
validation: config.validation,
|
||||
})
|
||||
})
|
||||
|
||||
modelConfigMap.set(zone, zoneConfigs)
|
||||
})
|
||||
|
||||
return modelConfigMap
|
||||
}
|
||||
|
||||
private processFieldDefinition(
|
||||
formZoneMap: FormZoneMap,
|
||||
fieldDef: FormExtension
|
||||
) {
|
||||
const { zone, tab, fields: fieldsDefinition } = fieldDef
|
||||
const zoneStructure = this.getOrCreateZoneStructure(formZoneMap, zone)
|
||||
|
||||
Object.entries(fieldsDefinition).forEach(([fieldKey, fieldDefinition]) => {
|
||||
const formField = this.createFormField(fieldKey, fieldDefinition)
|
||||
this.addFormFieldToZoneStructure(zoneStructure, formField, tab)
|
||||
})
|
||||
}
|
||||
|
||||
private getOrCreateZoneStructure(
|
||||
formZoneMap: FormZoneMap,
|
||||
zone: CustomFieldFormZone
|
||||
): ZoneStructure {
|
||||
let zoneStructure = formZoneMap.get(zone)
|
||||
if (!zoneStructure) {
|
||||
zoneStructure = { components: [], tabs: new Map() }
|
||||
formZoneMap.set(zone, zoneStructure)
|
||||
}
|
||||
return zoneStructure
|
||||
}
|
||||
|
||||
private createFormField(
|
||||
fieldKey: string,
|
||||
fieldDefinition: FormFieldExtension
|
||||
): FormField {
|
||||
return {
|
||||
name: fieldKey,
|
||||
validation: fieldDefinition.validation,
|
||||
label: fieldDefinition.label,
|
||||
description: fieldDefinition.description,
|
||||
Component: fieldDefinition.Component,
|
||||
}
|
||||
}
|
||||
|
||||
private addFormFieldToZoneStructure(
|
||||
zoneStructure: ZoneStructure,
|
||||
formField: FormField,
|
||||
tab?: CustomFieldFormTab
|
||||
) {
|
||||
if (tab) {
|
||||
let tabFields = zoneStructure.tabs.get(tab)
|
||||
if (!tabFields) {
|
||||
tabFields = []
|
||||
zoneStructure.tabs.set(tab, tabFields)
|
||||
}
|
||||
tabFields.push(formField)
|
||||
} else {
|
||||
zoneStructure.components.push(formField)
|
||||
}
|
||||
}
|
||||
|
||||
private populateDisplays(displayModule: DisplayModule): DisplayMap {
|
||||
const displays = new Map<
|
||||
CustomFieldModel,
|
||||
Map<CustomFieldContainerZone, React.ComponentType<{ data: any }>[]>
|
||||
>()
|
||||
|
||||
Object.entries(displayModule.displays).forEach(([model, customization]) => {
|
||||
displays.set(
|
||||
model as CustomFieldModel,
|
||||
this.processDisplays(customization)
|
||||
)
|
||||
})
|
||||
|
||||
return displays
|
||||
}
|
||||
|
||||
private processDisplays(
|
||||
displays: DisplayExtension[]
|
||||
): Map<CustomFieldContainerZone, React.ComponentType<{ data: any }>[]> {
|
||||
const modelDisplayMap = new Map<
|
||||
CustomFieldContainerZone,
|
||||
React.ComponentType<{ data: any }>[]
|
||||
>()
|
||||
|
||||
displays.forEach((display) => {
|
||||
const { zone, Component } = display
|
||||
if (!modelDisplayMap.has(zone)) {
|
||||
modelDisplayMap.set(zone, [])
|
||||
}
|
||||
modelDisplayMap.get(zone)!.push(Component)
|
||||
})
|
||||
|
||||
return modelDisplayMap
|
||||
}
|
||||
|
||||
private getMenu(path: MenuItemKey) {
|
||||
return this.menus.get(path) || []
|
||||
}
|
||||
|
||||
private getWidgets(zone: InjectionZone) {
|
||||
return this.widgets.get(zone) || []
|
||||
}
|
||||
|
||||
private getFormFields(
|
||||
model: CustomFieldModel,
|
||||
zone: CustomFieldFormZone,
|
||||
tab?: CustomFieldFormTab
|
||||
) {
|
||||
const zoneMap = this.fields.get(model)?.get(zone)
|
||||
|
||||
if (!zoneMap) {
|
||||
return []
|
||||
}
|
||||
|
||||
if (tab) {
|
||||
return zoneMap.tabs.get(tab) || []
|
||||
}
|
||||
|
||||
return zoneMap.components
|
||||
}
|
||||
|
||||
private getFormConfigs(model: CustomFieldModel, zone: CustomFieldFormZone) {
|
||||
return this.configs.get(model)?.get(zone) || []
|
||||
}
|
||||
|
||||
private getDisplays(model: CustomFieldModel, zone: CustomFieldContainerZone) {
|
||||
return this.displays.get(model)?.get(zone) || []
|
||||
}
|
||||
|
||||
get api() {
|
||||
return {
|
||||
getMenu: this.getMenu.bind(this),
|
||||
getWidgets: this.getWidgets.bind(this),
|
||||
getFormFields: this.getFormFields.bind(this),
|
||||
getFormConfigs: this.getFormConfigs.bind(this),
|
||||
getDisplays: this.getDisplays.bind(this),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from "./dashboard-extension-manager";
|
||||
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
import { createContext } from "react"
|
||||
import { DashboardExtensionManager } from "../dashboard-extension-manager"
|
||||
|
||||
type DasboardExtenstionContextValue = DashboardExtensionManager["api"]
|
||||
|
||||
export const DashboardExtensionContext =
|
||||
createContext<DasboardExtenstionContextValue | null>(null)
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
import { PropsWithChildren } from "react"
|
||||
import { DashboardExtensionManager } from "../dashboard-extension-manager/dashboard-extension-manager"
|
||||
import { DashboardExtensionContext } from "./dashboard-extension-context"
|
||||
|
||||
type DashboardExtensionProviderProps = PropsWithChildren<{
|
||||
api: DashboardExtensionManager["api"]
|
||||
}>
|
||||
|
||||
export const DashboardExtensionProvider = ({
|
||||
api,
|
||||
children,
|
||||
}: DashboardExtensionProviderProps) => {
|
||||
return (
|
||||
<DashboardExtensionContext.Provider value={api}>
|
||||
{children}
|
||||
</DashboardExtensionContext.Provider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { DashboardExtensionProvider } from "./dashboard-extension-provider"
|
||||
export { useDashboardExtension } from "./use-dashboard-extension"
|
||||
@@ -0,0 +1,32 @@
|
||||
import { InjectionZone } from "@medusajs/admin-shared"
|
||||
import { ComponentType } from "react"
|
||||
import { LoaderFunction } from "react-router-dom"
|
||||
import { CustomFieldConfiguration } from "../../extensions/custom-field-registry/types"
|
||||
|
||||
export type RouteExtension = {
|
||||
Component: ComponentType
|
||||
loader?: LoaderFunction
|
||||
path: string
|
||||
}
|
||||
|
||||
export type MenuItemExtension = {
|
||||
label: string
|
||||
path: string
|
||||
icon?: ComponentType
|
||||
}
|
||||
|
||||
export type WidgetExtension = {
|
||||
Component: ComponentType
|
||||
zone: InjectionZone[]
|
||||
}
|
||||
|
||||
export type RoutingExtensionConfig = {
|
||||
routes: RouteExtension[]
|
||||
menuItems: MenuItemExtension[]
|
||||
}
|
||||
|
||||
export type DashboardExtensionConfig = {
|
||||
customFields?: CustomFieldConfiguration
|
||||
menuItems?: MenuItemExtension[]
|
||||
widgets?: WidgetExtension[]
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
import { useContext } from "react"
|
||||
import { DashboardExtensionContext } from "./dashboard-extension-context"
|
||||
|
||||
export const useDashboardExtension = () => {
|
||||
const context = useContext(DashboardExtensionContext)
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
"useDashboardExtension must be used within a DashboardExtensionProvider"
|
||||
)
|
||||
}
|
||||
return context
|
||||
}
|
||||
+114
@@ -0,0 +1,114 @@
|
||||
import { Input, Switch } from "@medusajs/ui"
|
||||
import { ComponentType } from "react"
|
||||
import { ControllerRenderProps, UseFormReturn } from "react-hook-form"
|
||||
import { Form } from "../../../components/common/form"
|
||||
import { InlineTip } from "../../../components/common/inline-tip"
|
||||
import { FormField } from "../../types"
|
||||
import { FormFieldType } from "./types"
|
||||
import { getFieldType } from "./utils"
|
||||
|
||||
type FormExtensionZoneProps = {
|
||||
fields: FormField[]
|
||||
form: UseFormReturn<any>
|
||||
}
|
||||
|
||||
export const FormExtensionZone = ({ fields, form }: FormExtensionZoneProps) => {
|
||||
return (
|
||||
<div>
|
||||
{fields.map((field, index) => (
|
||||
<FormExtensionField key={index} field={field} form={form} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function getFieldLabel(field: FormField) {
|
||||
if (field.label) {
|
||||
return field.label
|
||||
}
|
||||
|
||||
return field.name
|
||||
.split("_")
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(" ")
|
||||
}
|
||||
|
||||
type FormExtensionFieldProps = {
|
||||
field: FormField
|
||||
form: UseFormReturn<any>
|
||||
}
|
||||
|
||||
const FormExtensionField = ({ field, form }: FormExtensionFieldProps) => {
|
||||
const label = getFieldLabel(field)
|
||||
const description = field.description
|
||||
const placeholder = field.placeholder
|
||||
const Component = field.Component
|
||||
|
||||
const type = getFieldType(field.validation)
|
||||
|
||||
const { control } = form
|
||||
|
||||
return (
|
||||
<Form.Field
|
||||
control={control}
|
||||
name={`additional_data.${field.name}`}
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<Form.Item>
|
||||
<Form.Label>{label}</Form.Label>
|
||||
{description && <Form.Hint>{description}</Form.Hint>}
|
||||
<Form.Control>
|
||||
<FormExtensionFieldComponent
|
||||
field={field}
|
||||
type={type}
|
||||
component={Component}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
</Form.Control>
|
||||
<Form.ErrorMessage />
|
||||
</Form.Item>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
type FormExtensionFieldComponentProps = {
|
||||
field: ControllerRenderProps
|
||||
type: FormFieldType
|
||||
component?: ComponentType<any>
|
||||
placeholder?: string
|
||||
}
|
||||
|
||||
const FormExtensionFieldComponent = ({
|
||||
field,
|
||||
type,
|
||||
component,
|
||||
placeholder,
|
||||
}: FormExtensionFieldComponentProps) => {
|
||||
if (component) {
|
||||
const Component = component
|
||||
|
||||
return <Component {...field} placeholder={placeholder} />
|
||||
}
|
||||
|
||||
switch (type) {
|
||||
case "text": {
|
||||
return <Input {...field} placeholder={placeholder} />
|
||||
}
|
||||
case "number": {
|
||||
return <Input {...field} placeholder={placeholder} type="number" />
|
||||
}
|
||||
case "boolean": {
|
||||
return <Switch {...field} />
|
||||
}
|
||||
default: {
|
||||
return (
|
||||
<InlineTip variant="warning">
|
||||
The field type does not support rendering a fallback component. Please
|
||||
provide a component prop.
|
||||
</InlineTip>
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./form-extension-zone"
|
||||
@@ -0,0 +1 @@
|
||||
export type FormFieldType = "text" | "number" | "boolean" | "unsupported"
|
||||
@@ -0,0 +1,66 @@
|
||||
import {
|
||||
ZodBoolean,
|
||||
ZodEffects,
|
||||
ZodNull,
|
||||
ZodNullable,
|
||||
ZodNumber,
|
||||
ZodOptional,
|
||||
ZodString,
|
||||
ZodType,
|
||||
ZodUndefined,
|
||||
} from "zod"
|
||||
import { FormFieldType } from "./types"
|
||||
|
||||
export function getFieldLabel(name: string, label?: string) {
|
||||
if (label) {
|
||||
return label
|
||||
}
|
||||
|
||||
return name
|
||||
.split("_")
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(" ")
|
||||
}
|
||||
|
||||
export function getFieldType(type: ZodType): FormFieldType {
|
||||
if (type instanceof ZodString) {
|
||||
return "text"
|
||||
}
|
||||
|
||||
if (type instanceof ZodNumber) {
|
||||
return "number"
|
||||
}
|
||||
|
||||
if (type instanceof ZodBoolean) {
|
||||
return "boolean"
|
||||
}
|
||||
|
||||
if (type instanceof ZodNullable) {
|
||||
const innerType = type.unwrap()
|
||||
|
||||
return getFieldType(innerType)
|
||||
}
|
||||
|
||||
if (type instanceof ZodOptional) {
|
||||
const innerType = type.unwrap()
|
||||
|
||||
return getFieldType(innerType)
|
||||
}
|
||||
|
||||
if (type instanceof ZodEffects) {
|
||||
const innerType = type.innerType()
|
||||
|
||||
return getFieldType(innerType)
|
||||
}
|
||||
|
||||
return "unsupported"
|
||||
}
|
||||
|
||||
export function getIsFieldOptional(type: ZodType) {
|
||||
return (
|
||||
type instanceof ZodOptional ||
|
||||
type instanceof ZodNull ||
|
||||
type instanceof ZodUndefined ||
|
||||
type instanceof ZodNullable
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { FieldValues, useForm, UseFormProps } from "react-hook-form"
|
||||
import { z, ZodEffects, ZodObject } from "zod"
|
||||
|
||||
import { ConfigField } from "../types"
|
||||
|
||||
interface UseExtendableFormProps<
|
||||
TSchema extends ZodObject<any> | ZodEffects<ZodObject<any>>,
|
||||
TContext = any,
|
||||
TData = any
|
||||
> extends Omit<UseFormProps<z.infer<TSchema>, TContext>, "resolver"> {
|
||||
schema: TSchema
|
||||
configs: ConfigField[]
|
||||
data?: TData
|
||||
}
|
||||
|
||||
function createAdditionalDataSchema(configs: ConfigField[]) {
|
||||
return configs.reduce((acc, config) => {
|
||||
acc[config.name] = config.validation
|
||||
return acc
|
||||
}, {} as Record<string, z.ZodTypeAny>)
|
||||
}
|
||||
|
||||
function createExtendedSchema<
|
||||
TSchema extends ZodObject<any> | ZodEffects<ZodObject<any>>
|
||||
>(baseSchema: TSchema, additionalDataSchema: Record<string, z.ZodTypeAny>) {
|
||||
const extendedObjectSchema = z.object({
|
||||
...(baseSchema instanceof ZodEffects
|
||||
? baseSchema.innerType().shape
|
||||
: baseSchema.shape),
|
||||
additional_data: z.object(additionalDataSchema).optional(),
|
||||
})
|
||||
|
||||
return baseSchema instanceof ZodEffects
|
||||
? baseSchema
|
||||
.superRefine((data, ctx) => {
|
||||
const result = extendedObjectSchema.safeParse(data)
|
||||
if (!result.success) {
|
||||
result.error.issues.forEach((issue) => ctx.addIssue(issue))
|
||||
}
|
||||
})
|
||||
.and(extendedObjectSchema)
|
||||
: extendedObjectSchema
|
||||
}
|
||||
|
||||
function createExtendedDefaultValues<TData>(
|
||||
baseDefaultValues: any,
|
||||
configs: ConfigField[],
|
||||
data?: TData
|
||||
) {
|
||||
const additional_data = configs.reduce((acc, config) => {
|
||||
const { name, defaultValue } = config
|
||||
|
||||
acc[name] =
|
||||
typeof defaultValue === "function" ? defaultValue(data) : defaultValue
|
||||
return acc
|
||||
}, {} as Record<string, any>)
|
||||
|
||||
return Object.assign(baseDefaultValues, { additional_data })
|
||||
}
|
||||
|
||||
export const useExtendableForm = <
|
||||
TSchema extends ZodObject<any> | ZodEffects<ZodObject<any>>,
|
||||
TContext = any,
|
||||
TTransformedValues extends FieldValues | undefined = undefined
|
||||
>({
|
||||
defaultValues: baseDefaultValues,
|
||||
schema: baseSchema,
|
||||
configs,
|
||||
data,
|
||||
...props
|
||||
}: UseExtendableFormProps<TSchema, TContext>) => {
|
||||
const additionalDataSchema = createAdditionalDataSchema(configs)
|
||||
const schema = createExtendedSchema(baseSchema, additionalDataSchema)
|
||||
const defaultValues = createExtendedDefaultValues(
|
||||
baseDefaultValues,
|
||||
configs,
|
||||
data
|
||||
)
|
||||
|
||||
return useForm<z.infer<TSchema>, TContext, TTransformedValues>({
|
||||
...props,
|
||||
defaultValues,
|
||||
resolver: zodResolver(schema),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from "./form-extension-zone"
|
||||
export * from "./hooks"
|
||||
@@ -0,0 +1,13 @@
|
||||
export * from "./dashboard-extension-manager"
|
||||
export * from "./dashboard-extension-provider"
|
||||
export * from "./forms"
|
||||
export * from "./links/utils"
|
||||
export * from "./routes/utils"
|
||||
|
||||
export {
|
||||
type DisplayModule,
|
||||
type FormModule,
|
||||
type MenuItemModule,
|
||||
type RouteModule,
|
||||
type WidgetModule,
|
||||
} from "./types"
|
||||
@@ -0,0 +1,20 @@
|
||||
import { CustomFieldModel } from "@medusajs/admin-shared"
|
||||
import linkModule from "virtual:medusa/links"
|
||||
|
||||
function appendLinkableFields(
|
||||
fields: string = "",
|
||||
linkable: (string | string[])[] = []
|
||||
) {
|
||||
const linkableFields = linkable.flatMap((link) => {
|
||||
return typeof link === "string"
|
||||
? [`+${link}.*`]
|
||||
: link.map((l) => `+${l}.*`)
|
||||
})
|
||||
|
||||
return [fields, ...linkableFields].join(",")
|
||||
}
|
||||
|
||||
export function getLinkedFields(model: CustomFieldModel, fields: string = "") {
|
||||
const links = linkModule.links[model]
|
||||
return appendLinkableFields(fields, links)
|
||||
}
|
||||
+29
-9
@@ -1,21 +1,37 @@
|
||||
import { RouteObject } from "react-router-dom"
|
||||
import { ErrorBoundary } from "../components/utilities/error-boundary"
|
||||
import { ComponentType } from "react"
|
||||
import { LoaderFunction, RouteObject } from "react-router-dom"
|
||||
import { ErrorBoundary } from "../../components/utilities/error-boundary"
|
||||
import { RouteExtension, RouteModule } from "../types"
|
||||
|
||||
/**
|
||||
* Used to test if a route is a settings route.
|
||||
*/
|
||||
export const settingsRouteRegex = /^\/settings\//
|
||||
const settingsRouteRegex = /^\/settings\//
|
||||
|
||||
export const getRouteExtensions = (
|
||||
module: RouteModule,
|
||||
type: "settings" | "core"
|
||||
) => {
|
||||
return module.routes.filter((route) => {
|
||||
if (type === "settings") {
|
||||
return settingsRouteRegex.test(route.path)
|
||||
}
|
||||
|
||||
return !settingsRouteRegex.test(route.path)
|
||||
})
|
||||
}
|
||||
|
||||
export const createRouteMap = (
|
||||
routes: { path: string; Component: () => JSX.Element }[],
|
||||
routes: RouteExtension[],
|
||||
ignore?: string
|
||||
): RouteObject[] => {
|
||||
const root: RouteObject[] = []
|
||||
|
||||
const addRoute = (
|
||||
pathSegments: string[],
|
||||
Component: () => JSX.Element,
|
||||
currentLevel: RouteObject[]
|
||||
Component: ComponentType,
|
||||
currentLevel: RouteObject[],
|
||||
loader?: LoaderFunction
|
||||
) => {
|
||||
if (!pathSegments.length) {
|
||||
return
|
||||
@@ -35,22 +51,26 @@ export const createRouteMap = (
|
||||
path: "",
|
||||
ErrorBoundary: ErrorBoundary,
|
||||
async lazy() {
|
||||
if (loader) {
|
||||
return { Component, loader }
|
||||
}
|
||||
|
||||
return { Component }
|
||||
},
|
||||
})
|
||||
} else {
|
||||
route.children ||= []
|
||||
addRoute(remainingSegments, Component, route.children)
|
||||
addRoute(remainingSegments, Component, route.children, loader)
|
||||
}
|
||||
}
|
||||
|
||||
routes.forEach(({ path, Component }) => {
|
||||
routes.forEach(({ path, Component, loader }) => {
|
||||
// Remove the ignore segment from the path if it is provided
|
||||
const cleanedPath = ignore
|
||||
? path.replace(ignore, "").replace(/^\/+/, "")
|
||||
: path.replace(/^\/+/, "")
|
||||
const pathSegments = cleanedPath.split("/").filter(Boolean)
|
||||
addRoute(pathSegments, Component, root)
|
||||
addRoute(pathSegments, Component, root, loader)
|
||||
})
|
||||
|
||||
return root
|
||||
@@ -0,0 +1,117 @@
|
||||
import {
|
||||
CustomFieldContainerZone,
|
||||
CustomFieldFormTab,
|
||||
CustomFieldFormZone,
|
||||
CustomFieldModel,
|
||||
InjectionZone,
|
||||
} from "@medusajs/admin-shared"
|
||||
import { ComponentType } from "react"
|
||||
import { LoaderFunction } from "react-router-dom"
|
||||
import { ZodFirstPartySchemaTypes } from "zod"
|
||||
|
||||
export type RouteExtension = {
|
||||
Component: ComponentType
|
||||
loader?: LoaderFunction
|
||||
path: string
|
||||
}
|
||||
|
||||
export type MenuItemExtension = {
|
||||
label: string
|
||||
path: string
|
||||
icon?: ComponentType
|
||||
}
|
||||
|
||||
export type WidgetExtension = {
|
||||
Component: ComponentType
|
||||
zone: InjectionZone[]
|
||||
}
|
||||
|
||||
export type DisplayExtension = {
|
||||
Component: ComponentType<{ data: any }>
|
||||
zone: CustomFieldContainerZone
|
||||
}
|
||||
|
||||
export type FormFieldExtension = {
|
||||
validation: ZodFirstPartySchemaTypes
|
||||
Component?: ComponentType<any>
|
||||
label?: string
|
||||
description?: string
|
||||
placeholder?: string
|
||||
}
|
||||
|
||||
export type FormExtension = {
|
||||
zone: CustomFieldFormZone
|
||||
tab?: CustomFieldFormTab
|
||||
fields: Record<string, FormFieldExtension>
|
||||
}
|
||||
|
||||
export type ConfigFieldExtension = {
|
||||
defaultValue: ((data: any) => any) | any
|
||||
validation: ZodFirstPartySchemaTypes
|
||||
}
|
||||
|
||||
export type ConfigExtension = {
|
||||
zone: CustomFieldFormZone
|
||||
fields: Record<string, ConfigFieldExtension>
|
||||
}
|
||||
|
||||
export type LinkModule = {
|
||||
links: Record<CustomFieldModel, (string | string[])[]>
|
||||
}
|
||||
|
||||
export type DisplayModule = {
|
||||
displays: Record<CustomFieldModel, DisplayExtension[]>
|
||||
}
|
||||
|
||||
export type FormModule = {
|
||||
customFields: Record<
|
||||
CustomFieldModel,
|
||||
{
|
||||
forms: FormExtension[]
|
||||
configs: ConfigExtension[]
|
||||
}
|
||||
>
|
||||
}
|
||||
|
||||
export type WidgetModule = {
|
||||
widgets: WidgetExtension[]
|
||||
}
|
||||
|
||||
export type RouteModule = {
|
||||
routes: RouteExtension[]
|
||||
}
|
||||
|
||||
export type MenuItemModule = {
|
||||
menuItems: MenuItemExtension[]
|
||||
}
|
||||
|
||||
export type MenuItemKey = "coreExtensions" | "settingsExtensions"
|
||||
|
||||
export type FormField = FormFieldExtension & {
|
||||
name: string
|
||||
}
|
||||
|
||||
export type TabFieldMap = Map<CustomFieldFormTab, FormField[]>
|
||||
|
||||
export type ZoneStructure = {
|
||||
components: FormField[]
|
||||
tabs: TabFieldMap
|
||||
}
|
||||
|
||||
export type FormZoneMap = Map<CustomFieldFormZone, ZoneStructure>
|
||||
|
||||
export type FormFieldMap = Map<CustomFieldModel, FormZoneMap>
|
||||
|
||||
export type ConfigField = ConfigFieldExtension & {
|
||||
name: string
|
||||
}
|
||||
|
||||
export type ConfigFieldMap = Map<
|
||||
CustomFieldModel,
|
||||
Map<CustomFieldFormZone, ConfigField[]>
|
||||
>
|
||||
|
||||
export type DisplayMap = Map<
|
||||
CustomFieldModel,
|
||||
Map<CustomFieldContainerZone, React.ComponentType<{ data: any }>[]>
|
||||
>
|
||||
@@ -19,6 +19,7 @@ export const customerGroupsQueryKeys = queryKeysFactory(
|
||||
|
||||
export const useCustomerGroup = (
|
||||
id: string,
|
||||
query?: HttpTypes.AdminGetCustomerGroupParams,
|
||||
options?: Omit<
|
||||
UseQueryOptions<
|
||||
HttpTypes.AdminCustomerGroupResponse,
|
||||
@@ -30,8 +31,8 @@ export const useCustomerGroup = (
|
||||
>
|
||||
) => {
|
||||
const { data, ...rest } = useQuery({
|
||||
queryKey: customerGroupsQueryKeys.detail(id),
|
||||
queryFn: async () => sdk.admin.customerGroup.retrieve(id),
|
||||
queryKey: customerGroupsQueryKeys.detail(id, query),
|
||||
queryFn: async () => sdk.admin.customerGroup.retrieve(id, query),
|
||||
...options,
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./render"
|
||||
+30
-22
@@ -1,27 +1,35 @@
|
||||
declare module "virtual:medusa/widgets/*" {
|
||||
import type { ComponentType } from "react"
|
||||
|
||||
const widgets: { Component: ComponentType<any> }[]
|
||||
|
||||
export default {
|
||||
widgets,
|
||||
}
|
||||
declare module "virtual:medusa/forms" {
|
||||
import type { FormModule } from "./extensions"
|
||||
const formModule: FormModule
|
||||
export default formModule
|
||||
}
|
||||
|
||||
declare module "virtual:medusa/routes/pages" {
|
||||
const pages: { path: string; Component: () => JSX.Element }[]
|
||||
|
||||
export default {
|
||||
pages,
|
||||
}
|
||||
declare module "virtual:medusa/links" {
|
||||
import type { LinkModule } from "./extensions"
|
||||
const linkModule: LinkModule
|
||||
export default linkModule
|
||||
}
|
||||
|
||||
declare module "virtual:medusa/routes/links" {
|
||||
import type { ComponentType } from "react"
|
||||
|
||||
const links: { path: string; label: string; icon?: ComponentType }[]
|
||||
|
||||
export default {
|
||||
links,
|
||||
}
|
||||
declare module "virtual:medusa/displays" {
|
||||
import type { DisplayModule } from "./extensions"
|
||||
const displayModule: DisplayModule
|
||||
export default displayModule
|
||||
}
|
||||
|
||||
declare module "virtual:medusa/routes" {
|
||||
import type { RouteModule } from "./extensions"
|
||||
const routeModule: RouteModule
|
||||
export default routeModule
|
||||
}
|
||||
|
||||
declare module "virtual:medusa/menu-items" {
|
||||
import type { MenuItemModule } from "./extensions"
|
||||
const menuItemModule: MenuItemModule
|
||||
export default menuItemModule
|
||||
}
|
||||
|
||||
declare module "virtual:medusa/widgets" {
|
||||
import type { WidgetModule } from "./extensions"
|
||||
const widgetModule: WidgetModule
|
||||
export default widgetModule
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./providers"
|
||||
@@ -0,0 +1,35 @@
|
||||
import { Toaster, TooltipProvider } from "@medusajs/ui"
|
||||
import { QueryClientProvider } from "@tanstack/react-query"
|
||||
import type { PropsWithChildren } from "react"
|
||||
import { HelmetProvider } from "react-helmet-async"
|
||||
|
||||
import { I18n } from "../components/utilities/i18n"
|
||||
import {
|
||||
DashboardExtensionManager,
|
||||
DashboardExtensionProvider,
|
||||
} from "../extensions"
|
||||
import { queryClient } from "../lib/query-client"
|
||||
import { I18nProvider } from "./i18n-provider"
|
||||
import { ThemeProvider } from "./theme-provider"
|
||||
|
||||
type ProvidersProps = PropsWithChildren<{
|
||||
api: DashboardExtensionManager["api"]
|
||||
}>
|
||||
|
||||
export const Providers = ({ api, children }: ProvidersProps) => {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<DashboardExtensionProvider api={api}>
|
||||
<HelmetProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ThemeProvider>
|
||||
<I18n />
|
||||
<I18nProvider>{children}</I18nProvider>
|
||||
<Toaster />
|
||||
</ThemeProvider>
|
||||
</QueryClientProvider>
|
||||
</HelmetProvider>
|
||||
</DashboardExtensionProvider>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
@@ -1,12 +1,12 @@
|
||||
import routes from "virtual:medusa/routes/pages"
|
||||
import routeModule from "virtual:medusa/routes"
|
||||
import {
|
||||
createRouteMap,
|
||||
getRouteExtensions,
|
||||
} from "../../extensions/routes/utils"
|
||||
|
||||
import { createRouteMap, settingsRouteRegex } from "../../lib/extension-helpers"
|
||||
|
||||
const pages = routes.pages
|
||||
.filter((ext) => !settingsRouteRegex.test(ext.path))
|
||||
.map((ext) => ext)
|
||||
const routes = getRouteExtensions(routeModule, "core")
|
||||
|
||||
/**
|
||||
* Core Route extensions.
|
||||
*/
|
||||
export const RouteExtensions = createRouteMap(pages)
|
||||
export const RouteExtensions = createRouteMap(routes)
|
||||
|
||||
@@ -1,15 +1,11 @@
|
||||
import {
|
||||
AdminProductCategoryResponse,
|
||||
AdminTaxRegionResponse,
|
||||
HttpTypes,
|
||||
} from "@medusajs/types"
|
||||
import { AdminProductCategoryResponse, HttpTypes } from "@medusajs/types"
|
||||
import { Outlet, RouteObject } from "react-router-dom"
|
||||
|
||||
import { ProtectedRoute } from "../../components/authentication/protected-route"
|
||||
import { MainLayout } from "../../components/layout/main-layout"
|
||||
import { PublicLayout } from "../../components/layout/public-layout"
|
||||
import { SettingsLayout } from "../../components/layout/settings-layout"
|
||||
import { ErrorBoundary } from "../../components/utilities/error-boundary"
|
||||
|
||||
import { getCountryByIso2 } from "../../lib/data/countries"
|
||||
import {
|
||||
getProvinceByIso2,
|
||||
@@ -22,32 +18,14 @@ import { SettingsExtensions } from "./settings-extensions"
|
||||
|
||||
// TODO: Add translations for all breadcrumbs
|
||||
export const RouteMap: RouteObject[] = [
|
||||
{
|
||||
path: "/login",
|
||||
lazy: () => import("../../routes/login"),
|
||||
},
|
||||
{
|
||||
path: "/reset-password",
|
||||
lazy: () => import("../../routes/reset-password"),
|
||||
},
|
||||
{
|
||||
path: "*",
|
||||
lazy: () => import("../../routes/no-match"),
|
||||
},
|
||||
{
|
||||
path: "/invite",
|
||||
lazy: () => import("../../routes/invite"),
|
||||
},
|
||||
{
|
||||
element: <ProtectedRoute />,
|
||||
errorElement: <ErrorBoundary />,
|
||||
children: [
|
||||
{
|
||||
path: "/",
|
||||
element: <MainLayout />,
|
||||
children: [
|
||||
{
|
||||
index: true,
|
||||
path: "/",
|
||||
errorElement: <ErrorBoundary />,
|
||||
lazy: () => import("../../routes/home"),
|
||||
},
|
||||
@@ -677,6 +655,11 @@ export const RouteMap: RouteObject[] = [
|
||||
"../../routes/inventory/inventory-detail/components/edit-inventory-item-attributes"
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "metadata/edit",
|
||||
lazy: () =>
|
||||
import("../../routes/inventory/inventory-metadata"),
|
||||
},
|
||||
{
|
||||
path: "locations",
|
||||
lazy: () =>
|
||||
@@ -691,21 +674,6 @@ export const RouteMap: RouteObject[] = [
|
||||
"../../routes/inventory/inventory-detail/components/adjust-inventory"
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "metadata/edit",
|
||||
lazy: () =>
|
||||
import("../../routes/inventory/inventory-metadata"),
|
||||
},
|
||||
{
|
||||
// TODO: create reservation
|
||||
path: "reservations",
|
||||
lazy: () => import("../../routes/customers/customer-edit"),
|
||||
},
|
||||
{
|
||||
// TODO: edit reservation
|
||||
path: "reservations/:reservation_id",
|
||||
lazy: () => import("../../routes/customers/customer-edit"),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
@@ -780,10 +748,6 @@ export const RouteMap: RouteObject[] = [
|
||||
lazy: () =>
|
||||
import("../../routes/regions/region-add-countries"),
|
||||
},
|
||||
{
|
||||
path: "metadata/edit",
|
||||
lazy: () => import("../../routes/regions/region-metadata"),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
@@ -1160,10 +1124,8 @@ export const RouteMap: RouteObject[] = [
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
path: "publishable-api-keys",
|
||||
errorElement: <ErrorBoundary />,
|
||||
element: <Outlet />,
|
||||
handle: {
|
||||
crumb: () => "Publishable API Keys",
|
||||
@@ -1223,7 +1185,6 @@ export const RouteMap: RouteObject[] = [
|
||||
},
|
||||
{
|
||||
path: "secret-api-keys",
|
||||
errorElement: <ErrorBoundary />,
|
||||
element: <Outlet />,
|
||||
handle: {
|
||||
crumb: () => "Secret API Keys",
|
||||
@@ -1276,7 +1237,6 @@ export const RouteMap: RouteObject[] = [
|
||||
},
|
||||
{
|
||||
path: "tax-regions",
|
||||
errorElement: <ErrorBoundary />,
|
||||
element: <Outlet />,
|
||||
handle: {
|
||||
crumb: () => "Tax Regions",
|
||||
@@ -1298,7 +1258,7 @@ export const RouteMap: RouteObject[] = [
|
||||
Component: Outlet,
|
||||
loader: taxRegionLoader,
|
||||
handle: {
|
||||
crumb: (data: AdminTaxRegionResponse) => {
|
||||
crumb: (data: HttpTypes.AdminTaxRegionResponse) => {
|
||||
return (
|
||||
getCountryByIso2(data.tax_region.country_code)
|
||||
?.display_name ||
|
||||
@@ -1356,7 +1316,7 @@ export const RouteMap: RouteObject[] = [
|
||||
"../../routes/tax-regions/tax-region-province-detail"
|
||||
),
|
||||
handle: {
|
||||
crumb: (data: AdminTaxRegionResponse) => {
|
||||
crumb: (data: HttpTypes.AdminTaxRegionResponse) => {
|
||||
const countryCode =
|
||||
data.tax_region.country_code?.toUpperCase()
|
||||
const provinceCode =
|
||||
@@ -1395,7 +1355,6 @@ export const RouteMap: RouteObject[] = [
|
||||
},
|
||||
{
|
||||
path: "return-reasons",
|
||||
errorElement: <ErrorBoundary />,
|
||||
element: <Outlet />,
|
||||
handle: {
|
||||
crumb: () => "Return Reasons",
|
||||
@@ -1435,4 +1394,30 @@ export const RouteMap: RouteObject[] = [
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
element: <PublicLayout />,
|
||||
children: [
|
||||
{
|
||||
errorElement: <ErrorBoundary />,
|
||||
children: [
|
||||
{
|
||||
path: "/login",
|
||||
lazy: () => import("../../routes/login"),
|
||||
},
|
||||
{
|
||||
path: "/reset-password",
|
||||
lazy: () => import("../../routes/reset-password"),
|
||||
},
|
||||
{
|
||||
path: "/invite",
|
||||
lazy: () => import("../../routes/invite"),
|
||||
},
|
||||
{
|
||||
path: "*",
|
||||
lazy: () => import("../../routes/no-match"),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import routes from "virtual:medusa/routes/pages"
|
||||
import routeModule from "virtual:medusa/routes"
|
||||
import {
|
||||
createRouteMap,
|
||||
getRouteExtensions,
|
||||
} from "../../extensions/routes/utils"
|
||||
|
||||
import { createRouteMap, settingsRouteRegex } from "../../lib/extension-helpers"
|
||||
|
||||
const pages = routes.pages
|
||||
.filter((ext) => settingsRouteRegex.test(ext.path))
|
||||
.map((ext) => ext)
|
||||
const routes = getRouteExtensions(routeModule, "settings")
|
||||
|
||||
/**
|
||||
* Settings Route extensions.
|
||||
*/
|
||||
export const SettingsExtensions = createRouteMap(pages, "/settings")
|
||||
export const SettingsExtensions = createRouteMap(routes, "/settings")
|
||||
|
||||
+17
-36
@@ -1,39 +1,28 @@
|
||||
import { Outlet, useLoaderData, useParams } from "react-router-dom"
|
||||
import { useLoaderData, useParams } from "react-router-dom"
|
||||
|
||||
import { JsonViewSection } from "../../../components/common/json-view-section"
|
||||
import {
|
||||
GeneralSectionSkeleton,
|
||||
JsonViewSectionSkeleton,
|
||||
TableSectionSkeleton,
|
||||
} from "../../../components/common/skeleton"
|
||||
import { SingleColumnPageSkeleton } from "../../../components/common/skeleton"
|
||||
import { SingleColumnPage } from "../../../components/layout/pages"
|
||||
import { useDashboardExtension } from "../../../extensions"
|
||||
import { useApiKey } from "../../../hooks/api/api-keys"
|
||||
import { ApiKeyType } from "../common/constants"
|
||||
import { ApiKeyGeneralSection } from "./components/api-key-general-section"
|
||||
import { ApiKeySalesChannelSection } from "./components/api-key-sales-channel-section"
|
||||
import { apiKeyLoader } from "./loader"
|
||||
|
||||
import after from "virtual:medusa/widgets/api_key/details/after"
|
||||
import before from "virtual:medusa/widgets/api_key/details/before"
|
||||
|
||||
export const ApiKeyManagementDetail = () => {
|
||||
const initialData = useLoaderData() as Awaited<
|
||||
ReturnType<typeof apiKeyLoader>
|
||||
>
|
||||
|
||||
const { id } = useParams()
|
||||
const { getWidgets } = useDashboardExtension()
|
||||
|
||||
const { api_key, isLoading, isError, error } = useApiKey(id!, undefined, {
|
||||
const { api_key, isLoading, isError, error } = useApiKey(id!, {
|
||||
initialData: initialData,
|
||||
})
|
||||
|
||||
if (isLoading || !api_key) {
|
||||
return (
|
||||
<div className="flex flex-col gap-y-2">
|
||||
<GeneralSectionSkeleton rowCount={4} />
|
||||
<TableSectionSkeleton />
|
||||
<JsonViewSectionSkeleton />
|
||||
</div>
|
||||
)
|
||||
return <SingleColumnPageSkeleton showJSON sections={1} />
|
||||
}
|
||||
|
||||
const isPublishable = api_key?.type === ApiKeyType.PUBLISHABLE
|
||||
@@ -43,25 +32,17 @@ export const ApiKeyManagementDetail = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-y-2">
|
||||
{before.widgets.map((w, i) => {
|
||||
return (
|
||||
<div key={i}>
|
||||
<w.Component apiKey={api_key} />
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<SingleColumnPage
|
||||
hasOutlet
|
||||
showJSON
|
||||
widgets={{
|
||||
before: getWidgets("api_key.details.before"),
|
||||
after: getWidgets("api_key.details.after"),
|
||||
}}
|
||||
data={api_key}
|
||||
>
|
||||
<ApiKeyGeneralSection apiKey={api_key} />
|
||||
{isPublishable && <ApiKeySalesChannelSection apiKey={api_key} />}
|
||||
{after.widgets.map((w, i) => {
|
||||
return (
|
||||
<div key={i}>
|
||||
<w.Component apiKey={api_key} />
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<JsonViewSection data={api_key} />
|
||||
<Outlet />
|
||||
</div>
|
||||
</SingleColumnPage>
|
||||
)
|
||||
}
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
import { LoaderFunctionArgs } from "react-router-dom"
|
||||
|
||||
import { AdminApiKeyResponse } from "@medusajs/types"
|
||||
import { HttpTypes } from "@medusajs/types"
|
||||
import { apiKeysQueryKeys } from "../../../hooks/api/api-keys"
|
||||
import { sdk } from "../../../lib/client"
|
||||
import { queryClient } from "../../../lib/query-client"
|
||||
@@ -15,7 +15,7 @@ export const apiKeyLoader = async ({ params }: LoaderFunctionArgs) => {
|
||||
const query = apiKeyDetailQuery(id!)
|
||||
|
||||
return (
|
||||
queryClient.getQueryData<AdminApiKeyResponse>(query.queryKey) ??
|
||||
queryClient.getQueryData<HttpTypes.AdminApiKeyResponse>(query.queryKey) ??
|
||||
(await queryClient.fetchQuery(query))
|
||||
)
|
||||
}
|
||||
|
||||
+12
-20
@@ -1,33 +1,25 @@
|
||||
import { Outlet, useLocation } from "react-router-dom"
|
||||
import { useLocation } from "react-router-dom"
|
||||
import { getApiKeyTypeFromPathname } from "../common/utils"
|
||||
import { ApiKeyManagementListTable } from "./components/api-key-management-list-table"
|
||||
|
||||
import after from "virtual:medusa/widgets/api_key/list/after"
|
||||
import before from "virtual:medusa/widgets/api_key/list/before"
|
||||
import { SingleColumnPage } from "../../../components/layout/pages"
|
||||
import { useDashboardExtension } from "../../../extensions"
|
||||
|
||||
export const ApiKeyManagementList = () => {
|
||||
const { pathname } = useLocation()
|
||||
const { getWidgets } = useDashboardExtension()
|
||||
|
||||
const keyType = getApiKeyTypeFromPathname(pathname)
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-y-2">
|
||||
{before.widgets.map((w, i) => {
|
||||
return (
|
||||
<div key={i}>
|
||||
<w.Component />
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<SingleColumnPage
|
||||
hasOutlet
|
||||
widgets={{
|
||||
before: getWidgets("api_key.list.before"),
|
||||
after: getWidgets("api_key.list.after"),
|
||||
}}
|
||||
>
|
||||
<ApiKeyManagementListTable keyType={keyType} />
|
||||
{after.widgets.map((w, i) => {
|
||||
return (
|
||||
<div key={i}>
|
||||
<w.Component />
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<Outlet />
|
||||
</div>
|
||||
</SingleColumnPage>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -7,12 +7,9 @@ import { CampaignPromotionSection } from "./components/campaign-promotion-sectio
|
||||
import { CampaignSpend } from "./components/campaign-spend"
|
||||
import { campaignLoader } from "./loader"
|
||||
|
||||
import after from "virtual:medusa/widgets/campaign/details/after"
|
||||
import before from "virtual:medusa/widgets/campaign/details/before"
|
||||
import sideAfter from "virtual:medusa/widgets/campaign/details/side/after"
|
||||
import sideBefore from "virtual:medusa/widgets/campaign/details/side/before"
|
||||
import { TwoColumnPageSkeleton } from "../../../components/common/skeleton"
|
||||
import { TwoColumnPage } from "../../../components/layout/pages"
|
||||
import { useDashboardExtension } from "../../../extensions"
|
||||
import { CampaignConfigurationSection } from "./components/campaign-configuration-section"
|
||||
|
||||
export const CampaignDetail = () => {
|
||||
@@ -27,6 +24,8 @@ export const CampaignDetail = () => {
|
||||
{ initialData }
|
||||
)
|
||||
|
||||
const { getWidgets } = useDashboardExtension()
|
||||
|
||||
if (isLoading || !campaign) {
|
||||
return (
|
||||
<TwoColumnPageSkeleton
|
||||
@@ -45,10 +44,10 @@ export const CampaignDetail = () => {
|
||||
return (
|
||||
<TwoColumnPage
|
||||
widgets={{
|
||||
after,
|
||||
before,
|
||||
sideAfter,
|
||||
sideBefore,
|
||||
after: getWidgets("campaign.details.after"),
|
||||
before: getWidgets("campaign.details.before"),
|
||||
sideAfter: getWidgets("campaign.details.side.after"),
|
||||
sideBefore: getWidgets("campaign.details.side.before"),
|
||||
}}
|
||||
hasOutlet
|
||||
showJSON
|
||||
|
||||
@@ -1,29 +1,19 @@
|
||||
import { Outlet } from "react-router-dom"
|
||||
|
||||
import { SingleColumnPage } from "../../../components/layout/pages"
|
||||
import { useDashboardExtension } from "../../../extensions"
|
||||
import { CampaignListTable } from "./components/campaign-list-table"
|
||||
|
||||
import after from "virtual:medusa/widgets/campaign/list/after"
|
||||
import before from "virtual:medusa/widgets/campaign/list/before"
|
||||
|
||||
export const CampaignList = () => {
|
||||
const { getWidgets } = useDashboardExtension()
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-y-2">
|
||||
{before.widgets.map((w, i) => {
|
||||
return (
|
||||
<div key={i}>
|
||||
<w.Component />
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<SingleColumnPage
|
||||
widgets={{
|
||||
after: getWidgets("campaign.list.after"),
|
||||
before: getWidgets("campaign.list.before"),
|
||||
}}
|
||||
hasOutlet
|
||||
>
|
||||
<CampaignListTable />
|
||||
{after.widgets.map((w, i) => {
|
||||
return (
|
||||
<div key={i}>
|
||||
<w.Component />
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<Outlet />
|
||||
</div>
|
||||
</SingleColumnPage>
|
||||
)
|
||||
}
|
||||
|
||||
+17
-39
@@ -5,11 +5,9 @@ import { CategoryOrganizeSection } from "./components/category-organize-section"
|
||||
import { CategoryProductSection } from "./components/category-product-section"
|
||||
import { categoryLoader } from "./loader"
|
||||
|
||||
import after from "virtual:medusa/widgets/product_category/details/after"
|
||||
import before from "virtual:medusa/widgets/product_category/details/before"
|
||||
import sideAfter from "virtual:medusa/widgets/product_category/details/side/after"
|
||||
import sideBefore from "virtual:medusa/widgets/product_category/details/side/before"
|
||||
import { TwoColumnPageSkeleton } from "../../../components/common/skeleton"
|
||||
import { TwoColumnPage } from "../../../components/layout/pages"
|
||||
import { useDashboardExtension } from "../../../extensions"
|
||||
|
||||
export const CategoryDetail = () => {
|
||||
const { id } = useParams()
|
||||
@@ -18,6 +16,8 @@ export const CategoryDetail = () => {
|
||||
ReturnType<typeof categoryLoader>
|
||||
>
|
||||
|
||||
const { getWidgets } = useDashboardExtension()
|
||||
|
||||
const { product_category, isLoading, isError, error } = useProductCategory(
|
||||
id!,
|
||||
undefined,
|
||||
@@ -27,7 +27,14 @@ export const CategoryDetail = () => {
|
||||
)
|
||||
|
||||
if (isLoading || !product_category) {
|
||||
return <div>Loading...</div>
|
||||
return (
|
||||
<TwoColumnPageSkeleton
|
||||
mainSections={2}
|
||||
sidebarSections={1}
|
||||
showJSON
|
||||
showMetadata
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
@@ -37,50 +44,21 @@ export const CategoryDetail = () => {
|
||||
return (
|
||||
<TwoColumnPage
|
||||
widgets={{
|
||||
after,
|
||||
before,
|
||||
sideAfter,
|
||||
sideBefore,
|
||||
after: getWidgets("product_category.details.after"),
|
||||
before: getWidgets("product_category.details.before"),
|
||||
sideAfter: getWidgets("product_category.details.side.after"),
|
||||
sideBefore: getWidgets("product_category.details.side.before"),
|
||||
}}
|
||||
data={product_category}
|
||||
showJSON
|
||||
showMetadata
|
||||
hasOutlet
|
||||
data={product_category}
|
||||
>
|
||||
{before.widgets.map((w, i) => {
|
||||
return (
|
||||
<div key={i}>
|
||||
<w.Component data={product_category} />
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<TwoColumnPage.Main>
|
||||
<CategoryGeneralSection category={product_category} />
|
||||
<CategoryProductSection category={product_category} />
|
||||
{after.widgets.map((w, i) => {
|
||||
return (
|
||||
<div key={i}>
|
||||
<w.Component data={product_category} />
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</TwoColumnPage.Main>
|
||||
<TwoColumnPage.Sidebar>
|
||||
{sideBefore.widgets.map((w, i) => {
|
||||
return (
|
||||
<div key={i}>
|
||||
<w.Component data={product_category} />
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<CategoryOrganizeSection category={product_category} />
|
||||
{sideAfter.widgets.map((w, i) => {
|
||||
return (
|
||||
<div key={i}>
|
||||
<w.Component data={product_category} />
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</TwoColumnPage.Sidebar>
|
||||
</TwoColumnPage>
|
||||
)
|
||||
|
||||
@@ -1,28 +1,19 @@
|
||||
import { Outlet } from "react-router-dom"
|
||||
import { SingleColumnPage } from "../../../components/layout/pages"
|
||||
import { useDashboardExtension } from "../../../extensions"
|
||||
import { CategoryListTable } from "./components/category-list-table"
|
||||
|
||||
import after from "virtual:medusa/widgets/product_category/list/after"
|
||||
import before from "virtual:medusa/widgets/product_category/list/before"
|
||||
|
||||
export const CategoryList = () => {
|
||||
const { getWidgets } = useDashboardExtension()
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-y-2">
|
||||
{before.widgets.map((w, i) => {
|
||||
return (
|
||||
<div key={i}>
|
||||
<w.Component />
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<SingleColumnPage
|
||||
widgets={{
|
||||
after: getWidgets("product_category.list.after"),
|
||||
before: getWidgets("product_category.list.before"),
|
||||
}}
|
||||
hasOutlet
|
||||
>
|
||||
<CategoryListTable />
|
||||
{after.widgets.map((w, i) => {
|
||||
return (
|
||||
<div key={i}>
|
||||
<w.Component />
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<Outlet />
|
||||
</div>
|
||||
</SingleColumnPage>
|
||||
)
|
||||
}
|
||||
|
||||
+20
-31
@@ -1,14 +1,13 @@
|
||||
import { Outlet, json, useLoaderData, useParams } from "react-router-dom"
|
||||
import { useLoaderData, useParams } from "react-router-dom"
|
||||
|
||||
import { JsonViewSection } from "../../../components/common/json-view-section"
|
||||
import { SingleColumnPageSkeleton } from "../../../components/common/skeleton"
|
||||
import { SingleColumnPage } from "../../../components/layout/pages"
|
||||
import { useDashboardExtension } from "../../../extensions"
|
||||
import { useCollection } from "../../../hooks/api/collections"
|
||||
import { CollectionGeneralSection } from "./components/collection-general-section"
|
||||
import { CollectionProductSection } from "./components/collection-product-section"
|
||||
import { collectionLoader } from "./loader"
|
||||
|
||||
import after from "virtual:medusa/widgets/product_collection/details/after"
|
||||
import before from "virtual:medusa/widgets/product_collection/details/before"
|
||||
|
||||
export const CollectionDetail = () => {
|
||||
const initialData = useLoaderData() as Awaited<
|
||||
ReturnType<typeof collectionLoader>
|
||||
@@ -19,38 +18,28 @@ export const CollectionDetail = () => {
|
||||
initialData,
|
||||
})
|
||||
|
||||
if (isLoading) {
|
||||
return <div>Loading...</div>
|
||||
const { getWidgets } = useDashboardExtension()
|
||||
|
||||
if (isLoading || !collection) {
|
||||
return <SingleColumnPageSkeleton sections={2} showJSON showMetadata />
|
||||
}
|
||||
|
||||
if (isError || !collection) {
|
||||
if (error) {
|
||||
throw error
|
||||
}
|
||||
|
||||
throw json("An unknown error occurred", 500)
|
||||
if (isError) {
|
||||
throw error
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-y-3">
|
||||
{before.widgets.map((w, i) => {
|
||||
return (
|
||||
<div key={i}>
|
||||
<w.Component data={collection} />
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<SingleColumnPage
|
||||
widgets={{
|
||||
after: getWidgets("product_collection.details.after"),
|
||||
before: getWidgets("product_collection.details.before"),
|
||||
}}
|
||||
showJSON
|
||||
showMetadata
|
||||
data={collection}
|
||||
>
|
||||
<CollectionGeneralSection collection={collection} />
|
||||
<CollectionProductSection collection={collection} />
|
||||
{after.widgets.map((w, i) => {
|
||||
return (
|
||||
<div key={i}>
|
||||
<w.Component data={collection} />
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<JsonViewSection data={collection} />
|
||||
<Outlet />
|
||||
</div>
|
||||
</SingleColumnPage>
|
||||
)
|
||||
}
|
||||
|
||||
+11
-22
@@ -1,29 +1,18 @@
|
||||
import { Outlet } from "react-router-dom"
|
||||
|
||||
import { SingleColumnPage } from "../../../components/layout/pages"
|
||||
import { useDashboardExtension } from "../../../extensions"
|
||||
import { CollectionListTable } from "./components/collection-list-table"
|
||||
|
||||
import after from "virtual:medusa/widgets/product_collection/list/after"
|
||||
import before from "virtual:medusa/widgets/product_collection/list/before"
|
||||
|
||||
export const CollectionList = () => {
|
||||
const { getWidgets } = useDashboardExtension()
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-y-2">
|
||||
{before.widgets.map((w, i) => {
|
||||
return (
|
||||
<div key={i}>
|
||||
<w.Component />
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<SingleColumnPage
|
||||
widgets={{
|
||||
after: getWidgets("product_collection.list.after"),
|
||||
before: getWidgets("product_collection.list.before"),
|
||||
}}
|
||||
>
|
||||
<CollectionListTable />
|
||||
{after.widgets.map((w, i) => {
|
||||
return (
|
||||
<div key={i}>
|
||||
<w.Component />
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<Outlet />
|
||||
</div>
|
||||
</SingleColumnPage>
|
||||
)
|
||||
}
|
||||
|
||||
+5
-4
@@ -6,9 +6,8 @@ import { CustomerGroupCustomerSection } from "./components/customer-group-custom
|
||||
import { CustomerGroupGeneralSection } from "./components/customer-group-general-section"
|
||||
import { customerGroupLoader } from "./loader"
|
||||
|
||||
import after from "virtual:medusa/widgets/customer_group/details/after"
|
||||
import before from "virtual:medusa/widgets/customer_group/details/before"
|
||||
import { SingleColumnPageSkeleton } from "../../../components/common/skeleton"
|
||||
import { useDashboardExtension } from "../../../extensions"
|
||||
|
||||
export const CustomerGroupDetail = () => {
|
||||
const initialData = useLoaderData() as Awaited<
|
||||
@@ -24,6 +23,8 @@ export const CustomerGroupDetail = () => {
|
||||
{ initialData }
|
||||
)
|
||||
|
||||
const { getWidgets } = useDashboardExtension()
|
||||
|
||||
if (isLoading || !customer_group) {
|
||||
return <SingleColumnPageSkeleton sections={2} showJSON showMetadata />
|
||||
}
|
||||
@@ -35,8 +36,8 @@ export const CustomerGroupDetail = () => {
|
||||
return (
|
||||
<SingleColumnPage
|
||||
widgets={{
|
||||
before,
|
||||
after,
|
||||
before: getWidgets("customer_group.details.before"),
|
||||
after: getWidgets("customer_group.details.after"),
|
||||
}}
|
||||
showJSON
|
||||
showMetadata
|
||||
|
||||
+11
-22
@@ -1,29 +1,18 @@
|
||||
import { Outlet } from "react-router-dom"
|
||||
|
||||
import { SingleColumnPage } from "../../../components/layout/pages"
|
||||
import { useDashboardExtension } from "../../../extensions"
|
||||
import { CustomerGroupListTable } from "./components/customer-group-list-table"
|
||||
|
||||
import after from "virtual:medusa/widgets/customer_group/list/after"
|
||||
import before from "virtual:medusa/widgets/customer_group/list/before"
|
||||
|
||||
export const CustomerGroupsList = () => {
|
||||
const { getWidgets } = useDashboardExtension()
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-y-2">
|
||||
{before.widgets.map((w, i) => {
|
||||
return (
|
||||
<div key={i}>
|
||||
<w.Component />
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<SingleColumnPage
|
||||
widgets={{
|
||||
after: getWidgets("customer_group.list.after"),
|
||||
before: getWidgets("customer_group.list.before"),
|
||||
}}
|
||||
>
|
||||
<CustomerGroupListTable />
|
||||
{after.widgets.map((w, i) => {
|
||||
return (
|
||||
<div key={i}>
|
||||
<w.Component />
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<Outlet />
|
||||
</div>
|
||||
</SingleColumnPage>
|
||||
)
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user