feat(dashboard): Edit variant options + create option callback for combobox (#6920)

This commit is contained in:
Kasper Fabricius Kristensen
2024-04-05 11:40:33 +02:00
committed by GitHub
parent 0bf0bf819b
commit e915169e11
3 changed files with 296 additions and 176 deletions

View File

@@ -1,19 +1,27 @@
import {
Combobox as PrimitiveCombobox,
ComboboxDisclosure as PrimitiveComboboxDisclosure,
ComboboxItem as PrimitiveComboboxItem,
ComboboxItemCheck as PrimitiveComboboxItemCheck,
ComboboxItemValue as PrimitiveComboboxItemValue,
ComboboxList as PrimitiveComboboxList,
ComboboxPopover as PrimitiveComboboxPopover,
ComboboxProvider as PrimitiveComboboxProvider,
Separator as PrimitiveSeparator,
} 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 * as Popover from "@radix-ui/react-popover"
import { matchSorter } from "match-sorter"
import {
ComponentPropsWithoutRef,
ForwardedRef,
Fragment,
useCallback,
useDeferredValue,
useImperativeHandle,
useMemo,
useRef,
@@ -21,6 +29,7 @@ import {
useTransition,
} from "react"
import { useTranslation } from "react-i18next"
import { genericForwardRef } from "../generic-forward-ref"
type ComboboxOption = {
@@ -39,6 +48,7 @@ interface ComboboxProps<T extends Value = Value>
options: ComboboxOption[]
fetchNextPage?: () => void
isFetchingNextPage?: boolean
onCreateOption?: (value: string) => void
}
const ComboboxImpl = <T extends Value = string>(
@@ -52,6 +62,7 @@ const ComboboxImpl = <T extends Value = string>(
placeholder,
fetchNextPage,
isFetchingNextPage,
onCreateOption,
...inputProps
}: ComboboxProps<T>,
ref: ForwardedRef<HTMLInputElement>
@@ -74,6 +85,8 @@ const ComboboxImpl = <T extends Value = string>(
const [uncontrolledSearchValue, setUncontrolledSearchValue] = useState(
controlledSearchValue || ""
)
const defferedSearchValue = useDeferredValue(uncontrolledSearchValue)
const [uncontrolledValue, setUncontrolledValue] = useState<T>(emptyState)
const searchValue = isSearchControlled
@@ -82,15 +95,25 @@ const ComboboxImpl = <T extends Value = string>(
const selectedValues = isValueControlled ? controlledValue : uncontrolledValue
const handleValueChange = (newValues?: T) => {
if (!isArrayValue) {
const label = options.find((o) => o.value === newValues)?.label
// check if the value already exists in options
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) {
setUncontrolledValue(newValues || emptyState)
}
if (onChange) {
onChange(newValues)
}
@@ -117,28 +140,10 @@ const ComboboxImpl = <T extends Value = string>(
return []
}
return matchSorter(options, uncontrolledSearchValue, {
return matchSorter(options, defferedSearchValue, {
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(
new IntersectionObserver(
@@ -167,6 +172,14 @@ const ComboboxImpl = <T extends Value = string>(
[isFetchingNextPage]
)
const handleOpenChange = (open: boolean) => {
if (!open) {
setUncontrolledSearchValue("")
}
setOpen(open)
}
const hasValue = selectedValues.length > 0
const showTag = hasValue && isArrayValue
@@ -177,161 +190,153 @@ const ComboboxImpl = <T extends Value = string>(
const hidePlaceholder = showSelected || open
const results = isSearchControlled ? options : matches
const results = useMemo(() => {
return isSearchControlled ? options : matches
}, [matches, options, isSearchControlled])
return (
<Popover.Root modal open={open} onOpenChange={setOpen}>
<PrimitiveComboboxProvider
open={open}
setOpen={setOpen}
selectedValue={selectedValues}
setSelectedValue={(value) => handleValueChange(value as T)}
value={uncontrolledSearchValue}
setValue={(query) => {
startTransition(() => handleSearchChange(query))
}}
<PrimitiveComboboxProvider
open={open}
setOpen={handleOpenChange}
selectedValue={selectedValues}
setSelectedValue={(value) => handleValueChange(value as T)}
value={uncontrolledSearchValue}
setValue={(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>
<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
)}
>
{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>
{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"
tabIndex={-1}
className="text-ui-fg-muted pointer-events-none absolute right-2 size-fit outline-none"
className="size-fit outline-none"
onClick={(e) => {
e.preventDefault()
handleValueChange(undefined)
}}
>
<TrianglesMini />
<XMarkMini className="text-ui-fg-muted" />
</button>
</div>
</Popover.Anchor>
<Popover.Portal>
<Popover.Content
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()
)}
<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
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}
>
<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"
>
{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)}
)}
placeholder={hidePlaceholder ? undefined : placeholder}
{...inputProps}
/>
</div>
<PrimitiveComboboxDisclosure
render={() => {
return (
<button
type="button"
className="text-ui-fg-muted pointer-events-none absolute right-2 size-fit outline-none"
>
<TrianglesMini />
</button>
)
}}
/>
)}
</Popover.Root>
</div>
<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")} &quot;{searchValue}&quot;
</Text>
</PrimitiveComboboxItem>
</Fragment>
)}
</PrimitiveComboboxPopover>
</PrimitiveComboboxProvider>
)
}

View File

@@ -1,4 +1,9 @@
import { ComponentPropsWithoutRef, forwardRef } from "react"
import {
ComponentPropsWithoutRef,
forwardRef,
useImperativeHandle,
useRef,
} from "react"
import { TrianglesMini } from "@medusajs/icons"
import { clx } from "@medusajs/ui"
@@ -10,6 +15,11 @@ export const CountrySelect = forwardRef<
ComponentPropsWithoutRef<"select"> & { placeholder?: string }
>(({ className, disabled, placeholder, ...props }, ref) => {
const { t } = useTranslation()
const innerRef = useRef<HTMLSelectElement>(null)
useImperativeHandle(ref, () => innerRef.current as HTMLSelectElement)
const isPlaceholder = innerRef.current?.value === ""
return (
<div className="relative">
@@ -31,13 +41,16 @@ export const CountrySelect = forwardRef<
"aria-[invalid=true]:border-ui-border-error aria-[invalid=true]:shadow-borders-error",
"invalid::border-ui-border-error invalid:shadow-borders-error",
"disabled:!bg-ui-bg-disabled disabled:!text-ui-fg-disabled",
{
"text-ui-fg-muted": isPlaceholder,
},
className
)}
{...props}
ref={ref}
ref={innerRef}
>
{/* 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")}
</option>
{countries.map((country) => {

View File

@@ -1,12 +1,13 @@
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 { useAdminUpdateVariant } from "medusa-react"
import { useForm } from "react-hook-form"
import { useTranslation } from "react-i18next"
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 { Divider } from "../../../../../components/common/divider"
import { Form } from "../../../../../components/common/form"
@@ -40,6 +41,11 @@ const ProductEditVariantSchema = z.object({
mid_code: z.string().optional(),
hs_code: z.string().optional(),
origin_country: z.string().optional(),
options: z.record(
z.object({
value: z.string().min(1),
})
),
})
export const ProductEditVariantForm = ({
@@ -47,6 +53,10 @@ export const ProductEditVariantForm = ({
variant,
isStockAndInventoryEnabled = false,
}: ProductEditVariantFormProps) => {
const [optionValues, setOptionValues] = useState<Record<string, string[]>>(
initOptionValues(product)
)
const { t } = useTranslation()
const { handleSuccess } = useRouteModal()
@@ -68,6 +78,7 @@ export const ProductEditVariantForm = ({
mid_code: variant.mid_code || "",
hs_code: variant.hs_code || "",
origin_country: variant.origin_country || "",
options: getDefaultOptionValues(product, variant),
},
resolver: zodResolver(ProductEditVariantSchema),
})
@@ -99,6 +110,7 @@ export const ProductEditVariantForm = ({
ean,
upc,
barcode,
options,
...rest
} = 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(
{
variant_id: variant.id,
@@ -126,6 +145,7 @@ export const ProductEditVariantForm = ({
height: parseNumber(height),
width: parseNumber(width),
length: parseNumber(length),
options: optionsPayload,
...conditionalPayload,
...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 (
<RouteDrawer.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>
<Divider />
{!isStockAndInventoryEnabled && (
@@ -463,3 +527,41 @@ export const ProductEditVariantForm = ({
</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 */