feat(dashboard): Rework route modals (#6459)

**What**
- Reworks how RouteModals are setup.

**Why**
- With the current implementation it was possible to create a race-condition in the logic that handled displaying a prompt if the user tried to close a modal, while a child form was dirty. The race condition would cause a new prompt to spawn each time the user clicked the screen, making it impossible to dismiss the prompt. This only occurred in a few specific cases.

**How**
- Creates two new components: RouteFocusModal and RouteDrawer. The component shares logic for handling their own open/closed state, and now accept a form prop, that allows the modals to keep track of whether their child form is dirty. This change ensures that race conditions cannot occur, and that the prompt always only renders once when needed.
This commit is contained in:
Kasper Fabricius Kristensen
2024-02-22 09:03:40 +00:00
committed by GitHub
parent add861d205
commit 72a17d6cd7
88 changed files with 1750 additions and 1799 deletions
@@ -0,0 +1,3 @@
export { RouteDrawer } from "./route-drawer"
export { RouteFocusModal } from "./route-focus-modal"
export { useRouteModal } from "./route-modal-provider"
@@ -0,0 +1 @@
export * from "./route-drawer"
@@ -0,0 +1,59 @@
import { Drawer } from "@medusajs/ui"
import { PropsWithChildren, useEffect, useState } from "react"
import { useNavigate } from "react-router-dom"
import { RouteForm } from "../route-form"
import { RouteModalProvider } from "../route-modal-provider/route-provider"
type RouteDrawerProps = PropsWithChildren<{
prev?: string
}>
const Root = ({ prev = "..", children }: RouteDrawerProps) => {
const navigate = useNavigate()
const [open, setOpen] = useState(false)
/**
* Open the modal when the component mounts. This
* ensures that the entry animation is played.
*/
useEffect(() => {
setOpen(true)
}, [])
const handleOpenChange = (open: boolean) => {
if (!open) {
document.body.style.pointerEvents = "auto"
navigate(prev, { replace: true })
return
}
setOpen(open)
}
return (
<Drawer open={open} onOpenChange={handleOpenChange}>
<RouteModalProvider prev={prev}>
<Drawer.Content>{children}</Drawer.Content>
</RouteModalProvider>
</Drawer>
)
}
const Header = Drawer.Header
const Body = Drawer.Body
const Footer = Drawer.Footer
const Close = Drawer.Close
const Form = RouteForm
/**
* Drawer that is used to render a form on a separate route.
*
* Typically used for forms editing a resource.
*/
export const RouteDrawer = Object.assign(Root, {
Header,
Body,
Footer,
Close,
Form,
})
@@ -0,0 +1 @@
export * from "./route-focus-modal"
@@ -0,0 +1,58 @@
import { FocusModal } from "@medusajs/ui"
import { PropsWithChildren, useEffect, useState } from "react"
import { useNavigate } from "react-router-dom"
import { RouteForm } from "../route-form"
import { RouteModalProvider } from "../route-modal-provider/route-provider"
type RouteFocusModalProps = PropsWithChildren<{
prev?: string
}>
const Root = ({ prev = "..", children }: RouteFocusModalProps) => {
const navigate = useNavigate()
const [open, setOpen] = useState(false)
/**
* Open the modal when the component mounts. This
* ensures that the entry animation is played.
*/
useEffect(() => {
setOpen(true)
}, [])
const handleOpenChange = (open: boolean) => {
if (!open) {
document.body.style.pointerEvents = "auto"
navigate(prev, { replace: true })
return
}
setOpen(open)
}
return (
<FocusModal open={open} onOpenChange={handleOpenChange}>
<RouteModalProvider prev={prev}>
<FocusModal.Content>{children}</FocusModal.Content>
</RouteModalProvider>
</FocusModal>
)
}
const Header = FocusModal.Header
const Body = FocusModal.Body
const Close = FocusModal.Close
const Form = RouteForm
/**
* FocusModal that is used to render a form on a separate route.
*
* Typically used for forms creating a resource or forms that require
* a lot of space.
*/
export const RouteFocusModal = Object.assign(Root, {
Header,
Body,
Close,
Form,
})
@@ -0,0 +1 @@
export * from "./route-form"
@@ -0,0 +1,63 @@
import { Prompt } from "@medusajs/ui"
import { PropsWithChildren } from "react"
import { FieldValues, UseFormReturn } from "react-hook-form"
import { useTranslation } from "react-i18next"
import { useBlocker } from "react-router-dom"
import { Form } from "../../common/form"
type RouteFormProps<TFieldValues extends FieldValues> = PropsWithChildren<{
form: UseFormReturn<TFieldValues>
}>
export const RouteForm = <TFieldValues extends FieldValues = any>({
form,
children,
}: RouteFormProps<TFieldValues>) => {
const { t } = useTranslation()
const {
formState: { isDirty },
} = form
const blocker = useBlocker(({ currentLocation, nextLocation }) => {
const { isSubmitSuccessful } = nextLocation.state || {}
if (isSubmitSuccessful) {
return false
}
return isDirty && currentLocation.pathname !== nextLocation.pathname
})
const handleCancel = () => {
blocker?.reset?.()
}
const handleContinue = () => {
blocker?.proceed?.()
}
return (
<Form {...form}>
{children}
<Prompt open={blocker.state === "blocked"} variant="confirmation">
<Prompt.Content>
<Prompt.Header>
<Prompt.Title>{t("general.unsavedChangesTitle")}</Prompt.Title>
<Prompt.Description>
{t("general.unsavedChangesDescription")}
</Prompt.Description>
</Prompt.Header>
<Prompt.Footer>
<Prompt.Cancel onClick={handleCancel} type="button">
{t("actions.cancel")}
</Prompt.Cancel>
<Prompt.Action onClick={handleContinue} type="button">
{t("actions.continue")}
</Prompt.Action>
</Prompt.Footer>
</Prompt.Content>
</Prompt>
</Form>
)
}
@@ -0,0 +1 @@
export * from "./route-provider"
@@ -0,0 +1,41 @@
import { PropsWithChildren, createContext, useContext } from "react"
import { useNavigate } from "react-router-dom"
type RouteModalProviderContextType = {
handleSuccess: (path?: string) => void
}
const RouteModalProviderContext =
createContext<RouteModalProviderContextType | null>(null)
export const useRouteModal = () => {
const context = useContext(RouteModalProviderContext)
if (!context) {
throw new Error("useRouteModal must be used within a RouteModalProvider")
}
return context
}
type RouteModalProviderProps = PropsWithChildren<{
prev: string
}>
export const RouteModalProvider = ({
prev,
children,
}: RouteModalProviderProps) => {
const navigate = useNavigate()
const handleSuccess = (path?: string) => {
const to = path || prev
navigate(to, { replace: true, state: { isSubmitSuccessful: true } })
}
return (
<RouteModalProviderContext.Provider value={{ handleSuccess }}>
{children}
</RouteModalProviderContext.Provider>
)
}
@@ -0,0 +1,56 @@
import { Country } from "@medusajs/medusa"
import { Tooltip } from "@medusajs/ui"
import { useTranslation } from "react-i18next"
import { PlaceholderCell } from "../../common/placeholder-cell"
type CountriesCellProps = {
countries?: Country[] | null
}
export const CountriesCell = ({ countries }: CountriesCellProps) => {
const { t } = useTranslation()
if (!countries || countries.length === 0) {
return <PlaceholderCell />
}
const displayValue = countries
.slice(0, 2)
.map((c) => c.display_name)
.join(", ")
const additionalCountries = countries.slice(2).map((c) => c.display_name)
return (
<div className="flex size-full items-center gap-x-1">
<span>{displayValue}</span>
{additionalCountries.length > 0 && (
<Tooltip
content={
<ul>
{additionalCountries.map((c) => (
<li key={c}>{c}</li>
))}
</ul>
}
>
<span>
{t("general.plusCountMore", {
count: additionalCountries.length,
})}
</span>
</Tooltip>
)}
</div>
)
}
export const CountriesHeader = () => {
const { t } = useTranslation()
return (
<div className="flex size-full items-center">
<span>{t("fields.countries")}</span>
</div>
)
}
@@ -0,0 +1 @@
export * from "./countries-cell"
@@ -0,0 +1,58 @@
import { FulfillmentProvider } from "@medusajs/medusa"
import { Tooltip } from "@medusajs/ui"
import { useTranslation } from "react-i18next"
import { PlaceholderCell } from "../../common/placeholder-cell"
type FulfillmentProvidersCellProps = {
fulfillmentProviders?: FulfillmentProvider[] | null
}
export const FulfillmentProvidersCell = ({
fulfillmentProviders,
}: FulfillmentProvidersCellProps) => {
const { t } = useTranslation()
if (!fulfillmentProviders || fulfillmentProviders.length === 0) {
return <PlaceholderCell />
}
const displayValue = fulfillmentProviders
.slice(0, 2)
.map((p) => p.id)
.join(", ")
const additionalProviders = fulfillmentProviders.slice(2).map((c) => c.id)
return (
<div className="flex size-full items-center gap-x-1">
<span>{displayValue}</span>
{additionalProviders.length > 0 && (
<Tooltip
content={
<ul>
{additionalProviders.map((c) => (
<li key={c}>{c}</li>
))}
</ul>
}
>
<span>
{t("general.plusCountMore", {
count: additionalProviders.length,
})}
</span>
</Tooltip>
)}
</div>
)
}
export const FulfillmentProvidersHeader = () => {
const { t } = useTranslation()
return (
<div className="flex size-full items-center">
<span>{t("fields.fulfillmentProviders")}</span>
</div>
)
}
@@ -0,0 +1 @@
export * from "./fulfillment-providers-cell"
@@ -0,0 +1 @@
export * from "./payment-providers-cell"
@@ -0,0 +1,58 @@
import { PaymentProvider } from "@medusajs/medusa"
import { Tooltip } from "@medusajs/ui"
import { useTranslation } from "react-i18next"
import { PlaceholderCell } from "../../common/placeholder-cell"
type PaymentProvidersCellProps = {
paymentProviders?: PaymentProvider[] | null
}
export const PaymentProvidersCell = ({
paymentProviders,
}: PaymentProvidersCellProps) => {
const { t } = useTranslation()
if (!paymentProviders || paymentProviders.length === 0) {
return <PlaceholderCell />
}
const displayValue = paymentProviders
.slice(0, 2)
.map((p) => p.id)
.join(", ")
const additionalProviders = paymentProviders.slice(2).map((c) => c.id)
return (
<div className="flex size-full items-center gap-x-1">
<span>{displayValue}</span>
{additionalProviders.length > 0 && (
<Tooltip
content={
<ul>
{additionalProviders.map((c) => (
<li key={c}>{c}</li>
))}
</ul>
}
>
<span>
{t("general.plusCountMore", {
count: additionalProviders.length,
})}
</span>
</Tooltip>
)}
</div>
)
}
export const PaymentProvidersHeader = () => {
const { t } = useTranslation()
return (
<div className="flex size-full items-center">
<span>{t("fields.paymentProviders")}</span>
</div>
)
}
@@ -0,0 +1 @@
export * from "./region-cell"
@@ -0,0 +1,23 @@
import { useTranslation } from "react-i18next"
type RegionCellProps = {
name: string
}
export const RegionCell = ({ name }: RegionCellProps) => {
return (
<div className="flex h-full w-full items-center overflow-hidden">
<span className="truncate">{name}</span>
</div>
)
}
export const RegionHeader = () => {
const { t } = useTranslation()
return (
<div className="flex h-full w-full items-center">
<span className="truncate">{t("fields.name")}</span>
</div>
)
}