diff --git a/integration-tests/http/__tests__/collection/admin/colllection.spec.ts b/integration-tests/http/__tests__/collection/admin/colllection.spec.ts index 71d50aa758ab1..b363fb4839bbb 100644 --- a/integration-tests/http/__tests__/collection/admin/colllection.spec.ts +++ b/integration-tests/http/__tests__/collection/admin/colllection.spec.ts @@ -77,17 +77,16 @@ medusaIntegrationTestRunner({ ) expect(response.status).toEqual(200) - expect(response.data).toEqual( - expect.objectContaining({ - collection: expect.objectContaining({ - id: expect.stringMatching(/^pcol_*/), - title: "New collection", - handle: "test-new-collection", - created_at: expect.any(String), - updated_at: expect.any(String), - }), - }) - ) + expect(response.data).toEqual({ + collection: { + id: expect.stringMatching(/^pcol_*/), + title: "New collection", + handle: "test-new-collection", + created_at: expect.any(String), + updated_at: expect.any(String), + metadata: null, + }, + }) }) it("lists collections", async () => { @@ -96,22 +95,33 @@ medusaIntegrationTestRunner({ expect(response.data).toEqual( expect.objectContaining({ count: 3, + limit: 10, + offset: 0, collections: expect.arrayContaining([ - expect.objectContaining({ - id: baseCollection2.id, + { + id: baseCollection.id, created_at: expect.any(String), updated_at: expect.any(String), - }), - expect.objectContaining({ + handle: "test-collection", + metadata: null, + title: "test-collection", + }, + { id: baseCollection1.id, created_at: expect.any(String), updated_at: expect.any(String), - }), - expect.objectContaining({ - id: baseCollection.id, + handle: "test-collection1", + metadata: null, + title: "test-collection1", + }, + { + id: baseCollection2.id, created_at: expect.any(String), updated_at: expect.any(String), - }), + handle: "test-collection2", + metadata: null, + title: "test-collection2", + }, ]), }) ) @@ -127,11 +137,14 @@ medusaIntegrationTestRunner({ expect.objectContaining({ count: 1, collections: expect.arrayContaining([ - expect.objectContaining({ + { id: baseCollection.id, created_at: expect.any(String), updated_at: expect.any(String), - }), + handle: "test-collection", + metadata: null, + title: "test-collection", + }, ]), }) ) @@ -154,13 +167,14 @@ medusaIntegrationTestRunner({ expect(response.status).toEqual(200) expect(response.data).toEqual( expect.objectContaining({ - collection: expect.objectContaining({ + collection: { id: expect.stringMatching(/^pcol_*/), title: "test collection creation", handle: "test-handle-creation", created_at: expect.any(String), updated_at: expect.any(String), - }), + metadata: null, + }, }) ) }) diff --git a/integration-tests/http/__tests__/product-type/admin/product-type.spec.ts b/integration-tests/http/__tests__/product-type/admin/product-type.spec.ts index 654b3d60a3484..21dca450fe0de 100644 --- a/integration-tests/http/__tests__/product-type/admin/product-type.spec.ts +++ b/integration-tests/http/__tests__/product-type/admin/product-type.spec.ts @@ -44,12 +44,20 @@ medusaIntegrationTestRunner({ expect(res.status).toEqual(200) expect(res.data.product_types).toEqual( expect.arrayContaining([ - expect.objectContaining({ + { + id: expect.stringMatching(/ptyp_.{24}/), value: "test1", - }), - expect.objectContaining({ + created_at: expect.any(String), + updated_at: expect.any(String), + metadata: null, + }, + { + id: expect.stringMatching(/ptyp_.{24}/), value: "test2", - }), + created_at: expect.any(String), + updated_at: expect.any(String), + metadata: null, + }, ]) ) }) @@ -61,13 +69,35 @@ medusaIntegrationTestRunner({ // The value of the type should match the search param expect(res.data.product_types).toEqual([ - expect.objectContaining({ + { + id: expect.stringMatching(/ptyp_.{24}/), value: "test1", - }), + created_at: expect.any(String), + updated_at: expect.any(String), + metadata: null, + }, ]) }) // BREAKING: Removed a test around filtering based on discount condition id, which is no longer supported }) + + describe("/admin/product-types/:id", () => { + it("returns a product type", async () => { + const res = await api.get( + `/admin/product-types/${type1.id}`, + adminHeaders + ) + + expect(res.status).toEqual(200) + expect(res.data.product_type).toEqual({ + id: expect.stringMatching(/ptyp_.{24}/), + value: "test1", + created_at: expect.any(String), + updated_at: expect.any(String), + metadata: null, + }) + }) + }) }, }) diff --git a/integration-tests/modules/__tests__/cart/store/cart.workflows.spec.ts b/integration-tests/modules/__tests__/cart/store/cart.workflows.spec.ts index 23150eb71efb2..9c10a9ac4aaee 100644 --- a/integration-tests/modules/__tests__/cart/store/cart.workflows.spec.ts +++ b/integration-tests/modules/__tests__/cart/store/cart.workflows.spec.ts @@ -1170,6 +1170,7 @@ medusaIntegrationTestRunner({ await deleteLineItemsWorkflow(appContainer).run({ input: { + cart_id: cart.id, ids: items.map((i) => i.id), }, throwOnError: false, @@ -1211,6 +1212,7 @@ medusaIntegrationTestRunner({ const { errors } = await workflow.run({ input: { + cart_id: cart.id, ids: items.map((i) => i.id), }, throwOnError: false, diff --git a/integration-tests/modules/__tests__/cart/store/carts.spec.ts b/integration-tests/modules/__tests__/cart/store/carts.spec.ts index 34c55da8d0061..a028f5306a565 100644 --- a/integration-tests/modules/__tests__/cart/store/carts.spec.ts +++ b/integration-tests/modules/__tests__/cart/store/carts.spec.ts @@ -20,7 +20,7 @@ import { ProductStatus, PromotionRuleOperator, PromotionType, - RuleOperator + RuleOperator, } from "@medusajs/utils" import { medusaIntegrationTestRunner } from "medusa-test-utils" import { @@ -2131,6 +2131,7 @@ medusaIntegrationTestRunner({ ) expect(response.status).toEqual(200) + expect(response.data.cart).toEqual( expect.objectContaining({ id: cart.id, diff --git a/integration-tests/modules/__tests__/event-bus/index.spec.ts b/integration-tests/modules/__tests__/event-bus/index.spec.ts index 61e7b60cdddd2..3f8a8e02fa259 100644 --- a/integration-tests/modules/__tests__/event-bus/index.spec.ts +++ b/integration-tests/modules/__tests__/event-bus/index.spec.ts @@ -1,7 +1,7 @@ import { MedusaContainer } from "@medusajs/types" import { Modules, composeMessage } from "@medusajs/utils" import { medusaIntegrationTestRunner } from "medusa-test-utils" -import testEventPayloadHandlerMock from "../../dist/subscribers/test-event-payload" +import testEventPayloadHandlerMock from "../../src/subscribers/test-event-payload" jest.setTimeout(30000) diff --git a/integration-tests/modules/medusa-config.js b/integration-tests/modules/medusa-config.js index 030678e49a419..08f890690bd25 100644 --- a/integration-tests/modules/medusa-config.js +++ b/integration-tests/modules/medusa-config.js @@ -1,5 +1,5 @@ const { Modules } = require("@medusajs/utils") -const { FulfillmentModuleOptions } = require("@medusajs/fulfillment") + const DB_HOST = process.env.DB_HOST const DB_USERNAME = process.env.DB_USERNAME const DB_PASSWORD = process.env.DB_PASSWORD @@ -78,7 +78,6 @@ module.exports = { [Modules.SALES_CHANNEL]: true, [Modules.CART]: true, [Modules.WORKFLOW_ENGINE]: true, - [Modules.REGION]: true, [Modules.API_KEY]: true, [Modules.STORE]: true, [Modules.TAX]: true, diff --git a/integration-tests/modules/tsconfig.json b/integration-tests/modules/tsconfig.json index 4ed062890ede2..55e69b061484d 100644 --- a/integration-tests/modules/tsconfig.json +++ b/integration-tests/modules/tsconfig.json @@ -2,7 +2,7 @@ "extends": "../../_tsconfig.base.json", "include": ["src", "./medusa/**/*"], "exclude": [ - "./dist/**/*", + "dist", "__tests__", "helpers", "./**/helpers", @@ -10,4 +10,3 @@ "node_modules" ] } - diff --git a/integration-tests/tsconfig.json b/integration-tests/tsconfig.json index 4569ac352975f..1f73dce637ac8 100644 --- a/integration-tests/tsconfig.json +++ b/integration-tests/tsconfig.json @@ -1,11 +1,5 @@ { "extends": "../_tsconfig.base.json", "include": ["**/*"], - "exclude": [ - "dist", - "./**/helpers", - "./**/__snapshots__", - "node_modules" - ] + "exclude": ["dist", "./**/helpers", "./**/__snapshots__", "node_modules"] } - diff --git a/packages/admin/admin-bundler/src/entry.tsx b/packages/admin/admin-bundler/src/entry.tsx index 84a8f37b2e2e8..996d8d8030f94 100644 --- a/packages/admin/admin-bundler/src/entry.tsx +++ b/packages/admin/admin-bundler/src/entry.tsx @@ -1,9 +1,15 @@ -import App from "@medusajs/dashboard" -import React from "react" -import { createRoot } from "react-dom/client" +import App from "@medusajs/dashboard"; +import React from "react"; +import ReactDOM from "react-dom/client"; +import "./index.css"; -import "./index.css" +ReactDOM.createRoot(document.getElementById("medusa")!).render( + + + +) -const container = document.getElementById("root") -const root = createRoot(container!) -root.render() \ No newline at end of file + +if (import.meta.hot) { + import.meta.hot.accept() +} diff --git a/packages/admin/admin-bundler/src/index.html b/packages/admin/admin-bundler/src/index.html index 07f49710e1a32..d01be5fa97633 100644 --- a/packages/admin/admin-bundler/src/index.html +++ b/packages/admin/admin-bundler/src/index.html @@ -1,13 +1,16 @@ - + - + -
+
- \ No newline at end of file + diff --git a/packages/admin/admin-bundler/src/lib/config.ts b/packages/admin/admin-bundler/src/lib/config.ts index 8bc70fb48dd72..3243e51df8ce3 100644 --- a/packages/admin/admin-bundler/src/lib/config.ts +++ b/packages/admin/admin-bundler/src/lib/config.ts @@ -28,8 +28,8 @@ export async function getViteConfig( outDir: path.resolve(process.cwd(), options.outDir), }, optimizeDeps: { - include: ["@medusajs/dashboard", "react-dom/client"], - exclude: VIRTUAL_MODULES, + include: ["react-dom/client", "@medusajs/ui", "@medusajs/dashboard"], + exclude: [...VIRTUAL_MODULES], }, define: { __BASE__: JSON.stringify(options.path), @@ -43,7 +43,6 @@ export async function getViteConfig( hmr: { port: hmrPort, }, - middlewareMode: true, }, css: { postcss: { @@ -64,7 +63,6 @@ export async function getViteConfig( if (options.vite) { const customConfig = options.vite(baseConfig) - return mergeConfig(baseConfig, customConfig) } diff --git a/packages/admin/admin-bundler/src/lib/develop.ts b/packages/admin/admin-bundler/src/lib/develop.ts index ca575be30e75c..f64d1b6e3eef8 100644 --- a/packages/admin/admin-bundler/src/lib/develop.ts +++ b/packages/admin/admin-bundler/src/lib/develop.ts @@ -1,11 +1,72 @@ -import express from "express" -import type { InlineConfig } from "vite" +import express, { RequestHandler } from "express" +import fs from "fs" +import path from "path" +import type { InlineConfig, ViteDevServer } from "vite" import { BundlerOptions } from "../types" import { getViteConfig } from "./config" const router = express.Router() +function findTemplateFilePath( + reqPath: string, + root: string +): string | undefined { + if (reqPath.endsWith(".html")) { + const pathToTest = path.join(root, reqPath) + if (fs.existsSync(pathToTest)) { + return pathToTest + } + } + + const basePath = reqPath.slice(0, reqPath.lastIndexOf("/")) + const dirs = basePath.split("/") + + while (dirs.length > 0) { + const pathToTest = path.join(root, ...dirs, "index.html") + if (fs.existsSync(pathToTest)) { + return pathToTest + } + dirs.pop() + } + + return undefined +} + +async function injectViteMiddleware( + router: express.Router, + middleware: RequestHandler +) { + router.use((req, res, next) => { + req.path.endsWith(".html") ? next() : middleware(req, res, next) + }) +} + +async function injectHtmlMiddleware( + router: express.Router, + server: ViteDevServer +) { + router.use(async (req, res, next) => { + if (req.method !== "GET") { + return next() + } + + const templateFilePath = findTemplateFilePath(req.path, server.config.root) + if (!templateFilePath) { + return next() + } + + const template = fs.readFileSync(templateFilePath, "utf8") + const html = await server.transformIndexHtml( + templateFilePath, + template, + req.originalUrl + ) + + res.send(html) + }) +} + export async function develop(options: BundlerOptions) { const vite = await import("vite") @@ -14,14 +75,18 @@ export async function develop(options: BundlerOptions) { const developConfig: InlineConfig = { mode: "development", - logLevel: "warn", + logLevel: "error", + appType: "spa", + server: { + middlewareMode: true, + }, } - const server = await vite.createServer( - vite.mergeConfig(viteConfig, developConfig) - ) + const mergedConfig = vite.mergeConfig(viteConfig, developConfig) + const server = await vite.createServer(mergedConfig) - router.use(server.middlewares) + await injectViteMiddleware(router, server.middlewares) + await injectHtmlMiddleware(router, server) } catch (error) { console.error(error) throw new Error( diff --git a/packages/admin/admin-sdk/package.json b/packages/admin/admin-sdk/package.json index 2bf07e6c71d4d..fd060452bb12b 100644 --- a/packages/admin/admin-sdk/package.json +++ b/packages/admin/admin-sdk/package.json @@ -21,10 +21,14 @@ "devDependencies": { "@types/react": "^18.3.2", "tsup": "^8.0.1", - "typescript": "^5.3.3" + "typescript": "^5.3.3", + "zod": "^3.22" }, "dependencies": { "@medusajs/admin-shared": "^0.0.1" }, + "peerDependencies": { + "zod": "^3.22" + }, "packageManager": "yarn@3.2.1" } diff --git a/packages/admin/admin-sdk/src/config/index.ts b/packages/admin/admin-sdk/src/config/index.ts index 21aecd353d30f..230754a0063f0 100644 --- a/packages/admin/admin-sdk/src/config/index.ts +++ b/packages/admin/admin-sdk/src/config/index.ts @@ -1,2 +1,3 @@ -export * from "./types" -export * from "./utils" +// We don't export anything related to CustomFields for the time being +export type { RouteConfig, WidgetConfig } from "./types" +export { defineRouteConfig, defineWidgetConfig } from "./utils" diff --git a/packages/admin/admin-sdk/src/config/types.ts b/packages/admin/admin-sdk/src/config/types.ts index 070d8d16f3d99..f3e8b75959a33 100644 --- a/packages/admin/admin-sdk/src/config/types.ts +++ b/packages/admin/admin-sdk/src/config/types.ts @@ -1,11 +1,144 @@ -import type { InjectionZone } from "@medusajs/admin-shared" +import type { + CustomFieldFormKeys, + CustomFieldModel, + CustomFieldModelContainerMap, + CustomFieldModelFormTabsMap, + InjectionZone, +} from "@medusajs/admin-shared" import type { ComponentType } from "react" +import { ZodFirstPartySchemaTypes } from "zod" -export type WidgetConfig = { +export interface WidgetConfig { + /** + * The injection zone or zones that the widget should be injected into. + */ zone: InjectionZone | InjectionZone[] } -export type RouteConfig = { +export interface RouteConfig { + /** + * An optional label to display in the sidebar. If not provided, the route will not be displayed in the sidebar. + */ label?: string + /** + * An optional icon to display in the sidebar together with the label. If no label is provided, the icon will be ignored. + */ icon?: ComponentType } + +export type CustomFormField< + TData = unknown, + TValidation extends ZodFirstPartySchemaTypes = ZodFirstPartySchemaTypes +> = { + /** + * The rules that the field should be validated against. + * + * @example + * ```ts + * rules: z.string().email() // The field must be a valid email + * ``` + */ + validation: TValidation + /** + * The default value of the field. + */ + defaultValue: ((data: TData) => any) | any + /** + * The label of the field. If not provided, the label will be inferred from the field name. + */ + label?: string + /** + * The description of the field. + */ + description?: string + /** + * The placeholder of the field. + */ + placeholder?: string + /** + * Custom component to render the field. If not provided, the field will be rendered using the + * default component for the field type, which is determined by the field's validation schema. + */ + component?: ComponentType +} + +// Define the main configuration type +export interface CustomFieldConfig { + /** + * The name of the model that the custom models are linked to. + * This should be the name of one of the built-in models, such as `product` or `customer`. + * + * @example + * ```ts + * model: "product" + * ``` + */ + model: TModel + /** + * The name of the custom model(s) that the custom fields belong to. + * This is used to ensure that the custom fields are fetched when + * querying the entrypoint model. + * + * @example + * ```ts + * export default unstable_defineCustomFieldsConfig({ + * model: "product", + * link: "brand" + * // ... + * }) + * ``` + * or + * ```ts + * export default unstable_defineCustomFieldsConfig({ + * model: "product", + * link: ["brand", "seller"] + * // ... + * }) + * ``` + */ + link: string | string[] + forms: Array< + { + [K in CustomFieldFormKeys & + keyof CustomFieldModelFormTabsMap[TModel]]: { + /** + * The form to extend. + * + * @example + * ```ts + * export default unstable_defineCustomFieldsConfig({ + * model: "product", + * link: "brand", + * forms: [ + * { + * zone: "create", + * // ... + * } + * ], + * // ... + * }) + * ``` + */ + zone: K + fields: Record> + } & (CustomFieldModelFormTabsMap[TModel][K] extends never + ? {} + : { tab: CustomFieldModelFormTabsMap[TModel][K] }) + }[CustomFieldFormKeys & keyof CustomFieldModelFormTabsMap[TModel]] + > + /** + * Optionally define how to display the custom fields, in an existing container on the entity details page. + * Alternatively, you can create a new widget to display the custom fields. + */ + displays?: Array<{ + /** + * The identifier of the container that the custom fields should be injected into. + */ + zone: CustomFieldModelContainerMap[TModel] + /** + * The component that should be rendered to display the custom fields. + * This component will receive the entity data as a prop. + */ + component: ComponentType + }> +} diff --git a/packages/admin/admin-sdk/src/config/utils.ts b/packages/admin/admin-sdk/src/config/utils.ts index 395cb820444b1..cf502dc262d43 100644 --- a/packages/admin/admin-sdk/src/config/utils.ts +++ b/packages/admin/admin-sdk/src/config/utils.ts @@ -1,8 +1,13 @@ -import { RouteConfig, WidgetConfig } from "./types" +import type { CustomFieldModelFormMap } from "@medusajs/admin-shared" +import { z, ZodFirstPartySchemaTypes } from "zod" +import { + CustomFieldConfig, + CustomFormField, + RouteConfig, + WidgetConfig, +} from "./types" -function createConfigHelper>( - config: TConfig -): TConfig { +function createConfigHelper(config: TConfig): TConfig { return { ...config, /** @@ -35,3 +40,77 @@ export function defineWidgetConfig(config: WidgetConfig) { export function defineRouteConfig(config: RouteConfig) { return createConfigHelper(config) } + +/** + * Define a custom fields configuration. + * + * @param config The custom fields configuration. + * @returns The custom fields configuration. + * + * @experimental This API is experimental and may change in the future. + */ +export function unstable_defineCustomFieldsConfig< + TModel extends keyof CustomFieldModelFormMap +>(config: CustomFieldConfig) { + return createConfigHelper(config) +} + +/** + * Creates a type-safe form builder. + * + * @returns The form helper. + * + * @example + * ```ts + * import { unstable_createFormHelper, unstable_defineCustomFieldsConfig } from "@medusajs/admin-sdk" + * import type { HttpTypes } from "@medusajs/types" + * import type { Brand } from "../../types/brand" + * + * type ExtendedProduct = HttpTypes.Product & { + * brand: Brand | null + * } + * + * const form = unstable_createFormHelper() + * + * export default unstable_defineCustomFieldsConfig({ + * entryPoint: "product", + * link: "brand", + * forms: [{ + * form: "create", + * fields: { + * brand_id: form.define({ + * rules: form.string().nullish(), + * defaultValue: "", + * }), + * } + * }] + * }) + * ``` + * + * @experimental This API is experimental and may change in the future. + */ +export function unstable_createFormHelper() { + return { + /** + * Define a custom form field. + * + * @param field The field to define. + * @returns The field. + */ + define: ( + field: Omit, "validation"> & { validation: T } + ): CustomFormField => { + return field as CustomFormField + }, + string: () => z.string(), + number: () => z.number(), + boolean: () => z.boolean(), + date: () => z.date(), + array: z.array, + object: z.object, + null: () => z.null(), + nullable: z.nullable, + undefined: () => z.undefined(), + coerce: z.coerce, + } +} diff --git a/packages/admin/admin-shared/src/extensions/custom-fields/constants.ts b/packages/admin/admin-shared/src/extensions/custom-fields/constants.ts new file mode 100644 index 0000000000000..96bc6b22d485e --- /dev/null +++ b/packages/admin/admin-shared/src/extensions/custom-fields/constants.ts @@ -0,0 +1,40 @@ +import { + PRODUCT_CUSTOM_FIELD_DISPLAY_PATHS, + PRODUCT_CUSTOM_FIELD_DISPLAY_ZONES, + PRODUCT_CUSTOM_FIELD_FORM_CONFIG_PATHS, + PRODUCT_CUSTOM_FIELD_FORM_FIELD_PATHS, + PRODUCT_CUSTOM_FIELD_FORM_TABS, + PRODUCT_CUSTOM_FIELD_FORM_ZONES, + PRODUCT_CUSTOM_FIELD_LINK_PATHS, + PRODUCT_CUSTOM_FIELD_MODEL, +} from "./product" + +export const CUSTOM_FIELD_MODELS = [PRODUCT_CUSTOM_FIELD_MODEL] as const + +export const CUSTOM_FIELD_CONTAINER_ZONES = [ + ...PRODUCT_CUSTOM_FIELD_DISPLAY_ZONES, +] as const + +export const CUSTOM_FIELD_FORM_ZONES = [ + ...PRODUCT_CUSTOM_FIELD_FORM_ZONES, +] as const + +export const CUSTOM_FIELD_FORM_TABS = [ + ...PRODUCT_CUSTOM_FIELD_FORM_TABS, +] as const + +export const CUSTOM_FIELD_FORM_CONFIG_PATHS = [ + ...PRODUCT_CUSTOM_FIELD_FORM_CONFIG_PATHS, +] as const + +export const CUSTOM_FIELD_FORM_FIELD_PATHS = [ + ...PRODUCT_CUSTOM_FIELD_FORM_FIELD_PATHS, +] as const + +export const CUSTOM_FIELD_DISPLAY_PATHS = [ + ...PRODUCT_CUSTOM_FIELD_DISPLAY_PATHS, +] as const + +export const CUSTOM_FIELD_LINK_PATHS = [ + ...PRODUCT_CUSTOM_FIELD_LINK_PATHS, +] as const diff --git a/packages/admin/admin-shared/src/extensions/custom-fields/index.ts b/packages/admin/admin-shared/src/extensions/custom-fields/index.ts new file mode 100644 index 0000000000000..89240645513dc --- /dev/null +++ b/packages/admin/admin-shared/src/extensions/custom-fields/index.ts @@ -0,0 +1,3 @@ +export * from "./product" +export * from "./types" +export * from "./utils" diff --git a/packages/admin/admin-shared/src/extensions/custom-fields/product/constants.ts b/packages/admin/admin-shared/src/extensions/custom-fields/product/constants.ts new file mode 100644 index 0000000000000..a69cd5333da1c --- /dev/null +++ b/packages/admin/admin-shared/src/extensions/custom-fields/product/constants.ts @@ -0,0 +1,48 @@ +export const PRODUCT_CUSTOM_FIELD_MODEL = "product" as const + +export const PRODUCT_CUSTOM_FIELD_FORM_ZONES = [ + "create", + "edit", + "organize", + "attributes", +] as const + +export const PRODUCT_CUSTOM_FIELD_CREATE_FORM_TABS = [ + "general", + "organize", +] as const +export const PRODUCT_CUSTOM_FIELD_FORM_TABS = [ + ...PRODUCT_CUSTOM_FIELD_CREATE_FORM_TABS, +] as const + +export const PRODUCT_CUSTOM_FIELD_DISPLAY_ZONES = [ + "general", + "organize", + "attributes", +] as const + +export const PRODUCT_CUSTOM_FIELD_LINK_PATHS = [ + `${PRODUCT_CUSTOM_FIELD_MODEL}.$link`, +] as const + +export const PRODUCT_CUSTOM_FIELD_FORM_CONFIG_PATHS = [ + ...PRODUCT_CUSTOM_FIELD_FORM_ZONES.map( + (form) => `${PRODUCT_CUSTOM_FIELD_MODEL}.${form}.$config` + ), +] as const + +export const PRODUCT_CUSTOM_FIELD_FORM_FIELD_PATHS = [ + ...PRODUCT_CUSTOM_FIELD_FORM_ZONES.flatMap((form) => { + return form === "create" + ? PRODUCT_CUSTOM_FIELD_CREATE_FORM_TABS.map( + (tab) => `${PRODUCT_CUSTOM_FIELD_MODEL}.${form}.${tab}.$field` + ) + : [`${PRODUCT_CUSTOM_FIELD_MODEL}.${form}.$field`] + }), +] as const + +export const PRODUCT_CUSTOM_FIELD_DISPLAY_PATHS = [ + ...PRODUCT_CUSTOM_FIELD_DISPLAY_ZONES.map( + (id) => `${PRODUCT_CUSTOM_FIELD_MODEL}.${id}.$display` + ), +] as const diff --git a/packages/admin/admin-shared/src/extensions/routes/index.ts b/packages/admin/admin-shared/src/extensions/custom-fields/product/index.ts similarity index 100% rename from packages/admin/admin-shared/src/extensions/routes/index.ts rename to packages/admin/admin-shared/src/extensions/custom-fields/product/index.ts diff --git a/packages/admin/admin-shared/src/extensions/custom-fields/product/types.ts b/packages/admin/admin-shared/src/extensions/custom-fields/product/types.ts new file mode 100644 index 0000000000000..64bc42be780dc --- /dev/null +++ b/packages/admin/admin-shared/src/extensions/custom-fields/product/types.ts @@ -0,0 +1,10 @@ +import { + PRODUCT_CUSTOM_FIELD_DISPLAY_ZONES, + PRODUCT_CUSTOM_FIELD_FORM_TABS, + PRODUCT_CUSTOM_FIELD_FORM_ZONES, +} from "./constants" + +export type ProductFormZone = (typeof PRODUCT_CUSTOM_FIELD_FORM_ZONES)[number] +export type ProductFormTab = (typeof PRODUCT_CUSTOM_FIELD_FORM_TABS)[number] +export type ProductDisplayZone = + (typeof PRODUCT_CUSTOM_FIELD_DISPLAY_ZONES)[number] diff --git a/packages/admin/admin-shared/src/extensions/custom-fields/types.ts b/packages/admin/admin-shared/src/extensions/custom-fields/types.ts new file mode 100644 index 0000000000000..a0c41fadd3a40 --- /dev/null +++ b/packages/admin/admin-shared/src/extensions/custom-fields/types.ts @@ -0,0 +1,48 @@ +import { + CUSTOM_FIELD_CONTAINER_ZONES, + CUSTOM_FIELD_FORM_TABS, + CUSTOM_FIELD_FORM_ZONES, + CUSTOM_FIELD_MODELS, +} from "./constants" +import type { + ProductDisplayZone, + ProductFormTab, + ProductFormZone, +} from "./product" + +export type CustomFieldModel = (typeof CUSTOM_FIELD_MODELS)[number] + +export type CustomFieldFormZone = (typeof CUSTOM_FIELD_FORM_ZONES)[number] + +export type CustomFieldFormTab = (typeof CUSTOM_FIELD_FORM_TABS)[number] + +export type CustomFieldContainerZone = + (typeof CUSTOM_FIELD_CONTAINER_ZONES)[number] + +export type CustomFieldZone = CustomFieldFormZone | CustomFieldContainerZone + +export type CustomFieldImportType = "display" | "field" | "link" | "config" + +export interface CustomFieldModelFormMap { + product: ProductFormZone +} + +export interface CustomFieldModelContainerMap { + product: ProductDisplayZone +} + +export type CustomFieldModelFormTabsMap = { + product: { + create: ProductFormTab + edit: never + organize: never + attributes: never + } + customer: { + create: never + edit: never + } +} + +export type CustomFieldFormKeys = + CustomFieldModelFormMap[T] diff --git a/packages/admin/admin-shared/src/extensions/custom-fields/utils.ts b/packages/admin/admin-shared/src/extensions/custom-fields/utils.ts new file mode 100644 index 0000000000000..b14c322835d98 --- /dev/null +++ b/packages/admin/admin-shared/src/extensions/custom-fields/utils.ts @@ -0,0 +1,54 @@ +import { + CUSTOM_FIELD_CONTAINER_ZONES, + CUSTOM_FIELD_DISPLAY_PATHS, + CUSTOM_FIELD_FORM_CONFIG_PATHS, + CUSTOM_FIELD_FORM_FIELD_PATHS, + CUSTOM_FIELD_FORM_TABS, + CUSTOM_FIELD_FORM_ZONES, + CUSTOM_FIELD_LINK_PATHS, + CUSTOM_FIELD_MODELS, +} from "./constants" +import { + CustomFieldContainerZone, + CustomFieldFormTab, + CustomFieldFormZone, + CustomFieldModel, +} from "./types" + +// Validators for individual segments of the custom field extension system + +export function isValidCustomFieldModel(id: any): id is CustomFieldModel { + return CUSTOM_FIELD_MODELS.includes(id) +} + +export function isValidCustomFieldFormZone(id: any): id is CustomFieldFormZone { + return CUSTOM_FIELD_FORM_ZONES.includes(id) +} + +export function isValidCustomFieldFormTab(id: any): id is CustomFieldFormTab { + return CUSTOM_FIELD_FORM_TABS.includes(id) +} + +export function isValidCustomFieldDisplayZone( + id: any +): id is CustomFieldContainerZone { + return CUSTOM_FIELD_CONTAINER_ZONES.includes(id) +} + +// Validators for full paths of custom field extensions + +export function isValidCustomFieldDisplayPath(id: any): id is string { + return CUSTOM_FIELD_DISPLAY_PATHS.includes(id) +} + +export function isValidCustomFieldFormConfigPath(id: any): id is string { + return CUSTOM_FIELD_FORM_CONFIG_PATHS.includes(id) +} + +export function isValidCustomFieldFormFieldPath(id: any): id is string { + return CUSTOM_FIELD_FORM_FIELD_PATHS.includes(id) +} + +export function isValidCustomFieldLinkPath(id: any): id is string { + return CUSTOM_FIELD_LINK_PATHS.includes(id) +} diff --git a/packages/admin/admin-shared/src/extensions/routes/constants.ts b/packages/admin/admin-shared/src/extensions/routes/constants.ts deleted file mode 100644 index a048d89c2068c..0000000000000 --- a/packages/admin/admin-shared/src/extensions/routes/constants.ts +++ /dev/null @@ -1 +0,0 @@ -export const ROUTE_IMPORTS = ["routes/pages", "routes/links"] as const diff --git a/packages/admin/admin-shared/src/extensions/routes/types.ts b/packages/admin/admin-shared/src/extensions/routes/types.ts deleted file mode 100644 index 62417f6e5bfcc..0000000000000 --- a/packages/admin/admin-shared/src/extensions/routes/types.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { ROUTE_IMPORTS } from "./constants" - -export type RouteImport = (typeof ROUTE_IMPORTS)[number] diff --git a/packages/admin/admin-shared/src/extensions/virtual/constants.ts b/packages/admin/admin-shared/src/extensions/virtual/constants.ts deleted file mode 100644 index 6b8ee3eb7df73..0000000000000 --- a/packages/admin/admin-shared/src/extensions/virtual/constants.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { ROUTE_IMPORTS } from "../routes" -import { INJECTION_ZONES } from "../widgets" -import { getVirtualId, getWidgetImport, resolveVirtualId } from "./utils" - -const VIRTUAL_WIDGET_MODULES = INJECTION_ZONES.map((zone) => { - return getVirtualId(getWidgetImport(zone)) -}) - -const VIRTUAL_ROUTE_MODULES = ROUTE_IMPORTS.map((route) => { - return getVirtualId(route) -}) - -/** - * All virtual modules that are used in the admin panel. Virtual modules are used - * to inject custom widgets, routes and settings. A virtual module is imported using - * a string that corresponds to the id of the virtual module. - * - * @example - * ```ts - * import ProductDetailsBefore from "virtual:medusa/widgets/product/details/before" - * ``` - */ -export const VIRTUAL_MODULES = [ - ...VIRTUAL_WIDGET_MODULES, - ...VIRTUAL_ROUTE_MODULES, -] - -/** - * Reolved paths to all virtual widget modules. - */ -export const RESOLVED_WIDGET_MODULES = VIRTUAL_WIDGET_MODULES.map((id) => { - return resolveVirtualId(id) -}) - -/** - * Reolved paths to all virtual route modules. - */ -export const RESOLVED_ROUTE_MODULES = VIRTUAL_ROUTE_MODULES.map((id) => { - return resolveVirtualId(id) -}) diff --git a/packages/admin/admin-shared/src/extensions/virtual/utils.ts b/packages/admin/admin-shared/src/extensions/virtual/utils.ts deleted file mode 100644 index a3e523fb1f749..0000000000000 --- a/packages/admin/admin-shared/src/extensions/virtual/utils.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { InjectionZone } from "../widgets" - -const PREFIX = "virtual:medusa/" - -export const getVirtualId = (name: string) => { - return `${PREFIX}${name}` -} - -export const resolveVirtualId = (id: string) => { - return `\0${id}` -} - -export const getWidgetImport = (zone: InjectionZone) => { - return `widgets/${zone.replace(/\./g, "/")}` -} - -export const getWidgetZone = (resolvedId: string): InjectionZone => { - const virtualPrefix = `\0${PREFIX}widgets/` - - const zone = resolvedId - .replace(virtualPrefix, "") - .replace(/\//g, ".") as InjectionZone - - return zone as InjectionZone -} diff --git a/packages/admin/admin-shared/src/index.ts b/packages/admin/admin-shared/src/index.ts index adb5864e0a22c..19779d6831dc1 100644 --- a/packages/admin/admin-shared/src/index.ts +++ b/packages/admin/admin-shared/src/index.ts @@ -1,3 +1,3 @@ -export * from "./extensions/virtual" +export * from "./extensions/custom-fields" export * from "./extensions/widgets" - +export * from "./virtual-modules" diff --git a/packages/admin/admin-shared/src/virtual-modules/constants.ts b/packages/admin/admin-shared/src/virtual-modules/constants.ts new file mode 100644 index 0000000000000..6cf49975dbfe1 --- /dev/null +++ b/packages/admin/admin-shared/src/virtual-modules/constants.ts @@ -0,0 +1,15 @@ +export const LINK_VIRTUAL_MODULE = `virtual:medusa/links` +export const FORM_VIRTUAL_MODULE = `virtual:medusa/forms` +export const DISPLAY_VIRTUAL_MODULE = `virtual:medusa/displays` +export const ROUTE_VIRTUAL_MODULE = `virtual:medusa/routes` +export const MENU_ITEM_VIRTUAL_MODULE = `virtual:medusa/menu-items` +export const WIDGET_VIRTUAL_MODULE = `virtual:medusa/widgets` + +export const VIRTUAL_MODULES = [ + LINK_VIRTUAL_MODULE, + FORM_VIRTUAL_MODULE, + DISPLAY_VIRTUAL_MODULE, + ROUTE_VIRTUAL_MODULE, + MENU_ITEM_VIRTUAL_MODULE, + WIDGET_VIRTUAL_MODULE, +] as const diff --git a/packages/admin/admin-shared/src/extensions/virtual/index.ts b/packages/admin/admin-shared/src/virtual-modules/index.ts similarity index 53% rename from packages/admin/admin-shared/src/extensions/virtual/index.ts rename to packages/admin/admin-shared/src/virtual-modules/index.ts index b570da8e42381..23fdb69814b6d 100644 --- a/packages/admin/admin-shared/src/extensions/virtual/index.ts +++ b/packages/admin/admin-shared/src/virtual-modules/index.ts @@ -1,2 +1 @@ export * from "./constants" -export * from "./utils" diff --git a/packages/admin/admin-vite-plugin/package.json b/packages/admin/admin-vite-plugin/package.json index 2d2e9f4c0f7ec..da5072fc2e832 100644 --- a/packages/admin/admin-vite-plugin/package.json +++ b/packages/admin/admin-vite-plugin/package.json @@ -25,8 +25,7 @@ "watch": "tsup --watch" }, "devDependencies": { - "@babel/types": "7.22.5", - "@types/babel__traverse": "7.20.5", + "@babel/types": "7.25.6", "@types/node": "^20.10.4", "tsup": "8.0.1", "typescript": "5.3.3", @@ -36,12 +35,14 @@ "vite": "^5.0.0" }, "dependencies": { - "@babel/parser": "7.23.5", - "@babel/traverse": "7.23.5", + "@babel/parser": "7.25.6", + "@babel/traverse": "7.25.6", "@medusajs/admin-shared": "0.0.1", "chokidar": "3.5.3", "fdir": "6.1.1", - "magic-string": "0.30.5" + "magic-string": "0.30.5", + "outdent": "^0.8.0", + "picocolors": "^1.1.0" }, "packageManager": "yarn@3.2.1" } diff --git a/packages/admin/admin-vite-plugin/src/babel.ts b/packages/admin/admin-vite-plugin/src/babel.ts index bfef3673c84a1..0f166887075b2 100644 --- a/packages/admin/admin-vite-plugin/src/babel.ts +++ b/packages/admin/admin-vite-plugin/src/babel.ts @@ -4,7 +4,24 @@ import { ExportDefaultDeclaration, ExportNamedDeclaration, File, + isArrayExpression, + isCallExpression, + isFunctionDeclaration, + isIdentifier, + isJSXElement, + isJSXFragment, + isMemberExpression, + isObjectExpression, + isObjectProperty, + isStringLiteral, + isTemplateLiteral, + isVariableDeclaration, + isVariableDeclarator, + ObjectExpression, + ObjectMethod, ObjectProperty, + SpreadElement, + StringLiteral, } from "@babel/types" /** @@ -20,13 +37,33 @@ if (typeof _traverse === "function") { traverse = (_traverse as any).default } -export { parse, traverse } +export { + isArrayExpression, + isCallExpression, + isFunctionDeclaration, + isIdentifier, + isJSXElement, + isJSXFragment, + isMemberExpression, + isObjectExpression, + isObjectProperty, + isStringLiteral, + isTemplateLiteral, + isVariableDeclaration, + isVariableDeclarator, + parse, + traverse, +} export type { ExportDefaultDeclaration, ExportNamedDeclaration, File, NodePath, + ObjectExpression, + ObjectMethod, ObjectProperty, ParseResult, ParserOptions, + SpreadElement, + StringLiteral, } diff --git a/packages/admin/admin-vite-plugin/src/custom-fields/generate-custom-field-displays.ts b/packages/admin/admin-vite-plugin/src/custom-fields/generate-custom-field-displays.ts new file mode 100644 index 0000000000000..2ac717b118fe2 --- /dev/null +++ b/packages/admin/admin-vite-plugin/src/custom-fields/generate-custom-field-displays.ts @@ -0,0 +1,292 @@ +import { + isValidCustomFieldDisplayPath, + isValidCustomFieldDisplayZone, + type CustomFieldContainerZone, + type CustomFieldModel, +} from "@medusajs/admin-shared" +import fs from "fs/promises" +import { + ExportDefaultDeclaration, + File, + isArrayExpression, + isIdentifier, + isObjectExpression, + isObjectProperty, + isStringLiteral, + NodePath, + ObjectProperty, + parse, + ParseResult, + traverse, +} from "../babel" +import { logger } from "../logger" +import { crawl, getParserOptions } from "../utils" +import { getConfigArgument, getModel, validateLink } from "./helpers" + +type CustomFieldDisplay = { + zone: CustomFieldContainerZone + Component: string +} + +type ParsedCustomFieldDisplayConfig = { + import: string + model: CustomFieldModel + displays: CustomFieldDisplay[] | null +} + +export async function generateCustomFieldDisplays(sources: Set) { + const files = await getFilesFromSources(sources) + const results = await getCustomFieldDisplayResults(files) + + const imports = results.map((result) => result.import).flat() + const code = generateDisplayCode(results) + + return { + imports, + code, + } +} + +async function getFilesFromSources(sources: Set): Promise { + const files = ( + await Promise.all( + Array.from(sources).map(async (source) => + crawl(`${source}/custom-fields`) + ) + ) + ).flat() + return files +} + +function generateDisplayCode( + results: ParsedCustomFieldDisplayConfig[] +): string { + const groupedByModel = new Map< + CustomFieldModel, + ParsedCustomFieldDisplayConfig[] + >() + + results.forEach((result) => { + const model = result.model + if (!groupedByModel.has(model)) { + groupedByModel.set(model, []) + } + groupedByModel.get(model)!.push(result) + }) + + const segments: string[] = [] + + groupedByModel.forEach((results, model) => { + const displays = results + .map((result) => formatDisplays(result.displays)) + .filter((display) => display !== "") + .join(",\n") + + segments.push(` + ${model}: [ + ${displays} + ], + `) + }) + + return ` + displays: { + ${segments.join("\n")} + } + ` +} + +function formatDisplays(displays: CustomFieldDisplay[] | null): string { + if (!displays || displays.length === 0) { + return "" + } + + return displays + .map( + (display) => ` + { + zone: "${display.zone}", + Component: ${display.Component}, + } + ` + ) + .join(",\n") +} + +async function getCustomFieldDisplayResults( + files: string[] +): Promise { + return ( + await Promise.all( + files.map(async (file, index) => parseDisplayFile(file, index)) + ) + ).filter(Boolean) as ParsedCustomFieldDisplayConfig[] +} + +async function parseDisplayFile( + file: string, + index: number +): Promise { + const content = await fs.readFile(file, "utf8") + let ast: ParseResult + + try { + ast = parse(content, getParserOptions(file)) + } catch (e) { + logger.error(`An error occurred while parsing the file`, { file, error: e }) + return null + } + + const import_ = generateImport(file, index) + + let displays: CustomFieldDisplay[] | null = null + let model: CustomFieldModel | null = null + let hasLink = false + try { + traverse(ast, { + ExportDefaultDeclaration(path) { + const _model = getModel(path, file) + + if (!_model) { + return + } + + model = _model + displays = getDisplays(path, model, index, file) + hasLink = validateLink(path, file) + }, + }) + } catch (err) { + logger.error(`An error occurred while traversing the file.`, { + file, + error: err, + }) + return null + } + + if (!model) { + logger.warn(`'model' property is missing.`, { file }) + return null + } + + if (!hasLink) { + logger.warn(`'link' property is missing.`, { file }) + return null + } + + return { + import: import_, + model, + displays, + } +} + +function getDisplays( + path: NodePath, + model: CustomFieldModel, + index: number, + file: string +): CustomFieldDisplay[] | null { + const configArgument = getConfigArgument(path) + + if (!configArgument) { + return null + } + + const displayProperty = configArgument.properties.find( + (p) => isObjectProperty(p) && isIdentifier(p.key, { name: "displays" }) + ) as ObjectProperty | undefined + + if (!displayProperty) { + return null + } + + if (!isArrayExpression(displayProperty.value)) { + logger.warn( + `'displays' is not an array. The 'displays' property must be an array of objects.`, + { file } + ) + return null + } + + const displays: CustomFieldDisplay[] = [] + + displayProperty.value.elements.forEach((element, j) => { + if (!isObjectExpression(element)) { + return + } + + const zoneProperty = element.properties.find( + (p) => isObjectProperty(p) && isIdentifier(p.key, { name: "zone" }) + ) as ObjectProperty | undefined + + if (!zoneProperty) { + logger.warn( + `'zone' property is missing at the ${j} index of the 'displays' property.`, + { file } + ) + return + } + + if (!isStringLiteral(zoneProperty.value)) { + logger.warn( + `'zone' property at index ${j} in the 'displays' property is not a string literal. 'zone' must be a string literal, e.g. 'general' or 'attributes'.`, + { file } + ) + return + } + + const zone = zoneProperty.value.value + const fullPath = getDisplayEntryPath(model, zone) + + if ( + !isValidCustomFieldDisplayZone(zone) || + !isValidCustomFieldDisplayPath(fullPath) + ) { + logger.warn( + `'zone' is invalid at index ${j} in the 'displays' property. Received: ${zone}.`, + { file } + ) + return + } + + const componentProperty = element.properties.find( + (p) => isObjectProperty(p) && isIdentifier(p.key, { name: "component" }) + ) as ObjectProperty | undefined + + if (!componentProperty) { + logger.warn( + `'component' property is missing at index ${j} in the 'displays' property.`, + { file } + ) + return + } + + displays.push({ + zone: zone, + Component: getDisplayComponent(index, j), + }) + }) + + return displays.length > 0 ? displays : null +} + +function getDisplayEntryPath(model: CustomFieldModel, zone: string): string { + return `${model}.${zone}.$display` +} + +function getDisplayComponent( + fileIndex: number, + displayEntryIndex: number +): string { + const import_ = generateCustomFieldConfigName(fileIndex) + return `${import_}.displays[${displayEntryIndex}].component` +} + +function generateCustomFieldConfigName(index: number): string { + return `CustomFieldConfig${index}` +} + +function generateImport(file: string, index: number): string { + return `import ${generateCustomFieldConfigName(index)} from "${file}"` +} diff --git a/packages/admin/admin-vite-plugin/src/custom-fields/generate-custom-field-forms.ts b/packages/admin/admin-vite-plugin/src/custom-fields/generate-custom-field-forms.ts new file mode 100644 index 0000000000000..2f11ee3ff94c3 --- /dev/null +++ b/packages/admin/admin-vite-plugin/src/custom-fields/generate-custom-field-forms.ts @@ -0,0 +1,707 @@ +import { ArrayExpression } from "@babel/types" +import { + isValidCustomFieldFormConfigPath, + isValidCustomFieldFormFieldPath, + isValidCustomFieldFormTab, + isValidCustomFieldFormZone, + type CustomFieldFormTab, + type CustomFieldFormZone, + type CustomFieldModel, +} from "@medusajs/admin-shared" +import fs from "fs/promises" +import { outdent } from "outdent" +import { + ExportDefaultDeclaration, + File, + isArrayExpression, + isCallExpression, + isIdentifier, + isMemberExpression, + isObjectExpression, + isObjectProperty, + isStringLiteral, + isTemplateLiteral, + NodePath, + ObjectExpression, + ObjectProperty, + parse, + ParseResult, + traverse, +} from "../babel" +import { logger } from "../logger" +import { crawl, getParserOptions } from "../utils" +import { getConfigArgument, getModel, validateLink } from "./helpers" + +type CustomFieldConfigField = { + name: string + defaultValue: string + validation: string +} + +type CustomFieldConfig = { + zone: CustomFieldFormZone + fields: CustomFieldConfigField[] +} + +type CustomFieldFormField = { + name: string + label: string + description: string + placeholder: string + Component: string + validation: string +} + +type CustomFieldFormSection = { + zone: CustomFieldFormZone + tab?: CustomFieldFormTab + fields: CustomFieldFormField[] +} + +type ParsedCustomFieldConfig = { + import: string + model: CustomFieldModel + configs: CustomFieldConfig[] | null + forms: CustomFieldFormSection[] | null +} + +export async function generateCustomFieldForms(sources: Set) { + 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): Promise { + 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() + + 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 { + return ( + await Promise.all(files.map(async (file, index) => parseFile(file, index))) + ).filter(Boolean) as ParsedCustomFieldConfig[] +} + +async function parseFile( + file: string, + index: number +): Promise { + const content = await fs.readFile(file, "utf8") + let ast: ParseResult + + try { + ast = parse(content, getParserOptions(file)) + } catch (e) { + logger.error(`An error occurred while parsing the file`, { file, error: e }) + return null + } + + const import_ = generateImport(file, index) + + let configs: CustomFieldConfig[] | null = [] + let forms: CustomFieldFormSection[] | null = [] + let model: CustomFieldModel | null = null + let hasLink = false + try { + traverse(ast, { + ExportDefaultDeclaration(path) { + const _model = getModel(path, file) + + if (!_model) { + return + } + + model = _model + hasLink = validateLink(path, file) // Add this line to validate link + configs = getConfigs(path, model, index, file) + forms = getForms(path, model, index, file) + }, + }) + } catch (err) { + logger.error(`An error occurred while traversing the file.`, { + file, + error: err, + }) + return null + } + + if (!model) { + logger.warn(`'model' property is missing.`, { file }) + return null + } + + if (!hasLink) { + logger.warn(`'link' property is missing.`, { file }) + return null + } + + return { + import: import_, + model, + configs, + forms, + } +} + +function generateCustomFieldConfigName(index: number): string { + return `CustomFieldConfig${index}` +} + +function generateImport(file: string, index: number): string { + return `import ${generateCustomFieldConfigName(index)} from "${file}"` +} + +function getForms( + path: NodePath, + 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, + 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, + 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 +} diff --git a/packages/admin/admin-vite-plugin/src/custom-fields/generate-custom-field-hashes.ts b/packages/admin/admin-vite-plugin/src/custom-fields/generate-custom-field-hashes.ts new file mode 100644 index 0000000000000..94dea86849688 --- /dev/null +++ b/packages/admin/admin-vite-plugin/src/custom-fields/generate-custom-field-hashes.ts @@ -0,0 +1,86 @@ +import fs from "fs/promises" +import { isIdentifier, isObjectProperty, parse, traverse } from "../babel" +import { logger } from "../logger" +import { crawl, generateHash, getParserOptions } from "../utils" +import { getConfigArgument } from "./helpers" + +export async function generateCustomFieldHashes( + sources: Set +): Promise<{ linkHash: string; formHash: string; displayHash: string }> { + const files = await getFilesFromSources(sources) + const contents = await Promise.all(files.map(getCustomFieldContents)) + + const linkContents = contents.map((c) => c.link).filter(Boolean) + const formContents = contents.map((c) => c.form).filter(Boolean) + const displayContents = contents.map((c) => c.display).filter(Boolean) + + const totalLinkContent = linkContents.join("") + const totalFormContent = formContents.join("") + const totalDisplayContent = displayContents.join("") + + return { + linkHash: generateHash(totalLinkContent), + formHash: generateHash(totalFormContent), + displayHash: generateHash(totalDisplayContent), + } +} + +async function getFilesFromSources(sources: Set): Promise { + return ( + await Promise.all( + Array.from(sources).map(async (source) => + crawl(`${source}/custom-fields`) + ) + ) + ).flat() +} + +async function getCustomFieldContents(file: string): Promise<{ + link: string | null + form: string | null + display: string | null +}> { + const code = await fs.readFile(file, "utf-8") + const ast = parse(code, getParserOptions(file)) + + let linkContent: string | null = null + let formContent: string | null = null + let displayContent: string | null = null + + try { + traverse(ast, { + ExportDefaultDeclaration(path) { + const configArgument = getConfigArgument(path) + if (!configArgument) { + return + } + + configArgument.properties.forEach((prop) => { + if (!isObjectProperty(prop) || !prop.key || !isIdentifier(prop.key)) { + return + } + + switch (prop.key.name) { + case "link": + linkContent = code.slice(prop.start!, prop.end!) + break + case "forms": + formContent = code.slice(prop.start!, prop.end!) + break + case "display": + displayContent = code.slice(prop.start!, prop.end!) + break + } + }) + }, + }) + } catch (e) { + logger.error( + `An error occurred while processing ${file}. See the below error for more details:\n${e}`, + { file, error: e } + ) + return { link: null, form: null, display: null } + } + + return { link: linkContent, form: formContent, display: displayContent } +} diff --git a/packages/admin/admin-vite-plugin/src/custom-fields/generate-custom-field-links.ts b/packages/admin/admin-vite-plugin/src/custom-fields/generate-custom-field-links.ts new file mode 100644 index 0000000000000..ac4e4fd7b6906 --- /dev/null +++ b/packages/admin/admin-vite-plugin/src/custom-fields/generate-custom-field-links.ts @@ -0,0 +1,167 @@ +import { CustomFieldModel } from "@medusajs/admin-shared" +import fs from "fs/promises" +import { + ExportDefaultDeclaration, + File, + isIdentifier, + isObjectProperty, + NodePath, + ObjectProperty, + parse, + ParseResult, + traverse, +} from "../babel" +import { logger } from "../logger" +import { crawl, getParserOptions } from "../utils" +import { getConfigArgument, getModel } from "./helpers" + +type ParsedCustomFieldLink = { + import: string + model: CustomFieldModel + link: string +} + +export async function generateCustomFieldLinks(sources: Set) { + const files = await getFilesFromSources(sources) + const results = await getCustomFieldLinkResults(files) + + const imports = results.map((result) => result.import) + const code = generateCode(results) + + return { + imports, + code, + } +} + +async function getFilesFromSources(sources: Set): Promise { + const files = ( + await Promise.all( + Array.from(sources).map(async (source) => + crawl(`${source}/custom-fields`) + ) + ) + ).flat() + return files +} + +function generateCode(results: ParsedCustomFieldLink[]): string { + const groupedByModel = new Map() + + results.forEach((result) => { + const model = result.model + if (!groupedByModel.has(model)) { + groupedByModel.set(model, []) + } + groupedByModel.get(model)!.push(result) + }) + + const segments: string[] = [] + + groupedByModel.forEach((results, model) => { + const links = results.map((result) => result.link).join(",\n") + + segments.push(` + ${model}: [ + ${links} + ], + `) + }) + + return ` + links: { + ${segments.join("\n")} + } + ` +} + +async function getCustomFieldLinkResults( + files: string[] +): Promise { + return ( + await Promise.all(files.map(async (file, index) => parseFile(file, index))) + ).filter(Boolean) as ParsedCustomFieldLink[] +} + +async function parseFile( + file: string, + index: number +): Promise { + const content = await fs.readFile(file, "utf8") + let ast: ParseResult + + try { + ast = parse(content, getParserOptions(file)) + } catch (e) { + logger.error(`An error occurred while parsing the file`, { file, error: e }) + return null + } + + const import_ = generateImport(file, index) + + let link: string | null = null + let model: CustomFieldModel | null = null + try { + traverse(ast, { + ExportDefaultDeclaration(path) { + const _model = getModel(path, file) + + if (!_model) { + return + } + + model = _model + link = getLink(path, index, file) + }, + }) + } catch (err) { + logger.error(`An error occurred while traversing the file.`, { + file, + error: err, + }) + return null + } + + if (!link || !model) { + return null + } + + return { + import: import_, + model, + link, + } +} + +function generateCustomFieldConfigName(index: number): string { + return `CustomFieldConfig${index}` +} + +function generateImport(file: string, index: number): string { + return `import ${generateCustomFieldConfigName(index)} from "${file}"` +} + +function getLink( + path: NodePath, + index: number, + file: string +): string | null { + const configArgument = getConfigArgument(path) + + if (!configArgument) { + return null + } + + const linkProperty = configArgument.properties.find( + (p) => isObjectProperty(p) && isIdentifier(p.key, { name: "link" }) + ) as ObjectProperty | undefined + + if (!linkProperty) { + logger.warn(`'link' is missing.`, { file }) + return null + } + + const import_ = generateCustomFieldConfigName(index) + + return `${import_}.link` +} diff --git a/packages/admin/admin-vite-plugin/src/custom-fields/helpers.ts b/packages/admin/admin-vite-plugin/src/custom-fields/helpers.ts new file mode 100644 index 0000000000000..8e9ea03b008a6 --- /dev/null +++ b/packages/admin/admin-vite-plugin/src/custom-fields/helpers.ts @@ -0,0 +1,116 @@ +import { + CustomFieldModel, + isValidCustomFieldModel, +} from "@medusajs/admin-shared" +import { + ExportDefaultDeclaration, + isCallExpression, + isIdentifier, + isObjectExpression, + isObjectProperty, + isStringLiteral, + isTemplateLiteral, + NodePath, + ObjectExpression, + ObjectProperty, +} from "../babel" +import { logger } from "../logger" + +export function getModel( + path: NodePath, + file: string +): CustomFieldModel | null { + const configArgument = getConfigArgument(path) + + if (!configArgument) { + return null + } + + const modelProperty = configArgument.properties.find( + (p) => isObjectProperty(p) && isIdentifier(p.key, { name: "model" }) + ) as ObjectProperty | undefined + + if (!modelProperty) { + return null + } + + if (isTemplateLiteral(modelProperty.value)) { + logger.warn( + `'model' property cannot be a template literal (e.g. \`product\`).`, + { file } + ) + return null + } + + if (!isStringLiteral(modelProperty.value)) { + logger.warn( + `'model' is invalid. The 'model' property must be a string literal, e.g. 'product' or 'customer'.`, + { file } + ) + return null + } + + const model = modelProperty.value.value.trim() + + if (!isValidCustomFieldModel(model)) { + logger.warn( + `'model' is invalid, received: ${model}. The 'model' property must be set to a valid model, e.g. 'product' or 'customer'.`, + { file } + ) + return null + } + + return model +} + +export function getConfigArgument( + path: NodePath +): ObjectExpression | null { + if (!isCallExpression(path.node.declaration)) { + return null + } + + if ( + !isIdentifier(path.node.declaration.callee, { + name: "unstable_defineCustomFieldsConfig", + }) + ) { + return null + } + + const configArgument = path.node.declaration.arguments[0] + + if (!isObjectExpression(configArgument)) { + return null + } + + return configArgument +} + +/** + * Validates that the 'link' property is present in the custom field config. + * @param path - The NodePath to the export default declaration. + * @param file - The file path. + * @returns - True if the 'link' property is present, false otherwise. + */ +export function validateLink( + path: NodePath, + file: string +): boolean { + const configArgument = getConfigArgument(path) + + if (!configArgument) { + return false + } + + const linkProperty = configArgument.properties.find( + (p) => isObjectProperty(p) && isIdentifier(p.key, { name: "link" }) + ) as ObjectProperty | undefined + + if (!linkProperty) { + logger.warn(`'link' property is missing.`, { file }) + return false + } + + return true +} diff --git a/packages/admin/admin-vite-plugin/src/custom-fields/index.ts b/packages/admin/admin-vite-plugin/src/custom-fields/index.ts new file mode 100644 index 0000000000000..4e1df67f7c4ba --- /dev/null +++ b/packages/admin/admin-vite-plugin/src/custom-fields/index.ts @@ -0,0 +1,4 @@ +export * from "./generate-custom-field-displays" +export * from "./generate-custom-field-forms" +export * from "./generate-custom-field-hashes" +export * from "./generate-custom-field-links" diff --git a/packages/admin/admin-vite-plugin/src/index.ts b/packages/admin/admin-vite-plugin/src/index.ts index fd5f6fdca7480..8f7c00d33e99d 100644 --- a/packages/admin/admin-vite-plugin/src/index.ts +++ b/packages/admin/admin-vite-plugin/src/index.ts @@ -1,4 +1,5 @@ -import { medusaVitePlugin, type MedusaVitePlugin } from "./plugin" +import { medusaVitePlugin } from "./plugin" +import type { MedusaVitePlugin } from "./types" export default medusaVitePlugin export type { MedusaVitePlugin } diff --git a/packages/admin/admin-vite-plugin/src/logger.ts b/packages/admin/admin-vite-plugin/src/logger.ts new file mode 100644 index 0000000000000..acb3b02b497b6 --- /dev/null +++ b/packages/admin/admin-vite-plugin/src/logger.ts @@ -0,0 +1,64 @@ +import colors from "picocolors" + +type LoggerOptions = { + file?: string | string[] + error?: any +} + +function getTimestamp(): string { + const now = new Date() + return now.toLocaleTimeString("en-US", { hour12: true }) +} + +function getPrefix(type: "warn" | "info" | "error") { + const timestamp = colors.dim(getTimestamp()) + const typeColor = + type === "warn" + ? colors.yellow + : type === "info" + ? colors.green + : colors.red + + const prefix = typeColor("[@medusajs/admin-vite-plugin]") + + return `${timestamp} ${prefix}` +} + +function getFile(options: LoggerOptions): string { + if (!options.file) { + return "" + } + + const value = Array.isArray(options.file) + ? options.file.map((f) => f).join(", ") + : options.file + + return colors.dim(`${value}`) +} + +function formatError(error: any): string { + if (error instanceof Error) { + return colors.red(`${error.name}: ${error.message}\n${error.stack}`) + } else if (typeof error === "object") { + return colors.red(JSON.stringify(error, null, 2)) + } else { + return colors.red(String(error)) + } +} + +const logger = { + warn(msg: string, options: LoggerOptions = {}) { + console.warn(`${getPrefix("warn")} ${msg} ${getFile(options)}`) + }, + info(msg: string, options: LoggerOptions = {}) { + console.info(`${getPrefix("info")} ${msg} ${getFile(options)}`) + }, + error(msg: string, options: LoggerOptions = {}) { + console.error(`${getPrefix("error")} ${msg} ${getFile(options)}`) + if (options.error) { + console.error(formatError(options.error)) + } + }, +} + +export { logger } diff --git a/packages/admin/admin-vite-plugin/src/plugin.ts b/packages/admin/admin-vite-plugin/src/plugin.ts index cee77ef0a923e..5cc2233ae0bcb 100644 --- a/packages/admin/admin-vite-plugin/src/plugin.ts +++ b/packages/admin/admin-vite-plugin/src/plugin.ts @@ -1,777 +1,67 @@ -import { - InjectionZone, - RESOLVED_ROUTE_MODULES, - RESOLVED_WIDGET_MODULES, - VIRTUAL_MODULES, - getVirtualId, - getWidgetImport, - getWidgetZone, - isValidInjectionZone, - resolveVirtualId, -} from "@medusajs/admin-shared" -import { fdir } from "fdir" -import fs from "fs/promises" -import MagicString from "magic-string" +import { SourceMap } from "magic-string" import path from "path" import type * as Vite from "vite" - +import { generateCustomFieldHashes } from "./custom-fields" +import { generateRouteHashes } from "./routes" +import { MedusaVitePlugin } from "./types" +import { AdminSubdirectory, isFileInAdminSubdirectory } from "./utils" import { - ExportNamedDeclaration, - ObjectProperty, - parse, - traverse, - type ExportDefaultDeclaration, - type File, - type NodePath, - type ParseResult, - type ParserOptions, -} from "./babel" - -const VALID_FILE_EXTENSIONS = [".tsx", ".jsx"] - -function convertToImportPath(file: string) { - return path.normalize(file).split(path.sep).join("/") -} - -/** - * Returns the module type of a given file. - */ -function getModuleType(file: string) { - const normalizedPath = convertToImportPath(file) - - if (normalizedPath.includes("/admin/widgets/")) { - return "widget" - } else if (normalizedPath.includes("/admin/routes/")) { - return "route" - } else { - return "none" - } -} - -/** - * Returns the parser options for a given file. - */ -function getParserOptions(file: string): ParserOptions { - const options: ParserOptions = { - sourceType: "module", - plugins: ["jsx"], - } - - if (file.endsWith(".tsx")) { - options.plugins?.push("typescript") - } - - return options -} - -/** - * Generates a module with a source map from a code string - */ -function generateModule(code: string) { - const magicString = new MagicString(code) - - return { - code: magicString.toString(), - map: magicString.generateMap({ hires: true }), - } -} - -/** - * Crawls a directory and returns all files that match the criteria. - */ -async function crawl( - dir: string, - file?: string, - depth?: { min: number; max?: number } -) { - const dirDepth = dir.split(path.sep).length - - const crawler = new fdir() - .withBasePath() - .exclude((dirName) => dirName.startsWith("_")) - .filter((path) => { - return VALID_FILE_EXTENSIONS.some((ext) => path.endsWith(ext)) - }) - - if (file) { - crawler.filter((path) => { - return VALID_FILE_EXTENSIONS.some((ext) => path.endsWith(file + ext)) - }) - } - - if (depth) { - crawler.filter((file) => { - const pathDepth = file.split(path.sep).length - 1 - - if (depth.max && pathDepth > dirDepth + depth.max) { - return false - } - - if (pathDepth < dirDepth + depth.min) { - return false - } - - return true - }) - } - - return crawler.crawl(dir).withPromise() -} - -/** - * Extracts and returns the properties of a `config` object from a named export declaration. - */ -function getConfigObjectProperties(path: NodePath) { - const declaration = path.node.declaration - - if (declaration && declaration.type === "VariableDeclaration") { - const configDeclaration = declaration.declarations.find( - (d) => - d.type === "VariableDeclarator" && - d.id.type === "Identifier" && - d.id.name === "config" - ) - - if ( - configDeclaration && - configDeclaration.init?.type === "CallExpression" && - configDeclaration.init.arguments.length > 0 && - configDeclaration.init.arguments[0].type === "ObjectExpression" - ) { - return configDeclaration.init.arguments[0].properties - } - } - - return null -} - -/** - * Validates if the default export in a given AST is a component (JSX element or fragment). - */ -function isDefaultExportComponent( - path: NodePath, - ast: File -): boolean { - let hasComponentExport = false - const declaration = path.node.declaration - - if ( - declaration && - (declaration.type === "Identifier" || - declaration.type === "FunctionDeclaration") - ) { - const exportName = - declaration.type === "Identifier" - ? declaration.name - : declaration.id && declaration.id.name - - if (exportName) { - try { - traverse(ast, { - VariableDeclarator({ node, scope }) { - let isDefaultExport = false - - if (node.id.type === "Identifier" && node.id.name === exportName) { - isDefaultExport = true - } - - if (!isDefaultExport) { - return - } - - traverse( - node, - { - ReturnStatement(path) { - if ( - path.node.argument?.type === "JSXElement" || - path.node.argument?.type === "JSXFragment" - ) { - hasComponentExport = true - } - }, - }, - scope - ) - }, - }) - } catch (e) { - return false - } - } - } - - return hasComponentExport -} - -/** Widget utilities */ - -/** - * Validates the widget configuration. - */ -function validateWidgetConfig( - path: NodePath, - zone?: InjectionZone -): { zoneIsValid: boolean; zoneValue: string | string[] | null } { - let zoneIsValid = false - let zoneValue: string | string[] | null = null - - const properties = getConfigObjectProperties(path) - - if (!properties) { - return { zoneIsValid, zoneValue } - } - - const zoneProperty = properties.find( - (p) => - p.type === "ObjectProperty" && - p.key.type === "Identifier" && - p.key.name === "zone" - ) as ObjectProperty | undefined - - if (!zoneProperty) { - return { zoneIsValid, zoneValue } - } - - if (zoneProperty.value.type === "StringLiteral") { - zoneIsValid = !zone - ? isValidInjectionZone(zoneProperty.value.value) - : zone === zoneProperty.value.value - zoneValue = zoneProperty.value.value - } else if (zoneProperty.value.type === "ArrayExpression") { - zoneIsValid = zoneProperty.value.elements.every((e) => { - if (!e || e.type !== "StringLiteral") { - return false - } - - const isZoneMatch = !zone ? true : zone === e.value - - return isValidInjectionZone(e.value) && isZoneMatch - }) - - const values: string[] = [] - - for (const element of zoneProperty.value.elements) { - if (element && element.type === "StringLiteral") { - values.push(element.value) - } - } - - zoneValue = values - } - - return { zoneIsValid, zoneValue } -} - -/** - * Validates a widget file. - */ -async function validateWidget( - file: string, - zone?: InjectionZone -): Promise< - { valid: true; zone: InjectionZone } | { valid: false; zone: null } -> { - let _zoneValue: string | string[] | null = null - - const content = await fs.readFile(file, "utf-8") - const parserOptions = getParserOptions(file) - - let ast: ParseResult - - try { - ast = parse(content, parserOptions) - } catch (e) { - return { valid: false, zone: _zoneValue } - } - - let hasDefaultExport = false - let hasNamedExport = false - - try { - traverse(ast, { - ExportDefaultDeclaration(path) { - hasDefaultExport = isDefaultExportComponent(path, ast) - }, - ExportNamedDeclaration(path) { - const { zoneIsValid, zoneValue } = validateWidgetConfig(path, zone) - - hasNamedExport = zoneIsValid - _zoneValue = zoneValue - }, - }) - } catch (err) { - return { valid: false, zone: _zoneValue } - } - - return { valid: hasNamedExport && hasDefaultExport, zone: _zoneValue as any } -} - -async function generateWidgetEntrypoint( - sources: Set, - zone: InjectionZone -) { - const files = ( - await Promise.all( - Array.from(sources).map(async (source) => crawl(`${source}/widgets`)) - ) - ).flat() - - const validatedWidgets = ( - await Promise.all( - files.map(async (widget) => { - const { valid } = await validateWidget(widget, zone) - return valid ? widget : null - }) - ) - ).filter(Boolean) as string[] - - if (!validatedWidgets.length) { - const code = `export default { - widgets: [], - }` - - return { module: generateModule(code), paths: [] } - } - - const importString = validatedWidgets - .map( - (path, index) => - `import WidgetExt${index} from "${convertToImportPath(path)}";` - ) - .join("\n") - - const exportString = `export default { - widgets: [${validatedWidgets - .map((_, index) => `{ Component: WidgetExt${index} }`) - .join(", ")}], - }` - - const code = `${importString}\n${exportString}` - - return { module: generateModule(code), paths: validatedWidgets } -} - -/** Route utilities */ - -function validateRouteConfig( - path: NodePath, - resolveMenuItem: boolean -) { - const properties = getConfigObjectProperties(path) - - /** - * When resolving links for the sidebar, we a config to get the props needed to - * render the link correctly. - * - * If the user has not provided any config, then the route can never be a valid - * menu item, so we can skip the validation, and return false. - */ - if (!properties && resolveMenuItem) { - return false - } - - /** - * A config is not required for a component to be a valid route. - */ - if (!properties) { - return true - } - - const labelProperty = properties.find( - (p) => - p.type === "ObjectProperty" && - p.key.type === "Identifier" && - p.key.name === "label" - ) as ObjectProperty | undefined - - const labelIsValid = - !labelProperty || labelProperty.value.type === "StringLiteral" - - return labelIsValid -} - -async function validateRoute(file: string, resolveMenuItem = false) { - const content = await fs.readFile(file, "utf-8") - const parserOptions = getParserOptions(file) - - let ast: ParseResult - - try { - ast = parse(content, parserOptions) - } catch (_e) { - return false - } - - let hasDefaultExport = false - let hasNamedExport = resolveMenuItem ? false : true - - try { - traverse(ast, { - ExportDefaultDeclaration(path) { - hasDefaultExport = isDefaultExportComponent(path, ast) - }, - ExportNamedDeclaration(path) { - hasNamedExport = validateRouteConfig(path, resolveMenuItem) - }, - }) - } catch (_e) { - return false - } - - return hasNamedExport && hasDefaultExport -} - -function createRoutePath(file: string) { - const importPath = convertToImportPath(file) - - return importPath - .replace(/.*\/admin\/(routes|settings)/, "") - .replace(/\[([^\]]+)\]/g, ":$1") - .replace(/\/page\.(tsx|jsx)/, "") -} - -async function generateRouteEntrypoint( - sources: Set, - type: "page" | "link" -) { - const files = ( - await Promise.all( - Array.from(sources).map(async (source) => - crawl(`${source}/routes`, "page", { min: 1 }) - ) - ) - ).flat() - - const validatedRoutes = ( - await Promise.all( - files.map(async (route) => { - const valid = await validateRoute(route, type === "link") - return valid ? route : null - }) - ) - ).filter(Boolean) as string[] - - if (!validatedRoutes.length) { - const code = `export default { - ${type}s: [], - }` - - return { module: generateModule(code), paths: [] } - } - - const importString = validatedRoutes - .map((path, index) => { - return type === "page" - ? `import RouteExt${index} from "${convertToImportPath(path)}";` - : `import { config as routeConfig${index} } from "${convertToImportPath( - path - )}";` - }) - .join("\n") - - const exportString = `export default { - ${type}s: [${validatedRoutes - .map((file, index) => { - return type === "page" - ? `{ path: "${createRoutePath(file)}", Component: RouteExt${index} }` - : `{ path: "${createRoutePath(file)}", ...routeConfig${index} }` - }) - .join(", ")}], - }` - - const code = `${importString}\n${exportString}` - - return { module: generateModule(code), paths: validatedRoutes } -} - -type LoadModuleOptions = - | { - type: "widget" - get: InjectionZone - } - | { - type: "route" - get: "page" | "link" - } - -export type MedusaVitePluginOptions = { - /** - * A list of directories to source extensions from. - */ - sources?: string[] -} + generateVirtualDisplayModule, + generateVirtualFormModule, + generateVirtualLinkModule, + generateVirtualMenuItemModule, + generateVirtualRouteModule, + generateVirtualWidgetModule, +} from "./virtual-modules" +import { + isResolvedVirtualModuleId, + isVirtualModuleId, + resolveVirtualId, + VirtualModule, + vmod, +} from "./vmod" +import { generateWidgetHash } from "./widgets" -export type MedusaVitePlugin = (config?: MedusaVitePluginOptions) => Vite.Plugin export const medusaVitePlugin: MedusaVitePlugin = (options) => { - const _extensionGraph = new Map>() + const hashMap = new Map() const _sources = new Set(options?.sources ?? []) - let server: Vite.ViteDevServer | undefined let watcher: Vite.FSWatcher | undefined - async function loadModule(options: LoadModuleOptions) { - switch (options.type) { - case "widget": { - return await generateWidgetEntrypoint(_sources, options.get) + function isFileInSources(file: string): boolean { + for (const source of _sources) { + if (file.startsWith(path.resolve(source))) { + return true } - case "route": - return await generateRouteEntrypoint(_sources, options.get) - default: - return null } + return false } - async function register(id: string, options: LoadModuleOptions) { - const result = await loadModule(options) - - if (!result) { - return - } - - const { module, paths } = result + async function loadVirtualModule( + config: ModuleConfig + ): Promise<{ code: string; map: SourceMap } | null> { + const hash = await config.hashGenerator(_sources) + hashMap.set(config.hashKey, hash) - for (const path of paths) { - const ids = _extensionGraph.get(path) || new Set() - ids.add(id) - _extensionGraph.set(path, ids) - } - - return module + return config.moduleGenerator(_sources) } - async function handleWidgetChange(file: string, event: "add" | "change") { - const { valid, zone } = await validateWidget(file) - const zoneValues = Array.isArray(zone) ? zone : [zone] - - if (event === "change") { - /** - * If the file is in the extension graph, and it has become - * invalid, we need to remove it from the graph and reload all modules - * that import the widget. - */ - if (!valid) { - const extensionIds = _extensionGraph.get(file) - _extensionGraph.delete(file) - - if (!extensionIds) { - return - } - - for (const moduleId of extensionIds) { - const module = server?.moduleGraph.getModuleById(moduleId) - - if (module) { - await server?.reloadModule(module) - } - } - - return - } - - /** - * If the file is not in the extension graph, we need to add it. - * We also need to reload all modules that import the widget. - */ - if (!_extensionGraph.has(file)) { - const imports = new Set() - - for (const zoneValue of zoneValues) { - const zonePath = getWidgetImport(zoneValue) - const moduleId = getVirtualId(zonePath) - const resolvedModuleId = resolveVirtualId(moduleId) - const module = server?.moduleGraph.getModuleById(resolvedModuleId) - if (module) { - imports.add(resolvedModuleId) - await server?.reloadModule(module) - } - } - - _extensionGraph.set(file, imports) - } - - if (_extensionGraph.has(file)) { - const modules = _extensionGraph.get(file) - - if (!modules) { - return - } - - for (const moduleId of modules) { - const module = server?.moduleGraph.getModuleById(moduleId) - - if (!module || !module.id) { - continue - } - - const matchedInjectionZone = getWidgetZone(module.id) - - /** - * If the widget is imported in a module that does not match the new - * zone value, we need to reload the module, so the widget will be removed. - */ - if (!zoneValues.includes(matchedInjectionZone)) { - modules.delete(moduleId) - await server?.reloadModule(module) - } - } - - const imports = new Set(modules) - - /** - * If the widget is not currently being imported by the virtual module that - * matches its zone value, we need to reload the module, so the widget will be added. - */ - for (const zoneValue of zoneValues) { - const zonePath = getWidgetImport(zoneValue) - const moduleId = getVirtualId(zonePath) - const resolvedModuleId = resolveVirtualId(moduleId) - - if (!modules.has(resolvedModuleId)) { - const module = server?.moduleGraph.getModuleById(resolvedModuleId) - if (module) { - imports.add(resolvedModuleId) - await server?.reloadModule(module) - } - } - } - - _extensionGraph.set(file, imports) - } - } - - if (event === "add") { - /** - * If a new file is added in /admin/widgets, but it is not valid, - * we don't need to do anything. - */ - if (!valid) { - return - } - - /** - * If a new file is added in /admin/widgets, and it is valid, we need to - * add it to the extension graph and reload all modules that need to import - * the widget so that they can be updated with the new widget. - */ - const imports = new Set() - - for (const zoneValue of zoneValues) { - const zonePath = getWidgetImport(zoneValue) - const moduleId = getVirtualId(zonePath) - const resolvedModuleId = resolveVirtualId(moduleId) - - const module = server?.moduleGraph.getModuleById(resolvedModuleId) - - if (module) { - imports.add(resolvedModuleId) - await server?.reloadModule(module) - } - } - - _extensionGraph.set(file, imports) - } - } - - async function handleRouteChange(file: string, event: "add" | "change") { - const valid = await validateRoute(file) - - if (event === "change") { - /** - * If the file is in the extension graph, and it has become - * invalid, we need to remove it from the graph and reload all modules - * that import the route. - */ - if (!valid) { - const extensionIds = _extensionGraph.get(file) - _extensionGraph.delete(file) - - if (!extensionIds) { - return - } - - for (const moduleId of extensionIds) { - const module = server?.moduleGraph.getModuleById(moduleId) - - if (module) { - await server?.reloadModule(module) - } - } - - return - } - - /** - * If the file is not in the extension graph, we need to add it. - * We also need to reload all modules that import the route. - */ - if (!_extensionGraph.has(file)) { - const imports = new Set() - - for (const resolvedModuleId of RESOLVED_ROUTE_MODULES) { - const module = server?.moduleGraph.getModuleById(resolvedModuleId) - if (module) { - imports.add(resolvedModuleId) - await server?.reloadModule(module) - } - } - - _extensionGraph.set(file, imports) - } - } - - if (event === "add") { - /** - * If a new file is added in /admin/routes, but it is not valid, - * we don't need to do anything. - */ - if (!valid) { - return - } - - const imports = new Set() - - for (const resolvedModuleId of RESOLVED_ROUTE_MODULES) { - const module = server?.moduleGraph.getModuleById(resolvedModuleId) - if (module) { - imports.add(resolvedModuleId) - await server?.reloadModule(module) + async function handleFileChange( + server: Vite.ViteDevServer, + config: WatcherConfig + ) { + const hashes = await config.hashGenerator(_sources) + + for (const module of config.modules) { + const newHash = hashes[module.hashKey] + if (newHash !== hashMap.get(module.virtualModule)) { + const moduleToReload = server.moduleGraph.getModuleById( + module.resolvedModule + ) + if (moduleToReload) { + await server.reloadModule(moduleToReload) } - } - - _extensionGraph.set(file, imports) - } - } - - async function handleAddOrChange(path: string, event: "add" | "change") { - const type = getModuleType(path) - - switch (type) { - case "widget": - await handleWidgetChange(path, event) - break - case "route": - await handleRouteChange(path, event) - break - default: - // In all other cases we don't need to do anything. - break - } - } - - async function handleUnlink(path: string) { - const moduleIds = _extensionGraph.get(path) - _extensionGraph.delete(path) - - if (!moduleIds) { - return - } - - for (const moduleId of moduleIds) { - const module = server?.moduleGraph.getModuleById(moduleId) - - if (module) { - await server?.reloadModule(module) + hashMap.set(module.virtualModule, newHash) } } } @@ -779,47 +69,41 @@ export const medusaVitePlugin: MedusaVitePlugin = (options) => { return { name: "@medusajs/admin-vite-plugin", enforce: "pre", - configureServer(_server) { - server = _server - watcher = _server.watcher + configureServer(server) { + watcher = server.watcher + watcher?.add(Array.from(_sources)) - _sources.forEach((source) => { - watcher?.add(source) - }) + watcher?.on("all", async (_event, file) => { + if (!isFileInSources(file)) { + return + } - watcher.on("all", async (event, path) => { - switch (event) { - case "add": - case "change": { - await handleAddOrChange(path, event) - break + for (const config of watcherConfigs) { + if (isFileInAdminSubdirectory(file, config.subdirectory)) { + await handleFileChange(server, config) } - case "unlinkDir": - case "unlink": - await handleUnlink(path) - break - default: - break } }) }, resolveId(id) { - if (VIRTUAL_MODULES.includes(id)) { - return resolveVirtualId(id) + if (!isVirtualModuleId(id)) { + return null } - return null + return resolveVirtualId(id) }, async load(id) { - if (RESOLVED_WIDGET_MODULES.includes(id)) { - const zone = getWidgetZone(id) - return register(id, { type: "widget", get: zone }) + if (!isResolvedVirtualModuleId(id)) { + return null } - if (RESOLVED_ROUTE_MODULES.includes(id)) { - const type = id.includes("link") ? "link" : "page" - return register(id, { type: "route", get: type }) + const config = loadConfigs[id] + + if (!config) { + return null } + + return loadVirtualModule(config) }, async closeBundle() { if (watcher) { @@ -828,3 +112,112 @@ export const medusaVitePlugin: MedusaVitePlugin = (options) => { }, } } + +type ModuleConfig = { + hashGenerator: (sources: Set) => Promise + moduleGenerator: ( + sources: Set + ) => Promise<{ code: string; map: SourceMap }> + hashKey: VirtualModule +} + +const loadConfigs: Record = { + [vmod.resolved.widget]: { + hashGenerator: async (sources) => generateWidgetHash(sources), + moduleGenerator: async (sources) => generateVirtualWidgetModule(sources), + hashKey: vmod.virtual.widget, + }, + [vmod.resolved.link]: { + hashGenerator: async (sources) => + (await generateCustomFieldHashes(sources)).linkHash, + moduleGenerator: async (sources) => generateVirtualLinkModule(sources), + hashKey: vmod.virtual.link, + }, + [vmod.resolved.form]: { + hashGenerator: async (sources) => + (await generateCustomFieldHashes(sources)).formHash, + moduleGenerator: async (sources) => generateVirtualFormModule(sources), + hashKey: vmod.virtual.form, + }, + [vmod.resolved.display]: { + hashGenerator: async (sources) => + (await generateCustomFieldHashes(sources)).displayHash, + moduleGenerator: async (sources) => generateVirtualDisplayModule(sources), + hashKey: vmod.virtual.display, + }, + [vmod.resolved.route]: { + hashGenerator: async (sources) => + (await generateRouteHashes(sources)).defaultExportHash, + moduleGenerator: async (sources) => generateVirtualRouteModule(sources), + hashKey: vmod.virtual.route, + }, + [vmod.resolved.menuItem]: { + hashGenerator: async (sources) => + (await generateRouteHashes(sources)).configHash, + moduleGenerator: async (sources) => generateVirtualMenuItemModule(sources), + hashKey: vmod.virtual.menuItem, + }, +} + +type WatcherConfig = { + subdirectory: AdminSubdirectory + hashGenerator: (sources: Set) => Promise> + modules: { + virtualModule: VirtualModule + resolvedModule: string + hashKey: string + }[] +} + +const watcherConfigs: WatcherConfig[] = [ + { + subdirectory: "routes", + hashGenerator: async (sources) => generateRouteHashes(sources), + modules: [ + { + virtualModule: vmod.virtual.route, + resolvedModule: vmod.resolved.route, + hashKey: "defaultExportHash", + }, + { + virtualModule: vmod.virtual.menuItem, + resolvedModule: vmod.resolved.menuItem, + hashKey: "configHash", + }, + ], + }, + { + subdirectory: "widgets", + hashGenerator: async (sources) => ({ + widgetConfigHash: await generateWidgetHash(sources), + }), + modules: [ + { + virtualModule: vmod.virtual.widget, + resolvedModule: vmod.resolved.widget, + hashKey: "widgetConfigHash", + }, + ], + }, + { + subdirectory: "custom-fields", + hashGenerator: async (sources) => generateCustomFieldHashes(sources), + modules: [ + { + virtualModule: vmod.virtual.link, + resolvedModule: vmod.resolved.link, + hashKey: "linkHash", + }, + { + virtualModule: vmod.virtual.form, + resolvedModule: vmod.resolved.form, + hashKey: "formHash", + }, + { + virtualModule: vmod.virtual.display, + resolvedModule: vmod.resolved.display, + hashKey: "displayHash", + }, + ], + }, +] diff --git a/packages/admin/admin-vite-plugin/src/routes/generate-menu-items.ts b/packages/admin/admin-vite-plugin/src/routes/generate-menu-items.ts new file mode 100644 index 0000000000000..7c738903462c4 --- /dev/null +++ b/packages/admin/admin-vite-plugin/src/routes/generate-menu-items.ts @@ -0,0 +1,157 @@ +import fs from "fs/promises" +import { outdent } from "outdent" +import { isIdentifier, isObjectProperty, parse, traverse } from "../babel" +import { logger } from "../logger" +import { crawl, getConfigObjectProperties, getParserOptions } from "../utils" +import { getRoute } from "./helpers" + +type MenuItem = { + icon?: string + label: string + path: string +} + +type MenuItemResult = { + import: string + menuItem: MenuItem +} + +export async function generateMenuItems(sources: Set) { + const files = await getFilesFromSources(sources) + const results = await getMenuItemResults(files) + + const imports = results.map((result) => result.import).flat() + const code = generateCode(results) + + return { + imports, + code, + } +} + +function generateCode(results: MenuItemResult[]): string { + return outdent` + menuItems: [ + ${results + .map((result) => formatMenuItem(result.menuItem)) + .join(",\n")} + ] + } + ` +} + +function formatMenuItem(route: MenuItem): string { + return `{ + label: ${route.label}, + icon: ${route.icon ? route.icon : "undefined"}, + path: "${route.path}", + }` +} + +async function getFilesFromSources(sources: Set): Promise { + const files = ( + await Promise.all( + Array.from(sources).map(async (source) => + crawl(`${source}/routes`, "page", { min: 1 }) + ) + ) + ).flat() + return files +} + +async function getMenuItemResults(files: string[]): Promise { + const results = await Promise.all(files.map(parseFile)) + return results.filter((item): item is MenuItemResult => item !== null) +} + +async function parseFile( + file: string, + index: number +): Promise { + const config = await getRouteConfig(file) + + if (!config) { + return null + } + + if (!config.label) { + logger.warn(`Config is missing a label.`, { + file, + }) + } + + const import_ = generateImport(file, index) + const menuItem = generateMenuItem(config, file, index) + + return { + import: import_, + menuItem, + } +} + +function generateImport(file: string, index: number): string { + return `import { config as ${generateRouteConfigName(index)} } from "${file}"` +} + +function generateMenuItem( + config: { label: boolean; icon: boolean }, + file: string, + index: number +): MenuItem { + const configName = generateRouteConfigName(index) + const routePath = getRoute(file) + + return { + label: `${configName}.label`, + icon: config.icon ? `${configName}.icon` : undefined, + path: routePath, + } +} + +async function getRouteConfig( + file: string +): Promise<{ label: boolean; icon: boolean } | null> { + const code = await fs.readFile(file, "utf-8") + const ast = parse(code, getParserOptions(file)) + + let config: { label: boolean; icon: boolean } | null = null + + try { + traverse(ast, { + ExportNamedDeclaration(path) { + const properties = getConfigObjectProperties(path) + + if (!properties) { + return + } + + const hasLabel = properties.some( + (prop) => + isObjectProperty(prop) && isIdentifier(prop.key, { name: "label" }) + ) + + if (!hasLabel) { + return + } + + const hasIcon = properties.some( + (prop) => + isObjectProperty(prop) && isIdentifier(prop.key, { name: "icon" }) + ) + + config = { label: hasLabel, icon: hasIcon } + }, + }) + } catch (e) { + logger.error(`An error occurred while traversing the file.`, { + file, + error: e, + }) + } + + return config +} + +function generateRouteConfigName(index: number): string { + return `RouteConfig${index}` +} diff --git a/packages/admin/admin-vite-plugin/src/routes/generate-route-hashes.ts b/packages/admin/admin-vite-plugin/src/routes/generate-route-hashes.ts new file mode 100644 index 0000000000000..feaacc228afb4 --- /dev/null +++ b/packages/admin/admin-vite-plugin/src/routes/generate-route-hashes.ts @@ -0,0 +1,71 @@ +import fs from "fs/promises" +import { parse, traverse } from "../babel" +import { logger } from "../logger" +import { + crawl, + generateHash, + getConfigObjectProperties, + getParserOptions, +} from "../utils" + +export async function generateRouteHashes( + sources: Set +): Promise<{ defaultExportHash: string; configHash: string }> { + const files = await getFilesFromSources(sources) + const contents = await Promise.all(files.map(getRouteContents)) + + const defaultExportContents = contents + .map((c) => c.defaultExport) + .filter(Boolean) + const configContents = contents.map((c) => c.config).filter(Boolean) + + const totalDefaultExportContent = defaultExportContents.join("") + const totalConfigContent = configContents.join("") + + return { + defaultExportHash: generateHash(totalDefaultExportContent), + configHash: generateHash(totalConfigContent), + } +} + +async function getFilesFromSources(sources: Set): Promise { + return ( + await Promise.all( + Array.from(sources).map(async (source) => + crawl(`${source}/routes`, "page", { min: 1 }) + ) + ) + ).flat() +} + +async function getRouteContents( + file: string +): Promise<{ defaultExport: string | null; config: string | null }> { + const code = await fs.readFile(file, "utf-8") + const ast = parse(code, getParserOptions(file)) + + let defaultExportContent: string | null = null + let configContent: string | null = null + + try { + traverse(ast, { + ExportDefaultDeclaration(path) { + defaultExportContent = code.slice(path.node.start!, path.node.end!) + }, + ExportNamedDeclaration(path) { + const properties = getConfigObjectProperties(path) + if (properties) { + configContent = code.slice(path.node.start!, path.node.end!) + } + }, + }) + } catch (e) { + logger.error( + `An error occurred while processing ${file}. See the below error for more details:\n${e}`, + { file, error: e } + ) + return { defaultExport: null, config: null } + } + + return { defaultExport: defaultExportContent, config: configContent } +} diff --git a/packages/admin/admin-vite-plugin/src/routes/generate-routes.ts b/packages/admin/admin-vite-plugin/src/routes/generate-routes.ts new file mode 100644 index 0000000000000..ec8141cbd9595 --- /dev/null +++ b/packages/admin/admin-vite-plugin/src/routes/generate-routes.ts @@ -0,0 +1,155 @@ +import fs from "fs/promises" +import { outdent } from "outdent" +import { parse } from "../babel" +import { logger } from "../logger" +import { + crawl, + getParserOptions, + hasDefaultExport, + normalizePath, +} from "../utils" +import { getRoute } from "./helpers" + +type Route = { + Component: string + loader?: string + path: string +} + +type RouteResult = { + imports: string[] + route: Route +} + +export async function generateRoutes(sources: Set) { + const files = await getFilesFromSources(sources) + const results = await getRouteResults(files) + + const imports = results.map((result) => result.imports).flat() + const code = generateCode(results) + + return { + imports, + code, + } +} + +function generateCode(results: RouteResult[]): string { + return outdent` + routes: [ + ${results.map((result) => formatRoute(result.route)).join(",\n")} + ] + } + ` +} + +function formatRoute(route: Route): string { + return `{ + Component: ${route.Component}, + loader: ${route.loader ? route.loader : "undefined"}, + path: "${route.path}", + }` +} + +async function getFilesFromSources(sources: Set): Promise { + const files = ( + await Promise.all( + Array.from(sources).map(async (source) => + crawl(`${source}/routes`, "page", { min: 1 }) + ) + ) + ).flat() + return files +} + +async function getRouteResults(files: string[]): Promise { + const results = (await Promise.all(files.map(parseFile))).filter( + (result): result is RouteResult => result !== null + ) + return results +} + +async function parseFile( + file: string, + index: number +): Promise { + if (!(await isValidRouteFile(file))) { + return null + } + + const loaderPath = await getLoader(file) + const routePath = getRoute(file) + + const imports = generateImports(file, loaderPath, index) + const route = generateRoute(routePath, loaderPath, index) + + return { + imports, + route, + } +} + +async function isValidRouteFile(file: string): Promise { + const code = await fs.readFile(file, "utf-8") + const ast = parse(code, getParserOptions(file)) + + try { + return await hasDefaultExport(ast) + } catch (e) { + logger.error( + `An error occurred while checking for a default export in ${file}. The file will be ignored. See the below error for more details:\n${e}` + ) + return false + } +} + +async function getLoader(file: string): Promise { + const loaderExtensions = ["ts", "js", "tsx", "jsx"] + for (const ext of loaderExtensions) { + const loaderPath = file.replace(/\/page\.(tsx|jsx)/, `/loader.${ext}`) + const exists = await fs.stat(loaderPath).catch(() => null) + if (exists) { + return loaderPath + } + } + return null +} + +function generateImports( + file: string, + loader: string | null, + index: number +): string[] { + const imports: string[] = [] + const route = generateRouteComponentName(index) + const importPath = normalizePath(file) + + imports.push(`import ${route} from "${importPath}"`) + + if (loader) { + const loaderName = generateRouteLoaderName(index) + imports.push(`import ${loaderName} from "${normalizePath(loader)}"`) + } + + return imports +} + +function generateRoute( + route: string, + loader: string | null, + index: number +): Route { + return { + Component: generateRouteComponentName(index), + loader: loader ? generateRouteLoaderName(index) : undefined, + path: route, + } +} + +function generateRouteComponentName(index: number): string { + return `RouteComponent${index}` +} + +function generateRouteLoaderName(index: number): string { + return `RouteLoader${index}` +} diff --git a/packages/admin/admin-vite-plugin/src/routes/helpers.ts b/packages/admin/admin-vite-plugin/src/routes/helpers.ts new file mode 100644 index 0000000000000..cecf2fd4b54b8 --- /dev/null +++ b/packages/admin/admin-vite-plugin/src/routes/helpers.ts @@ -0,0 +1,9 @@ +import { normalizePath } from "../utils" + +export function getRoute(file: string): string { + const importPath = normalizePath(file) + return importPath + .replace(/.*\/admin\/(routes)/, "") + .replace(/\[([^\]]+)\]/g, ":$1") + .replace(/\/page\.(tsx|jsx)/, "") +} diff --git a/packages/admin/admin-vite-plugin/src/routes/index.ts b/packages/admin/admin-vite-plugin/src/routes/index.ts new file mode 100644 index 0000000000000..793881a7b381a --- /dev/null +++ b/packages/admin/admin-vite-plugin/src/routes/index.ts @@ -0,0 +1,3 @@ +export * from "./generate-menu-items" +export * from "./generate-route-hashes" +export * from "./generate-routes" diff --git a/packages/admin/admin-vite-plugin/src/types.ts b/packages/admin/admin-vite-plugin/src/types.ts new file mode 100644 index 0000000000000..a14af187e9818 --- /dev/null +++ b/packages/admin/admin-vite-plugin/src/types.ts @@ -0,0 +1,61 @@ +import type { + CustomFieldFormTab, + CustomFieldModel, + CustomFieldZone, + InjectionZone, +} from "@medusajs/admin-shared" +import type * as Vite from "vite" + +export type ExtensionGraph = Map> + +export type CustomFieldLinkPath = { + model: CustomFieldModel +} + +export type CustomFieldDisplayPath = { + model: CustomFieldModel + zone: CustomFieldZone +} + +export type CustomFieldConfigPath = { + model: CustomFieldModel + zone: CustomFieldZone +} + +export type CustomFieldFieldPath = { + model: CustomFieldModel + zone: CustomFieldZone + tab?: CustomFieldFormTab +} + +export type LoadModuleOptions = + | { + type: "widget" + get: InjectionZone + } + | { + type: "route" + get: "page" | "link" + } + | { + type: "link" + get: CustomFieldLinkPath + } + | { + type: "field" + get: CustomFieldFieldPath + } + | { + type: "config" + get: CustomFieldConfigPath + } + | { + type: "display" + get: CustomFieldDisplayPath + } + +export interface MedusaVitePluginOptions { + sources?: string[] +} + +export type MedusaVitePlugin = (config?: MedusaVitePluginOptions) => Vite.Plugin diff --git a/packages/admin/admin-vite-plugin/src/utils.ts b/packages/admin/admin-vite-plugin/src/utils.ts new file mode 100644 index 0000000000000..0522ef49a00f0 --- /dev/null +++ b/packages/admin/admin-vite-plugin/src/utils.ts @@ -0,0 +1,147 @@ +import { fdir } from "fdir" +import MagicString from "magic-string" +import crypto from "node:crypto" +import path from "path" +import { + File, + isCallExpression, + isIdentifier, + isObjectExpression, + isVariableDeclaration, + isVariableDeclarator, + ParseResult, + traverse, + type ExportNamedDeclaration, + type NodePath, + type ParserOptions, +} from "./babel" + +export function normalizePath(file: string) { + return path.normalize(file).split(path.sep).join("/") +} + +/** + * Returns the parser options for a given file. + */ +export function getParserOptions(file: string): ParserOptions { + const options: ParserOptions = { + sourceType: "module", + plugins: ["jsx"], + } + + if (file.endsWith(".tsx")) { + options.plugins?.push("typescript") + } + + return options +} + +/** + * Generates a module with a source map from a code string + */ +export function generateModule(code: string) { + const magicString = new MagicString(code) + + return { + code: magicString.toString(), + map: magicString.generateMap({ hires: true }), + } +} + +const VALID_FILE_EXTENSIONS = [".tsx", ".jsx"] + +/** + * Crawls a directory and returns all files that match the criteria. + */ +export async function crawl( + dir: string, + file?: string, + depth?: { min: number; max?: number } +) { + const dirDepth = dir.split(path.sep).length + + const crawler = new fdir() + .withBasePath() + .exclude((dirName) => dirName.startsWith("_")) + .filter((path) => { + return VALID_FILE_EXTENSIONS.some((ext) => path.endsWith(ext)) + }) + + if (file) { + crawler.filter((path) => { + return VALID_FILE_EXTENSIONS.some((ext) => path.endsWith(file + ext)) + }) + } + + if (depth) { + crawler.filter((file) => { + const pathDepth = file.split(path.sep).length - 1 + + if (depth.max && pathDepth > dirDepth + depth.max) { + return false + } + + if (pathDepth < dirDepth + depth.min) { + return false + } + + return true + }) + } + + return crawler.crawl(dir).withPromise() +} + +/** + * Extracts and returns the properties of a `config` object from a named export declaration. + */ +export function getConfigObjectProperties( + path: NodePath +) { + const declaration = path.node.declaration + + if (isVariableDeclaration(declaration)) { + const configDeclaration = declaration.declarations.find( + (d) => isVariableDeclarator(d) && isIdentifier(d.id, { name: "config" }) + ) + + if ( + configDeclaration && + isCallExpression(configDeclaration.init) && + configDeclaration.init.arguments.length > 0 && + isObjectExpression(configDeclaration.init.arguments[0]) + ) { + return configDeclaration.init.arguments[0].properties + } + } + + return null +} + +export async function hasDefaultExport( + ast: ParseResult +): Promise { + let hasDefaultExport = false + traverse(ast, { + ExportDefaultDeclaration() { + hasDefaultExport = true + }, + }) + return hasDefaultExport +} + +export function generateHash(content: string) { + return crypto.createHash("md5").update(content).digest("hex") +} + +const ADMIN_SUBDIRECTORIES = ["routes", "custom-fields", "widgets"] as const + +export type AdminSubdirectory = (typeof ADMIN_SUBDIRECTORIES)[number] + +export function isFileInAdminSubdirectory( + file: string, + subdirectory: AdminSubdirectory +): boolean { + const normalizedPath = normalizePath(file) + return normalizedPath.includes(`/src/admin/${subdirectory}/`) +} diff --git a/packages/admin/admin-vite-plugin/src/virtual-modules/generate-virtual-display-module.ts b/packages/admin/admin-vite-plugin/src/virtual-modules/generate-virtual-display-module.ts new file mode 100644 index 0000000000000..ec0c476a55de2 --- /dev/null +++ b/packages/admin/admin-vite-plugin/src/virtual-modules/generate-virtual-display-module.ts @@ -0,0 +1,17 @@ +import { outdent } from "outdent" +import { generateCustomFieldDisplays } from "../custom-fields" +import { generateModule } from "../utils" + +export async function generateVirtualDisplayModule(sources: Set) { + const displays = await generateCustomFieldDisplays(sources) + + const code = outdent` + ${displays.imports.join("\n")} + + export default { + ${displays.code} + } + ` + + return generateModule(code) +} diff --git a/packages/admin/admin-vite-plugin/src/virtual-modules/generate-virtual-form-module.ts b/packages/admin/admin-vite-plugin/src/virtual-modules/generate-virtual-form-module.ts new file mode 100644 index 0000000000000..e2ce152616bb0 --- /dev/null +++ b/packages/admin/admin-vite-plugin/src/virtual-modules/generate-virtual-form-module.ts @@ -0,0 +1,29 @@ +import outdent from "outdent" +import { generateCustomFieldForms } from "../custom-fields" +import { generateMenuItems } from "../routes" +import { generateModule } from "../utils" +import { generateWidgets } from "../widgets" + +export async function generateVirtualFormModule(sources: Set) { + const menuItems = await generateMenuItems(sources) + const widgets = await generateWidgets(sources) + const customFields = await generateCustomFieldForms(sources) + + const imports = [ + ...menuItems.imports, + ...widgets.imports, + ...customFields.imports, + ] + + const code = outdent` + ${imports.join("\n")} + + export default { + ${menuItems.code}, + ${widgets.code}, + ${customFields.code}, + } + ` + + return generateModule(code) +} diff --git a/packages/admin/admin-vite-plugin/src/virtual-modules/generate-virtual-link-module.ts b/packages/admin/admin-vite-plugin/src/virtual-modules/generate-virtual-link-module.ts new file mode 100644 index 0000000000000..ef37c4606e260 --- /dev/null +++ b/packages/admin/admin-vite-plugin/src/virtual-modules/generate-virtual-link-module.ts @@ -0,0 +1,17 @@ +import { outdent } from "outdent" +import { generateCustomFieldLinks } from "../custom-fields" +import { generateModule } from "../utils" + +export async function generateVirtualLinkModule(sources: Set) { + const links = await generateCustomFieldLinks(sources) + + const code = outdent` + ${links.imports.join("\n")} + + export default { + ${links.code} + } + ` + + return generateModule(code) +} diff --git a/packages/admin/admin-vite-plugin/src/virtual-modules/generate-virtual-menu-item-module.ts b/packages/admin/admin-vite-plugin/src/virtual-modules/generate-virtual-menu-item-module.ts new file mode 100644 index 0000000000000..b76e0acc9b93b --- /dev/null +++ b/packages/admin/admin-vite-plugin/src/virtual-modules/generate-virtual-menu-item-module.ts @@ -0,0 +1,18 @@ +import outdent from "outdent" + +import { generateMenuItems } from "../routes" +import { generateModule } from "../utils" + +export async function generateVirtualMenuItemModule(sources: Set) { + const menuItems = await generateMenuItems(sources) + + const code = outdent` + ${menuItems.imports.join("\n")} + + export default { + ${menuItems.code}, + } + ` + + return generateModule(code) +} diff --git a/packages/admin/admin-vite-plugin/src/virtual-modules/generate-virtual-route-module.ts b/packages/admin/admin-vite-plugin/src/virtual-modules/generate-virtual-route-module.ts new file mode 100644 index 0000000000000..91d44e5614a71 --- /dev/null +++ b/packages/admin/admin-vite-plugin/src/virtual-modules/generate-virtual-route-module.ts @@ -0,0 +1,19 @@ +import { outdent } from "outdent" +import { generateRoutes } from "../routes" +import { generateModule } from "../utils" + +export async function generateVirtualRouteModule(sources: Set) { + const routes = await generateRoutes(sources) + + const imports = [...routes.imports] + + const code = outdent` + ${imports.join("\n")} + + export default { + ${routes.code} + } + ` + + return generateModule(code) +} diff --git a/packages/admin/admin-vite-plugin/src/virtual-modules/generate-virtual-widget-module.ts b/packages/admin/admin-vite-plugin/src/virtual-modules/generate-virtual-widget-module.ts new file mode 100644 index 0000000000000..fef19bb4c7fc9 --- /dev/null +++ b/packages/admin/admin-vite-plugin/src/virtual-modules/generate-virtual-widget-module.ts @@ -0,0 +1,19 @@ +import outdent from "outdent" +import { generateModule } from "../utils" +import { generateWidgets } from "../widgets" + +export async function generateVirtualWidgetModule(sources: Set) { + const widgets = await generateWidgets(sources) + + const imports = [...widgets.imports] + + const code = outdent` + ${imports.join("\n")} + + export default { + ${widgets.code}, + } + ` + + return generateModule(code) +} diff --git a/packages/admin/admin-vite-plugin/src/virtual-modules/index.ts b/packages/admin/admin-vite-plugin/src/virtual-modules/index.ts new file mode 100644 index 0000000000000..b18b9589feb1d --- /dev/null +++ b/packages/admin/admin-vite-plugin/src/virtual-modules/index.ts @@ -0,0 +1,6 @@ +export * from "./generate-virtual-display-module" +export * from "./generate-virtual-form-module" +export * from "./generate-virtual-link-module" +export * from "./generate-virtual-menu-item-module" +export * from "./generate-virtual-route-module" +export * from "./generate-virtual-widget-module" diff --git a/packages/admin/admin-vite-plugin/src/vmod.ts b/packages/admin/admin-vite-plugin/src/vmod.ts new file mode 100644 index 0000000000000..43a32201a7844 --- /dev/null +++ b/packages/admin/admin-vite-plugin/src/vmod.ts @@ -0,0 +1,80 @@ +import { + DISPLAY_VIRTUAL_MODULE, + FORM_VIRTUAL_MODULE, + LINK_VIRTUAL_MODULE, + MENU_ITEM_VIRTUAL_MODULE, + ROUTE_VIRTUAL_MODULE, + WIDGET_VIRTUAL_MODULE, +} from "@medusajs/admin-shared" + +const RESOLVED_LINK_VIRTUAL_MODULE = `\0${LINK_VIRTUAL_MODULE}` +const RESOLVED_FORM_VIRTUAL_MODULE = `\0${FORM_VIRTUAL_MODULE}` +const RESOLVED_DISPLAY_VIRTUAL_MODULE = `\0${DISPLAY_VIRTUAL_MODULE}` +const RESOLVED_ROUTE_VIRTUAL_MODULE = `\0${ROUTE_VIRTUAL_MODULE}` +const RESOLVED_MENU_ITEM_VIRTUAL_MODULE = `\0${MENU_ITEM_VIRTUAL_MODULE}` +const RESOLVED_WIDGET_VIRTUAL_MODULE = `\0${WIDGET_VIRTUAL_MODULE}` + +const VIRTUAL_MODULES = [ + LINK_VIRTUAL_MODULE, + FORM_VIRTUAL_MODULE, + DISPLAY_VIRTUAL_MODULE, + ROUTE_VIRTUAL_MODULE, + MENU_ITEM_VIRTUAL_MODULE, + WIDGET_VIRTUAL_MODULE, +] as const + +const RESOLVED_VIRTUAL_MODULES = [ + RESOLVED_LINK_VIRTUAL_MODULE, + RESOLVED_FORM_VIRTUAL_MODULE, + RESOLVED_DISPLAY_VIRTUAL_MODULE, + RESOLVED_ROUTE_VIRTUAL_MODULE, + RESOLVED_MENU_ITEM_VIRTUAL_MODULE, + RESOLVED_WIDGET_VIRTUAL_MODULE, +] as const + +export function resolveVirtualId(id: string) { + return `\0${id}` +} + +export function isVirtualModuleId(id: string): id is VirtualModule { + return VIRTUAL_MODULES.includes(id as VirtualModule) +} + +export function isResolvedVirtualModuleId( + id: string +): id is (typeof RESOLVED_VIRTUAL_MODULES)[number] { + return RESOLVED_VIRTUAL_MODULES.includes( + id as (typeof RESOLVED_VIRTUAL_MODULES)[number] + ) +} + +export type VirtualModule = + | typeof LINK_VIRTUAL_MODULE + | typeof FORM_VIRTUAL_MODULE + | typeof DISPLAY_VIRTUAL_MODULE + | typeof ROUTE_VIRTUAL_MODULE + | typeof MENU_ITEM_VIRTUAL_MODULE + | typeof WIDGET_VIRTUAL_MODULE + +const resolvedVirtualModuleIds = { + link: RESOLVED_LINK_VIRTUAL_MODULE, + form: RESOLVED_FORM_VIRTUAL_MODULE, + display: RESOLVED_DISPLAY_VIRTUAL_MODULE, + route: RESOLVED_ROUTE_VIRTUAL_MODULE, + menuItem: RESOLVED_MENU_ITEM_VIRTUAL_MODULE, + widget: RESOLVED_WIDGET_VIRTUAL_MODULE, +} as const + +const virtualModuleIds = { + link: LINK_VIRTUAL_MODULE, + form: FORM_VIRTUAL_MODULE, + display: DISPLAY_VIRTUAL_MODULE, + route: ROUTE_VIRTUAL_MODULE, + menuItem: MENU_ITEM_VIRTUAL_MODULE, + widget: WIDGET_VIRTUAL_MODULE, +} as const + +export const vmod = { + resolved: resolvedVirtualModuleIds, + virtual: virtualModuleIds, +} diff --git a/packages/admin/admin-vite-plugin/src/widgets/generate-widget-hash.ts b/packages/admin/admin-vite-plugin/src/widgets/generate-widget-hash.ts new file mode 100644 index 0000000000000..a8c1539471105 --- /dev/null +++ b/packages/admin/admin-vite-plugin/src/widgets/generate-widget-hash.ts @@ -0,0 +1,59 @@ +import fs from "fs/promises" +import { File, parse, ParseResult, traverse } from "../babel" +import { logger } from "../logger" +import { + generateHash, + getConfigObjectProperties, + getParserOptions, +} from "../utils" +import { getWidgetFilesFromSources } from "./helpers" + +export async function generateWidgetHash( + sources: Set +): Promise { + const files = await getWidgetFilesFromSources(sources) + const contents = await Promise.all(files.map(getWidgetContents)) + const totalContent = contents + .flatMap(({ config, defaultExport }) => [config, defaultExport]) + .filter(Boolean) + .join("") + + return generateHash(totalContent) +} + +async function getWidgetContents( + file: string +): Promise<{ config: string | null; defaultExport: string | null }> { + const code = await fs.readFile(file, "utf-8") + let ast: ParseResult + + try { + ast = parse(code, getParserOptions(file)) + } catch (e) { + logger.error( + `An error occurred while parsing the file. Due to the error we cannot validate whether the widget has changed. If your changes aren't correctly reflected try restarting the dev server.`, + { + file, + error: e, + } + ) + return { config: null, defaultExport: null } + } + + let configContent: string | null = null + let defaultExportContent: string | null = null + + traverse(ast, { + ExportNamedDeclaration(path) { + const properties = getConfigObjectProperties(path) + if (properties) { + configContent = code.slice(path.node.start!, path.node.end!) + } + }, + ExportDefaultDeclaration(path) { + defaultExportContent = code.slice(path.node.start!, path.node.end!) + }, + }) + + return { config: configContent, defaultExport: defaultExportContent } +} diff --git a/packages/admin/admin-vite-plugin/src/widgets/generate-widgets.ts b/packages/admin/admin-vite-plugin/src/widgets/generate-widgets.ts new file mode 100644 index 0000000000000..5a96dbf973e57 --- /dev/null +++ b/packages/admin/admin-vite-plugin/src/widgets/generate-widgets.ts @@ -0,0 +1,203 @@ +import { InjectionZone, isValidInjectionZone } from "@medusajs/admin-shared" +import fs from "fs/promises" +import outdent from "outdent" +import { + File, + isArrayExpression, + isStringLiteral, + isTemplateLiteral, + ObjectProperty, + parse, + ParseResult, + traverse, +} from "../babel" +import { logger } from "../logger" +import { + getConfigObjectProperties, + getParserOptions, + hasDefaultExport, +} from "../utils" +import { getWidgetFilesFromSources } from "./helpers" + +type WidgetConfig = { + Component: string + zone: InjectionZone[] +} + +type ParsedWidgetConfig = { + import: string + widget: WidgetConfig +} + +export async function generateWidgets(sources: Set) { + const files = await getWidgetFilesFromSources(sources) + const results = await getWidgetResults(files) + + const imports = results.map((r) => r.import) + const code = generateCode(results) + + return { + imports, + code, + } +} + +async function getWidgetResults( + files: string[] +): Promise { + return (await Promise.all(files.map(parseFile))).filter( + (r) => r !== null + ) as ParsedWidgetConfig[] +} + +function generateCode(results: ParsedWidgetConfig[]): string { + return outdent` + widgets: [ + ${results.map((r) => formatWidget(r.widget)).join(",\n")} + ] + ` +} + +function formatWidget(widget: WidgetConfig): string { + return outdent` + { + Component: ${widget.Component}, + zone: [${widget.zone.map((z) => `"${z}"`).join(", ")}] + } + ` +} + +async function parseFile( + file: string, + index: number +): Promise { + const code = await fs.readFile(file, "utf-8") + let ast: ParseResult + + try { + ast = parse(code, getParserOptions(file)) + } catch (e) { + logger.error(`An error occurred while parsing the file.`, { + file, + error: e, + }) + return null + } + + let fileHasDefaultExport = false + + try { + fileHasDefaultExport = await hasDefaultExport(ast) + } catch (e) { + logger.error(`An error occurred while checking for a default export.`, { + file, + error: e, + }) + return null + } + + if (!fileHasDefaultExport) { + return null + } + + let zone: InjectionZone[] | null + + try { + zone = await getWidgetZone(ast, file) + } catch (e) { + logger.error(`An error occurred while traversing the file.`, { + file, + error: e, + }) + return null + } + + if (!zone) { + logger.warn(`'zone' property is missing from the widget config.`, { file }) + return null + } + + const import_ = generateImport(file, index) + const widget = generateWidget(zone, index) + + return { + widget, + import: import_, + } +} + +function generateWidgetComponentName(index: number): string { + return `WidgetComponent${index}` +} + +function generateWidgetConfigName(index: number): string { + return `WidgetConfig${index}` +} + +function generateImport(file: string, index: number): string { + return `import ${generateWidgetComponentName( + index + )}, { config as ${generateWidgetConfigName(index)} } from "${file}"` +} + +function generateWidget(zone: InjectionZone[], index: number): WidgetConfig { + return { + Component: generateWidgetComponentName(index), + zone: zone, + } +} + +async function getWidgetZone( + ast: ParseResult, + file: string +): Promise { + const zones: string[] = [] + + traverse(ast, { + ExportNamedDeclaration(path) { + const properties = getConfigObjectProperties(path) + if (!properties) { + return + } + + const zoneProperty = properties.find( + (p) => + p.type === "ObjectProperty" && + p.key.type === "Identifier" && + p.key.name === "zone" + ) as ObjectProperty | undefined + + if (!zoneProperty) { + logger.warn(`'zone' property is missing from the widget config.`, { + file, + }) + return + } + + if (isTemplateLiteral(zoneProperty.value)) { + logger.warn( + `'zone' property cannot be a template literal (e.g. \`product.details.after\`).`, + { file } + ) + return + } + + if (isStringLiteral(zoneProperty.value)) { + zones.push(zoneProperty.value.value) + } else if (isArrayExpression(zoneProperty.value)) { + const values: string[] = [] + + for (const element of zoneProperty.value.elements) { + if (isStringLiteral(element)) { + values.push(element.value) + } + } + + zones.push(...values) + } + }, + }) + + const validatedZones = zones.filter(isValidInjectionZone) + return validatedZones.length > 0 ? validatedZones : null +} diff --git a/packages/admin/admin-vite-plugin/src/widgets/helpers.ts b/packages/admin/admin-vite-plugin/src/widgets/helpers.ts new file mode 100644 index 0000000000000..6e05e8551f39a --- /dev/null +++ b/packages/admin/admin-vite-plugin/src/widgets/helpers.ts @@ -0,0 +1,11 @@ +import { crawl } from "../utils" + +export async function getWidgetFilesFromSources( + sources: Set +): Promise { + return ( + await Promise.all( + Array.from(sources).map(async (source) => crawl(`${source}/widgets`)) + ) + ).flat() +} diff --git a/packages/admin/admin-vite-plugin/src/widgets/index.ts b/packages/admin/admin-vite-plugin/src/widgets/index.ts new file mode 100644 index 0000000000000..e72cb7037fbff --- /dev/null +++ b/packages/admin/admin-vite-plugin/src/widgets/index.ts @@ -0,0 +1,2 @@ +export * from "./generate-widget-hash" +export * from "./generate-widgets" diff --git a/packages/admin/dashboard/package.json b/packages/admin/dashboard/package.json index 5bb3dcaa5fd01..4ecf82980a183 100644 --- a/packages/admin/dashboard/package.json +++ b/packages/admin/dashboard/package.json @@ -21,7 +21,9 @@ "./css": { "import": "./dist/app.css", "require": "./dist/app.css" - } + }, + "./root": "./", + "./package.json": "./package.json" }, "repository": { "type": "git", @@ -29,8 +31,10 @@ "directory": "packages/admin/dashboard" }, "files": [ - "dist", - "package.json" + "package.json", + "src", + "index.html", + "dist" ], "dependencies": { "@ariakit/react": "^0.4.1", @@ -38,6 +42,7 @@ "@dnd-kit/sortable": "^8.0.0", "@hookform/error-message": "^2.0.1", "@hookform/resolvers": "3.4.2", + "@medusajs/admin-shared": "0.0.1", "@medusajs/icons": "1.2.1", "@medusajs/js-sdk": "0.0.1", "@medusajs/ui": "3.0.0", @@ -81,7 +86,8 @@ "tailwindcss": "^3.4.1", "tsup": "^8.0.2", "typescript": "5.2.2", - "vite": "^5.2.11" + "vite": "^5.2.11", + "vite-plugin-inspect": "^0.8.7" }, "packageManager": "yarn@3.2.1" } diff --git a/packages/admin/dashboard/src/app.tsx b/packages/admin/dashboard/src/app.tsx index 901b8c2b3aae8..855bcfbaf02fc 100644 --- a/packages/admin/dashboard/src/app.tsx +++ b/packages/admin/dashboard/src/app.tsx @@ -1,30 +1,26 @@ -import { Toaster, TooltipProvider } from "@medusajs/ui" -import { QueryClientProvider } from "@tanstack/react-query" -import { HelmetProvider } from "react-helmet-async" - -import { I18n } from "./components/utilities/i18n" -import { queryClient } from "./lib/query-client" -import { I18nProvider } from "./providers/i18n-provider" +import { DashboardExtensionManager } from "./extensions" +import { Providers } from "./providers/providers" import { RouterProvider } from "./providers/router-provider" -import { ThemeProvider } from "./providers/theme-provider" + +import displayModule from "virtual:medusa/displays" +import formModule from "virtual:medusa/forms" +import menuItemModule from "virtual:medusa/menu-items" +import widgetModule from "virtual:medusa/widgets" import "./index.css" function App() { + const manager = new DashboardExtensionManager({ + displayModule, + formModule, + menuItemModule, + widgetModule, + }) + return ( - - - - - - - - - - - - - + + + ) } diff --git a/packages/admin/dashboard/src/components/common/chip-group/chip-group.tsx b/packages/admin/dashboard/src/components/common/chip-group/chip-group.tsx index fef85505db7bd..48ed2ef3d8379 100644 --- a/packages/admin/dashboard/src/components/common/chip-group/chip-group.tsx +++ b/packages/admin/dashboard/src/components/common/chip-group/chip-group.tsx @@ -76,7 +76,7 @@ const Chip = ({ index, className, children }: ChipProps) => { return (
  • { ) } -const useCoreRoutes = (): Omit[] => { +const useCoreRoutes = (): Omit[] => { const { t } = useTranslation() return [ @@ -297,14 +296,11 @@ const CoreRouteSection = () => { const ExtensionRouteSection = () => { const { t } = useTranslation() + const { getMenu } = useDashboardExtension() - const links = routes.links + const menuItems = getMenu("coreExtensions") - const extensionLinks = links - .filter((link) => !settingsRouteRegex.test(link.path)) - .sort((a, b) => a.label.localeCompare(b.label)) - - if (!extensionLinks.length) { + if (!menuItems.length) { return null } @@ -330,13 +326,14 @@ const ExtensionRouteSection = () => {