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

This commit is contained in:
Kasper Fabricius Kristensen authored and GitHub committed 2024-04-05 11:40:33 +02:00
1 parent 0bf0bf819b
commit e915169e11
3 files changed
+195 -75

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,13 +190,14 @@ 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={setOpen} setOpen={handleOpenChange}
selectedValue={selectedValues} selectedValue={selectedValues}
setSelectedValue={(value) => handleValueChange(value as T)} setSelectedValue={(value) => handleValueChange(value as T)}
value={uncontrolledSearchValue} value={uncontrolledSearchValue}
@@ -191,7 +205,6 @@ const ComboboxImpl = <T extends Value = string>(
startTransition(() => handleSearchChange(query)) startTransition(() => handleSearchChange(query))
}} }}
> >
<Popover.Anchor asChild>
<div <div
className={clx( className={clx(
"relative flex cursor-pointer items-center gap-x-2 overflow-hidden", "relative flex cursor-pointer items-center gap-x-2 overflow-hidden",
@@ -236,6 +249,7 @@ const ComboboxImpl = <T extends Value = string>(
</div> </div>
)} )}
<PrimitiveCombobox <PrimitiveCombobox
autoSelect
ref={comboboxRef} ref={comboboxRef}
className={clx( 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", "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",
@@ -247,42 +261,34 @@ const ComboboxImpl = <T extends Value = string>(
{...inputProps} {...inputProps}
/> />
</div> </div>
<PrimitiveComboboxDisclosure
render={() => {
return (
<button <button
type="button" type="button"
tabIndex={-1}
className="text-ui-fg-muted pointer-events-none absolute right-2 size-fit outline-none" className="text-ui-fg-muted pointer-events-none absolute right-2 size-fit outline-none"
> >
<TrianglesMini /> <TrianglesMini />
</button> </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()
}
}} }}
aria-busy={isPending} />
> </div>
<PrimitiveComboboxList <PrimitiveComboboxPopover
gutter={4}
ref={listboxRef} ref={listboxRef}
role="listbox" role="listbox"
className={clx( className={clx(
"shadow-elevation-flyout bg-ui-bg-base w-[var(--radix-popper-anchor-width)] rounded-[8px] p-1", "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", "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=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-[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" "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 }) => ( {results.map(({ value, label }) => (
<PrimitiveComboboxItem <PrimitiveComboboxItem
@@ -308,30 +314,29 @@ const ComboboxImpl = <T extends Value = string>(
)} )}
{!results.length && ( {!results.length && (
<div className="flex items-center gap-x-2 rounded-[4px] px-2 py-1.5"> <div className="flex items-center gap-x-2 rounded-[4px] px-2 py-1.5">
<Text <Text size="small" leading="compact" className="text-ui-fg-subtle">
size="small"
leading="compact"
className="text-ui-fg-subtle"
>
{t("general.noResultsTitle")} {t("general.noResultsTitle")}
</Text> </Text>
</div> </div>
)} )}
</PrimitiveComboboxList> {!results.length && onCreateOption && (
</Popover.Content> <Fragment>
</Popover.Portal> <PrimitiveSeparator className="bg-ui-border-base -mx-1" />
</PrimitiveComboboxProvider> <PrimitiveComboboxItem
{open && ( value={uncontrolledSearchValue}
<div focusOnHover
tabIndex={-1} setValueOnClick={false}
aria-hidden="true" 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"
data-aria-hidden="true" >
data-state="open" <PlusMini className="text-ui-fg-subtle" />
className="fixed inset-0 size-full" <Text size="small" leading="compact">
onClick={() => setOpen(false)} {t("actions.create")} &quot;{searchValue}&quot;
/> </Text>
</PrimitiveComboboxItem>
</Fragment>
)} )}
</Popover.Root> </PrimitiveComboboxPopover>
</PrimitiveComboboxProvider>
) )
} }
@@ -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) => {
@@ -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 */