feat(admin,admin-ui,medusa): Add Medusa Admin plugin (#3334)

This commit is contained in:
Kasper Fabricius Kristensen
2023-03-03 10:09:16 +01:00
committed by GitHub
parent d6b1ad1ccd
commit 40de54b010
928 changed files with 85441 additions and 384 deletions

29
packages/admin/.gitignore vendored Normal file
View File

@@ -0,0 +1,29 @@
# TS build info
*.tsbuildinfo
# Output of `yarn build` command.
/api
/commands
/utils
/types
/loaders
/setup
# Vite cache
.cache
# Vite build output
build
# Platform-specific files
.DS_Store
Thumbs.db
# Yarn Integrity file
.yarn-integrity
# dotenv environment variables file
.env
# Local dev preview
/dashboard/dist

View File

@@ -0,0 +1,2 @@
/src
.turbo

108
packages/admin/README.md Normal file
View File

@@ -0,0 +1,108 @@
<p align="center">
<a href="https://www.medusajs.com">
<img alt="Medusa" src="https://user-images.githubusercontent.com/7554214/153162406-bf8fd16f-aa98-4604-b87b-e13ab4baf604.png" width="100" />
</a>
</p>
<h1 align="center">
@medusajs/admin
</h1>
<h4 align="center">
<a href="https://docs.medusajs.com">Documentation</a> |
<a href="https://demo.medusajs.com/">Medusa Admin Demo</a> |
<a href="https://www.medusajs.com">Website</a>
</h4>
<p align="center">
An open source composable commerce engine built for developers.
</p>
<p align="center">
<a href="https://github.com/medusajs/medusa/blob/master/LICENSE">
<img src="https://img.shields.io/badge/license-MIT-blue.svg" alt="Medusa is released under the MIT license." />
</a>
<a href="https://circleci.com/gh/medusajs/medusa">
<img src="https://circleci.com/gh/medusajs/medusa.svg?style=shield" alt="Current CircleCI build status." />
</a>
<a href="https://github.com/medusajs/medusa/blob/master/CONTRIBUTING.md">
<img src="https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat" alt="PRs welcome!" />
</a>
<a href="https://www.producthunt.com/posts/medusa"><img src="https://img.shields.io/badge/Product%20Hunt-%231%20Product%20of%20the%20Day-%23DA552E" alt="Product Hunt"></a>
<a href="https://discord.gg/xpCwq3Kfn8">
<img src="https://img.shields.io/badge/chat-on%20discord-7289DA.svg" alt="Discord Chat" />
</a>
<a href="https://twitter.com/intent/follow?screen_name=medusajs">
<img src="https://img.shields.io/twitter/follow/medusajs.svg?label=Follow%20@medusajs" alt="Follow @medusajs" />
</a>
</p>
## Getting started
Install the package:
```bash
yarn add @medusajs/admin
```
Add the plugin to your `medusa-config.js`:
```js
module.exports = {
// ...
plugins: [
{
resolve: "@medusajs/admin",
options: {
// ...
},
},
],
// ...
}
```
## Configuration
The plugin can be configured with the following options:
| Option | Type | Description | Default |
| --------- | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- |
| `serve` | `boolean?` | Whether to serve the admin dashboard or not. | `true` |
| `path` | `string?` | The path the admin server should run on. Should not be prefixed or suffixed with a slash. Cannot be one of the reserved paths: `"admin"` and `"store"`. | `"app"` |
| `outDir` | `string?` | Optional path for where to output the admin build files | `undefined` |
| `backend` | `string?` | URL to server. Should only be set if you plan on hosting the admin dashboard separately from your server | `undefined` |
**Hint**: You can import the PluginOptions type for inline documentation for the different options:
```js
module.exports = {
// ...
plugins: [
{
resolve: "@medusajs/admin",
/** @type {import('@medusajs/admin').PluginOptions} */
options: {
path: "app",
},
},
],
// ...
}
```
## Building the admin dashboard
The admin will be built automatically the first time you start your server. Any subsequent changes to the plugin options will result in a rebuild of the admin dashboard.
You may need to manually trigger a rebuild sometimes, for example after you have upgraded to a newer version of `@medusajs/admin`. You can do so by adding the following script to your `package.json`:
```json
{
"scripts": {
"build:admin": "medusa-admin build"
}
}
```
## Accessing the admin dashboard
The admin dashboard will be available at `your-server-url/app`, unless you have specified a custom path in the plugin options. If you are running your server locally on port 9000 with the default path `"app"`, you will be able access the admin dashboard at `http://localhost:9000/app`.

View File

@@ -0,0 +1,2 @@
#!/usr/bin/env node
require("../commands/index.js")

View File

@@ -0,0 +1,45 @@
{
"name": "@medusajs/admin",
"version": "0.0.1",
"bin": {
"medusa-admin": "./bin/medusa-admin.js"
},
"main": "index.js",
"types": "types/index.d.ts",
"files": [
"bin",
"types",
"*.js",
"api",
"loaders",
"commands",
"utils",
"src"
],
"scripts": {
"build": "tsc --build",
"test": "jest --passWithNoTests"
},
"repository": {
"type": "git",
"url": "https://github.com/medusajs/medusa.git",
"directory": "packages/admin"
},
"dependencies": {
"@medusajs/admin-ui": "*",
"commander": "^10.0.0",
"express": "^4.17.1",
"fs-extra": "^11.1.0",
"medusa-core-utils": "*",
"ora": "5.4.0",
"picocolors": "^1.0.0"
},
"peerDependencies": {
"@medusajs/medusa": "*"
},
"devDependencies": {
"@types/express": "^4.17.13",
"typescript": "^4.9.3"
},
"packageManager": "yarn@3.2.1"
}

View File

@@ -0,0 +1,78 @@
import express, { Request, Response, Router } from "express"
import fse from "fs-extra"
import { ServerResponse } from "http"
import { resolve } from "path"
import colors from "picocolors"
import { PluginOptions } from "../types"
import { reporter } from "../utils"
export default function (_rootDirectory: string, options: PluginOptions) {
const app = Router()
const { serve = true, path = "app", outDir } = options
if (serve) {
let buildPath: string
let htmlPath: string
if (outDir) {
buildPath = resolve(process.cwd(), outDir)
htmlPath = resolve(buildPath, "index.html")
} else {
buildPath = resolve(
require.resolve("@medusajs/admin-ui"),
"..",
"..",
"build"
)
htmlPath = resolve(buildPath, "index.html")
}
/**
* The admin UI should always be built at this point, but in the
* rare case that another plugin terminated a previous startup, the admin
* may not have been built correctly. Here we check if the admin UI
* build files exist, and if not, we throw an error, providing the
* user with instructions on how to fix their build.
*/
try {
fse.ensureFileSync(htmlPath)
} catch (_err) {
reporter.panic(
new Error(
`Could not find the admin UI build files. Please run ${colors.bold(
"`medusa-admin build`"
)} to build the admin UI.`
)
)
}
const html = fse.readFileSync(htmlPath, "utf-8")
const sendHtml = (_req: Request, res: Response) => {
res.setHeader("Cache-Control", "no-cache")
res.setHeader("Vary", "Origin, Cache-Control")
res.send(html)
}
const setStaticHeaders = (res: ServerResponse) => {
res.setHeader("Cache-Control", "max-age=31536000, immutable")
res.setHeader("Vary", "Origin, Cache-Control")
}
app.get(`/${path}`, sendHtml)
app.use(
`/${path}`,
express.static(buildPath, {
setHeaders: setStaticHeaders,
})
)
app.get(`/${path}/*`, sendHtml)
} else {
app.get(`/${path}`, (_req, res) => {
res.send("Admin not enabled")
})
}
return app
}

View File

@@ -0,0 +1,48 @@
import { build as buildAdmin } from "@medusajs/admin-ui"
import ora from "ora"
import { EOL } from "os"
import { loadConfig, reporter, validatePath } from "../utils"
type BuildArgs = {
outDir?: string
backend?: string
path?: string
}
export default async function build(args: BuildArgs) {
const { path, backend, outDir } = mergeArgs(args)
try {
validatePath(path)
} catch (err) {
reporter.panic(err)
}
const time = Date.now()
const spinner = ora().start(`Building Admin UI${EOL}`)
await buildAdmin({
build: {
outDir: outDir,
},
globals: {
base: path,
backend: backend,
},
}).catch((err) => {
spinner.fail(`Failed to build Admin UI${EOL}`)
reporter.panic(err)
})
spinner.succeed(`Admin UI build - ${Date.now() - time}ms`)
}
const mergeArgs = (args: BuildArgs) => {
const { path, backend, outDir } = loadConfig()
return {
path: args.path || path,
backend: args.backend || backend,
outDir: args.outDir || outDir,
}
}

View File

@@ -0,0 +1,15 @@
import { Command } from "commander"
import build from "./build"
export async function createCli(): Promise<Command> {
const program = new Command()
const buildCommand = program.command("build")
buildCommand.description("Build the admin dashboard")
buildCommand.option("-o, --out-dir <path>", "Output directory")
buildCommand.option("-b, --backend <url>", "Backend URL")
buildCommand.option("-p, --path <path>", "Base path")
buildCommand.action(build)
return program
}

View File

@@ -0,0 +1,8 @@
import { createCli } from "./create-cli"
createCli()
.then(async (cli) => cli.parseAsync(process.argv))
.catch((err) => {
console.error(err)
process.exit(1)
})

View File

@@ -0,0 +1,84 @@
import { build } from "@medusajs/admin-ui"
import fse from "fs-extra"
import ora from "ora"
import { EOL } from "os"
import { resolve } from "path"
import { loadConfig, reporter, validatePath } from "../utils"
export default async function setupAdmin() {
const { path, backend, outDir } = loadConfig()
try {
validatePath(path)
} catch (err) {
reporter.panic(err)
}
let dir: string
let shouldBuild = false
if (outDir) {
dir = resolve(process.cwd(), outDir)
} else {
const uiPath = require.resolve("@medusajs/admin-ui")
dir = resolve(uiPath, "..", "..", "build")
}
try {
await fse.ensureDir(dir)
} catch (_e) {
shouldBuild = true
}
const manifestPath = resolve(dir, "build-manifest.json")
const buildOptions = {
build: {
outDir: outDir,
},
globals: {
base: path,
backend: backend,
},
}
try {
const manifest = await fse.readJSON(manifestPath)
/**
* If the manifest is not the same as the current build options,
* we should rebuild the admin UI.
*/
if (JSON.stringify(manifest) !== JSON.stringify(buildOptions)) {
shouldBuild = true
}
} catch (_e) {
/**
* If the manifest file does not exist, we should rebuild the admin UI.
* This is the case when the admin UI is built for the first time.
*/
shouldBuild = true
}
if (shouldBuild) {
const time = Date.now()
const spinner = ora().start(
`Admin build is out of sync with the current configuration. Rebuild initialized${EOL}`
)
await build({
build: {
outDir: outDir,
},
globals: {
base: path,
backend: backend,
},
}).catch((err) => {
spinner.fail(`Failed to build Admin UI${EOL}`)
reporter.panic(err)
})
spinner.succeed(`Admin UI build - ${Date.now() - time}ms`)
}
}

View File

@@ -0,0 +1,36 @@
export type PluginOptions = {
/**
* Determines whether the admin dashboard should be served.
*/
serve?: boolean
/**
* The path to the admin dashboard. Should not be either prefixed or suffixed with a slash.
* The chosen path cannot be one of the reserved paths: "admin", "store".
* @default "app"
*/
path?: string
/**
* Backend to use for the admin dashboard. This should only be used if you
* intend on hosting the dashboard separately from your Medusa server.
* @default undefined
*/
backend?: string
/**
* The directory to output the build to. By default the plugin will build
* the dashboard to the `build` directory of the `@medusajs/admin-ui` package.
* If you intend on hosting the dashboard separately from your Medusa server,
* you should use this option to specify a custom build directory, that you can
* deploy to your host of choice.
* @default undefined
*/
outDir?: string
}
type PluginObject = {
resolve: string
options: Record<string, unknown>
}
export type ConfigModule = {
plugins: [PluginObject | string]
}

View File

@@ -0,0 +1,3 @@
export { loadConfig } from "./load-config"
export { reporter } from "./reporter"
export { validatePath } from "./validate-path"

View File

@@ -0,0 +1,32 @@
import { getConfigFile } from "medusa-core-utils"
import { ConfigModule, PluginOptions } from "../types"
export const loadConfig = () => {
const { configModule } = getConfigFile<ConfigModule>(
process.cwd(),
"medusa-config"
)
const plugin = configModule.plugins.find(
(p) =>
(typeof p === "string" && p === "@medusajs/admin") ||
(typeof p === "object" && p.resolve === "@medusajs/admin")
)
let defaultConfig: PluginOptions = {
serve: true,
path: "app",
}
if (typeof plugin !== "string") {
const { options } = plugin as { options: PluginOptions }
defaultConfig = {
serve: options.serve ?? defaultConfig.serve,
path: options.path ?? defaultConfig.path,
backend: options.backend ?? defaultConfig.backend,
outDir: options.outDir ?? defaultConfig.outDir,
}
}
return defaultConfig
}

View File

@@ -0,0 +1,19 @@
import colors from "picocolors"
const PREFIX = colors.cyan("[@medusajs/admin]")
export const reporter = {
panic: (err: Error) => {
console.error(`${PREFIX} ${colors.red(err.message)}`)
process.exit(1)
},
error: (message: string) => {
console.error(`${PREFIX} ${colors.red(message)}`)
},
info: (message: string) => {
console.log(`${PREFIX} ${colors.blue(message)}`)
},
warn: (message: string) => {
console.warn(`${PREFIX} ${colors.yellow(message)}`)
},
}

View File

@@ -0,0 +1,15 @@
export const validatePath = (path: string) => {
if (path.startsWith("/")) {
throw new Error(`Path cannot start with a slash.`)
}
if (path.endsWith("/")) {
throw new Error(`Path cannot end with a slash.`)
}
if (path === "admin" || path === "store") {
throw new Error(
`Path cannot be one of the reserved paths: "admin", "store".`
)
}
}

View File

@@ -0,0 +1,19 @@
{
"compilerOptions": {
"declaration": true,
"declarationMap": true,
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"module": "commonjs",
"moduleResolution": "node",
"noEmit": false,
"resolveJsonModule": true,
"esModuleInterop": true,
"outDir": ".",
"rootDir": "src",
"jsx": "react",
"skipLibCheck": true
},
"include": ["src"],
"exclude": ["**/node_modules"]
}