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

View File

@@ -0,0 +1,80 @@
import React from "react"
import { Route, Routes } from "react-router-dom"
import { useRoutes } from "../../../providers/route-provider"
import { Route as AdminRoute, RouteSegment } from "../../../types/extensions"
import { isRoute } from "../../../utils/extensions"
import RouteErrorElement from "./route-error-element"
import { useRouteContainerProps } from "./use-route-container-props"
type RouteContainerProps = {
route: AdminRoute | RouteSegment
previousPath?: string
}
const RouteContainer = ({ route, previousPath = "" }: RouteContainerProps) => {
const routeContainerProps = useRouteContainerProps()
const isFullRoute = isRoute(route)
const { path } = route
const { getNestedRoutes } = useRoutes()
const fullPath = `${previousPath}${path}`
const nestedRoutes = getNestedRoutes(fullPath)
const hasNestedRoutes = nestedRoutes.length > 0
/**
* If the route is only a segment, we need to render the nested routes that
* are children of the segment. If the segment has no nested routes, we
* return null.
*/
if (!isFullRoute) {
if (hasNestedRoutes) {
return (
<Routes>
{nestedRoutes.map((r, i) => (
<Route
path={r.path}
key={i}
element={<RouteContainer route={r} previousPath={fullPath} />}
/>
))}
</Routes>
)
}
return null
}
const { Page, origin } = route
const PageWithProps = React.createElement(Page, routeContainerProps)
if (!hasNestedRoutes) {
return PageWithProps
}
return (
<>
<Routes>
<Route
path={"/"}
element={PageWithProps}
errorElement={<RouteErrorElement origin={origin} />}
/>
{nestedRoutes.map((r, i) => (
<Route
path={r.path}
key={i}
element={<RouteContainer route={r} previousPath={fullPath} />}
/>
))}
</Routes>
</>
)
}
export default RouteContainer

View File

@@ -0,0 +1,75 @@
import { useEffect } from "react"
import { useRouteError } from "react-router-dom"
import Button from "../../fundamentals/button"
import RefreshIcon from "../../fundamentals/icons/refresh-icon"
import WarningCircleIcon from "../../fundamentals/icons/warning-circle"
type PageErrorElementProps = {
origin: string
}
const isProd = process.env.NODE_ENV === "production"
const RouteErrorElement = ({ origin }: PageErrorElementProps) => {
const error = useRouteError()
useEffect(() => {
if (!isProd && error) {
console.group(
`%cAn error occurred in a page from ${origin}:`,
"color: red; font-weight: bold;"
)
console.error(error)
console.groupEnd()
}
}, [error, origin])
const reload = () => {
window.location.reload()
}
return (
<div className="flex h-full w-full items-center justify-center">
<div className="rounded-rounded p-base bg-rose-10 border-rose-40 gap-x-small flex justify-start border">
<div>
<WarningCircleIcon
size={20}
fillType="solid"
className="text-rose-40"
/>
</div>
<div className="text-rose-40 inter-small-regular w-full pr-[20px]">
<h1 className="inter-base-semibold mb-2xsmall">Uncaught error</h1>
<p className="mb-small">
{isProd
? "An error unknown error occurred, and the page could not be loaded."
: `A Page from <strong>${origin}</strong> crashed. See the console for more info.`}
</p>
<p className="mb-large">
<strong>What should I do?</strong>
<br />
If you are the developer of this page, you should fix the error and
reload the page. If you are not the developer, you should contact
the maintainer and report the error.
</p>
<div className="gap-x-base flex items-center">
<Button
variant="nuclear"
size="small"
type="button"
onClick={reload}
className="w-full"
>
<div className="flex items-center">
<RefreshIcon size="20" />
<span className="ml-xsmall">Reload</span>
</div>
</Button>
</div>
</div>
</div>
</div>
)
}
export default RouteErrorElement

View File

@@ -0,0 +1,8 @@
import { useExtensionBaseProps } from "../../../hooks/use-extension-base-props"
import { RouteProps } from "../../../types/extensions"
export const useRouteContainerProps = (): RouteProps => {
const baseProps = useExtensionBaseProps()
return baseProps
}

View File

@@ -0,0 +1,14 @@
import React, { ComponentType } from "react"
import { useSettingContainerProps } from "./use-setting-container-props"
type SettingContainerProps = {
Page: ComponentType<any>
}
const SettingContainer = ({ Page }: SettingContainerProps) => {
const props = useSettingContainerProps()
return React.createElement(Page, props)
}
export default SettingContainer

View File

