feat(admin-sdk,admin-bundler,admin-shared,medusa): Restructure admin packages (#8988)

**What**
- Renames /admin-next -> /admin
- Renames @medusajs/admin-sdk -> @medusajs/admin-bundler
- Creates a new package called @medusajs/admin-sdk that will hold all tooling relevant to creating admin extensions. This is currently `defineRouteConfig` and `defineWidgetConfig`, but will eventually also export methods for adding custom fields, register translation, etc. 
  - cc: @shahednasser we should update the examples in the docs so these functions are imported from `@medusajs/admin-sdk`. People will also need to install the package in their project, as it's no longer a transient dependency.
  - cc: @olivermrbl we might want to publish a changelog when this is merged, as it is a breaking change, and will require people to import the `defineXConfig` from the new package instead of `@medusajs/admin-shared`.
- Updates CODEOWNERS so /admin packages does not require a review from the UI team.
This commit is contained in:
Kasper Fabricius Kristensen
2024-09-04 19:00:25 +00:00
committed by GitHub
parent beaa851302
commit 0fe1201435
1440 changed files with 122 additions and 86 deletions
@@ -0,0 +1,16 @@
import { I18nProvider as Provider } from "@medusajs/ui"
import { PropsWithChildren } from "react"
import { useTranslation } from "react-i18next"
import { languages } from "../../i18n/languages"
type I18nProviderProps = PropsWithChildren
export const I18nProvider = ({ children }: I18nProviderProps) => {
const { i18n } = useTranslation()
const locale =
languages.find((lan) => lan.code === i18n.language)?.code ||
languages[0].code
return <Provider locale={locale}>{children}</Provider>
}
@@ -0,0 +1 @@
export * from "./i18n-provider"
@@ -0,0 +1,319 @@
import debounceFn from "lodash/debounce"
import { useCallback, useContext, useEffect, useState } from "react"
import { useTranslation } from "react-i18next"
import { useNavigate } from "react-router-dom"
import { useLogout } from "../../hooks/api/auth"
import { queryClient } from "../../lib/query-client"
import { KeybindContext } from "./keybind-context"
import { Shortcut } from "./types"
import { findShortcut } from "./utils"
export const useKeybind = () => {
const context = useContext(KeybindContext)
if (!context) {
throw new Error("useKeybind must be used within a KeybindProvider")
}
return context
}
export const useRegisterShortcut = () => {}
export const useShortcuts = ({
shortcuts = [],
debounce,
}: {
shortcuts?: Shortcut[]
debounce: number
}) => {
const [keys, setKeys] = useState<string[]>([])
const navigate = useNavigate()
// eslint-disable-next-line react-hooks/exhaustive-deps
const removeKeys = useCallback(
debounceFn(() => setKeys([]), debounce),
[]
)
// eslint-disable-next-line react-hooks/exhaustive-deps
const invokeShortcut = useCallback(
debounceFn((shortcut: Shortcut | null) => {
if (shortcut && shortcut.callback) {
shortcut.callback()
setKeys([])
return
}
if (shortcut && shortcut.to) {
navigate(shortcut.to)
setKeys([])
return
}
}, debounce / 2),
[]
)
useEffect(() => {
if (keys.length > 0 && shortcuts.length > 0) {
const shortcut = findShortcut(shortcuts, keys)
invokeShortcut(shortcut)
}
return () => invokeShortcut.cancel()
}, [keys, shortcuts, invokeShortcut])
useEffect(() => {
const listener = (event: KeyboardEvent) => {
const target = event.target as HTMLElement
/**
* Ignore key events from input, textarea and contenteditable elements
*/
if (
target.tagName === "INPUT" ||
target.tagName === "TEXTAREA" ||
target.contentEditable === "true"
) {
removeKeys()
return
}
setKeys((oldKeys) => [...oldKeys, event.key])
removeKeys()
}
window.addEventListener("keydown", listener)
return () => {
window.removeEventListener("keydown", listener)
}
}, [removeKeys])
}
export const useGlobalShortcuts = () => {
const { t } = useTranslation()
const navigate = useNavigate()
const { mutateAsync } = useLogout()
const handleLogout = async () => {
await mutateAsync(undefined, {
onSuccess: () => {
queryClient.clear()
navigate("/login")
},
})
}
const globalShortcuts: Shortcut[] = [
// Pages
{
keys: {
Mac: ["G", "O"],
},
label: t("app.keyboardShortcuts.navigation.goToOrders"),
type: "pageShortcut",
to: "/orders",
},
{
keys: {
Mac: ["G", "P"],
},
label: t("app.keyboardShortcuts.navigation.goToProducts"),
type: "pageShortcut",
to: "/products",
},
{
keys: {
Mac: ["G", "C"],
},
label: t("app.keyboardShortcuts.navigation.goToCollections"),
type: "pageShortcut",
to: "/collections",
},
{
keys: {
Mac: ["G", "A"],
},
label: t("app.keyboardShortcuts.navigation.goToCategories"),
type: "pageShortcut",
to: "/categories",
},
{
keys: {
Mac: ["G", "U"],
},
label: t("app.keyboardShortcuts.navigation.goToCustomers"),
type: "pageShortcut",
to: "/customers",
},
{
keys: {
Mac: ["G", "G"],
},
label: t("app.keyboardShortcuts.navigation.goToCustomerGroups"),
type: "pageShortcut",
to: "/customer-groups",
},
{
keys: {
Mac: ["G", "I"],
},
label: t("app.keyboardShortcuts.navigation.goToInventory"),
type: "pageShortcut",
to: "/inventory",
},
{
keys: {
Mac: ["G", "R"],
},
label: t("app.keyboardShortcuts.navigation.goToReservations"),
type: "pageShortcut",
to: "/reservations",
},
{
keys: {
Mac: ["G", "L"],
},
label: t("app.keyboardShortcuts.navigation.goToPriceLists"),
type: "pageShortcut",
to: "/price-lists",
},
{
keys: {
Mac: ["G", "M"],
},
label: t("app.keyboardShortcuts.navigation.goToPromotions"),
type: "pageShortcut",
to: "/promotions",
},
{
keys: {
Mac: ["G", "K"],
},
label: t("app.keyboardShortcuts.navigation.goToCampaigns"),
type: "pageShortcut",
to: "/campaigns",
},
// Settings
{
keys: {
Mac: ["G", ","],
},
label: t("app.keyboardShortcuts.settings.goToSettings"),
type: "settingShortcut",
to: "/settings",
},
{
keys: {
Mac: ["G", ",", "S"],
},
label: t("app.keyboardShortcuts.settings.goToStore"),
type: "settingShortcut",
to: "/settings/store",
},
{
keys: {
Mac: ["G", ",", "U"],
},
label: t("app.keyboardShortcuts.settings.goToUsers"),
type: "settingShortcut",
to: "/settings/users",
},
{
keys: {
Mac: ["G", ",", "R"],
},
label: t("app.keyboardShortcuts.settings.goToRegions"),
type: "settingShortcut",
to: "/settings/regions",
},
{
keys: {
Mac: ["G", ",", "T"],
},
label: t("app.keyboardShortcuts.settings.goToTaxRegions"),
type: "settingShortcut",
to: "/settings/tax-regions",
},
{
keys: {
Mac: ["G", ",", "A"],
},
label: t("app.keyboardShortcuts.settings.goToSalesChannels"),
type: "settingShortcut",
to: "/settings/sales-channels",
},
{
keys: {
Mac: ["G", ",", "P"],
},
label: t("app.keyboardShortcuts.settings.goToProductTypes"),
type: "settingShortcut",
to: "/settings/product-types",
},
{
keys: {
Mac: ["G", ",", "L"],
},
label: t("app.keyboardShortcuts.settings.goToLocations"),
type: "settingShortcut",
to: "/settings/locations",
},
{
keys: {
Mac: ["G", ",", "M"],
},
label: t("app.keyboardShortcuts.settings.goToReturnReasons"),
type: "settingShortcut",
to: "/settings/return-reasons",
},
{
keys: {
Mac: ["G", ",", "J"],
},
label: t("app.keyboardShortcuts.settings.goToPublishableApiKeys"),
type: "settingShortcut",
to: "/settings/publishable-api-keys",
},
{
keys: {
Mac: ["G", ",", "K"],
},
label: t("app.keyboardShortcuts.settings.goToSecretApiKeys"),
type: "settingShortcut",
to: "/settings/secret-api-keys",
},
{
keys: {
Mac: ["G", ",", "W"],
},
label: t("app.keyboardShortcuts.settings.goToWorkflows"),
type: "settingShortcut",
to: "/settings/workflows",
},
{
keys: {
Mac: ["G", ",", "M"],
},
label: t("app.keyboardShortcuts.settings.goToProfile"),
type: "settingShortcut",
to: "/settings/profile",
},
// Commands
{
keys: {
Mac: ["B", "Y", "E"],
},
label: t("actions.logout"),
type: "commandShortcut",
callback: () => handleLogout(),
},
]
return globalShortcuts
}
@@ -0,0 +1,3 @@
export { useRegisterShortcut } from "./hooks"
export * from "./keybind-provider"
export type { Shortcut, ShortcutType } from "./types"
@@ -0,0 +1,4 @@
import { createContext } from "react"
import { KeybindContextState } from "./types"
export const KeybindContext = createContext<KeybindContextState | null>(null)
@@ -0,0 +1,64 @@
import { PropsWithChildren, useCallback, useMemo, useState } from "react"
import { useShortcuts } from "./hooks"
import { KeybindContext } from "./keybind-context"
import { KeybindContextState, Shortcut } from "./types"
import {
findFirstPlatformMatch,
findShortcutIndex,
getShortcutKeys,
getShortcutWithDefaultValues,
} from "./utils"
type KeybindProviderProps = PropsWithChildren<{
shortcuts: Shortcut[]
debounce?: number
}>
export const KeybindProvider = ({
shortcuts,
debounce = 500,
children,
}: KeybindProviderProps) => {
const [storeShortcuts, setStoreCommands] = useState(
shortcuts.map((shr) => getShortcutWithDefaultValues(shr))
)
const registerShortcut = useCallback(
(shortcut: Shortcut) => {
setStoreCommands((prevShortcuts) => {
const idx = findShortcutIndex(shortcuts, getShortcutKeys(shortcut))
const newShortcuts = [...prevShortcuts]
if (idx > -1) {
newShortcuts[idx] = getShortcutWithDefaultValues(shortcut)
return prevShortcuts
}
return [...prevShortcuts, getShortcutWithDefaultValues(shortcut)]
})
},
[shortcuts]
)
const getKeysByPlatform = useCallback((command: Shortcut) => {
return findFirstPlatformMatch(command.keys)
}, [])
useShortcuts({ shortcuts: storeShortcuts, debounce })
const commandsContext = useMemo<KeybindContextState>(
() => ({
shortcuts: storeShortcuts,
registerShortcut,
getKeysByPlatform,
}),
[storeShortcuts, registerShortcut, getKeysByPlatform]
)
return (
<KeybindContext.Provider value={commandsContext}>
{children}
</KeybindContext.Provider>
)
}
@@ -0,0 +1,28 @@
export type KeybindContextState = {}
export type Platform = "Mac" | "Windows" | "Linux"
export type Keys = {
[key in Platform]?: string[]
}
export type ShortcutType =
| "pageShortcut"
| "settingShortcut"
| "commandShortcut"
export type Shortcut = {
keys: Keys
type: ShortcutType
label: string
_defaultKeys?: Keys
} & (
| {
callback: () => void
to?: never
}
| {
to: string
callback?: never
}
)
@@ -0,0 +1,92 @@
import { Keys, Platform, Shortcut } from "./types"
export const findFirstPlatformMatch = (keys: Keys) => {
const match =
Object.entries(keys as Record<any, any>).filter(
([, value]) => value.length > 0
)[0] ?? []
return match.length
? {
platform: match[0] as Platform,
keys: match[1] as string[],
}
: null
}
export const getShortcutKeys = (shortcut: Shortcut) => {
const platform: Platform = "Mac"
const keys: string[] | undefined = shortcut.keys[platform]
if (!keys) {
const defaultPlatform = findFirstPlatformMatch(shortcut.keys)
console.warn(
`No keys found for platform "${platform}" in "${shortcut.label}" ${
defaultPlatform
? `using keys for platform "${defaultPlatform.platform}"`
: ""
}`
)
return defaultPlatform ? defaultPlatform.keys : []
}
return keys
}
const keysMatch = (keys1: string[], keys2: string[]) => {
return (
keys1.length === keys2.length &&
keys1.every(
(key, index) => key.toLowerCase() === keys2[index].toLowerCase()
)
)
}
export const findShortcutIndex = (shortcuts: Shortcut[], keys: string[]) => {
if (!keys.length) {
return -1
}
let index = 0
for (const shortcut of shortcuts) {
const shortcutKeys = getShortcutKeys(shortcut)
if (keysMatch(shortcutKeys, keys)) {
return index
}
index++
}
return -1
}
export const findShortcut = (shortcuts: Shortcut[], keys: string[]) => {
const shortcutIndex = findShortcutIndex(shortcuts, keys)
return shortcutIndex > -1 ? shortcuts[shortcutIndex] : null
}
export const getShortcutWithDefaultValues = (
shortcut: Shortcut,
platform: Platform = "Mac"
): Shortcut => {
const platforms: Platform[] = ["Mac", "Windows", "Linux"]
const defaultKeys = Object.values(shortcut.keys)[0] ?? shortcut.keys[platform]
const keys = platforms.reduce((acc, curr) => {
return {
...acc,
[curr]: shortcut.keys[curr] ?? defaultKeys,
}
}, {})
return {
...shortcut,
keys,
_defaultKeys: shortcut.keys,
}
}
@@ -0,0 +1 @@
export * from "./router-provider"
@@ -0,0 +1,12 @@
import routes from "virtual:medusa/routes/pages"
import { createRouteMap, settingsRouteRegex } from "../../lib/extension-helpers"
const pages = routes.pages
.filter((ext) => !settingsRouteRegex.test(ext.path))
.map((ext) => ext)
/**
* Core Route extensions.
*/
export const RouteExtensions = createRouteMap(pages)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,14 @@
import {
RouterProvider as Provider,
createBrowserRouter,
} from "react-router-dom"
import { RouteMap } from "./route-map"
const router = createBrowserRouter(RouteMap, {
basename: __BASE__ || "/",
})
export const RouterProvider = () => {
return <Provider router={router} />
}
@@ -0,0 +1,12 @@
import routes from "virtual:medusa/routes/pages"
import { createRouteMap, settingsRouteRegex } from "../../lib/extension-helpers"
const pages = routes.pages
.filter((ext) => settingsRouteRegex.test(ext.path))
.map((ext) => ext)
/**
* Settings Route extensions.
*/
export const SettingsExtensions = createRouteMap(pages, "/settings")
@@ -0,0 +1,2 @@
export { SearchProvider } from "./search-provider"
export { useSearch } from "./use-search"
@@ -0,0 +1,9 @@
import { createContext } from "react"
type SearchContextValue = {
open: boolean
onOpenChange: (open: boolean) => void
toggleSearch: () => void
}
export const SearchContext = createContext<SearchContextValue | null>(null)
@@ -0,0 +1,50 @@
import { PropsWithChildren, useEffect, useState } from "react"
import { Search } from "../../components/search"
import { useSidebar } from "../sidebar-provider"
import { SearchContext } from "./search-context"
export const SearchProvider = ({ children }: PropsWithChildren) => {
const [open, setOpen] = useState(false)
const { mobile, toggle } = useSidebar()
const toggleSearch = () => {
const update = !open
/**
* If the mobile sidebar is open, then make sure
* to close it when opening the search
*/
if (update && mobile) {
toggle("mobile")
}
setOpen(update)
}
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
setOpen((prev) => !prev)
}
}
document.addEventListener("keydown", onKeyDown)
return () => {
document.removeEventListener("keydown", onKeyDown)
}
}, [])
return (
<SearchContext.Provider
value={{
open,
onOpenChange: setOpen,
toggleSearch,
}}
>
{children}
<Search />
</SearchContext.Provider>
)
}
@@ -0,0 +1,10 @@
import { useContext } from "react"
import { SearchContext } from "./search-context"
export const useSearch = () => {
const context = useContext(SearchContext)
if (!context) {
throw new Error("useSearch must be used within a SearchProvider")
}
return context
}
@@ -0,0 +1,2 @@
export * from "./sidebar-provider"
export * from "./use-sidebar"
@@ -0,0 +1,9 @@
import { createContext } from "react"
type SidebarContextValue = {
desktop: boolean
mobile: boolean
toggle: (view: "desktop" | "mobile") => void
}
export const SidebarContext = createContext<SidebarContextValue | null>(null)
@@ -0,0 +1,31 @@
import { PropsWithChildren, useEffect, useState } from "react"
import { useLocation } from "react-router-dom"
import { SidebarContext } from "./sidebar-context"
export const SidebarProvider = ({ children }: PropsWithChildren) => {
const [desktop, setDesktop] = useState(true)
const [mobile, setMobile] = useState(false)
const { pathname } = useLocation()
const toggle = (view: "desktop" | "mobile") => {
if (view === "desktop") {
setDesktop(!desktop)
} else {
setMobile(!mobile)
}
}
// close the mobile sidebar on route change
// this is to prevent the sidebar from staying open
// when navigating to a new page
useEffect(() => {
setMobile(false)
}, [pathname])
return (
<SidebarContext.Provider value={{ desktop, mobile, toggle }}>
{children}
</SidebarContext.Provider>
)
}
@@ -0,0 +1,12 @@
import { useContext } from "react"
import { SidebarContext } from "./sidebar-context"
export const useSidebar = () => {
const context = useContext(SidebarContext)
if (!context) {
throw new Error("useSidebar must be used within a SidebarProvider")
}
return context
}
@@ -0,0 +1,3 @@
export type { ThemeOption as Theme } from "./theme-context"
export * from "./theme-provider"
export * from "./use-theme"
@@ -0,0 +1,11 @@
import { createContext } from "react"
export type ThemeOption = "light" | "dark" | "system"
export type ThemeValue = "light" | "dark"
type ThemeContextValue = {
theme: ThemeOption
setTheme: (theme: ThemeOption) => void
}
export const ThemeContext = createContext<ThemeContextValue | null>(null)
@@ -0,0 +1,82 @@
import { PropsWithChildren, useEffect, useState } from "react"
import { ThemeContext, ThemeOption, ThemeValue } from "./theme-context"
const THEME_KEY = "medusa_admin_theme"
function getDefaultValue(): ThemeOption {
const persisted = localStorage?.getItem(THEME_KEY) as ThemeOption
if (persisted) {
return persisted
}
return "system"
}
function getThemeValue(selected: ThemeOption): ThemeValue {
if (selected === "system") {
if (window !== undefined) {
return window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light"
}
// Default to light theme if we can't detect the system preference
return "light"
}
return selected
}
export const ThemeProvider = ({ children }: PropsWithChildren) => {
const [state, setState] = useState<ThemeOption>(getDefaultValue())
const [value, setValue] = useState<ThemeValue>(getThemeValue(state))
const setTheme = (theme: ThemeOption) => {
localStorage.setItem(THEME_KEY, theme)
const themeValue = getThemeValue(theme)
setState(theme)
setValue(themeValue)
}
useEffect(() => {
const html = document.querySelector("html")
if (html) {
/**
* Temporarily disable transitions to prevent
* the theme change from flashing.
*/
const css = document.createElement("style")
css.appendChild(
document.createTextNode(
`* {
-webkit-transition: none !important;
-moz-transition: none !important;
-o-transition: none !important;
-ms-transition: none !important;
transition: none !important;
}`
)
)
document.head.appendChild(css)
html.classList.remove(value === "light" ? "dark" : "light")
html.classList.add(value)
/**
* Re-enable transitions after the theme has been set,
* and force the browser to repaint.
*/
window.getComputedStyle(css).opacity
document.head.removeChild(css)
}
}, [value])
return (
<ThemeContext.Provider value={{ theme: state, setTheme }}>
{children}
</ThemeContext.Provider>
)
}
@@ -0,0 +1,10 @@
import { useContext } from "react"
import { ThemeContext } from "./theme-context"
export const useTheme = () => {
const context = useContext(ThemeContext)
if (!context) {
throw new Error("useTheme must be used within a ThemeProvider")
}
return context
}