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:
committed by
GitHub
parent
26c78bbc03
commit
f1a05f4725
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,7 @@
|
||||
import { useExtensionBaseProps } from "../../../hooks/use-extension-base-props"
|
||||
|
||||
export const useSettingContainerProps = () => {
|
||||
const baseProps = useExtensionBaseProps()
|
||||
|
||||
return baseProps
|
||||
}
|
||||
@@ -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
|
||||
@@ -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",
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user