feat(dashboard): Edit variant options + create option callback for combobox (#6920)
This commit is contained in:
1 parent
0bf0bf819b
commit
e915169e11
3 files changed
+296
-176
No files matched your search
@@ -1,19 +1,27 @@
|
|||||||
import {
|
import {
|
||||||
Combobox as PrimitiveCombobox,
|
Combobox as PrimitiveCombobox,
|
||||||
|
ComboboxDisclosure as PrimitiveComboboxDisclosure,
|
||||||
ComboboxItem as PrimitiveComboboxItem,
|
ComboboxItem as PrimitiveComboboxItem,
|
||||||
ComboboxItemCheck as PrimitiveComboboxItemCheck,
|
ComboboxItemCheck as PrimitiveComboboxItemCheck,
|
||||||
ComboboxItemValue as PrimitiveComboboxItemValue,
|
ComboboxItemValue as PrimitiveComboboxItemValue,
|
||||||
ComboboxList as PrimitiveComboboxList,
|
ComboboxPopover as PrimitiveComboboxPopover,
|
||||||
ComboboxProvider as PrimitiveComboboxProvider,
|
ComboboxProvider as PrimitiveComboboxProvider,
|
||||||
|
Separator as PrimitiveSeparator,
|
||||||
} from "@ariakit/react"
|
} from "@ariakit/react"
|
||||||
import { EllipseMiniSolid, TrianglesMini, XMarkMini } from "@medusajs/icons"
|
import {
|
||||||
|
EllipseMiniSolid,
|
||||||
|
PlusMini,
|
||||||
|
TrianglesMini,
|
||||||
|
XMarkMini,
|
||||||
|
} from "@medusajs/icons"
|
||||||
import { Text, clx } from "@medusajs/ui"
|
import { Text, clx } from "@medusajs/ui"
|
||||||
import * as Popover from "@radix-ui/react-popover"
|
|
||||||
import { matchSorter } from "match-sorter"
|
import { matchSorter } from "match-sorter"
|
||||||
import {
|
import {
|
||||||
ComponentPropsWithoutRef,
|
ComponentPropsWithoutRef,
|
||||||
ForwardedRef,
|
ForwardedRef,
|
||||||
|
Fragment,
|
||||||
useCallback,
|
useCallback,
|
||||||
|
useDeferredValue,
|
||||||
useImperativeHandle,
|
useImperativeHandle,
|
||||||
useMemo,
|
useMemo,
|
||||||
useRef,
|
useRef,
|
||||||
@@ -21,6 +29,7 @@ import {
|
|||||||
useTransition,
|
useTransition,
|
||||||
} from "react"
|
} from "react"
|
||||||
import { useTranslation } from "react-i18next"
|
import { useTranslation } from "react-i18next"
|
||||||
|
|
||||||
import { genericForwardRef } from "../generic-forward-ref"
|
import { genericForwardRef } from "../generic-forward-ref"
|
||||||
|
|
||||||
type ComboboxOption = {
|
type ComboboxOption = {
|
||||||
@@ -39,6 +48,7 @@ interface ComboboxProps<T extends Value = Value>
|
|||||||
options: ComboboxOption[]
|
options: ComboboxOption[]
|
||||||
fetchNextPage?: () => void
|
fetchNextPage?: () => void
|
||||||
isFetchingNextPage?: boolean
|
isFetchingNextPage?: boolean
|
||||||
|
onCreateOption?: (value: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const ComboboxImpl = <T extends Value = string>(
|
const ComboboxImpl = <T extends Value = string>(
|
||||||
@@ -52,6 +62,7 @@ const ComboboxImpl = <T extends Value = string>(
|
|||||||
placeholder,
|
placeholder,
|
||||||
fetchNextPage,
|
fetchNextPage,
|
||||||
isFetchingNextPage,
|
isFetchingNextPage,
|
||||||
|
onCreateOption,
|
||||||
...inputProps
|
...inputProps
|
||||||
}: ComboboxProps<T>,
|
}: ComboboxProps<T>,
|
||||||
ref: ForwardedRef<HTMLInputElement>
|
ref: ForwardedRef<HTMLInputElement>
|
||||||
@@ -74,6 +85,8 @@ const ComboboxImpl = <T extends Value = string>(
|
|||||||
const [uncontrolledSearchValue, setUncontrolledSearchValue] = useState(
|
const [uncontrolledSearchValue, setUncontrolledSearchValue] = useState(
|
||||||
controlledSearchValue || ""
|
controlledSearchValue || ""
|
||||||
)
|
)
|
||||||
|
const defferedSearchValue = useDeferredValue(uncontrolledSearchValue)
|
||||||
|
|
||||||
const [uncontrolledValue, setUncontrolledValue] = useState<T>(emptyState)
|
const [uncontrolledValue, setUncontrolledValue] = useState<T>(emptyState)
|
||||||
|
|
||||||
const searchValue = isSearchControlled
|
const searchValue = isSearchControlled
|
||||||
@@ -82,15 +95,25 @@ const ComboboxImpl = <T extends Value = string>(
|
|||||||
const selectedValues = isValueControlled ? controlledValue : uncontrolledValue
|
const selectedValues = isValueControlled ? controlledValue : uncontrolledValue
|
||||||
|
|
||||||
const handleValueChange = (newValues?: T) => {
|
const handleValueChange = (newValues?: T) => {
|
||||||
if (!isArrayValue) {
|
// check if the value already exists in options
|
||||||
const label = options.find((o) => o.value === newValues)?.label
|
const exists = options.find((o) => {
|
||||||
|
if (isArrayValue) {
|
||||||
|
return newValues?.includes(o.value)
|
||||||
|
}
|
||||||
|
|
||||||
setUncontrolledSearchValue(label || "")
|
return o.value === newValues
|
||||||
|
})
|
||||||
|
|
||||||
|
// If the value does not exist in the options, and the component has a handler
|
||||||
|
// for creating new options, call it.
|
||||||
|
if (!exists && onCreateOption && newValues) {
|
||||||
|
onCreateOption(newValues as string)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isValueControlled) {
|
if (!isValueControlled) {
|
||||||
setUncontrolledValue(newValues || emptyState)
|
setUncontrolledValue(newValues || emptyState)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (onChange) {
|
if (onChange) {
|
||||||
onChange(newValues)
|
onChange(newValues)
|
||||||
}
|
}
|
||||||
@@ -117,28 +140,10 @@ const ComboboxImpl = <T extends Value = string>(
|
|||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
|
|
||||||
return matchSorter(options, uncontrolledSearchValue, {
|
return matchSorter(options, defferedSearchValue, {
|
||||||
keys: ["label"],
|
keys: ["label"],
|
||||||
baseSort: (a, b) => {
|
|
||||||
const aIndex = selectedValues.indexOf(a.item.value)
|
|
||||||
const bIndex = selectedValues.indexOf(b.item.value)
|
|
||||||
|
|
||||||
if (aIndex === -1 && bIndex === -1) {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
if (aIndex === -1) {
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
|
|
||||||
if (bIndex === -1) {
|
|
||||||
return -1
|
|
||||||
}
|
|
||||||
|
|
||||||
return aIndex - bIndex
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
}, [options, uncontrolledSearchValue, selectedValues, isSearchControlled])
|
}, [options, defferedSearchValue, isSearchControlled])
|
||||||
|
|
||||||
const observer = useRef(
|
const observer = useRef(
|
||||||
new IntersectionObserver(
|
new IntersectionObserver(
|
||||||
@@ -167,6 +172,14 @@ const ComboboxImpl = <T extends Value = string>(
|
|||||||
[isFetchingNextPage]
|
[isFetchingNextPage]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const handleOpenChange = (open: boolean) => {
|
||||||
|
if (!open) {
|
||||||
|
setUncontrolledSearchValue("")
|
||||||
|
}
|
||||||
|
|
||||||
|
setOpen(open)
|
||||||
|
}
|
||||||
|
|
||||||
const hasValue = selectedValues.length > 0
|
const hasValue = selectedValues.length > 0
|
||||||
|
|
||||||
const showTag = hasValue && isArrayValue
|
const showTag = hasValue && isArrayValue
|
||||||
@@ -177,161 +190,153 @@ const ComboboxImpl = <T extends Value = string>(
|
|||||||
|
|
||||||
const hidePlaceholder = showSelected || open
|
const hidePlaceholder = showSelected || open
|
||||||
|
|
||||||
const results = isSearchControlled ? options : matches
|
const results = useMemo(() => {
|
||||||
|
return isSearchControlled ? options : matches
|
||||||
|
}, [matches, options, isSearchControlled])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Popover.Root modal open={open} onOpenChange={setOpen}>
|
<PrimitiveComboboxProvider
|
||||||
<PrimitiveComboboxProvider
|
open={open}
|
||||||
open={open}
|
setOpen={handleOpenChange}
|
||||||
setOpen={setOpen}
|
selectedValue={selectedValues}
|
||||||
selectedValue={selectedValues}
|
setSelectedValue={(value) => handleValueChange(value as T)}
|
||||||
setSelectedValue={(value) => handleValueChange(value as T)}
|
value={uncontrolledSearchValue}
|
||||||
value={uncontrolledSearchValue}
|
setValue={(query) => {
|
||||||
setValue={(query) => {
|
startTransition(() => handleSearchChange(query))
|
||||||
startTransition(() => handleSearchChange(query))
|
}}
|
||||||
}}
|
>
|
||||||
|
<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-[[aria-invalid=true]]: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
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<Popover.Anchor asChild>
|
{showTag && (
|
||||||
<div
|
<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">
|
||||||
className={clx(
|
<span>{selectedValues.length}</span>
|
||||||
"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-[[aria-invalid=true]]: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>
|
|
||||||
)}
|
|
||||||
{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>
|
|
||||||
)}
|
|
||||||
<PrimitiveCombobox
|
|
||||||
ref={comboboxRef}
|
|
||||||
className={clx(
|
|
||||||
"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,
|
|
||||||
}
|
|
||||||
)}
|
|
||||||
placeholder={hidePlaceholder ? undefined : placeholder}
|
|
||||||
{...inputProps}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
tabIndex={-1}
|
className="size-fit outline-none"
|
||||||
className="text-ui-fg-muted pointer-events-none absolute right-2 size-fit outline-none"
|
onClick={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
handleValueChange(undefined)
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<TrianglesMini />
|
<XMarkMini className="text-ui-fg-muted" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</Popover.Anchor>
|
)}
|
||||||
<Popover.Portal>
|
<div className="relative flex size-full items-center">
|
||||||
<Popover.Content
|
{showSelected && (
|
||||||
align="center"
|
<Text size="small" leading="compact">
|
||||||
side="bottom"
|
{t("general.selected")}
|
||||||
sideOffset={8}
|
</Text>
|
||||||
onOpenAutoFocus={(event) => event.preventDefault()}
|
)}
|
||||||
onInteractOutside={(event) => {
|
{hideInput && (
|
||||||
const target = event.target as Element | null
|
<div className="absolute inset-y-0 left-0 flex size-full items-center overflow-hidden">
|
||||||
const isCombobox = target === comboboxRef.current
|
<Text size="small" leading="compact" className="truncate">
|
||||||
const inListbox = target && listboxRef.current?.contains(target)
|
{selectedLabel}
|
||||||
|
</Text>
|
||||||
if (isCombobox || inListbox) {
|
</div>
|
||||||
event.preventDefault()
|
)}
|
||||||
|
<PrimitiveCombobox
|
||||||
|
autoSelect
|
||||||
|
ref={comboboxRef}
|
||||||
|
className={clx(
|
||||||
|
"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,
|
||||||
}
|
}
|
||||||
}}
|
)}
|
||||||
aria-busy={isPending}
|
placeholder={hidePlaceholder ? undefined : placeholder}
|
||||||
>
|
{...inputProps}
|
||||||
<PrimitiveComboboxList
|
/>
|
||||||
ref={listboxRef}
|
</div>
|
||||||
role="listbox"
|
<PrimitiveComboboxDisclosure
|
||||||
className={clx(
|
render={() => {
|
||||||
"shadow-elevation-flyout bg-ui-bg-base w-[var(--radix-popper-anchor-width)] rounded-[8px] p-1",
|
return (
|
||||||
"max-h-[200px] overflow-y-auto",
|
<button
|
||||||
"data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
|
type="button"
|
||||||
"data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95",
|
className="text-ui-fg-muted pointer-events-none absolute right-2 size-fit outline-none"
|
||||||
"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"
|
>
|
||||||
)}
|
<TrianglesMini />
|
||||||
>
|
</button>
|
||||||
{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"
|
|
||||||
>
|
|
||||||
{t("general.noResultsTitle")}
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</PrimitiveComboboxList>
|
|
||||||
</Popover.Content>
|
|
||||||
</Popover.Portal>
|
|
||||||
</PrimitiveComboboxProvider>
|
|
||||||
{open && (
|
|
||||||
<div
|
|
||||||
tabIndex={-1}
|
|
||||||
aria-hidden="true"
|
|
||||||
data-aria-hidden="true"
|
|
||||||
data-state="open"
|
|
||||||
className="fixed inset-0 size-full"
|
|
||||||
onClick={() => setOpen(false)}
|
|
||||||
/>
|
/>
|
||||||
)}
|
</div>
|
||||||
</Popover.Root>
|
<PrimitiveComboboxPopover
|
||||||
|
gutter={4}
|
||||||
|
ref={listboxRef}
|
||||||
|
role="listbox"
|
||||||
|
className={clx(
|
||||||
|
"shadow-elevation-flyout bg-ui-bg-base -left-2 z-50 w-[calc(var(--popover-anchor-width)+16px)] 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"
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
pointerEvents: open ? "auto" : "none",
|
||||||
|
}}
|
||||||
|
aria-busy={isPending}
|
||||||
|
>
|
||||||
|
{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">
|
||||||
|
{t("general.noResultsTitle")}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!results.length && onCreateOption && (
|
||||||
|
<Fragment>
|
||||||
|
<PrimitiveSeparator className="bg-ui-border-base -mx-1" />
|
||||||
|
<PrimitiveComboboxItem
|
||||||
|
value={uncontrolledSearchValue}
|
||||||
|
focusOnHover
|
||||||
|
setValueOnClick={false}
|
||||||
|
className="transition-fg bg-ui-bg-base data-[active-item=true]:bg-ui-bg-base-hover group mt-1 flex cursor-pointer items-center gap-x-2 rounded-[4px] px-2 py-1.5"
|
||||||
|
>
|
||||||
|
<PlusMini className="text-ui-fg-subtle" />
|
||||||
|
<Text size="small" leading="compact">
|
||||||
|
{t("actions.create")} "{searchValue}"
|
||||||
|
</Text>
|
||||||
|
</PrimitiveComboboxItem>
|
||||||
|
</Fragment>
|
||||||
|
)}
|
||||||
|
</PrimitiveComboboxPopover>
|
||||||
|
</PrimitiveComboboxProvider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+16
-3
@@ -1,4 +1,9 @@
|
|||||||
import { ComponentPropsWithoutRef, forwardRef } from "react"
|
import {
|
||||||
|
ComponentPropsWithoutRef,
|
||||||
|
forwardRef,
|
||||||
|
useImperativeHandle,
|
||||||
|
useRef,
|
||||||
|
} from "react"
|
||||||
|
|
||||||
import { TrianglesMini } from "@medusajs/icons"
|
import { TrianglesMini } from "@medusajs/icons"
|
||||||
import { clx } from "@medusajs/ui"
|
import { clx } from "@medusajs/ui"
|
||||||
@@ -10,6 +15,11 @@ export const CountrySelect = forwardRef<
|
|||||||
ComponentPropsWithoutRef<"select"> & { placeholder?: string }
|
ComponentPropsWithoutRef<"select"> & { placeholder?: string }
|
||||||
>(({ className, disabled, placeholder, ...props }, ref) => {
|
>(({ className, disabled, placeholder, ...props }, ref) => {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
const innerRef = useRef<HTMLSelectElement>(null)
|
||||||
|
|
||||||
|
useImperativeHandle(ref, () => innerRef.current as HTMLSelectElement)
|
||||||
|
|
||||||
|
const isPlaceholder = innerRef.current?.value === ""
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
@@ -31,13 +41,16 @@ export const CountrySelect = forwardRef<
|
|||||||
"aria-[invalid=true]:border-ui-border-error aria-[invalid=true]:shadow-borders-error",
|
"aria-[invalid=true]:border-ui-border-error aria-[invalid=true]:shadow-borders-error",
|
||||||
"invalid::border-ui-border-error invalid:shadow-borders-error",
|
"invalid::border-ui-border-error invalid:shadow-borders-error",
|
||||||
"disabled:!bg-ui-bg-disabled disabled:!text-ui-fg-disabled",
|
"disabled:!bg-ui-bg-disabled disabled:!text-ui-fg-disabled",
|
||||||
|
{
|
||||||
|
"text-ui-fg-muted": isPlaceholder,
|
||||||
|
},
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
ref={ref}
|
ref={innerRef}
|
||||||
>
|
>
|
||||||
{/* Add an empty option so the first option is preselected */}
|
{/* Add an empty option so the first option is preselected */}
|
||||||
<option value="" disabled hidden className="text-ui-fg-muted">
|
<option value="" disabled className="text-ui-fg-muted">
|
||||||
{placeholder || t("fields.selectCountry")}
|
{placeholder || t("fields.selectCountry")}
|
||||||
</option>
|
</option>
|
||||||
{countries.map((country) => {
|
{countries.map((country) => {
|
||||||
|
|||||||
+104
-2
@@ -1,12 +1,13 @@
|
|||||||
import { zodResolver } from "@hookform/resolvers/zod"
|
import { zodResolver } from "@hookform/resolvers/zod"
|
||||||
import { Product, ProductVariant } from "@medusajs/medusa"
|
import { Product, ProductOption, ProductVariant } from "@medusajs/medusa"
|
||||||
import { Button, Heading, Input, Switch } from "@medusajs/ui"
|
import { Button, Heading, Input, Switch } from "@medusajs/ui"
|
||||||
import { useAdminUpdateVariant } from "medusa-react"
|
import { useAdminUpdateVariant } from "medusa-react"
|
||||||
import { useForm } from "react-hook-form"
|
import { useForm } from "react-hook-form"
|
||||||
import { useTranslation } from "react-i18next"
|
import { useTranslation } from "react-i18next"
|
||||||
import { z } from "zod"
|
import { z } from "zod"
|
||||||
|
|
||||||
import { Fragment } from "react"
|
import { Fragment, useState } from "react"
|
||||||
|
import { Combobox } from "../../../../../components/common/combobox"
|
||||||
import { CountrySelect } from "../../../../../components/common/country-select"
|
import { CountrySelect } from "../../../../../components/common/country-select"
|
||||||
import { Divider } from "../../../../../components/common/divider"
|
import { Divider } from "../../../../../components/common/divider"
|
||||||
import { Form } from "../../../../../components/common/form"
|
import { Form } from "../../../../../components/common/form"
|
||||||
@@ -40,6 +41,11 @@ const ProductEditVariantSchema = z.object({
|
|||||||
mid_code: z.string().optional(),
|
mid_code: z.string().optional(),
|
||||||
hs_code: z.string().optional(),
|
hs_code: z.string().optional(),
|
||||||
origin_country: z.string().optional(),
|
origin_country: z.string().optional(),
|
||||||
|
options: z.record(
|
||||||
|
z.object({
|
||||||
|
value: z.string().min(1),
|
||||||
|
})
|
||||||
|
),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const ProductEditVariantForm = ({
|
export const ProductEditVariantForm = ({
|
||||||
@@ -47,6 +53,10 @@ export const ProductEditVariantForm = ({
|
|||||||
variant,
|
variant,
|
||||||
isStockAndInventoryEnabled = false,
|
isStockAndInventoryEnabled = false,
|
||||||
}: ProductEditVariantFormProps) => {
|
}: ProductEditVariantFormProps) => {
|
||||||
|
const [optionValues, setOptionValues] = useState<Record<string, string[]>>(
|
||||||
|
initOptionValues(product)
|
||||||
|
)
|
||||||
|
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { handleSuccess } = useRouteModal()
|
const { handleSuccess } = useRouteModal()
|
||||||
|
|
||||||
@@ -68,6 +78,7 @@ export const ProductEditVariantForm = ({
|
|||||||
mid_code: variant.mid_code || "",
|
mid_code: variant.mid_code || "",
|
||||||
hs_code: variant.hs_code || "",
|
hs_code: variant.hs_code || "",
|
||||||
origin_country: variant.origin_country || "",
|
origin_country: variant.origin_country || "",
|
||||||
|
options: getDefaultOptionValues(product, variant),
|
||||||
},
|
},
|
||||||
resolver: zodResolver(ProductEditVariantSchema),
|
resolver: zodResolver(ProductEditVariantSchema),
|
||||||
})
|
})
|
||||||
@@ -99,6 +110,7 @@ export const ProductEditVariantForm = ({
|
|||||||
ean,
|
ean,
|
||||||
upc,
|
upc,
|
||||||
barcode,
|
barcode,
|
||||||
|
options,
|
||||||
...rest
|
...rest
|
||||||
} = data
|
} = data
|
||||||
|
|
||||||
@@ -119,6 +131,13 @@ export const ProductEditVariantForm = ({
|
|||||||
}
|
}
|
||||||
: {}
|
: {}
|
||||||
|
|
||||||
|
const optionsPayload = Object.entries(options).map(([key, value]) => {
|
||||||
|
return {
|
||||||
|
option_id: key,
|
||||||
|
value: value.value,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
await mutateAsync(
|
await mutateAsync(
|
||||||
{
|
{
|
||||||
variant_id: variant.id,
|
variant_id: variant.id,
|
||||||
@@ -126,6 +145,7 @@ export const ProductEditVariantForm = ({
|
|||||||
height: parseNumber(height),
|
height: parseNumber(height),
|
||||||
width: parseNumber(width),
|
width: parseNumber(width),
|
||||||
length: parseNumber(length),
|
length: parseNumber(length),
|
||||||
|
options: optionsPayload,
|
||||||
...conditionalPayload,
|
...conditionalPayload,
|
||||||
...rest,
|
...rest,
|
||||||
},
|
},
|
||||||
@@ -137,6 +157,18 @@ export const ProductEditVariantForm = ({
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const handleCreateOption = (optionId: string) => {
|
||||||
|
return (value: string) => {
|
||||||
|
setOptionValues((prev) => {
|
||||||
|
const values = prev[optionId] || []
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
[optionId]: [...values, value],
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<RouteDrawer.Form form={form}>
|
<RouteDrawer.Form form={form}>
|
||||||
<form
|
<form
|
||||||
@@ -175,6 +207,38 @@ export const ProductEditVariantForm = ({
|
|||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
{product.options.map((option) => {
|
||||||
|
return (
|
||||||
|
<Form.Field
|
||||||
|
key={option.id}
|
||||||
|
control={form.control}
|
||||||
|
name={`options.${option.id}`}
|
||||||
|
render={({ field: { value, onChange, ...field } }) => {
|
||||||
|
const options = optionValues[option.id].map((value) => ({
|
||||||
|
label: value,
|
||||||
|
value,
|
||||||
|
}))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form.Item>
|
||||||
|
<Form.Label>{option.title}</Form.Label>
|
||||||
|
<Form.Control>
|
||||||
|
<Combobox
|
||||||
|
value={value.value}
|
||||||
|
onChange={(v) => {
|
||||||
|
onChange({ value: v })
|
||||||
|
}}
|
||||||
|
onCreateOption={handleCreateOption(option.id)}
|
||||||
|
{...field}
|
||||||
|
options={options}
|
||||||
|
/>
|
||||||
|
</Form.Control>
|
||||||
|
</Form.Item>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
<Divider />
|
<Divider />
|
||||||
{!isStockAndInventoryEnabled && (
|
{!isStockAndInventoryEnabled && (
|
||||||
@@ -463,3 +527,41 @@ export const ProductEditVariantForm = ({
|
|||||||
</RouteDrawer.Form>
|
</RouteDrawer.Form>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* eslint-disable prettier/prettier */
|
||||||
|
const getDefaultOptionValues = (product: Product, variant: ProductVariant) => {
|
||||||
|
const opts = variant.options
|
||||||
|
|
||||||
|
return product.options.reduce(
|
||||||
|
(acc, option) => {
|
||||||
|
const variantOption = opts.find((o) => o.option_id === option.id)
|
||||||
|
|
||||||
|
acc[option.id] = {
|
||||||
|
value: variantOption?.value || "",
|
||||||
|
}
|
||||||
|
return acc
|
||||||
|
},
|
||||||
|
{} as Record<string, { value: string }>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const getOptionValues = (option: ProductOption) => {
|
||||||
|
const values = option.values.map((value) => value.value)
|
||||||
|
|
||||||
|
const filteredValues = values.filter((v, i) => values.indexOf(v) === i)
|
||||||
|
|
||||||
|
return filteredValues.map((value) => value)
|
||||||
|
}
|
||||||
|
|
||||||
|
const initOptionValues = (product: Product) => {
|
||||||
|
return product.options.reduce(
|
||||||
|
(acc, option) => {
|
||||||
|
const values = getOptionValues(option)
|
||||||
|
|
||||||
|
acc[option.id] = values
|
||||||
|
return acc
|
||||||
|
},
|
||||||
|
{} as Record<string, string[]>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/* eslint-enable prettier/prettier */
|
||||||
Reference in new issue
Block a user