Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(dashboard,admin-sdk,admin-shared,admin-vite-plugin): Add support for UI extensions #7383

Merged
merged 14 commits into from
May 23, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ module.exports = {

"./packages/admin-next/dashboard/tsconfig.json",
"./packages/admin-next/admin-sdk/tsconfig.json",
"./packages/admin-next/admin-shared/tsconfig.json",
"./packages/admin-next/admin-vite-plugin/tsconfig.json",

"./packages/inventory/tsconfig.spec.json",
Expand Down
3 changes: 1 addition & 2 deletions packages/admin-next/admin-sdk/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,20 +15,19 @@
"devDependencies": {
"@medusajs/types": "^1.11.16",
"@types/compression": "^1.7.5",
"@types/connect-history-api-fallback": "^1.5.4",
"copyfiles": "^2.4.1",
"express": "^4.18.2",
"tsup": "^8.0.1",
"typescript": "^5.3.3"
},
"dependencies": {
"@medusajs/admin-shared": "0.0.1",
"@medusajs/admin-vite-plugin": "0.0.1",
"@medusajs/dashboard": "0.0.1",
"@vitejs/plugin-react": "^4.2.1",
"autoprefixer": "^10.4.16",
"commander": "^11.1.0",
"compression": "^1.7.4",
"connect-history-api-fallback": "^2.0.0",
"deepmerge": "^4.3.1",
"glob": "^7.1.6",
"postcss": "^8.4.32",
Expand Down
4 changes: 4 additions & 0 deletions packages/admin-next/admin-sdk/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import { defineRouteConfig, defineWidgetConfig } from "@medusajs/admin-shared"

export { build } from "./lib/build"
export { develop } from "./lib/develop"
export { serve } from "./lib/serve"

export { defineRouteConfig, defineWidgetConfig }

export * from "./types"
14 changes: 6 additions & 8 deletions packages/admin-next/admin-sdk/src/lib/build.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,15 @@
import type { InlineConfig } from "vite"
import { BundlerOptions } from "../types"
import { getViteConfig } from "./config"

export async function build(options: BundlerOptions) {
const vite = await import("vite")

const viteConfig = await getViteConfig(options)

try {
await vite.build(
vite.mergeConfig(viteConfig, { mode: "production", logLevel: "silent" })
)
} catch (error) {
console.error(error)
throw new Error("Failed to build admin panel")
const buildConfig: InlineConfig = {
mode: "production",
logLevel: "error",
}

await vite.build(vite.mergeConfig(viteConfig, buildConfig))
}
37 changes: 19 additions & 18 deletions packages/admin-next/admin-sdk/src/lib/config.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { VIRTUAL_MODULES } from "@medusajs/admin-shared"
import path from "path"
import { Config } from "tailwindcss"
import type { InlineConfig } from "vite"
Expand All @@ -10,7 +11,7 @@ export async function getViteConfig(
): Promise<InlineConfig> {
const { searchForWorkspaceRoot } = await import("vite")
const { default: react } = await import("@vitejs/plugin-react")
const { default: inject } = await import("@medusajs/admin-vite-plugin")
const { default: medusa } = await import("@medusajs/admin-vite-plugin")

const getPort = await import("get-port")
const hmrPort = await getPort.default()
Expand All @@ -20,27 +21,23 @@ export async function getViteConfig(
const backendUrl = options.backendUrl ?? ""

return {
root: path.resolve(__dirname, "./"),
root,
base: options.path,
build: {
emptyOutDir: true,
outDir: path.resolve(process.cwd(), options.outDir),
},
optimizeDeps: {
include: ["@medusajs/dashboard", "react-dom/client"],
exclude: VIRTUAL_MODULES,
},
define: {
__BASE__: JSON.stringify(options.path),
__BACKEND_URL__: JSON.stringify(backendUrl),
},
server: {
open: true,
fs: {
allow: [
searchForWorkspaceRoot(process.cwd()),
path.resolve(__dirname, "../../medusa"),
path.resolve(__dirname, "../../app"),
],
allow: [searchForWorkspaceRoot(process.cwd())],
},
hmr: {
port: hmrPort,
Expand All @@ -51,28 +48,30 @@ export async function getViteConfig(
postcss: {
plugins: [
require("tailwindcss")({
config: createTailwindConfig(root),
config: createTailwindConfig(root, options.sources),
}),
],
},
},
/**
* TODO: Remove polyfills, they are currently only required for the
* `axios` dependency in the dashboard. Once we have the new SDK,
* we should remove this, and leave it up to the user to include
* polyfills if they need them.
*/
plugins: [
react(),
inject(),
medusa({
sources: options.sources,
}),
/**
* TODO: Remove polyfills, they are currently only required for the
* `axios` dependency in the dashboard. Once we have the new SDK,
* we should remove this, and leave it up to the user to include
* polyfills if they need them.
*/
nodePolyfills({
include: ["crypto", "util", "stream"],
}),
],
}
}

function createTailwindConfig(entry: string) {
function createTailwindConfig(entry: string, sources: string[] = []) {
const root = path.join(entry, "**/*.{js,ts,jsx,tsx}")
const html = path.join(entry, "index.html")

Expand All @@ -98,9 +97,11 @@ function createTailwindConfig(entry: string) {
// ignore
}

const extensions = sources.map((s) => path.join(s, "**/*.{js,ts,jsx,tsx}"))

const config: Config = {
presets: [require("@medusajs/ui-preset")],
content: [html, root, dashboard, ui],
content: [html, root, dashboard, ui, ...extensions],
darkMode: "class",
}

Expand Down
13 changes: 11 additions & 2 deletions packages/admin-next/admin-sdk/src/lib/develop.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import express from "express"
import type { InlineConfig } from "vite"

import { BundlerOptions } from "../types"
import { getViteConfig } from "./config"
Expand All @@ -10,14 +11,22 @@ export async function develop(options: BundlerOptions) {

try {
const viteConfig = await getViteConfig(options)

const developConfig: InlineConfig = {
mode: "development",
logLevel: "warn",
}

const server = await vite.createServer(
vite.mergeConfig(viteConfig, { logLevel: "info", mode: "development" })
vite.mergeConfig(viteConfig, developConfig)
)

router.use(server.middlewares)
} catch (error) {
console.error(error)
throw new Error("Could not start development server")
throw new Error(
"Failed to start admin development server. See error above."
)
}

return router
Expand Down
5 changes: 4 additions & 1 deletion packages/admin-next/admin-sdk/src/lib/serve.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import compression from "compression"
import { Request, Response, Router, static as static_ } from "express"
import fs from "fs"
import { ServerResponse } from "http"
Expand All @@ -24,7 +25,7 @@ export async function serve(options: ServeOptions) {

if (!indexExists) {
throw new Error(
`Could not find the admin UI build files. Please run \`npm run build\` or \`yarn build\` command and try again.`
`Could not find index.html in the admin build directory. Make sure to run 'medusa build' before starting the server.`
)
}

Expand All @@ -41,6 +42,8 @@ export async function serve(options: ServeOptions) {
res.setHeader("Vary", "Origin, Cache-Control")
}

router.use(compression())
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we pull the compression settings from the config (I see we have some there)?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we might want to have a separate compression config in projectConfig.admin that we could pass here, as I don't necessarily think the compressions options you want generally for your server === the compression you want for the admin dashboard. But I think we can leave that for later, the default compression options seems to work great for this use case, but we should definetly open them up to people if they want to modify it for whatever reason.


router.get("/", sendHtml)
router.use(
static_(options.outDir, {
Expand Down
4 changes: 3 additions & 1 deletion packages/admin-next/admin-sdk/src/types.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { AdminOptions } from "@medusajs/types"

export type BundlerOptions = Required<Pick<AdminOptions, "outDir" | "path">> &
Pick<AdminOptions, "vite" | "backendUrl">
Pick<AdminOptions, "vite" | "backendUrl"> & {
sources?: string[]
}
2 changes: 1 addition & 1 deletion packages/admin-next/admin-sdk/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,5 +17,5 @@
"noFallthroughCasesInSwitch": true
},
"include": ["src"],
"exclude": ["tsup.config.ts", "node_modules", "dist"]
"exclude": ["tsup.config.cjs", "node_modules", "dist"]
}
5 changes: 4 additions & 1 deletion packages/admin-next/admin-shared/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,17 @@
"author": "Kasper Kristensen <kasper@medusajs.com>",
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe the timing is not the best, but I feel like we can do better with the naming. To me
admin-sdk would be closer to what admin-shared does. And the current admin-sdk can be more something like admin-builder. I am not saying we need to do it in this PR, but something to think about before officially releasing v2.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agree that the naming can be better, and something we need to revisit, but would hold off on it to post RC, as it requires we update the packages a couple of different places.

"types": "dist/index.d.ts",
"main": "dist/index.js",
"module": "dist/index.mjs",
"files": [
"dist",
"package.json"
],
"scripts": {
"build": "tsc"
"build": "tsup"
},
"devDependencies": {
"@types/react": "^18.3.2",
"tsup": "^8.0.2",
"typescript": "^5.3.3"
},
"packageManager": "yarn@3.2.1"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from "./types"
export * from "./utils"
12 changes: 12 additions & 0 deletions packages/admin-next/admin-shared/src/extensions/config/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import type { ComponentType } from "react"

import { InjectionZone } from "../widgets"

export type WidgetConfig = {
zone: InjectionZone | InjectionZone[]
}

export type RouteConfig = {
label?: string
icon?: ComponentType
}
37 changes: 37 additions & 0 deletions packages/admin-next/admin-shared/src/extensions/config/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { RouteConfig, WidgetConfig } from "./types"

function createConfigHelper<TConfig extends Record<string, unknown>>(
config: TConfig
): TConfig {
return {
...config,
/**
* This property is required to allow the config to be exported,
* while still allowing HMR to work correctly.
*
* It tricks Fast Refresh into thinking that the config is a React component,
* which allows it to be updated without a full page reload.
*/
$$typeof: Symbol.for("react.memo"),
}
}

/**
* Define a widget configuration.
*
* @param config The widget configuration.
* @returns The widget configuration.
*/
export function defineWidgetConfig(config: WidgetConfig) {
return createConfigHelper(config)
}

/**
* Define a route configuration.
*
* @param config The route configuration.
* @returns The route configuration.
*/
export function defineRouteConfig(config: RouteConfig) {
return createConfigHelper(config)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const ROUTE_IMPORTS = ["routes/pages", "routes/links"] as const
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from "./constants"
export * from "./types"
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { ROUTE_IMPORTS } from "./constants"

export type RouteImport = (typeof ROUTE_IMPORTS)[number]
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
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)
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from "./constants"
export * from "./utils"
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
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
}
Loading
Loading