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
+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")} &quot;{searchValue}&quot;
</Text>
</PrimitiveComboboxItem>
</Fragment>
)}
</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 */