feat(admin, admin-ui, medusa-js, medusa-react, medusa): Support Admin Extensions (#4761)

Co-authored-by: Rares Stefan <948623+StephixOne@users.noreply.github.com>
Co-authored-by: Oli Juhl <59018053+olivermrbl@users.noreply.github.com>
This commit is contained in:
Kasper Fabricius Kristensen
2023-08-17 14:14:45 +02:00
committed by GitHub
parent 26c78bbc03
commit f1a05f4725
189 changed files with 14570 additions and 12773 deletions
+41 -91
View File
@@ -1,100 +1,50 @@
import { AdminBuildConfig, build as buildAdmin } from "@medusajs/admin-ui"
import dotenv from "dotenv"
import fse from "fs-extra"
import ora from "ora"
import { EOL } from "os"
import { build as adminBuild, clean } from "@medusajs/admin-ui"
import { resolve } from "path"
import { loadConfig, reporter, validatePath } from "../utils"
import { BuildOptions } from "../types"
import { getPluginPaths, loadConfig } from "../utils"
import { createBuildManifest } from "../utils/build-manifest"
type BuildArgs = {
deployment?: boolean
outDir?: string
backend?: string
include?: string[]
includeDist?: string
}
export default async function build({
backend,
path,
outDir,
deployment,
}: BuildOptions) {
const {
path: configPath,
backend: configBackend,
outDir: configOutDir,
} = loadConfig()
let ENV_FILE_NAME = ""
switch (process.env.NODE_ENV) {
case "production":
ENV_FILE_NAME = ".env.production"
break
case "staging":
ENV_FILE_NAME = ".env.staging"
break
case "test":
ENV_FILE_NAME = ".env.test"
break
case "development":
default:
ENV_FILE_NAME = ".env"
break
}
const plugins = await getPluginPaths()
const appDir = process.cwd()
try {
dotenv.config({ path: process.cwd() + "/" + ENV_FILE_NAME })
} catch (e) {
reporter.warn(`Failed to load environment variables from ${ENV_FILE_NAME}`)
}
const outDirOption = resolve(appDir, outDir || configOutDir)
const pathOption = deployment ? path || "/" : path || configPath
const backendOption = deployment
? backend || process.env.MEDUSA_ADMIN_BACKEND_URL
: backend || configBackend
export default async function build(args: BuildArgs) {
const { deployment, outDir: outDirArg, backend, include, includeDist } = args
let config: AdminBuildConfig = {}
if (deployment) {
config = {
build: {
outDir: outDirArg,
},
globals: {
backend: backend || process.env.MEDUSA_BACKEND_URL,
},
}
} else {
const { path, outDir } = loadConfig()
try {
validatePath(path)
} catch (err) {
reporter.panic(err)
}
config = {
build: {
outDir: outDir,
},
globals: {
base: path,
},
}
}
const time = Date.now()
const spinner = ora().start(`Building Admin UI${EOL}`)
await buildAdmin({
...config,
}).catch((err) => {
spinner.fail(`Failed to build Admin UI${EOL}`)
reporter.panic(err)
await clean({
appDir: appDir,
outDir: outDirOption,
})
/**
* If we have specified files to include in the build, we copy them
* to the build directory.
*/
if (include && include.length > 0) {
const dist = outDirArg || resolve(process.cwd(), "build")
await adminBuild({
appDir: appDir,
buildDir: outDirOption,
plugins,
options: {
path: pathOption,
backend: backendOption,
outDir: outDirOption,
},
})
try {
for (const filePath of include) {
await fse.copy(filePath, resolve(dist, includeDist, filePath))
}
} catch (err) {
reporter.panic(err)
}
}
spinner.succeed(`Admin UI build - ${Date.now() - time}ms`)
await createBuildManifest(appDir, {
outDir: outDir || configOutDir,
path: path || configPath,
backend: backend || configBackend,
deployment,
})
}
+188
View File
@@ -0,0 +1,188 @@
import {
ALIASED_PACKAGES,
findAllValidRoutes,
findAllValidSettings,
findAllValidWidgets,
logger,
normalizePath,
} from "@medusajs/admin-ui"
import commonjs from "@rollup/plugin-commonjs"
import json from "@rollup/plugin-json"
import { nodeResolve } from "@rollup/plugin-node-resolve"
import replace from "@rollup/plugin-replace"
import virtual from "@rollup/plugin-virtual"
import fse from "fs-extra"
import path from "path"
import { rollup } from "rollup"
import esbuild from "rollup-plugin-esbuild"
import dedent from "ts-dedent"
export async function bundle() {
const adminDir = path.resolve(process.cwd(), "src", "admin")
const pathExists = await fse.pathExists(adminDir)
if (!pathExists) {
logger.panic(
"The `src/admin` directory could not be found. It appears that your project does not contain any admin extensions."
)
}
const pkg = await fse.readJSON(path.join(process.cwd(), "package.json"))
if (!pkg) {
logger.panic(
"The `package.json` file could not be found. Your project does not meet the requirements of a valid Medusa plugin."
)
}
if (!pkg.name) {
logger.panic(
"The `package.json` does not contain a `name` field. Your project does not meet the requirements of a valid Medusa plugin."
)
}
const identifier = pkg.name as string
const [routes, widgets, settings] = await Promise.all([
findAllValidRoutes(path.resolve(adminDir, "routes")),
findAllValidWidgets(path.resolve(adminDir, "widgets")),
findAllValidSettings(path.resolve(adminDir, "settings")),
])
const widgetArray = widgets.map((file, index) => {
return {
importStatement: `import Widget${index}, { config as widgetConfig${index} } from "${normalizePath(
file
)}"`,
extension: `{ Component: Widget${index}, config: { ...widgetConfig${index}, type: "widget" } }`,
}
})
const routeArray = routes.map((route, index) => {
return {
importStatement: dedent`
import Route${index}${
route.hasConfig ? `, { config as routeConfig${index} }` : ""
} from "${normalizePath(route.file)}"`,
extension: `{
Component: Route${index},
config: { path: "${route.path}", type: "route"${
route.hasConfig ? `, ...routeConfig${index}` : ""
} }
}`,
}
})
const settingArray = settings.map((setting, index) => {
return {
importStatement: dedent`
import Setting${index}, { config as settingConfig${index} } from "${normalizePath(
setting.file
)}"`,
extension: `{ Component: Setting${index}, config: { path: "${setting.path}", type: "setting", ...settingConfig${index} } }`,
}
})
const extensionsArray = [...routeArray, ...settingArray, ...widgetArray]
const virtualEntry = dedent`
${extensionsArray.map((e) => e.importStatement).join("\n")}
const entry = {
identifier: "${identifier}",
extensions: [
${extensionsArray.map((e) => e.extension).join(",\n")}
],
}
export default entry
`
const dependencies = Object.keys(pkg.dependencies || {})
const peerDependencies = Object.keys(pkg.peerDependencies || {})
const external = [
...ALIASED_PACKAGES,
...dependencies,
...peerDependencies,
"react/jsx-runtime",
]
const dist = path.resolve(process.cwd(), "dist", "admin")
const distExists = await fse.pathExists(dist)
if (distExists) {
try {
await fse.remove(dist)
} catch (error) {
logger.panic(
`Failed to clean ${dist}. Make sure that you have write access to the folder.`
)
}
}
try {
const bundle = await rollup({
input: ["entry"],
external,
plugins: [
virtual({
entry: virtualEntry,
}),
nodeResolve({
preferBuiltins: true,
browser: true,
extensions: [".mjs", ".js", ".json", ".node", "jsx", "ts", "tsx"],
}),
commonjs(),
esbuild({
include: /\.[jt]sx?$/, // Transpile .js, .jsx, .ts, and .tsx files
exclude: /node_modules/,
minify: process.env.NODE_ENV === "production",
target: "es2017",
jsxFactory: "React.createElement",
jsxFragment: "React.Fragment",
jsx: "automatic",
}),
json(),
replace({
values: {
"process.env.NODE_ENV": JSON.stringify(process.env.NODE_ENV),
},
preventAssignment: true,
}),
],
onwarn: (warning, warn) => {
if (
warning.code === "CIRCULAR_DEPENDENCY" &&
warning.ids?.every((id) => /\bnode_modules\b/.test(id))
) {
return
}
warn(warning)
},
}).catch((error) => {
throw error
})
await bundle.write({
dir: path.resolve(process.cwd(), "dist", "admin"),
chunkFileNames: "[name].js",
format: "esm",
exports: "named",
})
await bundle.close()
logger.info("The extension bundle has been built successfully")
} catch (error) {
logger.panic(
`Error encountered while building the extension bundle: ${error}`,
error
)
}
}
+23 -36
View File
@@ -1,47 +1,34 @@
import { Command } from "commander"
import build from "./build"
import dev from "./dev"
import eject from "./eject"
import { bundle } from "./bundle"
import develop from "./develop"
export async function createCli(): Promise<Command> {
const program = new Command()
const buildCommand = program.command("build")
buildCommand.description("Build the admin dashboard")
program
.command("bundle")
.description("Bundle extensions to the admin dashboard")
.action(bundle)
buildCommand.option(
"--deployment",
"Build for deploying to and external host (e.g. Vercel)"
)
program
.command("develop")
.description("Start the admin dashboard in development mode")
.option("--port <port>", "Port to run the admin dashboard on")
.option("--path <path>", "Public path to serve the admin dashboard on")
.option("--backend <url>", "URL to the Medusa backend")
.action(develop)
buildCommand.option("-o, --out-dir <path>", "Output directory")
buildCommand.option("-b, --backend <url>", "Backend URL")
buildCommand.option(
"-i, --include [paths...]]",
"Paths to files that should be included in the build"
)
buildCommand.option(
"-d, --include-dist <path>",
"Path to where the files specified in the include option should be placed. Relative to the root of the build directory."
)
buildCommand.action(build)
const devCommand = program.command("dev")
devCommand.description("Start the admin dashboard in development mode")
devCommand.option("-p, --port <port>", "Port (default: 7001))")
devCommand.option(
"-b, --backend <url>",
"Backend URL (default http://localhost:9000)"
)
devCommand.action(dev)
const deployCommand = program.command("eject")
deployCommand.description(
"Eject the admin dashboard source code to a custom directory"
)
deployCommand.option("-o, --out-dir <path>", "Output directory")
deployCommand.action(eject)
program
.command("build")
.description("Build the admin dashboard for production")
.option("--path <path>", "Public path to serve the admin dashboard on")
.option("--backend <url>", "URL to the Medusa backend")
.option(
"--deployment",
"Build the admin dashboard for deployment to an exernal host"
)
.action(build)
return program
}
-6
View File
@@ -1,6 +0,0 @@
import type { AdminDevConfig } from "@medusajs/admin-ui"
import { dev as devAdmin } from "@medusajs/admin-ui"
export default async function dev(args: AdminDevConfig) {
await devAdmin(args)
}
+32
View File
@@ -0,0 +1,32 @@
import { AdminOptions, develop as adminDevelop } from "@medusajs/admin-ui"
import { getPluginPaths, loadConfig } from "../utils"
type DevelopArgs = AdminOptions & {
port: number
}
export default async function develop({ backend, path, port }: DevelopArgs) {
const config = loadConfig(true)
if (!config) {
// @medusajs/admin is not part of the projects plugins
// so we return early
return
}
const plugins = await getPluginPaths()
await adminDevelop({
appDir: process.cwd(),
buildDir: config.outDir,
plugins,
options: {
backend: backend || config.backend,
path: path || config.path,
develop: {
port: port || config.develop.port,
open: config.develop.open,
},
},
})
}
-111
View File
@@ -1,111 +0,0 @@
import * as fse from "fs-extra"
import path from "path"
import dedent from "ts-dedent"
type EjectParams = {
outDir?: string
}
const DEFAULT_DESTINATION = "medusa-admin-ui"
export default async function eject({
outDir = DEFAULT_DESTINATION,
}: EjectParams) {
const projectPath = require.resolve("@medusajs/admin-ui")
const uiPath = path.join(projectPath, "..", "..", "ui")
const packageJsonPath = path.join(projectPath, "..", "..", "package.json")
const pkg = await fse.readJSON(packageJsonPath)
const fieldsToRemove = ["exports", "types", "files", "main", "packageManager"]
fieldsToRemove.forEach((field) => delete pkg[field])
pkg.type = "module"
const dependenciesToMove = [
"tailwindcss",
"autoprefixer",
"postcss",
"tailwindcss-radix",
"@tailwindcss/forms",
"vite",
"@vitejs/plugin-react",
]
// Get dependencies in array from pkg.dependencies and move them to pkg.devDependencies
const dependencies = Object.keys(pkg.dependencies).filter((dep) =>
dependenciesToMove.includes(dep)
)
dependencies.forEach((dep) => {
pkg.devDependencies[dep] = pkg.dependencies[dep]
delete pkg.dependencies[dep]
})
pkg.scripts = {
build: "vite build",
dev: "vite --port 7001",
preview: "vite preview",
}
const viteConfig = dedent`
import { defineConfig } from "vite"
import dns from "dns"
import react from "@vitejs/plugin-react"
// Resolve localhost for Node v16 and older.
// @see https://vitejs.dev/config/server-options.html#server-host.
dns.setDefaultResultOrder("verbatim")
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
define: {
__BASE__: JSON.stringify("/"),
__MEDUSA_BACKEND_URL__: JSON.stringify("http://localhost:9000"),
},
})
`
// Create new tailwind.config.js file based on the current one
const tailwindConfig = await fse.readFile(
path.join(uiPath, "tailwind.config.js"),
"utf-8"
)
// Overwrite content field of module.exports in tailwind.config.js
let newTailwindConfig = tailwindConfig.replace(
/content:\s*\[[\s\S]*?\]/,
`content: ["src/**/*.{js,ts,jsx,tsx}", "./index.html"]`
)
// Remove require of "path" in tailwind.config.js
newTailwindConfig = newTailwindConfig.replace(
/const path = require\("path"\)/,
""
)
// Create a new postcss.config.js file
const postcssConfig = dedent`
module.exports = {
plugins: {
"tailwindcss/nesting": {},
tailwindcss: {},
autoprefixer: {},
}
}
`
const tmpPath = path.join(process.cwd(), outDir)
await fse.copy(uiPath, tmpPath)
await fse.remove(path.join(tmpPath, "tailwind.config.js"))
await fse.remove(path.join(tmpPath, "postcss.config.js"))
await fse.writeJSON(path.join(tmpPath, "package.json"), pkg)
await fse.writeFile(path.join(tmpPath, "vite.config.ts"), viteConfig)
await fse.writeFile(
path.join(tmpPath, "tailwind.config.cjs"),
newTailwindConfig
)
await fse.writeFile(path.join(tmpPath, "postcss.config.cjs"), postcssConfig)
}