feat(dashboard,admin-vite-plugin,admin-bundler,admin-sdk): Rework admin extensions and introduce custom fields API (#9338)

This commit is contained in:
Kasper Fabricius Kristensen
2024-10-09 11:44:40 +00:00
committed by GitHub
parent 35e69d32f2
commit d71343d6ab
159 changed files with 5264 additions and 2224 deletions
@@ -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";
@@ -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)
@@ -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[]
}
@@ -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
}
@@ -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 }>[]>
>