Skip to content

Commit

Permalink
feat(dashboard): Rework route modals (#6459)
Browse files Browse the repository at this point in the history
**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.
  • Loading branch information
kasperkristensen authored Feb 22, 2024
1 parent add861d commit 72a17d6
Show file tree
Hide file tree
Showing 88 changed files with 1,748 additions and 1,797 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@
"gallery": "Gallery",
"titleHint": "Give your product a short and clear title.<0/>50-60 characters is the recommended length for search engines.",
"descriptionHint": "Give your product a short and clear description.<0/>120-160 characters is the recommended length for search engines.",
"discountableHint": "When unchecked discounts will not be applied to this product.",
"handleTooltip": "The handle is used to reference the product in your storefront. If not specified, the handle will be generated from the product title.",
"availableInSalesChannels": "Available in <0>{{x}}</0> of <1>{{y}}</1> sales channels",
"noSalesChannels": "Not available in any sales channels",
Expand Down Expand Up @@ -249,7 +250,12 @@
"taxInclusiveHint": "When enabled all prices in the region will be tax inclusive.",
"providersHint": "The providers that are available in the region.",
"shippingOptions": "Shipping Options",
"returnShippingOptions": "Return Shipping Options"
"returnShippingOptions": "Return Shipping Options",
"return": "Return",
"outbound": "Outbound",
"priceType": "Price Type",
"flatRate": "Flat Rate",
"calculated": "Calculated"
},
"locations": {
"domain": "Locations",
Expand Down Expand Up @@ -399,6 +405,7 @@
"dateIssued": "Date issued",
"issuedDate": "Issued date",
"expiryDate": "Expiry date",
"price": "Price",
"height": "Height",
"width": "Width",
"length": "Length",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export { RouteDrawer } from "./route-drawer"
export { RouteFocusModal } from "./route-focus-modal"
export { useRouteModal } from "./route-modal-provider"
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./route-drawer"
Original file line number Diff line number Diff line change
@@ -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,
})
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./route-focus-modal"
Original file line number Diff line number Diff line change
@@ -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,
})
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./route-form"
Original file line number Diff line number Diff line change
@@ -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>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./route-provider"
Original file line number Diff line number Diff line change
@@ -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>
)
}
Original file line number Diff line number Diff line change
@@ -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>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./countries-cell"
Original file line number Diff line number Diff line change
@@ -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>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./fulfillment-providers-cell"
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./payment-providers-cell"
Loading

0 comments on commit 72a17d6

Please sign in to comment.