@@ -0,0 +1,77 @@
import { useEffect } from "react"
import { useRouteError } from "react-router-dom"
import Button from "../../fundamentals/button"
import RefreshIcon from "../../fundamentals/icons/refresh-icon"
import WarningCircleIcon from "../../fundamentals/icons/warning-circle"
type SettingsPageErrorElementProps = {
origin: string
}
const isProd = process.env.NODE_ENV === "production"
const SettingsPageErrorElement = ({
origin,
}: SettingsPageErrorElementProps) => {
const error = useRouteError()
useEffect(() => {
if (!isProd && error) {
console.group(
`%cAn error occurred in a settings page from ${origin}:`,
"color: red; font-weight: bold;"
)
console.error(error)
console.groupEnd()
}
}, [error, origin])
const reload = () => {
window.location.reload()
}
return (
<div className="flex h-full w-full items-center justify-center">
<div className="rounded-rounded p-base bg-rose-10 border-rose-40 gap-x-small flex justify-start border">
<div>
<WarningCircleIcon
size={20}
fillType="solid"
className="text-rose-40"
/>
</div>
<div className="text-rose-40 inter-small-regular w-full pr-[20px]">
<h1 className="inter-base-semibold mb-2xsmall">Uncaught error</h1>
<p className="mb-small">
{isProd
? "An error unknown error occurred, and the page could not be loaded."
: `A Page from <strong>${origin}</strong> crashed. See the console for more info.`}
</p>
<p className="mb-large">
<strong>What should I do?</strong>
<br />
If you are the developer of this setting page, you should fix the
error and reload the page. If you are not the developer, you should
contact the maintainer and report the error.
</p>
<div className="gap-x-base flex items-center">
<Button
variant="nuclear"
size="small"
type="button"
onClick={reload}
className="w-full"
>
<div className="flex items-center">
<RefreshIcon size="20" />
<span className="ml-xsmall">Reload</span>
</div>
</Button>
</div>
</div>
</div>
</div>
)
}
export default SettingsPageErrorElement

View File

@@ -0,0 +1,7 @@
import { useExtensionBaseProps } from "../../../hooks/use-extension-base-props"
export const useSettingContainerProps = () => {
const baseProps = useExtensionBaseProps()
return baseProps
}

View File

@@ -0,0 +1,32 @@
import React from "react"
import { InjectionZone, Widget } from "../../../types/extensions"
import { EntityMap } from "./types"
import { useWidgetContainerProps } from "./use-widget-container-props"
import WidgetErrorBoundary from "./widget-error-boundary"
type WidgetContainerProps<T extends keyof EntityMap> = {
injectionZone: T
widget: Widget
entity: EntityMap[T]
}
const WidgetContainer = <T extends InjectionZone>({
injectionZone,
widget,
entity,
}: WidgetContainerProps<T>) => {
const { Widget, origin } = widget
const props = useWidgetContainerProps({
injectionZone,
entity,
})
return (
<WidgetErrorBoundary origin={origin}>
{React.createElement(Widget, props)}
</WidgetErrorBoundary>
)
}
export default WidgetContainer

View File

@@ -0,0 +1,77 @@
import {
Customer,
CustomerGroup,
Discount,
DraftOrder,
GiftCard,
Order,
PriceList,
Product,
ProductCollection,
} from "@medusajs/medusa"
export type EntityMap = {
// Details
"product.details.after": Product
"product.details.before": Product
"product_collection.details.after": ProductCollection
"product_collection.details.before": ProductCollection
"order.details.after": Order
"order.details.before": Order
"draft_order.details.after": DraftOrder
"draft_order.details.before": DraftOrder
"customer.details.after": Customer
"customer.details.before": Customer
"customer_group.details.after": CustomerGroup
"customer_group.details.before": CustomerGroup
"discount.details.after": Discount
"discount.details.before": Discount
"price_list.details.after": PriceList
"price_list.details.before": PriceList
"gift_card.details.after": Product
"gift_card.details.before": Product
"custom_gift_card.after": GiftCard
"custom_gift_card.before": GiftCard
// List
"product.list.after"?: never | null | undefined
"product.list.before"?: never | null | undefined
"product_collection.list.after"?: never | null | undefined
"product_collection.list.before"?: never | null | undefined
"order.list.after"?: never | null | undefined
"order.list.before"?: never | null | undefined
"draft_order.list.after"?: never | null | undefined
"draft_order.list.before"?: never | null | undefined
"customer.list.after"?: never | null | undefined
"customer.list.before"?: never | null | undefined
"customer_group.list.after"?: never | null | undefined
"customer_group.list.before"?: never | null | undefined
"discount.list.after"?: never | null | undefined
"discount.list.before"?: never | null | undefined
"price_list.list.after"?: never | null | undefined
"price_list.list.before"?: never | null | undefined
"gift_card.list.after"?: never | null | undefined
"gift_card.list.before"?: never | null | undefined
// Login
"login.before"?: never | null | undefined
"login.after"?: never | null | undefined
}
export const PropKeyMap = {
"product.details.after": "product",
"product.details.before": "product",
"product_collection.details.after": "productCollection",
"product_collection.details.before": "productCollection",
"order.details.after": "order",
"order.details.before": "order",
"draft_order.details.after": "draftOrder",
"draft_order.details.before": "draftOrder",
"customer.details.after": "customer",
"customer.details.before": "customer",
"customer_group.details.after": "customerGroup",
"customer_group.details.before": "customerGroup",
"discount.details.after": "discount",
"discount.details.before": "discount",
"price_list.details.after": "priceList",
"price_list.details.before": "priceList",
custom_gift_card: "giftCard",
}

