feat(dashboard,admin-vite-plugin,admin-bundler,admin-sdk): Rework admin extensions and introduce custom fields API (#9338)
This commit is contained in:
+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)
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
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)
|
||||
})
|
||||
}
|
||||
|
||||
export const createRouteMap = (
|
||||
routes: RouteExtension[],
|
||||
ignore?: string
|
||||
): RouteObject[] => {
|
||||
const root: RouteObject[] = []
|
||||
|
||||
const addRoute = (
|
||||
pathSegments: string[],
|
||||
Component: ComponentType,
|
||||
currentLevel: RouteObject[],
|
||||
loader?: LoaderFunction
|
||||
) => {
|
||||
if (!pathSegments.length) {
|
||||
return
|
||||
}
|
||||
|
||||
const [currentSegment, ...remainingSegments] = pathSegments
|
||||
let route = currentLevel.find((r) => r.path === currentSegment)
|
||||
|
||||
if (!route) {
|
||||
route = { path: currentSegment, children: [] }
|
||||
currentLevel.push(route)
|
||||
}
|
||||
|
||||
if (remainingSegments.length === 0) {
|
||||
route.children ||= []
|
||||
route.children.push({
|
||||
path: "",
|
||||
ErrorBoundary: ErrorBoundary,
|
||||
async lazy() {
|
||||
if (loader) {
|
||||
return { Component, loader }
|
||||
}
|
||||
|
||||
return { Component }
|
||||
},
|
||||
})
|
||||
} else {
|
||||
route.children ||= []
|
||||
addRoute(remainingSegments, Component, route.children, loader)
|
||||
}
|
||||
}
|
||||
|
||||
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, 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 }>[]>
|
||||
>
|
||||
Reference in New Issue
Block a user