fix(medusa,utils,test-utils,types,framework,dashboard,admin-vite-plugin,admin-bundler): Fix broken plugin dependencies in development server (#11720)
**What**
- Reworks how admin extensions are loaded from plugins.
- Reworks how extensions are managed internally in the dashboard project.
**Why**
- Previously we loaded extensions from plugins the same way we do for extension found in a users application. This being scanning the source code for possible extensions in `.medusa/server/src/admin`, and including any extensions that were discovered in the final virtual modules.
- This was causing issues with how Vite optimizes dependencies, and would lead to CJS/ESM issues. Not sure of the exact cause of this, but the issue was pinpointed to Vite not being able to register correctly which dependencies to optimize when they were loaded through the virtual module from a plugin in `node_modules`.
**What changed**
- To circumvent the above issue we have changed to a different strategy for loading extensions from plugins. The changes are the following:
- We now build plugins slightly different, if a plugin has admin extensions we now build those to `.medusa/server/src/admin/index.mjs` and `.medusa/server/src/admin/index.js` for a ESM and CJS build.
- When determining how to load extensions from a source we follow these rules:
- If the source has a `medusa-plugin-options.json` or is the root application we determine that it is a `local` extension source, and load extensions as previously through a virtual module.
- If it has neither of the above, but has a `./admin` export in its package.json then we determine that it is a `package` extension, and we update the entry point for the dashboard to import the package and pass its extensions a long to the dashboard manager.
**Changes required by plugin authors**
- The change has no breaking changes, but requires plugin authors to update the `package.json` of their plugins to also include a `./admin` export. It should look like this:
```json
{
"name": "@medusajs/plugin",
"version": "0.0.1",
"description": "A starter for Medusa plugins.",
"author": "Medusa (https://medusajs.com)",
"license": "MIT",
"files": [
".medusa/server"
],
"exports": {
"./package.json": "./package.json",
"./workflows": "./.medusa/server/src/workflows/index.js",
"./.medusa/server/src/modules/*": "./.medusa/server/src/modules/*/index.js",
"./modules/*": "./.medusa/server/src/modules/*/index.js",
"./providers/*": "./.medusa/server/src/providers/*/index.js",
"./*": "./.medusa/server/src/*.js",
"./admin": {
"import": "./.medusa/server/src/admin/index.mjs",
"require": "./.medusa/server/src/admin/index.js",
"default": "./.medusa/server/src/admin/index.js"
}
},
}
```
This commit is contained in:
@@ -0,0 +1,460 @@
|
||||
import {
|
||||
CustomFieldContainerZone,
|
||||
CustomFieldFormTab,
|
||||
CustomFieldFormZone,
|
||||
CustomFieldModel,
|
||||
InjectionZone,
|
||||
NESTED_ROUTE_POSITIONS,
|
||||
} from "@medusajs/admin-shared"
|
||||
import * as React from "react"
|
||||
import {
|
||||
createBrowserRouter,
|
||||
RouteObject,
|
||||
RouterProvider,
|
||||
} from "react-router-dom"
|
||||
import { INavItem } from "../components/layout/nav-item"
|
||||
import { Providers } from "../providers"
|
||||
import { getRouteMap } from "./routes/get-route.map"
|
||||
import { createRouteMap, getRouteExtensions } from "./routes/utils"
|
||||
import {
|
||||
ConfigExtension,
|
||||
ConfigField,
|
||||
ConfigFieldMap,
|
||||
DashboardPlugin,
|
||||
DisplayExtension,
|
||||
DisplayMap,
|
||||
FormExtension,
|
||||
FormField,
|
||||
FormFieldExtension,
|
||||
FormFieldMap,
|
||||
FormZoneMap,
|
||||
MenuItemExtension,
|
||||
MenuItemKey,
|
||||
MenuMap,
|
||||
WidgetMap,
|
||||
ZoneStructure,
|
||||
} from "./types"
|
||||
|
||||
type DashboardAppProps = {
|
||||
plugins: DashboardPlugin[]
|
||||
}
|
||||
|
||||
export class DashboardApp {
|
||||
private widgets: WidgetMap
|
||||
private menus: MenuMap
|
||||
private fields: FormFieldMap
|
||||
private configs: ConfigFieldMap
|
||||
private displays: DisplayMap
|
||||
private coreRoutes: RouteObject[]
|
||||
private settingsRoutes: RouteObject[]
|
||||
|
||||
constructor({ plugins }: DashboardAppProps) {
|
||||
this.widgets = this.populateWidgets(plugins)
|
||||
this.menus = this.populateMenus(plugins)
|
||||
|
||||
const { coreRoutes, settingsRoutes } = this.populateRoutes(plugins)
|
||||
this.coreRoutes = coreRoutes
|
||||
this.settingsRoutes = settingsRoutes
|
||||
|
||||
const { fields, configs } = this.populateForm(plugins)
|
||||
this.fields = fields
|
||||
this.configs = configs
|
||||
this.displays = this.populateDisplays(plugins)
|
||||
}
|
||||
|
||||
private populateRoutes(plugins: DashboardPlugin[]) {
|
||||
const coreRoutes: RouteObject[] = []
|
||||
const settingsRoutes: RouteObject[] = []
|
||||
|
||||
for (const plugin of plugins) {
|
||||
const filteredCoreRoutes = getRouteExtensions(plugin.routeModule, "core")
|
||||
const filteredSettingsRoutes = getRouteExtensions(
|
||||
plugin.routeModule,
|
||||
"settings"
|
||||
)
|
||||
|
||||
const coreRoutesMap = createRouteMap(filteredCoreRoutes)
|
||||
const settingsRoutesMap = createRouteMap(filteredSettingsRoutes)
|
||||
|
||||
coreRoutes.push(...coreRoutesMap)
|
||||
settingsRoutes.push(...settingsRoutesMap)
|
||||
}
|
||||
|
||||
return { coreRoutes, settingsRoutes }
|
||||
}
|
||||
|
||||
private populateWidgets(plugins: DashboardPlugin[]) {
|
||||
const registry = new Map<InjectionZone, React.ComponentType[]>()
|
||||
|
||||
plugins.forEach((plugin) => {
|
||||
const widgets = plugin.widgetModule.widgets
|
||||
if (!widgets) {
|
||||
return
|
||||
}
|
||||
|
||||
widgets.forEach((widget) => {
|
||||
widget.zone.forEach((zone) => {
|
||||
if (!registry.has(zone)) {
|
||||
registry.set(zone, [])
|
||||
}
|
||||
registry.get(zone)!.push(widget.Component)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
return registry
|
||||
}
|
||||
|
||||
private populateMenus(plugins: DashboardPlugin[]) {
|
||||
const registry = new Map<MenuItemKey, INavItem[]>()
|
||||
const tempRegistry: Record<string, INavItem> = {}
|
||||
|
||||
// Collect all menu items from all plugins
|
||||
const allMenuItems: MenuItemExtension[] = []
|
||||
plugins.forEach((plugin) => {
|
||||
if (plugin.menuItemModule.menuItems) {
|
||||
allMenuItems.push(...plugin.menuItemModule.menuItems)
|
||||
}
|
||||
})
|
||||
|
||||
if (allMenuItems.length === 0) {
|
||||
return registry
|
||||
}
|
||||
|
||||
allMenuItems.sort((a, b) => a.path.length - b.path.length)
|
||||
|
||||
allMenuItems.forEach((item) => {
|
||||
if (item.path.includes("/:")) {
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
console.warn(
|
||||
`[@medusajs/dashboard] 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 pathParts = item.path.split("/").filter(Boolean)
|
||||
const parentPath = "/" + pathParts.slice(0, -1).join("/")
|
||||
|
||||
// Check if this is a nested settings path
|
||||
if (isSettingsPath && pathParts.length > 2) {
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
console.warn(
|
||||
`[@medusajs/dashboard] Nested settings menu item "${item.path}" can't be added to the sidebar. Only top-level settings items are allowed.`
|
||||
)
|
||||
}
|
||||
return // Skip this item entirely
|
||||
}
|
||||
|
||||
// Find the parent item if it exists
|
||||
const parentItem = allMenuItems.find(
|
||||
(menuItem) => menuItem.path === parentPath
|
||||
)
|
||||
|
||||
// Check if parent item is a nested route under existing route
|
||||
if (
|
||||
parentItem?.nested &&
|
||||
NESTED_ROUTE_POSITIONS.includes(parentItem?.nested) &&
|
||||
pathParts.length > 1
|
||||
) {
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
console.warn(
|
||||
`[@medusajs/dashboard] Nested menu item "${item.path}" can't be added to the sidebar as it is nested under "${parentItem.nested}".`
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const navItem: INavItem = {
|
||||
label: item.label,
|
||||
to: item.path,
|
||||
icon: item.icon ? <item.icon /> : undefined,
|
||||
items: [],
|
||||
nested: item.nested,
|
||||
}
|
||||
|
||||
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(plugins: DashboardPlugin[]): {
|
||||
fields: FormFieldMap
|
||||
configs: ConfigFieldMap
|
||||
} {
|
||||
const fields: FormFieldMap = new Map()
|
||||
const configs: ConfigFieldMap = new Map()
|
||||
|
||||
plugins.forEach((plugin) => {
|
||||
Object.entries(plugin.formModule.customFields).forEach(
|
||||
([model, customization]) => {
|
||||
// Initialize maps if they don't exist for this model
|
||||
if (!fields.has(model as CustomFieldModel)) {
|
||||
fields.set(model as CustomFieldModel, new Map())
|
||||
}
|
||||
if (!configs.has(model as CustomFieldModel)) {
|
||||
configs.set(model as CustomFieldModel, new Map())
|
||||
}
|
||||
|
||||
// Process forms
|
||||
const modelFields = this.processFields(customization.forms)
|
||||
const existingModelFields = fields.get(model as CustomFieldModel)!
|
||||
|
||||
// Merge the maps
|
||||
modelFields.forEach((zoneStructure, zone) => {
|
||||
if (!existingModelFields.has(zone)) {
|
||||
existingModelFields.set(zone, { components: [], tabs: new Map() })
|
||||
}
|
||||
|
||||
const existingZoneStructure = existingModelFields.get(zone)!
|
||||
|
||||
// Merge components
|
||||
existingZoneStructure.components.push(...zoneStructure.components)
|
||||
|
||||
// Merge tabs
|
||||
zoneStructure.tabs.forEach((fields, tab) => {
|
||||
if (!existingZoneStructure.tabs.has(tab)) {
|
||||
existingZoneStructure.tabs.set(tab, [])
|
||||
}
|
||||
existingZoneStructure.tabs.get(tab)!.push(...fields)
|
||||
})
|
||||
})
|
||||
|
||||
// Process configs
|
||||
const modelConfigs = this.processConfigs(customization.configs)
|
||||
const existingModelConfigs = configs.get(model as CustomFieldModel)!
|
||||
|
||||
// Merge the config maps
|
||||
modelConfigs.forEach((configFields, zone) => {
|
||||
if (!existingModelConfigs.has(zone)) {
|
||||
existingModelConfigs.set(zone, [])
|
||||
}
|
||||
existingModelConfigs.get(zone)!.push(...configFields)
|
||||
})
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
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(plugins: DashboardPlugin[]): DisplayMap {
|
||||
const displays = new Map<
|
||||
CustomFieldModel,
|
||||
Map<CustomFieldContainerZone, React.ComponentType<{ data: any }>[]>
|
||||
>()
|
||||
|
||||
plugins.forEach((plugin) => {
|
||||
Object.entries(plugin.displayModule.displays).forEach(
|
||||
([model, customization]) => {
|
||||
if (!displays.has(model as CustomFieldModel)) {
|
||||
displays.set(
|
||||
model as CustomFieldModel,
|
||||
new Map<
|
||||
CustomFieldContainerZone,
|
||||
React.ComponentType<{ data: any }>[]
|
||||
>()
|
||||
)
|
||||
}
|
||||
|
||||
const modelDisplays = displays.get(model as CustomFieldModel)!
|
||||
const processedDisplays = this.processDisplays(customization)
|
||||
|
||||
// Merge the displays
|
||||
processedDisplays.forEach((components, zone) => {
|
||||
if (!modelDisplays.has(zone)) {
|
||||
modelDisplays.set(zone, [])
|
||||
}
|
||||
modelDisplays.get(zone)!.push(...components)
|
||||
})
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
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),
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const routes = getRouteMap({
|
||||
settingsRoutes: this.settingsRoutes,
|
||||
coreRoutes: this.coreRoutes,
|
||||
})
|
||||
|
||||
const router = createBrowserRouter(routes, {
|
||||
basename: __BASE__ || "/",
|
||||
})
|
||||
|
||||
return (
|
||||
<Providers api={this.api}>
|
||||
<RouterProvider router={router} />
|
||||
</Providers>
|
||||
)
|
||||
}
|
||||
}
|
||||
+116
@@ -0,0 +1,116 @@
|
||||
import { InlineTip, Input, Switch } from "@medusajs/ui"
|
||||
import { ComponentType } from "react"
|
||||
import { ControllerRenderProps, UseFormReturn } from "react-hook-form"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { Form } from "../../../components/common/form"
|
||||
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) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
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" label={t("general.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,12 @@
|
||||
export * from "./dashboard-app"
|
||||
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)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,202 @@
|
||||
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.
|
||||
*/
|
||||
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)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a route object for a branch node in the route tree
|
||||
* @param segment - The path segment for this branch
|
||||
*/
|
||||
const createBranchRoute = (segment: string): RouteObject => ({
|
||||
path: segment,
|
||||
children: [],
|
||||
})
|
||||
|
||||
/**
|
||||
* Creates a route object for a leaf node with its component
|
||||
* @param Component - The React component to render at this route
|
||||
*/
|
||||
const createLeafRoute = (
|
||||
Component: ComponentType,
|
||||
loader?: LoaderFunction,
|
||||
handle?: object
|
||||
): RouteObject => ({
|
||||
path: "",
|
||||
ErrorBoundary: ErrorBoundary,
|
||||
async lazy() {
|
||||
const result: {
|
||||
Component: ComponentType
|
||||
loader?: LoaderFunction
|
||||
handle?: object
|
||||
} = { Component }
|
||||
|
||||
if (loader) {
|
||||
result.loader = loader
|
||||
}
|
||||
|
||||
if (handle) {
|
||||
result.handle = handle
|
||||
}
|
||||
|
||||
return result
|
||||
},
|
||||
})
|
||||
|
||||
/**
|
||||
* Creates a parallel route configuration
|
||||
* @param path - The route path
|
||||
* @param Component - The React component to render
|
||||
*/
|
||||
const createParallelRoute = (
|
||||
path: string,
|
||||
Component: ComponentType,
|
||||
loader?: LoaderFunction,
|
||||
handle?: object
|
||||
) => ({
|
||||
path,
|
||||
async lazy() {
|
||||
const result: {
|
||||
Component: ComponentType
|
||||
loader?: LoaderFunction
|
||||
handle?: object
|
||||
} = { Component }
|
||||
|
||||
if (loader) {
|
||||
result.loader = loader
|
||||
}
|
||||
|
||||
if (handle) {
|
||||
result.handle = handle
|
||||
}
|
||||
|
||||
return result
|
||||
},
|
||||
})
|
||||
|
||||
/**
|
||||
* Processes parallel routes by cleaning their paths relative to the current path
|
||||
* @param parallelRoutes - Array of parallel route extensions
|
||||
* @param currentFullPath - The full path of the current route
|
||||
*/
|
||||
const processParallelRoutes = (
|
||||
parallelRoutes: RouteExtension[] | undefined,
|
||||
currentFullPath: string
|
||||
): RouteObject[] | undefined => {
|
||||
return parallelRoutes
|
||||
?.map(({ path, Component, loader, handle }) => {
|
||||
const childPath = path?.replace(currentFullPath, "").replace(/^\/+/, "")
|
||||
if (!childPath) {
|
||||
return null
|
||||
}
|
||||
return createParallelRoute(childPath, Component, loader, handle)
|
||||
})
|
||||
.filter(Boolean) as RouteObject[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively builds the route tree by adding routes at the correct level
|
||||
* @param pathSegments - Array of remaining path segments to process
|
||||
* @param Component - The React component for the route
|
||||
* @param currentLevel - Current level in the route tree
|
||||
* @param parallelRoutes - Optional parallel routes to add
|
||||
* @param fullPath - The full path up to the current level
|
||||
*/
|
||||
const addRoute = (
|
||||
pathSegments: string[],
|
||||
Component: ComponentType,
|
||||
currentLevel: RouteObject[],
|
||||
loader?: LoaderFunction,
|
||||
handle?: object,
|
||||
parallelRoutes?: RouteExtension[],
|
||||
fullPath?: string
|
||||
) => {
|
||||
if (!pathSegments.length) {
|
||||
return
|
||||
}
|
||||
|
||||
const [currentSegment, ...remainingSegments] = pathSegments
|
||||
let route = currentLevel.find((r) => r.path === currentSegment)
|
||||
|
||||
if (!route) {
|
||||
route = createBranchRoute(currentSegment)
|
||||
}
|
||||
|
||||
const currentFullPath = fullPath
|
||||
? `${fullPath}/${currentSegment}`
|
||||
: currentSegment
|
||||
|
||||
if (remainingSegments.length === 0) {
|
||||
route.children ||= []
|
||||
const leaf = createLeafRoute(Component, loader)
|
||||
|
||||
/**
|
||||
* The handle needs to be set on the wrapper route object,
|
||||
* in order for it to be resolved correctly thoughout
|
||||
* the branch.
|
||||
*/
|
||||
if (handle) {
|
||||
route.handle = handle
|
||||
}
|
||||
|
||||
if (loader) {
|
||||
route.loader = loader
|
||||
}
|
||||
|
||||
leaf.children = processParallelRoutes(parallelRoutes, currentFullPath)
|
||||
route.children.push(leaf)
|
||||
|
||||
currentLevel.push(route)
|
||||
} else {
|
||||
route.children ||= []
|
||||
addRoute(
|
||||
remainingSegments,
|
||||
Component,
|
||||
route.children,
|
||||
loader,
|
||||
handle,
|
||||
parallelRoutes,
|
||||
currentFullPath
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a complete route map from route extensions
|
||||
* @param routes - Array of route extensions to process
|
||||
* @param ignore - Optional path prefix to ignore when processing routes
|
||||
* @returns An array of route objects forming a route tree
|
||||
*/
|
||||
export const createRouteMap = (
|
||||
routes: RouteExtension[],
|
||||
ignore?: string
|
||||
): RouteObject[] => {
|
||||
const root: RouteObject[] = []
|
||||
|
||||
routes.forEach(({ path, Component, loader, handle, children }) => {
|
||||
const cleanedPath = ignore
|
||||
? path.replace(ignore, "").replace(/^\/+/, "")
|
||||
: path.replace(/^\/+/, "")
|
||||
const pathSegments = cleanedPath.split("/").filter(Boolean)
|
||||
addRoute(pathSegments, Component, root, loader, handle, children)
|
||||
})
|
||||
|
||||
return root
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import {
|
||||
CustomFieldContainerZone,
|
||||
CustomFieldFormTab,
|
||||
CustomFieldFormZone,
|
||||
CustomFieldModel,
|
||||
InjectionZone,
|
||||
NestedRoutePosition,
|
||||
} from "@medusajs/admin-shared"
|
||||
import { ComponentType } from "react"
|
||||
import { LoaderFunction } from "react-router-dom"
|
||||
import { ZodFirstPartySchemaTypes } from "zod"
|
||||
import { INavItem } from "../components/layout/nav-item"
|
||||
|
||||
export type RouteExtension = {
|
||||
Component: ComponentType
|
||||
loader?: LoaderFunction
|
||||
handle?: object
|
||||
children?: RouteExtension[]
|
||||
path: string
|
||||
}
|
||||
|
||||
export type MenuItemExtension = {
|
||||
label: string
|
||||
path: string
|
||||
icon?: ComponentType
|
||||
nested?: NestedRoutePosition
|
||||
}
|
||||
|
||||
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 }>[]>
|
||||
>
|
||||
|
||||
export type MenuMap = Map<MenuItemKey, INavItem[]>
|
||||
|
||||
export type WidgetMap = Map<InjectionZone, React.ComponentType[]>
|
||||
|
||||
export type DashboardPlugin = {
|
||||
formModule: FormModule
|
||||
displayModule: DisplayModule
|
||||
menuItemModule: MenuItemModule
|
||||
widgetModule: WidgetModule
|
||||
routeModule: RouteModule
|
||||
}
|
||||
Reference in New Issue
Block a user