View File

@@ -0,0 +1,31 @@
import { useExtensionBaseProps } from "../../../hooks/use-extension-base-props"
import { WidgetProps } from "../../../types/extensions"
import { EntityMap, PropKeyMap } from "./types"
type UseWidgetContainerProps<T extends keyof EntityMap> = {
injectionZone: T
entity?: EntityMap[T]
}
export const useWidgetContainerProps = <T extends keyof EntityMap>({
injectionZone,
entity,
}: UseWidgetContainerProps<T>) => {
const baseProps = useExtensionBaseProps() satisfies WidgetProps
/**
* Not all InjectionZones have an entity, so we need to check for it first, and then
* add it to the props if it exists.
*/
if (entity) {
const propKey = injectionZone as keyof typeof PropKeyMap
const entityKey = PropKeyMap[propKey]
return {
...baseProps,
[entityKey]: entity,
}
}
return baseProps
}

View File

@@ -0,0 +1,136 @@
import React, { ErrorInfo } from "react"
import Button from "../../fundamentals/button"
import RefreshIcon from "../../fundamentals/icons/refresh-icon"
import WarningCircleIcon from "../../fundamentals/icons/warning-circle"
import XCircleIcon from "../../fundamentals/icons/x-circle-icon"
type Props = {
children: React.ReactNode
origin: string
}
type State = {
hasError: boolean
hidden?: boolean
}
class WidgetErrorBoundary extends React.Component<Props, State> {
public state: State = {
hasError: false,
}
public static getDerivedStateFromError(_: Error): State {
return { hasError: true, hidden: false }
}
public componentDidCatch(error: Error, errorInfo: ErrorInfo) {
if (process.env.NODE_ENV !== "production") {
console.group(
`%cAn error occurred in a widget from ${this.props.origin}:`,
"color: red; font-weight: bold, background-color: #fff;"
)
console.error(error)
console.error(
"%cComponent Stack:",
"color: red",
errorInfo.componentStack
)
console.groupEnd()
}
}
public handleResetError() {
this.setState({ hasError: false })
}
public hideError() {
this.setState({ hidden: true })
}
public renderFallback() {
if (process.env.NODE_ENV !== "production" && !this.state.hidden) {
return (
<FallbackWidget
origin={this.props.origin}
reset={this.handleResetError.bind(this)}
hide={this.hideError.bind(this)}
/>
)
}
// Don't render anything in production
return null
}
render() {
if (this.state.hasError) {
return this.renderFallback()
}
return this.props.children
}
}
const FallbackWidget = ({
origin,
reset,
hide,
}: {
origin: string
reset: () => void
hide: () => void
}) => {
return (
<div className="rounded-rounded p-base bg-rose-10 border-rose-40 gap-x-small flex justify-start border">
<div>
<WarningCircleIcon
size={20}
fillType="solid"
className="text-rose-40"
/>
</div>
<div className="text-rose-40 inter-small-regular w-full pr-[20px]">
<h1 className="inter-base-semibold mb-2xsmall">Uncaught error</h1>
<p className="mb-small">
A widget from <strong>{origin}</strong> crashed. See the console for
more info.
</p>
<p className="mb-large">
<strong>What should I do?</strong>
<br />
If you are the developer of this widget, you should fix the error and
reload the page. If you are not the developer, you should contact the
maintainer and report the error.
</p>
<div className="gap-x-base flex items-center">
<Button
variant="nuclear"
size="small"
type="button"
onClick={hide}
className="w-full"
>
<div className="flex items-center">
<XCircleIcon size="20" />
<span className="ml-xsmall">Hide</span>
</div>
</Button>
<Button
variant="nuclear"
size="small"
type="button"
onClick={reset}
className="w-full"
>
<div className="flex items-center">
<RefreshIcon size="20" />
<span className="ml-xsmall">Reload</span>
</div>
</Button>
</div>
</div>
</div>
)
}
export default WidgetErrorBoundary