feat(dashboard,medusa): Draft order detail (#6703)

**What**
- Adds draft order details page
- Adds Shipping and Billing address forms to both draft order and order pages
- Adds Email form to both draft order and order pages
- Adds transfer ownership form to draft order, order and customer pages
- Update Combobox component allowing it to work with async data (`useInfiniteQuery`)

**@medusajs/medusa**
- Include country as a default relation of draft order addresses
This commit is contained in:
Kasper Fabricius Kristensen
2024-03-15 11:29:59 +01:00
committed by GitHub
parent 68d869607f
commit c3f26a6826
70 changed files with 2884 additions and 573 deletions

View File

@@ -12,217 +12,318 @@ import * as Popover from "@radix-ui/react-popover"
import { matchSorter } from "match-sorter"
import {
ComponentPropsWithoutRef,
forwardRef,
ForwardedRef,
useCallback,
useImperativeHandle,
useMemo,
useRef,
useState,
useTransition,
} from "react"
import { useTranslation } from "react-i18next"
import { genericForwardRef } from "../generic-forward-ref"
type ComboboxOption = {
value: string
label: string
}
interface ComboboxProps
type Value = string[] | string
interface ComboboxProps<T extends Value = Value>
extends Omit<ComponentPropsWithoutRef<"input">, "onChange" | "value"> {
value?: string[]
onChange?: (value?: string[]) => void
value?: T
onChange?: (value?: T) => void
searchValue?: string
onSearchValueChange?: (value: string) => void
options: ComboboxOption[]
fetchNextPage?: () => void
isFetchingNextPage?: boolean
}
export const Combobox = forwardRef<HTMLInputElement, ComboboxProps>(
(
{
value: controlledValue,
onChange,
options,
className,
placeholder,
...inputProps
},
ref
) => {
const [open, setOpen] = useState(false)
const { t } = useTranslation()
const ComboboxImpl = <T extends Value = string>(
{
value: controlledValue,
onChange,
searchValue: controlledSearchValue,
onSearchValueChange,
options,
className,
placeholder,
fetchNextPage,
isFetchingNextPage,
...inputProps
}: ComboboxProps<T>,
ref: ForwardedRef<HTMLInputElement>
) => {
const [open, setOpen] = useState(false)
const [isPending, startTransition] = useTransition()
const { t } = useTranslation()
const comboboxRef = useRef<HTMLInputElement>(null)
const listboxRef = useRef<HTMLDivElement>(null)
const comboboxRef = useRef<HTMLInputElement>(null)
const listboxRef = useRef<HTMLDivElement>(null)
useImperativeHandle(ref, () => comboboxRef.current!)
useImperativeHandle(ref, () => comboboxRef.current!)
const isControlled = controlledValue !== undefined
const [searchValue, setSearchValue] = useState("")
const [uncontrolledValue, setUncontrolledValue] = useState<string[]>([])
const isValueControlled = controlledValue !== undefined
const isSearchControlled = controlledSearchValue !== undefined
const selectedValues = isControlled ? controlledValue : uncontrolledValue
const isArrayValue = Array.isArray(controlledValue)
const emptyState = (isArrayValue ? [] : "") as T
const handleValueChange = (newValues?: string[]) => {
if (!isControlled) {
setUncontrolledValue(newValues || [])
}
if (onChange) {
onChange(newValues)
}
const [uncontrolledSearchValue, setUncontrolledSearchValue] = useState(
controlledSearchValue || ""
)
const [uncontrolledValue, setUncontrolledValue] = useState<T>(emptyState)
const searchValue = isSearchControlled
? controlledSearchValue
: uncontrolledSearchValue
const selectedValues = isValueControlled ? controlledValue : uncontrolledValue
const handleValueChange = (newValues?: T) => {
if (!isArrayValue) {
const label = options.find((o) => o.value === newValues)?.label
setUncontrolledSearchValue(label || "")
}
/**
* Filter and sort the options based on the search value,
* and whether the value is already selected.
*/
const matches = useMemo(() => {
return matchSorter(options, searchValue, {
keys: ["label"],
baseSort: (a, b) => {
const aIndex = selectedValues.indexOf(a.item.value)
const bIndex = selectedValues.indexOf(b.item.value)
if (!isValueControlled) {
setUncontrolledValue(newValues || emptyState)
}
if (onChange) {
onChange(newValues)
}
if (aIndex === -1 && bIndex === -1) {
return 0
}
setUncontrolledSearchValue("")
}
if (aIndex === -1) {
return 1
}
const handleSearchChange = (query: string) => {
setUncontrolledSearchValue(query)
if (bIndex === -1) {
return -1
}
if (onSearchValueChange) {
onSearchValueChange(query)
}
}
return aIndex - bIndex
},
})
}, [options, searchValue, selectedValues])
/**
* Filter and sort the options based on the search value,
* and whether the value is already selected.
*
* This is only used when the search value is uncontrolled.
*/
const matches = useMemo(() => {
if (isSearchControlled) {
return []
}
const hasValues = selectedValues.length > 0
const showSelected = hasValues && !searchValue && !open
const hidePlaceholder = showSelected || open
return matchSorter(options, uncontrolledSearchValue, {
keys: ["label"],
baseSort: (a, b) => {
const aIndex = selectedValues.indexOf(a.item.value)
const bIndex = selectedValues.indexOf(b.item.value)
return (
<Popover.Root open={open} onOpenChange={setOpen}>
<PrimitiveComboboxProvider
open={open}
setOpen={setOpen}
selectedValue={selectedValues}
setSelectedValue={handleValueChange}
setValue={(value) => {
setSearchValue(value)
}}
>
<Popover.Anchor asChild>
<div
className={clx(
"relative flex cursor-pointer items-center gap-x-2 overflow-hidden",
"h-8 w-full rounded-md px-2 py-0.5",
"bg-ui-bg-field transition-fg shadow-borders-base",
"hover:bg-ui-bg-field-hover",
"has-[input:focus]:shadow-borders-interactive-with-active",
"has-[:invalid]:shadow-borders-error",
"has-[:disabled]:bg-ui-bg-disabled has-[:disabled]:text-ui-fg-disabled has-[:disabled]:cursor-not-allowed",
{
"pl-0.5": hasValues,
},
className
if (aIndex === -1 && bIndex === -1) {
return 0
}
if (aIndex === -1) {
return 1
}
if (bIndex === -1) {
return -1
}
return aIndex - bIndex
},
})
}, [options, uncontrolledSearchValue, selectedValues, isSearchControlled])
const observer = useRef(
new IntersectionObserver(
(entries) => {
const first = entries[0]
if (first.isIntersecting) {
fetchNextPage?.()
}
},
{ threshold: 1 }
)
)
const lastOptionRef = useCallback(
(node: HTMLDivElement) => {
if (isFetchingNextPage) {
return
}
if (observer.current) {
observer.current.disconnect()
}
if (node) {
observer.current.observe(node)
}
},
[isFetchingNextPage]
)
const hasValue = selectedValues.length > 0
const showTag = hasValue && isArrayValue
const showSelected = showTag && !searchValue && !open
const hideInput = !isArrayValue && !open
const selectedLabel = options.find((o) => o.value === selectedValues)?.label
const hidePlaceholder = showSelected || open
const results = isSearchControlled ? options : matches
return (
<Popover.Root open={open} onOpenChange={setOpen}>
<PrimitiveComboboxProvider
open={open}
setOpen={setOpen}
selectedValue={selectedValues}
setSelectedValue={(value) => handleValueChange(value as T)}
value={uncontrolledSearchValue}
setValue={(query) => {
startTransition(() => handleSearchChange(query))
}}
>
<Popover.Anchor asChild>
<div
className={clx(
"relative flex cursor-pointer items-center gap-x-2 overflow-hidden",
"h-8 w-full rounded-md px-2 py-0.5",
"bg-ui-bg-field transition-fg shadow-borders-base",
"hover:bg-ui-bg-field-hover",
"has-[input:focus]:shadow-borders-interactive-with-active",
"has-[:invalid]:shadow-borders-error",
"has-[:disabled]:bg-ui-bg-disabled has-[:disabled]:text-ui-fg-disabled has-[:disabled]:cursor-not-allowed",
{
"pl-0.5": hasValue && isArrayValue,
},
className
)}
>
{showTag && (
<div className="bg-ui-bg-base txt-compact-small-plus text-ui-fg-subtle focus-within:border-ui-fg-interactive relative flex h-[28px] items-center rounded-[4px] border py-[3px] pl-1.5 pr-1">
<span>{selectedValues.length}</span>
<button
type="button"
className="size-fit outline-none"
onClick={(e) => {
e.preventDefault()
handleValueChange(undefined)
}}
>
<XMarkMini className="text-ui-fg-muted" />
</button>
</div>
)}
<div className="relative flex size-full items-center">
{showSelected && (
<Text size="small" leading="compact">
{t("general.selected")}
</Text>
)}
>
{hasValues && (
<div className="bg-ui-bg-base txt-compact-small-plus text-ui-fg-subtle focus-within:border-ui-fg-interactive relative flex h-[28px] items-center rounded-[4px] border py-[3px] pl-1.5 pr-1">
<span>{selectedValues.length}</span>
<button
type="button"
className="size-fit outline-none"
onClick={(e) => {
e.preventDefault()
handleValueChange(undefined)
}}
>
<XMarkMini className="text-ui-fg-muted" />
</button>
{hideInput && (
<div className="absolute inset-y-0 left-0 flex size-full items-center overflow-hidden">
<Text size="small" leading="compact" className="truncate">
{selectedLabel}
</Text>
</div>
)}
<div className="relative flex size-full items-center">
{showSelected && (
<Text size="small" leading="compact">
{t("general.selected")}
</Text>
)}
<PrimitiveCombobox
ref={comboboxRef}
className="txt-compact-small text-ui-fg-base placeholder:text-ui-fg-subtle size-full cursor-pointer bg-transparent pr-7 outline-none focus:cursor-text"
placeholder={hidePlaceholder ? undefined : placeholder}
{...inputProps}
/>
</div>
<button
type="button"
tabIndex={-1}
className="text-ui-fg-muted pointer-events-none absolute right-2 size-fit outline-none"
>
<TrianglesMini />
</button>
</div>
</Popover.Anchor>
<Popover.Portal>
<Popover.Content
asChild
align="center"
side="bottom"
sideOffset={8}
onOpenAutoFocus={(event) => event.preventDefault()}
onInteractOutside={(event) => {
const target = event.target as Element | null
const isCombobox = target === comboboxRef.current
const inListbox = target && listboxRef.current?.contains(target)
if (isCombobox || inListbox) {
event.preventDefault()
}
}}
>
<PrimitiveComboboxList
ref={listboxRef}
role="listbox"
<PrimitiveCombobox
ref={comboboxRef}
className={clx(
"shadow-elevation-flyout bg-ui-bg-base w-[var(--radix-popper-anchor-width)] rounded-[8px] p-1",
"max-h-[200px] overflow-y-auto",
"data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
"data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95",
"data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2"
"txt-compact-small text-ui-fg-base placeholder:text-ui-fg-subtle size-full cursor-pointer bg-transparent pr-7 outline-none focus:cursor-text",
{
"opacity-0": hideInput,
}
)}
>
{matches.map(({ value, label }) => (
<PrimitiveComboboxItem
key={value}
value={value}
focusOnHover
className="transition-fg bg-ui-bg-base data-[active-item=true]:bg-ui-bg-base-hover group flex items-center gap-x-2 rounded-[4px] px-2 py-1.5"
placeholder={hidePlaceholder ? undefined : placeholder}
{...inputProps}
/>
</div>
<button
type="button"
tabIndex={-1}
className="text-ui-fg-muted pointer-events-none absolute right-2 size-fit outline-none"
>
<TrianglesMini />
</button>
</div>
</Popover.Anchor>
<Popover.Portal>
<Popover.Content
asChild
align="center"
side="bottom"
sideOffset={8}
onOpenAutoFocus={(event) => event.preventDefault()}
onInteractOutside={(event) => {
const target = event.target as Element | null
const isCombobox = target === comboboxRef.current
const inListbox = target && listboxRef.current?.contains(target)
if (isCombobox || inListbox) {
event.preventDefault()
}
}}
aria-busy={isPending}
>
<PrimitiveComboboxList
ref={listboxRef}
role="listbox"
className={clx(
"shadow-elevation-flyout bg-ui-bg-base w-[var(--radix-popper-anchor-width)] rounded-[8px] p-1",
"max-h-[200px] overflow-y-auto",
"data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
"data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95",
"data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2"
)}
>
{results.map(({ value, label }) => (
<PrimitiveComboboxItem
key={value}
value={value}
focusOnHover
setValueOnClick={false}
className="transition-fg bg-ui-bg-base data-[active-item=true]:bg-ui-bg-base-hover group flex cursor-pointer items-center gap-x-2 rounded-[4px] px-2 py-1.5"
>
<PrimitiveComboboxItemCheck className="flex !size-5 items-center justify-center">
<EllipseMiniSolid />
</PrimitiveComboboxItemCheck>
<PrimitiveComboboxItemValue className="txt-compact-small">
{label}
</PrimitiveComboboxItemValue>
</PrimitiveComboboxItem>
))}
{!!fetchNextPage && <div ref={lastOptionRef} className="w-px" />}
{isFetchingNextPage && (
<div className="transition-fg bg-ui-bg-base flex items-center rounded-[4px] px-2 py-1.5">
<div className="bg-ui-bg-component size-full h-5 w-full animate-pulse rounded-[4px]" />
</div>
)}
{!results.length && (
<div className="flex items-center gap-x-2 rounded-[4px] px-2 py-1.5">
<Text
size="small"
leading="compact"
className="text-ui-fg-subtle"
>
<PrimitiveComboboxItemCheck className="flex !size-5 items-center justify-center">
<EllipseMiniSolid />
</PrimitiveComboboxItemCheck>
<PrimitiveComboboxItemValue className="txt-compact-small group-aria-selected:txt-compact-small-plus">
{label}
</PrimitiveComboboxItemValue>
</PrimitiveComboboxItem>
))}
{!matches.length && (
<div className="flex items-center gap-x-2 rounded-[4px] px-2 py-1.5">
<Text
size="small"
leading="compact"
className="text-ui-fg-subtle"
>
{t("general.noResultsTitle")}
</Text>
</div>
)}
</PrimitiveComboboxList>
</Popover.Content>
</Popover.Portal>
</PrimitiveComboboxProvider>
</Popover.Root>
)
}
)
Combobox.displayName = "Combobox"
{t("general.noResultsTitle")}
</Text>
</div>
)}
</PrimitiveComboboxList>
</Popover.Content>
</Popover.Portal>
</PrimitiveComboboxProvider>
</Popover.Root>
)
}
export const Combobox = genericForwardRef(ComboboxImpl)

View File

@@ -0,0 +1,267 @@
import { Address, Cart, Order } from "@medusajs/medusa"
import { Avatar, Copy, Text } from "@medusajs/ui"
import { useTranslation } from "react-i18next"
import { Link } from "react-router-dom"
const ID = ({ data }: { data: Cart | Order }) => {
const { t } = useTranslation()
const id = data.customer_id
const name = getCartOrOrderCustomer(data)
const email = data.email
const fallback = (name || email).charAt(0).toUpperCase()
return (
<div className="text-ui-fg-subtle grid grid-cols-2 items-center px-6 py-4">
<Text size="small" leading="compact" weight="plus">
{t("fields.id")}
</Text>
<Link
to={`/customers/${id}`}
className="focus:shadow-borders-focus rounded-[4px] outline-none transition-shadow"
>
<div className="flex items-center gap-x-2 overflow-hidden">
<Avatar size="2xsmall" fallback={fallback} />
<Text
size="small"
leading="compact"
className="text-ui-fg-subtle hover:text-ui-fg-base transition-fg truncate"
>
{name || email}
</Text>
</div>
</Link>
</div>
)
}
const Company = ({ data }: { data: Order | Cart }) => {
const { t } = useTranslation()
const company =
data.shipping_address?.company || data.billing_address?.company
if (!company) {
return null
}
return (
<div className="text-ui-fg-subtle grid grid-cols-2 items-center px-6 py-4">
<Text size="small" leading="compact" weight="plus">
{t("fields.company")}
</Text>
<Text size="small" leading="compact" className="truncate">
{company}
</Text>
</div>
)
}
const Contact = ({ data }: { data: Cart | Order }) => {
const { t } = useTranslation()
const phone = data.shipping_address?.phone || data.billing_address?.phone
const email = data.email
return (
<div className="text-ui-fg-subtle grid grid-cols-2 items-start px-6 py-4">
<Text size="small" leading="compact" weight="plus">
{t("orders.customer.contactLabel")}
</Text>
<div className="flex flex-col gap-y-2">
<div className="grid grid-cols-[1fr_20px] items-start gap-x-2">
<Text
size="small"
leading="compact"
className="text-pretty break-all"
>
{email}
</Text>
<div className="flex justify-end">
<Copy content={email} className="text-ui-fg-muted" />
</div>
</div>
{phone && (
<div className="grid grid-cols-[1fr_20px] items-start gap-x-2">
<Text
size="small"
leading="compact"
className="text-pretty break-all"
>
{phone}
</Text>
<div className="flex justify-end">
<Copy content={email} className="text-ui-fg-muted" />
</div>
</div>
)}
</div>
</div>
)
}
const AddressPrint = ({
address,
type,
}: {
address: Address | null
type: "shipping" | "billing"
}) => {
const { t } = useTranslation()
return (
<div className="text-ui-fg-subtle grid grid-cols-2 items-start px-6 py-4">
<Text size="small" leading="compact" weight="plus">
{type === "shipping"
? t("addresses.shippingAddress.label")
: t("addresses.billingAddress.label")}
</Text>
{address ? (
<div className="grid grid-cols-[1fr_20px] items-start gap-x-2">
<Text size="small" leading="compact">
{getFormattedAddress({ address }).map((line, i) => {
return (
<span key={i} className="break-words">
{line}
<br />
</span>
)
})}
</Text>
<div className="flex justify-end">
<Copy
content={getFormattedAddress({ address }).join("\n")}
className="text-ui-fg-muted"
/>
</div>
</div>
) : (
<Text size="small" leading="compact">
-
</Text>
)}
</div>
)
}
const Addresses = ({ data }: { data: Cart | Order }) => {
const { t } = useTranslation()
return (
<div className="divide-y">
<AddressPrint address={data.shipping_address} type="shipping" />
{!isSameAddress(data.shipping_address, data.billing_address) ? (
<AddressPrint address={data.billing_address} type="billing" />
) : (
<div className="grid grid-cols-2 items-center px-6 py-4">
<Text
size="small"
leading="compact"
weight="plus"
className="text-ui-fg-subtle"
>
{t("addresses.billingAddress.label")}
</Text>
<Text size="small" leading="compact" className="text-ui-fg-muted">
{t("addresses.billingAddress.sameAsShipping")}
</Text>
</div>
)}
</div>
)
}
export const CustomerInfo = Object.assign(
{},
{
ID,
Company,
Contact,
Addresses,
}
)
const isSameAddress = (a: Address | null, b: Address | null) => {
if (!a || !b) {
return false
}
return (
a.first_name === b.first_name &&
a.last_name === b.last_name &&
a.address_1 === b.address_1 &&
a.address_2 === b.address_2 &&
a.city === b.city &&
a.postal_code === b.postal_code &&
a.province === b.province &&
a.country_code === b.country_code
)
}
const getFormattedAddress = ({ address }: { address: Address }) => {
const {
first_name,
last_name,
company,
address_1,
address_2,
city,
postal_code,
province,
country,
country_code,
} = address
const name = [first_name, last_name].filter(Boolean).join(" ")
const formattedAddress = []
if (name) {
formattedAddress.push(name)
}
if (company) {
formattedAddress.push(company)
}
if (address_1) {
formattedAddress.push(address_1)
}
if (address_2) {
formattedAddress.push(address_2)
}
const cityProvincePostal = [city, province, postal_code]
.filter(Boolean)
.join(" ")
if (cityProvincePostal) {
formattedAddress.push(cityProvincePostal)
}
if (country) {
formattedAddress.push(country.display_name)
} else if (country_code) {
formattedAddress.push(country_code.toUpperCase())
}
return formattedAddress
}
const getCartOrOrderCustomer = (obj: Cart | Order) => {
const { first_name: sFirstName, last_name: sLastName } =
obj.shipping_address || {}
const { first_name: bFirstName, last_name: bLastName } =
obj.billing_address || {}
const { first_name: cFirstName, last_name: cLastName } = obj.customer || {}
const customerName = [cFirstName, cLastName].filter(Boolean).join(" ")
const shippingName = [sFirstName, sLastName].filter(Boolean).join(" ")
const billingName = [bFirstName, bLastName].filter(Boolean).join(" ")
const name = customerName || shippingName || billingName
return name
}

View File

@@ -0,0 +1 @@
export * from "./customer-info"

View File

@@ -1,46 +0,0 @@
import { Input } from "@medusajs/ui"
import { ComponentProps, useEffect, useState } from "react"
import { useTranslation } from "react-i18next"
type DebouncedSearchProps = Omit<
ComponentProps<typeof Input>,
"value" | "defaultValue" | "onChange" | "type"
> & {
debounce?: number
value: string
onChange: (value: string) => void
}
export const DebouncedSearch = ({
value: initialValue,
onChange,
debounce = 500,
size = "small",
placeholder,
...props
}: DebouncedSearchProps) => {
const [value, setValue] = useState<string>(initialValue)
const { t } = useTranslation()
useEffect(() => {
setValue(initialValue)
}, [initialValue])
useEffect(() => {
const timeout = setTimeout(() => {
onChange?.(value)
}, debounce)
return () => clearTimeout(timeout)
}, [value])
return (
<Input
{...props}
placeholder={placeholder || t("general.search")}
type="search"
value={value}
onChange={(e) => setValue(e.target.value)}
/>
)
}

View File

@@ -1 +0,0 @@
export * from "./debounced-search"

View File

@@ -0,0 +1,7 @@
import { ReactNode, Ref, RefAttributes, forwardRef } from "react"
export function genericForwardRef<T, P = {}>(
render: (props: P, ref: Ref<T>) => ReactNode
): (props: P & RefAttributes<T>) => ReactNode {
return forwardRef(render) as any
}

View File

@@ -0,0 +1 @@
export * from "./generic-forward-ref"

View File

@@ -8,7 +8,7 @@ export const Skeleton = ({ className }: SkeletonProps) => {
return (
<div
className={clx(
"bg-ui-bg-component animate-pulse w-3 h-3 rounded-[4px]",
"bg-ui-bg-component h-3 w-3 animate-pulse rounded-[4px]",
className
)}
/>