Files
medusa-store/packages/admin/admin-vite-plugin/src/custom-fields/generate-custom-field-forms.ts
Kasper Fabricius Kristensen 813efeae51 fix(admin-vite-plugin): Normalize file paths and add tests (#9595)
**What**
- #9338 had a regression which caused the import path in some virtual modules to be invalid on Windows.
- This PR fixes the issue so we now again create the correct import paths, and adds tests to prevent this from slipping in again.
2024-10-15 16:48:56 +00:00

709 lines
18 KiB
TypeScript

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, normalizePath } 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 {
const path = normalizePath(file)
return `import ${generateCustomFieldConfigName(index)} from "${path}"`
}
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
}